From 001946c7f8ff362522293d3d63c943faa729bfc5 Mon Sep 17 00:00:00 2001 From: Shekhar Patnaik Date: Sun, 21 Sep 2025 09:35:36 +0100 Subject: [PATCH 1/3] Support Duo Flows in triggers This MR adds support for Duo Flows in triggers. 1. Update to the Trigger UI to support selecting flow and flow version 2. Added Graphql service to support flows 3. Updated Trigger service to start flows Changelog: changed EE: true --- ..._ai_catalog_references_to_flow_triggers.rb | 33 ++ db/schema_migrations/20250919154932 | 1 + db/structure.sql | 12 + doc/api/graphql/reference/_index.md | 6 + ...i_catalog_flows_for_triggers.query.graphql | 13 + ...get_ai_catalog_item_versions.query.graphql | 16 + .../components/flow_trigger_form.vue | 188 ++++++++++- .../mutations/ai/flow_triggers/create.rb | 10 + .../mutations/ai/flow_triggers/update.rb | 10 + ee/app/graphql/types/ai/flow_trigger_type.rb | 8 + ee/app/models/ai/catalog/item.rb | 2 + ee/app/models/ai/flow_trigger.rb | 26 +- .../ai/flow_triggers/create_service.rb | 30 +- .../ai/flow_triggers/update_service.rb | 30 +- .../components/flow_trigger_form_spec.js | 297 +++++++++++++----- .../mutations/ai/flow_triggers/create_spec.rb | 2 + .../mutations/ai/flow_triggers/update_spec.rb | 2 + .../types/ai/flow_trigger_type_spec.rb | 2 + ee/spec/models/ai/catalog/item_spec.rb | 47 +++ ee/spec/models/ai/flow_trigger_spec.rb | 74 ++++- .../ai/flow_triggers/create_service_spec.rb | 66 +++- .../ai/flow_triggers/update_service_spec.rb | 107 ++++++- locale/gitlab.pot | 27 ++ 23 files changed, 928 insertions(+), 81 deletions(-) create mode 100644 db/migrate/20250919154932_add_ai_catalog_references_to_flow_triggers.rb create mode 100644 db/schema_migrations/20250919154932 create mode 100644 ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_flows_for_triggers.query.graphql create mode 100644 ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_item_versions.query.graphql diff --git a/db/migrate/20250919154932_add_ai_catalog_references_to_flow_triggers.rb b/db/migrate/20250919154932_add_ai_catalog_references_to_flow_triggers.rb new file mode 100644 index 00000000000000..d3883135568601 --- /dev/null +++ b/db/migrate/20250919154932_add_ai_catalog_references_to_flow_triggers.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +# See https://docs.gitlab.com/ee/development/migration_style_guide.html +# for more information on how to write migrations for GitLab. + +class AddAiCatalogReferencesToFlowTriggers < Gitlab::Database::Migration[2.3] + disable_ddl_transaction! + + milestone '18.5' + + def up + add_column :ai_flow_triggers, :ai_catalog_item_id, :bigint + add_column :ai_flow_triggers, :ai_catalog_item_version_id, :bigint + + add_concurrent_index :ai_flow_triggers, :ai_catalog_item_id + add_concurrent_index :ai_flow_triggers, :ai_catalog_item_version_id + + add_concurrent_foreign_key :ai_flow_triggers, :ai_catalog_items, column: :ai_catalog_item_id, on_delete: :cascade + add_concurrent_foreign_key :ai_flow_triggers, :ai_catalog_item_versions, column: :ai_catalog_item_version_id, + on_delete: :cascade + end + + def down + remove_concurrent_foreign_key :ai_flow_triggers, :ai_catalog_items, column: :ai_catalog_item_id + remove_concurrent_foreign_key :ai_flow_triggers, :ai_catalog_item_versions, column: :ai_catalog_item_version_id + + remove_concurrent_index_by_name :ai_flow_triggers, 'index_ai_flow_triggers_on_ai_catalog_item_id' + remove_concurrent_index_by_name :ai_flow_triggers, 'index_ai_flow_triggers_on_ai_catalog_item_version_id' + + remove_column :ai_flow_triggers, :ai_catalog_item_id + remove_column :ai_flow_triggers, :ai_catalog_item_version_id + end +end diff --git a/db/schema_migrations/20250919154932 b/db/schema_migrations/20250919154932 new file mode 100644 index 00000000000000..9652ea270682b4 --- /dev/null +++ b/db/schema_migrations/20250919154932 @@ -0,0 +1 @@ +14889d08ac64debe0a4dee3d0d8a2a541227a91ca19ac86bbebcb7db0acb48d7 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index b86d63cff2aac0..2d08ff4f4de14a 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9553,6 +9553,8 @@ CREATE TABLE ai_flow_triggers ( event_types smallint[] DEFAULT '{}'::smallint[] NOT NULL, created_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL, + ai_catalog_item_id bigint, + ai_catalog_item_version_id bigint, CONSTRAINT check_87b77d9d54 CHECK ((char_length(description) <= 255)), CONSTRAINT check_f3a5b0bd6e CHECK ((char_length(config_path) <= 255)) ); @@ -37165,6 +37167,10 @@ CREATE INDEX index_ai_feature_settings_on_ai_self_hosted_model_id ON ai_feature_ CREATE UNIQUE INDEX index_ai_feature_settings_on_feature ON ai_feature_settings USING btree (feature); +CREATE INDEX index_ai_flow_triggers_on_ai_catalog_item_id ON ai_flow_triggers USING btree (ai_catalog_item_id); + +CREATE INDEX index_ai_flow_triggers_on_ai_catalog_item_version_id ON ai_flow_triggers USING btree (ai_catalog_item_version_id); + CREATE INDEX index_ai_flow_triggers_on_project_id ON ai_flow_triggers USING btree (project_id); CREATE INDEX index_ai_flow_triggers_on_user_id ON ai_flow_triggers USING btree (user_id); @@ -48581,6 +48587,9 @@ ALTER TABLE ONLY container_repositories ALTER TABLE ONLY ai_agents ADD CONSTRAINT fk_rails_3328b05449 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; +ALTER TABLE ONLY ai_flow_triggers + ADD CONSTRAINT fk_rails_337106921f FOREIGN KEY (ai_catalog_item_id) REFERENCES ai_catalog_items(id) ON DELETE CASCADE; + ALTER TABLE ONLY alert_management_alert_metric_images ADD CONSTRAINT fk_rails_338e55b408 FOREIGN KEY (alert_id) REFERENCES alert_management_alerts(id) ON DELETE CASCADE; @@ -49184,6 +49193,9 @@ ALTER TABLE ONLY packages_debian_group_distribution_keys ALTER TABLE ONLY group_scim_identities ADD CONSTRAINT fk_rails_77cb698c8d FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE; +ALTER TABLE ONLY ai_flow_triggers + ADD CONSTRAINT fk_rails_7848f871e5 FOREIGN KEY (ai_catalog_item_version_id) REFERENCES ai_catalog_item_versions(id) ON DELETE CASCADE; + ALTER TABLE ONLY terraform_states ADD CONSTRAINT fk_rails_78f54ca485 FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE; diff --git a/doc/api/graphql/reference/_index.md b/doc/api/graphql/reference/_index.md index 2a776e60b2ab1d..aca8bcab23b1a0 100644 --- a/doc/api/graphql/reference/_index.md +++ b/doc/api/graphql/reference/_index.md @@ -2859,6 +2859,8 @@ Input type: `AiFlowTriggerCreateInput` | Name | Type | Description | | ---- | ---- | ----------- | +| `aiCatalogItemId` | [`AiCatalogItemID`](#aicatalogitemid) | AI catalog item to use instead of config_path. | +| `aiCatalogItemVersionId` | [`AiCatalogItemVersionID`](#aicatalogitemversionid) | Specific version of the AI catalog item to use. | | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | `configPath` | [`String`](#string) | Path to the configuration file for the AI flow trigger. | | `description` | [`String!`](#string) | Description of the AI flow trigger. | @@ -2910,6 +2912,8 @@ Input type: `AiFlowTriggerUpdateInput` | Name | Type | Description | | ---- | ---- | ----------- | +| `aiCatalogItemId` | [`AiCatalogItemID`](#aicatalogitemid) | AI catalog item to use instead of config_path. | +| `aiCatalogItemVersionId` | [`AiCatalogItemVersionID`](#aicatalogitemversionid) | Specific version of the AI catalog item to use. | | `clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | | `configPath` | [`String`](#string) | Path to the configuration file for the AI flow trigger. | | `description` | [`String`](#string) | Description of the AI flow trigger. | @@ -23450,6 +23454,8 @@ Represents an AI flow trigger. | Name | Type | Description | | ---- | ---- | ----------- | +| `aiCatalogItem` | [`AiCatalogItem`](#aicatalogitem) | AI catalog item associated with the trigger. | +| `aiCatalogItemVersion` | [`AiCatalogItemVersion`](#aicatalogitemversion) | Specific version of the AI catalog item. | | `configPath` | [`String`](#string) | Path to the configuration file for the trigger. | | `configUrl` | [`String`](#string) | Web URL to the configuration file for the trigger. | | `createdAt` | [`Time!`](#time) | Timestamp of when the flow trigger was created. | diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_flows_for_triggers.query.graphql b/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_flows_for_triggers.query.graphql new file mode 100644 index 00000000000000..86666e623087ea --- /dev/null +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_flows_for_triggers.query.graphql @@ -0,0 +1,13 @@ +#import "ee/ai/catalog/graphql/fragments/ai_catalog_item.fragment.graphql" + +query getAiCatalogFlowsForTriggers($first: Int = 50) { + aiCatalogItems(itemType: FLOW, first: $first) { + nodes { + ...BaseAiCatalogItem + project { + id + nameWithNamespace + } + } + } +} \ No newline at end of file diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_item_versions.query.graphql b/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_item_versions.query.graphql new file mode 100644 index 00000000000000..73c6a98f8e5bf9 --- /dev/null +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/graphql/queries/get_ai_catalog_item_versions.query.graphql @@ -0,0 +1,16 @@ +#import "ee/ai/catalog/graphql/fragments/ai_catalog_item_version.fragment.graphql" + +query getAiCatalogItemVersions($id: AiCatalogItemID!, $first: Int = 50) { + aiCatalogItem(id: $id) { + id + name + latestVersion { + ...BaseAiCatalogItemVersion + } + versions(first: $first) { + nodes { + ...BaseAiCatalogItemVersion + } + } + } +} \ No newline at end of file diff --git a/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form.vue b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form.vue index e59b45f0e461b6..e6a1308c0908b4 100644 --- a/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form.vue +++ b/ee/app/assets/javascripts/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form.vue @@ -6,12 +6,16 @@ import { GlForm, GlFormGroup, GlFormInput, + GlFormRadio, + GlFormRadioGroup, GlFormTextarea, } from '@gitlab/ui'; import { s__ } from '~/locale'; import { createAlert } from '~/alert'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import projectServiceAccountsQuery from '../../../graphql/queries/get_project_service_accounts.query.graphql'; +import getAiCatalogFlowsForTriggersQuery from '../../../graphql/queries/get_ai_catalog_flows_for_triggers.query.graphql'; +import getAiCatalogItemVersionsQuery from '../../../graphql/queries/get_ai_catalog_item_versions.query.graphql'; import { FLOW_TRIGGERS_INDEX_ROUTE } from '../../../router/constants'; import AiLegalDisclaimer from '../../../components/common/ai_legal_disclaimer.vue'; @@ -27,6 +31,8 @@ export default { GlForm, GlFormGroup, GlFormInput, + GlFormRadio, + GlFormRadioGroup, GlFormTextarea, UserSelect, AiLegalDisclaimer, @@ -57,6 +63,8 @@ export default { eventTypes: [], configPath: '', user: null, + aiCatalogItemId: null, + aiCatalogItemVersionId: null, }; }, }, @@ -75,6 +83,13 @@ export default { eventTypes: this.initialValues.eventTypes, configPath: this.initialValues.configPath, selectedUsers: this.initialValues.user ? [{ ...this.initialValues.user }] : [], + configMode: this.initialValues.aiCatalogItemId ? 'catalog' : 'manual', + aiCatalogItemId: this.initialValues.aiCatalogItemId, + aiCatalogItemVersionId: this.initialValues.aiCatalogItemVersionId, + catalogFlows: [], + catalogVersions: [], + loadingFlows: false, + loadingVersions: false, }; }, computed: { @@ -99,6 +114,57 @@ export default { ? this.selectedUsers[0].name : s__('DuoAgentsPlatform|Select user'); }, + configModeOptions() { + return [ + { + text: s__('DuoAgentsPlatform|Manual configuration'), + value: 'manual', + }, + { + text: s__('DuoAgentsPlatform|AI Catalog flow'), + value: 'catalog', + }, + ]; + }, + catalogFlowOptions() { + return this.catalogFlows.map(flow => ({ + text: `${flow.name} (${flow.project?.nameWithNamespace})`, + value: flow.id, + })); + }, + catalogVersionOptions() { + if (!this.catalogVersions.id) return []; + + const options = []; + + // Add latest version option first if it exists + if (this.catalogVersions.latestVersion) { + options.push({ + text: `Latest (${this.catalogVersions.latestVersion.humanVersionName || this.catalogVersions.latestVersion.versionName})`, + value: this.catalogVersions.latestVersion.id, + }); + } + + // Add other versions + this.catalogVersions.versions?.nodes?.forEach(version => { + if (version.id !== this.catalogVersions.latestVersion?.id) { + options.push({ + text: version.humanVersionName || version.versionName, + value: version.id, + }); + } + }); + + return options; + }, + selectedCatalogItemText() { + const selectedItem = this.catalogFlowOptions.find(item => item.value === this.aiCatalogItemId); + return selectedItem?.text || s__('DuoAgentsPlatform|Select a flow'); + }, + selectedCatalogVersionText() { + const selectedVersion = this.catalogVersionOptions.find(version => version.value === this.aiCatalogItemVersionId); + return selectedVersion?.text || s__('DuoAgentsPlatform|Select version'); + }, }, watch: { async errorMessages(newValue) { @@ -111,6 +177,22 @@ export default { behavior: 'smooth', }); }, + configMode(newMode) { + if (newMode === 'catalog' && this.catalogFlows.length === 0) { + this.fetchCatalogFlows(); + } + }, + }, + async mounted() { + // If we're in catalog mode or editing an existing trigger with catalog item, fetch the data + if (this.configMode === 'catalog') { + await this.fetchCatalogFlows(); + + // If we already have a selected catalog item, fetch its versions + if (this.aiCatalogItemId) { + await this.fetchCatalogItemVersions(this.aiCatalogItemId); + } + } }, methods: { setEventType(eventTypesValue) { @@ -124,19 +206,81 @@ export default { }, onSubmit() { const formValues = { - configPath: this.configPath.trim(), description: this.description.trim(), eventTypes: this.eventTypes, userId: this.selectedUsers.length > 0 ? this.selectedUsers[0].id : null, }; + + if (this.configMode === 'catalog') { + formValues.aiCatalogItemId = this.aiCatalogItemId; + formValues.aiCatalogItemVersionId = this.aiCatalogItemVersionId; + formValues.configPath = null; + } else { + formValues.configPath = this.configPath.trim(); + formValues.aiCatalogItemId = null; + formValues.aiCatalogItemVersionId = null; + } + this.$emit('submit', formValues); }, usersProcessor(data) { return data.project?.projectMembers?.nodes?.map(({ user }) => user) || []; }, + onCatalogItemSelect(itemId) { + this.aiCatalogItemId = itemId; + // Reset version selection when item changes + this.aiCatalogItemVersionId = null; + // Fetch versions for the selected item + this.fetchCatalogItemVersions(itemId); + }, + onCatalogVersionSelect(versionId) { + this.aiCatalogItemVersionId = versionId; + }, + async fetchCatalogFlows() { + this.loadingFlows = true; + try { + const { data } = await this.$apollo.query({ + query: getAiCatalogFlowsForTriggersQuery, + fetchPolicy: 'cache-first', + }); + + this.catalogFlows = data.aiCatalogItems?.nodes || []; + } catch (error) { + console.error('Error fetching catalog flows:', error); + createAlert({ + message: `${s__('DuoAgentsPlatform|An error occurred while fetching catalog flows.')}: ${error.message}` + }); + } finally { + this.loadingFlows = false; + } + }, + async fetchCatalogItemVersions(itemId) { + if (!itemId) return; + + this.loadingVersions = true; + this.catalogVersions = []; + + try { + const { data } = await this.$apollo.query({ + query: getAiCatalogItemVersionsQuery, + variables: { id: itemId }, + fetchPolicy: 'cache-first', + }); + + this.catalogVersions = data.aiCatalogItem || {}; + } catch (error) { + createAlert({ + message: s__('DuoAgentsPlatform|An error occurred while fetching catalog item versions.') + }); + } finally { + this.loadingVersions = false; + } + }, }, indexRoute: FLOW_TRIGGERS_INDEX_ROUTE, projectServiceAccountsQuery, + getAiCatalogFlowsForTriggersQuery, + getAiCatalogItemVersionsQuery, }; @@ -211,7 +355,15 @@ export default { /> - + + + + + + +
(global_id, _ctx) { global_id.model_id.to_i }, + required: false, + description: 'AI catalog item to use instead of config_path.' + + argument :ai_catalog_item_version_id, ::Types::GlobalIDType[::Ai::Catalog::ItemVersion], + prepare: ->(global_id, _ctx) { global_id.model_id.to_i }, + required: false, + description: 'Specific version of the AI catalog item to use.' + field :ai_flow_trigger, Types::Ai::FlowTriggerType, description: 'Created AI flow trigger.' diff --git a/ee/app/graphql/mutations/ai/flow_triggers/update.rb b/ee/app/graphql/mutations/ai/flow_triggers/update.rb index 77400a6a3ae714..99f7201ceda07d 100644 --- a/ee/app/graphql/mutations/ai/flow_triggers/update.rb +++ b/ee/app/graphql/mutations/ai/flow_triggers/update.rb @@ -29,6 +29,16 @@ class Update < BaseMutation required: false, description: 'Path to the configuration file for the AI flow trigger.' + argument :ai_catalog_item_id, ::Types::GlobalIDType[::Ai::Catalog::Item], + prepare: ->(global_id, _ctx) { global_id.model_id.to_i }, + required: false, + description: 'AI catalog item to use instead of config_path.' + + argument :ai_catalog_item_version_id, ::Types::GlobalIDType[::Ai::Catalog::ItemVersion], + prepare: ->(global_id, _ctx) { global_id.model_id.to_i }, + required: false, + description: 'Specific version of the AI catalog item to use.' + field :ai_flow_trigger, Types::Ai::FlowTriggerType, description: 'Updated AI flow trigger.' diff --git a/ee/app/graphql/types/ai/flow_trigger_type.rb b/ee/app/graphql/types/ai/flow_trigger_type.rb index c84dc6bfbdfcbd..9d37fa052d6b33 100644 --- a/ee/app/graphql/types/ai/flow_trigger_type.rb +++ b/ee/app/graphql/types/ai/flow_trigger_type.rb @@ -31,6 +31,14 @@ class FlowTriggerType < BaseObject null: true, description: 'Web URL to the configuration file for the trigger.', calls_gitaly: true + + field :ai_catalog_item, Types::Ai::Catalog::ItemInterface, + null: true, + description: 'AI catalog item associated with the trigger.' + + field :ai_catalog_item_version, Types::Ai::Catalog::VersionInterface, + null: true, + description: 'Specific version of the AI catalog item.' # rubocop:enable GraphQL/ExtractType field :project, ::Types::ProjectType, diff --git a/ee/app/models/ai/catalog/item.rb b/ee/app/models/ai/catalog/item.rb index a676431376f1c1..0f84286797a4a3 100644 --- a/ee/app/models/ai/catalog/item.rb +++ b/ee/app/models/ai/catalog/item.rb @@ -24,6 +24,8 @@ class Item < ApplicationRecord has_many :versions, class_name: 'Ai::Catalog::ItemVersion', foreign_key: :ai_catalog_item_id, inverse_of: :item has_many :consumers, class_name: 'Ai::Catalog::ItemConsumer', foreign_key: :ai_catalog_item_id, inverse_of: :item + has_many :flow_triggers, class_name: 'Ai::FlowTrigger', foreign_key: :ai_catalog_item_id, + inverse_of: :ai_catalog_item has_many( :dependents, diff --git a/ee/app/models/ai/flow_trigger.rb b/ee/app/models/ai/flow_trigger.rb index 1f283229c69982..4b356e0e27e516 100644 --- a/ee/app/models/ai/flow_trigger.rb +++ b/ee/app/models/ai/flow_trigger.rb @@ -11,6 +11,8 @@ class FlowTrigger < ApplicationRecord belongs_to :project belongs_to :user + belongs_to :ai_catalog_item, class_name: 'Ai::Catalog::Item', optional: true + belongs_to :ai_catalog_item_version, class_name: 'Ai::Catalog::ItemVersion', optional: true scope :triggered_on, ->(event_type) { where("event_types @> ('{?}')", EVENT_TYPES[event_type]) } scope :by_users, ->(users) { where(user: users) } @@ -20,10 +22,13 @@ class FlowTrigger < ApplicationRecord validates :event_types, presence: true validates :description, length: { maximum: 255 }, presence: true - validates :config_path, length: { maximum: 255 }, presence: true + validates :config_path, length: { maximum: 255 }, presence: true, unless: :ai_catalog_item_id? + validates :ai_catalog_item, presence: true, unless: :config_path? validate :event_types_are_valid validate :user_is_service_account, if: :user + validate :catalog_item_version_matches_item, if: :ai_catalog_item_version_id? + validate :exactly_one_config_source scope :with_ids, ->(ids) { where(id: ids) } @@ -43,5 +48,24 @@ def user_is_service_account errors.add(:user, 'user must be a service account') end + + def catalog_item_version_matches_item + return unless ai_catalog_item_id && ai_catalog_item_version_id + + return if ai_catalog_item_version.ai_catalog_item_id == ai_catalog_item_id + + errors.add(:ai_catalog_item_version, 'must belong to the selected catalog item') + end + + def exactly_one_config_source + has_config_path = config_path.present? + has_catalog_item = ai_catalog_item_id.present? + + if has_config_path && has_catalog_item + errors.add(:base, 'cannot have both config_path and ai_catalog_item') + elsif !has_config_path && !has_catalog_item + errors.add(:base, 'must have either config_path or ai_catalog_item') + end + end end end diff --git a/ee/app/services/ai/flow_triggers/create_service.rb b/ee/app/services/ai/flow_triggers/create_service.rb index f0061eee00291e..1c445cfbbf49f2 100644 --- a/ee/app/services/ai/flow_triggers/create_service.rb +++ b/ee/app/services/ai/flow_triggers/create_service.rb @@ -10,9 +10,37 @@ def initialize(project:, current_user:) def execute(params) super do - project.ai_flow_triggers.create(params) + # Handle catalog item parameters + processed_params = process_catalog_params(params) + project.ai_flow_triggers.create(processed_params) end end + + private + + def process_catalog_params(params) + processed = params.dup + + # If ai_catalog_item_id is provided, ensure the item exists and is a flow type + if processed[:ai_catalog_item_id].present? + catalog_item = find_catalog_item(processed[:ai_catalog_item_id]) + + raise ArgumentError, 'Selected catalog item must be a flow type' unless catalog_item&.flow? + + # If no specific version is provided, use the latest version + if processed[:ai_catalog_item_version_id].blank? + processed[:ai_catalog_item_version_id] = catalog_item.latest_version_id + end + end + + processed + end + + def find_catalog_item(item_id) + # rubocop:disable CodeReuse/ActiveRecord, Gitlab/FinderWithFindBy -- ItemsFinder doesn't include FinderMethods + ::Ai::Catalog::ItemsFinder.new(current_user).execute.find_by(id: item_id) + # rubocop:enable CodeReuse/ActiveRecord, Gitlab/FinderWithFindBy + end end end end diff --git a/ee/app/services/ai/flow_triggers/update_service.rb b/ee/app/services/ai/flow_triggers/update_service.rb index 28a4a3413c9369..cac17f78c4a3f2 100644 --- a/ee/app/services/ai/flow_triggers/update_service.rb +++ b/ee/app/services/ai/flow_triggers/update_service.rb @@ -11,10 +11,38 @@ def initialize(project:, current_user:, trigger:) def execute(params) super do - @trigger.update(params) + # Handle catalog item parameters + processed_params = process_catalog_params(params) + @trigger.update(processed_params) @trigger end end + + private + + def process_catalog_params(params) + processed = params.dup + + # If ai_catalog_item_id is provided, ensure the item exists and is a flow type + if processed[:ai_catalog_item_id].present? + catalog_item = find_catalog_item(processed[:ai_catalog_item_id]) + + raise ArgumentError, 'Selected catalog item must be a flow type' unless catalog_item&.flow? + + # If no specific version is provided, use the latest version + if processed[:ai_catalog_item_version_id].blank? + processed[:ai_catalog_item_version_id] = catalog_item.latest_version_id + end + end + + processed + end + + def find_catalog_item(item_id) + # rubocop:disable CodeReuse/ActiveRecord, Gitlab/FinderWithFindBy -- ItemsFinder doesn't include FinderMethods + ::Ai::Catalog::ItemsFinder.new(current_user).execute.find_by(id: item_id) + # rubocop:enable CodeReuse/ActiveRecord, Gitlab/FinderWithFindBy + end end end end diff --git a/ee/spec/frontend/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form_spec.js b/ee/spec/frontend/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form_spec.js index 4271f775dcf81f..11cf395f9bfe49 100644 --- a/ee/spec/frontend/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form_spec.js +++ b/ee/spec/frontend/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form_spec.js @@ -1,117 +1,272 @@ +import { GlAlert, GlButton, GlCollapsibleListbox, GlFormRadioGroup } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import { GlAlert, GlForm, GlFormInput, GlFormTextarea, GlCollapsibleListbox } from '@gitlab/ui'; import createMockApollo from 'helpers/mock_apollo_helper'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; +import waitForPromises from 'helpers/wait_for_promises'; + import FlowTriggerForm from 'ee/ai/duo_agents_platform/pages/flow_triggers/components/flow_trigger_form.vue'; -import { mockTrigger, eventTypeOptions } from '../mocks'; +import getAiCatalogFlowsForTriggersQuery from 'ee/ai/duo_agents_platform/graphql/queries/get_ai_catalog_flows_for_triggers.query.graphql'; +import getAiCatalogItemVersionsQuery from 'ee/ai/duo_agents_platform/graphql/queries/get_ai_catalog_item_versions.query.graphql'; Vue.use(VueApollo); describe('FlowTriggerForm', () => { let wrapper; - - const findErrorAlert = () => wrapper.findComponent(GlAlert); - const findForm = () => wrapper.findComponent(GlForm); - const findDescription = () => wrapper.findComponent(GlFormTextarea); - const findConfigPath = () => wrapper.findComponent(GlFormInput); - const findEventTypes = () => wrapper.findComponent(GlCollapsibleListbox); - const findUserSelect = () => wrapper.findComponent(UserSelect); + let mockApollo; const defaultProps = { mode: 'create', - isLoading: false, errorMessages: [], - eventTypeOptions: [], - projectPath: 'myProject', + eventTypeOptions: [ + { text: 'Mention', value: 0 }, + { text: 'Assign', value: 1 }, + ], + initialValues: { + description: '', + eventTypes: [], + configPath: '', + user: null, + aiCatalogItemId: null, + aiCatalogItemVersionId: null, + }, + isLoading: false, + projectPath: 'group/project', + }; + + const mockCatalogFlowsData = { + aiCatalogItems: { + nodes: [ + { + id: 'gid://gitlab/Ai::Catalog::Item/1', + name: 'Test Flow 1', + project: { nameWithNamespace: 'Group/Project1' }, + }, + { + id: 'gid://gitlab/Ai::Catalog::Item/2', + name: 'Test Flow 2', + project: { nameWithNamespace: 'Group/Project2' }, + }, + ], + }, }; - const createWrapper = () => { - wrapper = shallowMountExtended(FlowTriggerForm, { - apolloProvider: createMockApollo(), - propsData: defaultProps, - stubs: { - UserSelect, + const mockCatalogVersionsData = { + aiCatalogItem: { + id: 'gid://gitlab/Ai::Catalog::Item/1', + name: 'Test Flow 1', + latestVersion: { + id: 'gid://gitlab/Ai::Catalog::ItemVersion/1', + humanVersionName: 'v1.2.0', + versionName: '1.2.0', + }, + versions: { + nodes: [ + { + id: 'gid://gitlab/Ai::Catalog::ItemVersion/1', + humanVersionName: 'v1.2.0', + versionName: '1.2.0', + }, + { + id: 'gid://gitlab/Ai::Catalog::ItemVersion/2', + humanVersionName: 'v1.1.0', + versionName: '1.1.0', + }, + ], + }, + }, + }; + + const createMockApolloProvider = ({ + catalogFlowsResolver = jest.fn().mockResolvedValue({ data: mockCatalogFlowsData }), + catalogVersionsResolver = jest.fn().mockResolvedValue({ data: mockCatalogVersionsData }), + } = {}) => { + return createMockApollo([ + [getAiCatalogFlowsForTriggersQuery, catalogFlowsResolver], + [getAiCatalogItemVersionsQuery, catalogVersionsResolver], + ]); + }; + + const createComponent = (props = {}, apolloOptions = {}) => { + mockApollo = createMockApolloProvider(apolloOptions); + + wrapper = shallowMount(FlowTriggerForm, { + apolloProvider: mockApollo, + propsData: { + ...defaultProps, + ...props, }, }); }; - describe('Rendering', () => { - it('does not render error alert', () => { - createWrapper(); + const findAlert = () => wrapper.findComponent(GlAlert); + const findConfigModeRadioGroup = () => wrapper.findComponent(GlFormRadioGroup); + const findCatalogFlowListbox = () => wrapper.findAllComponents(GlCollapsibleListbox).at(1); + const findCatalogVersionListbox = () => wrapper.findAllComponents(GlCollapsibleListbox).at(2); - expect(findErrorAlert().exists()).toBe(false); + describe('configuration mode selection', () => { + beforeEach(() => { + createComponent(); }); - }); - describe('with error messages', () => { - const mockErrorMessage = 'The flow could not be created'; + it('renders configuration mode radio group', () => { + const radioGroup = findConfigModeRadioGroup(); + expect(radioGroup.exists()).toBe(true); + expect(radioGroup.props('options')).toEqual([ + { text: 'Manual configuration', value: 'manual' }, + { text: 'AI Catalog flow', value: 'catalog' }, + ]); + }); + + it('defaults to manual mode when no catalog item is selected', () => { + expect(wrapper.vm.configMode).toBe('manual'); + }); - beforeEach(async () => { - window.scrollTo = jest.fn(); - createWrapper(); - await wrapper.setProps({ - errorMessages: [mockErrorMessage], + it('defaults to catalog mode when catalog item is provided in initial values', () => { + createComponent({ + initialValues: { + ...defaultProps.initialValues, + aiCatalogItemId: 'gid://gitlab/Ai::Catalog::Item/1', + }, }); + + expect(wrapper.vm.configMode).toBe('catalog'); + }); + }); + + describe('catalog configuration mode', () => { + let catalogFlowsResolver; + let catalogVersionsResolver; + + beforeEach(() => { + catalogFlowsResolver = jest.fn().mockResolvedValue({ data: mockCatalogFlowsData }); + catalogVersionsResolver = jest.fn().mockResolvedValue({ data: mockCatalogVersionsData }); + + createComponent( + { + initialValues: { + ...defaultProps.initialValues, + aiCatalogItemId: 'gid://gitlab/Ai::Catalog::Item/1', + }, + }, + { catalogFlowsResolver, catalogVersionsResolver } + ); + }); + + it('fetches catalog flows on mount when in catalog mode', async () => { + await waitForPromises(); + expect(catalogFlowsResolver).toHaveBeenCalled(); }); - it('renders error alert', () => { - expect(findErrorAlert().find('li').text()).toBe(mockErrorMessage); + it('displays catalog flow options correctly', async () => { + await waitForPromises(); + + const catalogFlowOptions = wrapper.vm.catalogFlowOptions; + expect(catalogFlowOptions).toEqual([ + { text: 'Test Flow 1 (Group/Project1)', value: 'gid://gitlab/Ai::Catalog::Item/1' }, + { text: 'Test Flow 2 (Group/Project2)', value: 'gid://gitlab/Ai::Catalog::Item/2' }, + ]); }); - it('scrolls to the top', () => { - expect(window.scrollTo).toHaveBeenCalledWith({ - top: 0, - left: 0, - behavior: 'smooth', + it('fetches versions when a catalog item is selected', async () => { + await wrapper.setData({ configMode: 'catalog' }); + await wrapper.vm.onCatalogItemSelect('gid://gitlab/Ai::Catalog::Item/1'); + await waitForPromises(); + + expect(catalogVersionsResolver).toHaveBeenCalledWith({ + id: 'gid://gitlab/Ai::Catalog::Item/1', }); }); - it('customSearchUsersProcessor handles project service account response mapping', () => { - const user1 = { id: 1, name: 'a' }; - const user2 = { id: 2, name: 'b' }; - const data = { project: { projectMembers: { nodes: [{ user: user1 }, { user: user2 }] } } }; + it('resets version selection when catalog item changes', async () => { + await wrapper.setData({ + aiCatalogItemId: 'gid://gitlab/Ai::Catalog::Item/1', + aiCatalogItemVersionId: 'gid://gitlab/Ai::Catalog::ItemVersion/1', + }); + + await wrapper.vm.onCatalogItemSelect('gid://gitlab/Ai::Catalog::Item/2'); + + expect(wrapper.vm.aiCatalogItemId).toBe('gid://gitlab/Ai::Catalog::Item/2'); + expect(wrapper.vm.aiCatalogItemVersionId).toBeNull(); + }); + }); - expect(findUserSelect().props('customSearchUsersProcessor')(data)).toContain(user1, user2); + describe('form submission', () => { + beforeEach(() => { + createComponent(); }); - it('renders error alert with list for multiple errors', async () => { - await wrapper.setProps({ - errorMessages: ['error1', 'error2'], + it('emits correct data for manual configuration', async () => { + await wrapper.setData({ + configMode: 'manual', + description: 'Test description', + eventTypes: [0, 1], + configPath: 'path/to/config.yml', + selectedUsers: [{ id: 123, name: 'Test User' }], }); - expect(findErrorAlert().findAll('li')).toHaveLength(2); + await wrapper.vm.onSubmit(); + + expect(wrapper.emitted('submit')[0][0]).toEqual({ + description: 'Test description', + eventTypes: [0, 1], + userId: 123, + configPath: 'path/to/config.yml', + aiCatalogItemId: null, + aiCatalogItemVersionId: null, + }); }); - it('emits dismiss-errors event', () => { - findErrorAlert().vm.$emit('dismiss'); + it('emits correct data for catalog configuration', async () => { + await wrapper.setData({ + configMode: 'catalog', + description: 'Test description', + eventTypes: [0], + aiCatalogItemId: 'gid://gitlab/Ai::Catalog::Item/1', + aiCatalogItemVersionId: 'gid://gitlab/Ai::Catalog::ItemVersion/1', + selectedUsers: [{ id: 123, name: 'Test User' }], + }); + + await wrapper.vm.onSubmit(); - expect(wrapper.emitted('dismiss-errors')).toHaveLength(1); + expect(wrapper.emitted('submit')[0][0]).toEqual({ + description: 'Test description', + eventTypes: [0], + userId: 123, + configPath: null, + aiCatalogItemId: 'gid://gitlab/Ai::Catalog::Item/1', + aiCatalogItemVersionId: 'gid://gitlab/Ai::Catalog::ItemVersion/1', + }); }); }); - describe('Form Submit', () => { - beforeEach(() => { - createWrapper(); + describe('error handling', () => { + it('displays error messages', () => { + createComponent({ + errorMessages: ['Error 1', 'Error 2'], + }); + + const alert = findAlert(); + expect(alert.exists()).toBe(true); + expect(alert.props('variant')).toBe('danger'); }); - describe('when using the submit button', () => { - it('submits the form', async () => { - const description = 'My description'; - const configPath = 'my/config/path'; - const eventTypes = [eventTypeOptions[0].value]; - await findDescription().vm.$emit('input', description); - await findConfigPath().vm.$emit('input', configPath); - await findEventTypes().vm.$emit('select', eventTypes); - await findUserSelect().vm.$emit('input', [mockTrigger.user]); - - findForm().vm.$emit('submit', { preventDefault: () => {} }); - - expect(wrapper.emitted('submit')).toEqual([ - [{ configPath, description, eventTypes, userId: 'gid://gitlab/User/1' }], - ]); - }); + it('handles catalog flows fetch error gracefully', async () => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + const catalogFlowsResolver = jest.fn().mockRejectedValue(new Error('Network error')); + + createComponent( + { + initialValues: { + ...defaultProps.initialValues, + aiCatalogItemId: 'gid://gitlab/Ai::Catalog::Item/1', + }, + }, + { catalogFlowsResolver } + ); + + await waitForPromises(); + expect(catalogFlowsResolver).toHaveBeenCalled(); }); }); -}); +}); \ No newline at end of file diff --git a/ee/spec/graphql/mutations/ai/flow_triggers/create_spec.rb b/ee/spec/graphql/mutations/ai/flow_triggers/create_spec.rb index fbf8d8ed403a28..667c473b0bcf51 100644 --- a/ee/spec/graphql/mutations/ai/flow_triggers/create_spec.rb +++ b/ee/spec/graphql/mutations/ai/flow_triggers/create_spec.rb @@ -20,6 +20,8 @@ :description, :event_types, :config_path, + :ai_catalog_item_id, + :ai_catalog_item_version_id, :client_mutation_id ) end diff --git a/ee/spec/graphql/mutations/ai/flow_triggers/update_spec.rb b/ee/spec/graphql/mutations/ai/flow_triggers/update_spec.rb index 0c7bb51beb0132..63ab5eeff05bcd 100644 --- a/ee/spec/graphql/mutations/ai/flow_triggers/update_spec.rb +++ b/ee/spec/graphql/mutations/ai/flow_triggers/update_spec.rb @@ -20,6 +20,8 @@ :description, :event_types, :config_path, + :ai_catalog_item_id, + :ai_catalog_item_version_id, :client_mutation_id ) end diff --git a/ee/spec/graphql/types/ai/flow_trigger_type_spec.rb b/ee/spec/graphql/types/ai/flow_trigger_type_spec.rb index ee6a4c231566d7..98e35abe39c1fa 100644 --- a/ee/spec/graphql/types/ai/flow_trigger_type_spec.rb +++ b/ee/spec/graphql/types/ai/flow_trigger_type_spec.rb @@ -16,6 +16,8 @@ config_url project user + ai_catalog_item + ai_catalog_item_version created_at updated_at ] diff --git a/ee/spec/models/ai/catalog/item_spec.rb b/ee/spec/models/ai/catalog/item_spec.rb index 8b05bed10df6a4..ef1a4c298cb5ae 100644 --- a/ee/spec/models/ai/catalog/item_spec.rb +++ b/ee/spec/models/ai/catalog/item_spec.rb @@ -11,6 +11,7 @@ it { is_expected.to have_many(:versions) } it { is_expected.to have_many(:consumers) } it { is_expected.to have_many(:dependents) } + it { is_expected.to have_many(:flow_triggers).class_name('Ai::FlowTrigger') } end describe 'validations' do @@ -388,4 +389,50 @@ expect(item.latest_version).to eq(item.versions.last) end end + + describe '#flow_triggers association' do + let_it_be(:organization) { create(:organization) } + let_it_be(:project) { create(:project, organization: organization) } + let_it_be(:user) { create(:service_account) } + let_it_be(:catalog_item) { create(:ai_catalog_item, :flow, organization: organization) } + let_it_be(:catalog_version) { create(:ai_catalog_item_version, :for_flow, item: catalog_item) } + + before do + catalog_item.flow_triggers.delete_all + catalog_item.association(:flow_triggers).reload + end + + it 'associates flow triggers with the catalog item' do + flow_trigger = create(:ai_flow_trigger, + project: project, + user: user, + config_path: nil, + ai_catalog_item: catalog_item, + ai_catalog_item_version: catalog_version) + + catalog_item.association(:flow_triggers).reload + expect(catalog_item.flow_triggers).to include(flow_trigger) + end + + it 'allows multiple flow triggers for the same catalog item' do + project2 = create(:project, organization: organization) + + flow_trigger1 = create(:ai_flow_trigger, + project: project, + user: user, + config_path: nil, + ai_catalog_item: catalog_item, + ai_catalog_item_version: catalog_version) + + flow_trigger2 = create(:ai_flow_trigger, + project: project2, + user: user, + config_path: nil, + ai_catalog_item: catalog_item, + ai_catalog_item_version: catalog_version) + + catalog_item.association(:flow_triggers).reload + expect(catalog_item.flow_triggers).to contain_exactly(flow_trigger1, flow_trigger2) + end + end end diff --git a/ee/spec/models/ai/flow_trigger_spec.rb b/ee/spec/models/ai/flow_trigger_spec.rb index 5394de47db9fd6..e16e83cf11c871 100644 --- a/ee/spec/models/ai/flow_trigger_spec.rb +++ b/ee/spec/models/ai/flow_trigger_spec.rb @@ -11,6 +11,8 @@ describe 'associations' do it { is_expected.to belong_to(:project) } it { is_expected.to belong_to(:user) } + it { is_expected.to belong_to(:ai_catalog_item).class_name('Ai::Catalog::Item').optional } + it { is_expected.to belong_to(:ai_catalog_item_version).class_name('Ai::Catalog::ItemVersion').optional } end describe 'validations' do @@ -18,7 +20,6 @@ it { is_expected.to validate_presence_of(:user) } it { is_expected.to validate_presence_of(:event_types) } it { is_expected.to validate_presence_of(:description) } - it { is_expected.to validate_presence_of(:config_path) } it { is_expected.to validate_length_of(:description).is_at_most(255) } it { is_expected.to validate_length_of(:config_path).is_at_most(255) } @@ -50,6 +51,73 @@ expect(flow_trigger.errors[:user]).to include('user must be a service account') end end + + describe 'configuration source validation' do + let_it_be(:organization) { create(:organization) } + let_it_be(:catalog_item) { create(:ai_catalog_item, :flow, organization: organization) } + let_it_be(:catalog_version) { create(:ai_catalog_item_version, :for_flow, item: catalog_item) } + + context 'when using config_path only' do + it 'is valid' do + flow_trigger = build(:ai_flow_trigger, config_path: 'path/to/config.yml') + expect(flow_trigger).to be_valid + end + end + + context 'when using ai_catalog_item only' do + it 'is valid' do + flow_trigger = build(:ai_flow_trigger, + config_path: nil, + ai_catalog_item: catalog_item, + ai_catalog_item_version: catalog_version) + expect(flow_trigger).to be_valid + end + end + + context 'when using both config_path and ai_catalog_item' do + it 'is invalid' do + flow_trigger = build(:ai_flow_trigger, + config_path: 'path/to/config.yml', + ai_catalog_item: catalog_item, + ai_catalog_item_version: catalog_version) + expect(flow_trigger).not_to be_valid + expect(flow_trigger.errors[:base]).to include('cannot have both config_path and ai_catalog_item') + end + end + + context 'when using neither config_path nor ai_catalog_item' do + it 'is invalid' do + flow_trigger = build(:ai_flow_trigger, config_path: nil) + expect(flow_trigger).not_to be_valid + expect(flow_trigger.errors[:base]).to include('must have either config_path or ai_catalog_item') + end + end + end + + describe 'catalog_item_version_matches_item validation' do + let_it_be(:organization) { create(:organization) } + let_it_be(:catalog_item1) { create(:ai_catalog_item, :flow, organization: organization) } + let_it_be(:catalog_item2) { create(:ai_catalog_item, :flow, organization: organization) } + let_it_be(:version1) { create(:ai_catalog_item_version, :for_flow, item: catalog_item1) } + let_it_be(:version2) { create(:ai_catalog_item_version, :for_flow, item: catalog_item2) } + + it 'is valid when version belongs to the selected item' do + flow_trigger = build(:ai_flow_trigger, + config_path: nil, + ai_catalog_item: catalog_item1, + ai_catalog_item_version: version1) + expect(flow_trigger).to be_valid + end + + it 'is invalid when version belongs to a different item' do + flow_trigger = build(:ai_flow_trigger, + config_path: nil, + ai_catalog_item: catalog_item1, + ai_catalog_item_version: version2) + expect(flow_trigger).not_to be_valid + expect(flow_trigger.errors[:ai_catalog_item_version]).to include('must belong to the selected catalog item') + end + end end describe 'database constraints' do @@ -57,14 +125,14 @@ expect(described_class.table_name).to eq('ai_flow_triggers') end - context 'with loose foreign key on users.id' do + context 'when using loose foreign key on users.id' do it_behaves_like 'cleanup by a loose foreign key' do let!(:model) { create(:ai_flow_trigger) } let!(:parent) { model.user } end end - context 'with loose foreign key on projects.id' do + context 'when using loose foreign key on projects.id' do it_behaves_like 'cleanup by a loose foreign key' do let!(:model) { create(:ai_flow_trigger) } let!(:parent) { model.project } diff --git a/ee/spec/services/ai/flow_triggers/create_service_spec.rb b/ee/spec/services/ai/flow_triggers/create_service_spec.rb index a95def74840be5..c197d07f2ea8a2 100644 --- a/ee/spec/services/ai/flow_triggers/create_service_spec.rb +++ b/ee/spec/services/ai/flow_triggers/create_service_spec.rb @@ -53,7 +53,7 @@ end end - context 'with invalid params' do + context 'when using invalid params' do let(:event_types) { [99] } it 'returns the error' do @@ -80,5 +80,69 @@ expect(response).not_to be_success end end + + context 'when using catalog item configuration' do + let_it_be(:organization) { create(:organization) } + let_it_be(:catalog_version) { create(:ai_catalog_item_version, :for_flow, item: catalog_item) } + let(:catalog_params) do + { + user_id: service_account.id, + event_types: event_types, + description: "catalog flow trigger", + ai_catalog_item_id: catalog_item.id, + ai_catalog_item_version_id: catalog_version.id + } + end + + let_it_be(:catalog_item) { create(:ai_catalog_item, :flow, organization: organization, public: true) } + + before do + human_user.update!(organization: organization) + stub_feature_flags(global_ai_catalog: true) + end + + it 'creates a flow trigger with catalog item' do + response = service.execute(catalog_params) + expect(response).to be_success + flow_trigger = response.payload + + expect(flow_trigger).to be_persisted + expect(flow_trigger.ai_catalog_item).to eq(catalog_item) + expect(flow_trigger.ai_catalog_item_version).to eq(catalog_version) + expect(flow_trigger.config_path).to be_nil + end + + it 'uses latest version when no version is specified' do + catalog_params_without_version = catalog_params.except(:ai_catalog_item_version_id) + + response = service.execute(catalog_params_without_version) + expect(response).to be_success + flow_trigger = response.payload + + expect(flow_trigger.ai_catalog_item_version).to eq(catalog_item.latest_version) + end + + context 'when catalog item is not a flow type' do + let_it_be(:agent_item) { create(:ai_catalog_item, :agent, organization: organization) } + + it 'raises an error' do + invalid_params = catalog_params.merge(ai_catalog_item_id: agent_item.id) + + expect { service.execute(invalid_params) }.to raise_error( + ArgumentError, 'Selected catalog item must be a flow type' + ) + end + end + + context 'when catalog item does not exist' do + it 'raises an error for non-existent item' do + invalid_params = catalog_params.merge(ai_catalog_item_id: 99999) + + expect { service.execute(invalid_params) }.to raise_error( + ArgumentError, 'Selected catalog item must be a flow type' + ) + end + end + end end end diff --git a/ee/spec/services/ai/flow_triggers/update_service_spec.rb b/ee/spec/services/ai/flow_triggers/update_service_spec.rb index ca0f7c721ce9bf..39cdfb35acd160 100644 --- a/ee/spec/services/ai/flow_triggers/update_service_spec.rb +++ b/ee/spec/services/ai/flow_triggers/update_service_spec.rb @@ -50,7 +50,7 @@ end end - context 'with invalid params' do + context 'when using invalid params' do let(:event_types) { [99] } it 'returns the error' do @@ -77,5 +77,110 @@ expect(response).not_to be_success end end + + context 'when updating catalog item configuration' do + let_it_be(:organization) { create(:organization) } + let_it_be(:catalog_item1) { create(:ai_catalog_item, :flow, organization: organization, public: true) } + let_it_be(:catalog_item2) { create(:ai_catalog_item, :flow, organization: organization, public: true) } + let_it_be(:catalog_version1) { create(:ai_catalog_item_version, :for_flow, item: catalog_item1) } + let_it_be(:catalog_version2) { create(:ai_catalog_item_version, :for_flow, item: catalog_item2) } + + before do + human_user.update!(organization: organization) + stub_feature_flags(global_ai_catalog: true) + end + + context 'when switching from config_path to catalog item' do + let(:trigger) do + create(:ai_flow_trigger, project: project, user: service_account, config_path: 'path/config.yml') + end + + it 'updates to use catalog item' do + catalog_params = { + user_id: service_account.id, + ai_catalog_item_id: catalog_item1.id, + ai_catalog_item_version_id: catalog_version1.id, + config_path: nil + } + + response = service.execute(catalog_params) + expect(response).to be_success + flow_trigger = response.payload + + expect(flow_trigger.ai_catalog_item).to eq(catalog_item1) + expect(flow_trigger.ai_catalog_item_version).to eq(catalog_version1) + expect(flow_trigger.config_path).to be_nil + end + end + + context 'when switching from catalog item to config_path' do + let(:trigger) do + create(:ai_flow_trigger, + project: project, + user: service_account, + config_path: nil, + ai_catalog_item: catalog_item1, + ai_catalog_item_version: catalog_version1) + end + + it 'updates to use config_path' do + config_params = { + user_id: service_account.id, + config_path: 'new/path/config.yml', + ai_catalog_item_id: nil, + ai_catalog_item_version_id: nil + } + + response = service.execute(config_params) + expect(response).to be_success + flow_trigger = response.payload + + expect(flow_trigger.config_path).to eq('new/path/config.yml') + expect(flow_trigger.ai_catalog_item).to be_nil + expect(flow_trigger.ai_catalog_item_version).to be_nil + end + end + + context 'when updating catalog item and version' do + let(:trigger) do + create(:ai_flow_trigger, + project: project, + user: service_account, + config_path: nil, + ai_catalog_item: catalog_item1, + ai_catalog_item_version: catalog_version1) + end + + it 'updates to different catalog item' do + update_params = { + user_id: service_account.id, + ai_catalog_item_id: catalog_item2.id, + ai_catalog_item_version_id: catalog_version2.id + } + + response = service.execute(update_params) + expect(response).to be_success + flow_trigger = response.payload + + expect(flow_trigger.ai_catalog_item).to eq(catalog_item2) + expect(flow_trigger.ai_catalog_item_version).to eq(catalog_version2) + end + + it 'uses latest version when no version specified' do + update_params = { + user_id: service_account.id, + ai_catalog_item_id: catalog_item2.id, + ai_catalog_item_version_id: nil + } + + response = service.execute(update_params) + expect(response).to be_success + flow_trigger = response.payload + + expect(flow_trigger.ai_catalog_item).to eq(catalog_item2) + expect(flow_trigger.ai_catalog_item_version).to eq(catalog_item2.latest_version) + end + end + end end end diff --git a/locale/gitlab.pot b/locale/gitlab.pot index a0c2a978c59e12..c567deafbd6883 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -24518,6 +24518,12 @@ msgstr "" msgid "DuoAgenticChat|GitLab Duo Agentic Chat" msgstr "" +msgid "DuoAgentsPlatform|AI Catalog Flow" +msgstr "" + +msgid "DuoAgentsPlatform|AI Catalog flow" +msgstr "" + msgid "DuoAgentsPlatform|Action" msgstr "" @@ -24530,6 +24536,12 @@ msgstr "" msgid "DuoAgentsPlatform|Agent sessions" msgstr "" +msgid "DuoAgentsPlatform|An error occurred while fetching catalog flows." +msgstr "" + +msgid "DuoAgentsPlatform|An error occurred while fetching catalog item versions." +msgstr "" + msgid "DuoAgentsPlatform|An error occurred while fetching users." msgstr "" @@ -24548,6 +24560,9 @@ msgstr "" msgid "DuoAgentsPlatform|Config path" msgstr "" +msgid "DuoAgentsPlatform|Configuration source" +msgstr "" + msgid "DuoAgentsPlatform|Convert Jenkins to CI" msgstr "" @@ -24659,6 +24674,9 @@ msgstr "" msgid "DuoAgentsPlatform|Manage automated flows within your project." msgstr "" +msgid "DuoAgentsPlatform|Manual configuration" +msgstr "" + msgid "DuoAgentsPlatform|New" msgstr "" @@ -24713,12 +24731,18 @@ msgstr "" msgid "DuoAgentsPlatform|Select a service account user" msgstr "" +msgid "DuoAgentsPlatform|Select an AI Catalog flow" +msgstr "" + msgid "DuoAgentsPlatform|Select one or multiple event types" msgstr "" msgid "DuoAgentsPlatform|Select user" msgstr "" +msgid "DuoAgentsPlatform|Select version" +msgstr "" + msgid "DuoAgentsPlatform|Service account user" msgstr "" @@ -24746,6 +24770,9 @@ msgstr "" msgid "DuoAgentsPlatform|Unknown" msgstr "" +msgid "DuoAgentsPlatform|Version" +msgstr "" + msgid "DuoAgentsPlatform|Write file" msgstr "" -- GitLab From d50b13740bf6724f487ac67877d59c66e2a5c740 Mon Sep 17 00:00:00 2001 From: Shekhar Patnaik Date: Mon, 22 Sep 2025 15:44:48 +0100 Subject: [PATCH 2/3] Trigger Catalog flows from RunService This triggers catalog flows from Run Service. --- .../services/ai/flow_triggers/run_service.rb | 135 +++++++++++++++--- 1 file changed, 119 insertions(+), 16 deletions(-) diff --git a/ee/app/services/ai/flow_triggers/run_service.rb b/ee/app/services/ai/flow_triggers/run_service.rb index c00f3a006d806d..ab9d812de8ac6c 100644 --- a/ee/app/services/ai/flow_triggers/run_service.rb +++ b/ee/app/services/ai/flow_triggers/run_service.rb @@ -15,15 +15,22 @@ def initialize(project:, current_user:, resource:, flow_trigger:) def execute(params) # Create Duo Workflow Header + workflow_params = { + workflow_definition: "Trigger - #{flow_trigger.description}", + status: :running, + goal: params[:input], + environment: :web + } + + # Add catalog item version if present + if flow_trigger.ai_catalog_item_version.present? + workflow_params[:ai_catalog_item_version] = flow_trigger.ai_catalog_item_version + end + wf_create_result = ::Ai::DuoWorkflows::CreateWorkflowService.new( container: project, current_user: current_user, - params: { - workflow_definition: "Trigger - #{flow_trigger.description}", - status: :running, - goal: params[:input], - environment: :web - } + params: workflow_params ).execute return ServiceResponse.error(message: wf_create_result[:message]) if wf_create_result.error? @@ -35,7 +42,11 @@ def execute(params) ) note_service.execute(params) do |updated_params| - run_workload(updated_params, workflow) + if flow_trigger.ai_catalog_item.present? + start_catalog_workflow(updated_params, workflow) + else + run_workload(updated_params, workflow) + end end end @@ -80,6 +91,105 @@ def run_workload(params, workflow) result end + def start_catalog_workflow(params, workflow) + start_workflow_params = build_start_workflow_params(params, workflow.id) + + response = ::Ai::DuoWorkflows::StartWorkflowService.new( + workflow: workflow, + params: start_workflow_params + ).execute + + status_event = response.success? ? "start" : "drop" + + ::Ai::DuoWorkflows::UpdateWorkflowStatusService.new( + workflow: workflow, status_event: status_event, current_user: current_user + ).execute + + response + end + + def build_start_workflow_params(params, workflow_id) + if can_use_composite_identity? + use_service_account = true + oauth_token = create_composite_oauth_token + else + use_service_account = false + oauth_token = create_gitlab_oauth_token + end + + duo_workflow_token = create_duo_workflow_token + + { + goal: params[:input], + workflow_definition: "Trigger - #{flow_trigger.description}", + workflow_id: workflow_id, + workflow_oauth_token: oauth_token.plaintext_token, + workflow_service_token: duo_workflow_token[:token], + use_service_account: use_service_account, + source_branch: nil, # Could be extracted from resource if needed + additional_context: build_additional_context(params), + workflow_metadata: Gitlab::DuoWorkflow::Client.metadata(current_user).to_json + } + end + + def create_gitlab_oauth_token + gitlab_oauth_token_result = ::Ai::DuoWorkflows::CreateOauthAccessTokenService.new( + current_user: current_user, + organization: project.organization, + workflow_definition: "Trigger - #{flow_trigger.description}" + ).execute + + return if gitlab_oauth_token_result[:status] == :error + + gitlab_oauth_token_result[:oauth_access_token] + end + + def create_composite_oauth_token + composite_oauth_token_result = ::Ai::DuoWorkflows::CreateCompositeOauthAccessTokenService.new( + current_user: current_user, + organization: project.organization, + scopes: ['api'], + service_account: flow_trigger_user + ).execute + + return if composite_oauth_token_result.error? + + composite_oauth_token_result[:oauth_access_token] + end + + def create_duo_workflow_token + duo_workflow_token_result = ::Ai::DuoWorkflow::DuoWorkflowService::Client.new( + duo_workflow_service_url: Gitlab::DuoWorkflow::Client.url(user: current_user), + current_user: current_user, + secure: Gitlab::DuoWorkflow::Client.secure? + ).generate_token + + return { token: nil, expires_at: nil } if duo_workflow_token_result[:status] == :error + + duo_workflow_token_result + end + + def build_additional_context(params) + serialized_resource = ::Ai::AiResource::Wrapper.new(current_user, resource).wrap.serialize_for_ai + + [ + { + 'Category' => 'ai_flow_context', + 'Content' => serialized_resource.to_json, + 'Metadata' => '{}' + }, + { + 'Category' => 'ai_flow_discussion', + 'Content' => ::Gitlab::Json.generate({ + discussion_id: params[:discussion_id], + event: params[:event].to_s, + flow_id: params[:flow_id] + }), + 'Metadata' => '{}' + } + ] + end + def fetch_flow_definition root_ref = project.repository.root_ref flow_definition = project.repository.blob_data_at(root_ref, flow_trigger.config_path) @@ -131,15 +241,8 @@ def branch_args def composite_identity_token return unless can_use_composite_identity? - composite_oauth_token_result = ::Ai::DuoWorkflows::CreateCompositeOauthAccessTokenService.new( - current_user: current_user, - organization: project.organization, - scopes: ['api'], - service_account: flow_trigger_user - ).execute - return if composite_oauth_token_result.error? - - composite_oauth_token_result[:oauth_access_token].plaintext_token + token = create_composite_oauth_token + token&.plaintext_token end def can_use_composite_identity? -- GitLab From 5ef2594bcef68b520ab31759fbe1b9718af2aeed Mon Sep 17 00:00:00 2001 From: Igor Drozdov Date: Tue, 23 Sep 2025 12:13:29 +0200 Subject: [PATCH 3/3] Use ::Ai::Catalog::Flows::ExecuteService to execute flow --- .../ai/flow_triggers/create_note_service.rb | 8 +- .../services/ai/flow_triggers/run_service.rb | 162 +++++------------- .../ai/flow_triggers/run_service_spec.rb | 6 - 3 files changed, 49 insertions(+), 127 deletions(-) diff --git a/ee/app/services/ai/flow_triggers/create_note_service.rb b/ee/app/services/ai/flow_triggers/create_note_service.rb index f524f1018a2ac1..155b4ded12f70f 100644 --- a/ee/app/services/ai/flow_triggers/create_note_service.rb +++ b/ee/app/services/ai/flow_triggers/create_note_service.rb @@ -15,9 +15,9 @@ def initialize(project:, resource:, author:, discussion: nil) def execute(params) note = create_note - response = yield(params.merge(discussion_id: note.discussion_id)) + response, workflow = yield(params.merge(discussion_id: note.discussion_id)) - update_note(note, response) + update_note(note, response, workflow) response end @@ -36,11 +36,11 @@ def create_note ).execute end - def update_note(note, response) + def update_note(note, response, workflow) updated_message = if response.success? link_start = format(''.html_safe, - url: response.payload.logs_url) + url: workflow.last_executor_logs_url) format(s_( "AiFlowTriggers|✅ Agent has started. You can view the progress %{link_start}here%{link_end}." ), link_start: link_start, link_end: ''.html_safe) diff --git a/ee/app/services/ai/flow_triggers/run_service.rb b/ee/app/services/ai/flow_triggers/run_service.rb index ab9d812de8ac6c..02b5b0f77926c1 100644 --- a/ee/app/services/ai/flow_triggers/run_service.rb +++ b/ee/app/services/ai/flow_triggers/run_service.rb @@ -14,7 +14,24 @@ def initialize(project:, current_user:, resource:, flow_trigger:) end def execute(params) - # Create Duo Workflow Header + note_service = ::Ai::FlowTriggers::CreateNoteService.new( + project: project, resource: resource, author: flow_trigger_user, discussion: params[:discussion] + ) + + note_service.execute(params) do |updated_params| + if flow_trigger.ai_catalog_item.present? + start_catalog_workflow + else + run_workload(updated_params) + end + end + end + + private + + attr_reader :project, :current_user, :resource, :flow_trigger, :flow_trigger_user + + def run_workload(params) workflow_params = { workflow_definition: "Trigger - #{flow_trigger.description}", status: :running, @@ -22,11 +39,6 @@ def execute(params) environment: :web } - # Add catalog item version if present - if flow_trigger.ai_catalog_item_version.present? - workflow_params[:ai_catalog_item_version] = flow_trigger.ai_catalog_item_version - end - wf_create_result = ::Ai::DuoWorkflows::CreateWorkflowService.new( container: project, current_user: current_user, @@ -36,25 +48,6 @@ def execute(params) return ServiceResponse.error(message: wf_create_result[:message]) if wf_create_result.error? workflow = wf_create_result[:workflow] - params[:flow_id] = workflow.id - note_service = ::Ai::FlowTriggers::CreateNoteService.new( - project: project, resource: resource, author: flow_trigger_user, discussion: params[:discussion] - ) - - note_service.execute(params) do |updated_params| - if flow_trigger.ai_catalog_item.present? - start_catalog_workflow(updated_params, workflow) - else - run_workload(updated_params, workflow) - end - end - end - - private - - attr_reader :project, :current_user, :resource, :flow_trigger, :flow_trigger_user - - def run_workload(params, workflow) flow_definition = fetch_flow_definition return ServiceResponse.error(message: 'invalid or missing flow definition') unless flow_definition @@ -86,108 +79,31 @@ def run_workload(params, workflow) workflow: workflow, status_event: status_event, current_user: current_user ).execute - workflow.workflows_workloads.create(project_id: project.id, workload_id: result.payload.id) + workflow.workflows_workloads.create(project_id: project.id, workload_id: result.payload.id) if result.success? - result + [result, workflow] end - def start_catalog_workflow(params, workflow) - start_workflow_params = build_start_workflow_params(params, workflow.id) - - response = ::Ai::DuoWorkflows::StartWorkflowService.new( - workflow: workflow, - params: start_workflow_params + def start_catalog_workflow + response = ::Ai::Catalog::Flows::ExecuteService.new( + project: project, + current_user: current_user, + params: { + flow: flow_trigger.ai_catalog_item, + flow_version: flow_trigger.ai_catalog_item_version, + execute_workflow: true + } ).execute status_event = response.success? ? "start" : "drop" + workflow = response.payload[:workflow] + ::Ai::DuoWorkflows::UpdateWorkflowStatusService.new( workflow: workflow, status_event: status_event, current_user: current_user ).execute - response - end - - def build_start_workflow_params(params, workflow_id) - if can_use_composite_identity? - use_service_account = true - oauth_token = create_composite_oauth_token - else - use_service_account = false - oauth_token = create_gitlab_oauth_token - end - - duo_workflow_token = create_duo_workflow_token - - { - goal: params[:input], - workflow_definition: "Trigger - #{flow_trigger.description}", - workflow_id: workflow_id, - workflow_oauth_token: oauth_token.plaintext_token, - workflow_service_token: duo_workflow_token[:token], - use_service_account: use_service_account, - source_branch: nil, # Could be extracted from resource if needed - additional_context: build_additional_context(params), - workflow_metadata: Gitlab::DuoWorkflow::Client.metadata(current_user).to_json - } - end - - def create_gitlab_oauth_token - gitlab_oauth_token_result = ::Ai::DuoWorkflows::CreateOauthAccessTokenService.new( - current_user: current_user, - organization: project.organization, - workflow_definition: "Trigger - #{flow_trigger.description}" - ).execute - - return if gitlab_oauth_token_result[:status] == :error - - gitlab_oauth_token_result[:oauth_access_token] - end - - def create_composite_oauth_token - composite_oauth_token_result = ::Ai::DuoWorkflows::CreateCompositeOauthAccessTokenService.new( - current_user: current_user, - organization: project.organization, - scopes: ['api'], - service_account: flow_trigger_user - ).execute - - return if composite_oauth_token_result.error? - - composite_oauth_token_result[:oauth_access_token] - end - - def create_duo_workflow_token - duo_workflow_token_result = ::Ai::DuoWorkflow::DuoWorkflowService::Client.new( - duo_workflow_service_url: Gitlab::DuoWorkflow::Client.url(user: current_user), - current_user: current_user, - secure: Gitlab::DuoWorkflow::Client.secure? - ).generate_token - - return { token: nil, expires_at: nil } if duo_workflow_token_result[:status] == :error - - duo_workflow_token_result - end - - def build_additional_context(params) - serialized_resource = ::Ai::AiResource::Wrapper.new(current_user, resource).wrap.serialize_for_ai - - [ - { - 'Category' => 'ai_flow_context', - 'Content' => serialized_resource.to_json, - 'Metadata' => '{}' - }, - { - 'Category' => 'ai_flow_discussion', - 'Content' => ::Gitlab::Json.generate({ - discussion_id: params[:discussion_id], - event: params[:event].to_s, - flow_id: params[:flow_id] - }), - 'Metadata' => '{}' - } - ] + [response, workflow] end def fetch_flow_definition @@ -210,7 +126,6 @@ def build_variables(params) AI_FLOW_DISCUSSION_ID: params[:discussion_id], AI_FLOW_EVENT: params[:event].to_s, AI_FLOW_GITLAB_TOKEN: composite_identity_token, - AI_FLOW_ID: params[:flow_id], AI_FLOW_INPUT: params[:input] } @@ -245,6 +160,19 @@ def composite_identity_token token&.plaintext_token end + def create_composite_oauth_token + composite_oauth_token_result = ::Ai::DuoWorkflows::CreateCompositeOauthAccessTokenService.new( + current_user: current_user, + organization: project.organization, + scopes: ['api'], + service_account: flow_trigger_user + ).execute + + return if composite_oauth_token_result.error? + + composite_oauth_token_result[:oauth_access_token] + end + def can_use_composite_identity? return false unless current_user return false unless Feature.enabled?(:duo_workflow_use_composite_identity, current_user) diff --git a/ee/spec/services/ai/flow_triggers/run_service_spec.rb b/ee/spec/services/ai/flow_triggers/run_service_spec.rb index 1064cbe343b3a3..44ff75ef4277b2 100644 --- a/ee/spec/services/ai/flow_triggers/run_service_spec.rb +++ b/ee/spec/services/ai/flow_triggers/run_service_spec.rb @@ -231,7 +231,6 @@ expect(variables[:AI_FLOW_INPUT]).to eq('test input') expect(variables[:AI_FLOW_EVENT]).to eq('mention') expect(variables[:AI_FLOW_DISCUSSION_ID]).to eq(existing_note.discussion_id) - expect(variables[:AI_FLOW_ID]).to be_present expect(variables[:AI_FLOW_AI_GATEWAY_TOKEN]).to eq('test-token-123') expect(variables[:AI_FLOW_AI_GATEWAY_HEADERS]).to eq( @@ -372,7 +371,6 @@ expect(variables[:AI_FLOW_INPUT]).to eq('test input') expect(variables[:AI_FLOW_EVENT]).to eq('mention') expect(variables[:AI_FLOW_DISCUSSION_ID]).to eq(existing_note.discussion_id) - expect(variables[:AI_FLOW_ID]).to be_present # These should not be present expect(variables).not_to have_key(:AI_FLOW_AI_GATEWAY_TOKEN) @@ -418,7 +416,6 @@ expect(variables[:AI_FLOW_INPUT]).to eq('test input') expect(variables[:AI_FLOW_EVENT]).to eq('mention') expect(variables[:AI_FLOW_DISCUSSION_ID]).to eq(existing_note.discussion_id) - expect(variables[:AI_FLOW_ID]).to be_present # These should not be present expect(variables).not_to have_key(:AI_FLOW_AI_GATEWAY_TOKEN) @@ -451,7 +448,6 @@ expect(variables[:AI_FLOW_INPUT]).to eq('test input') expect(variables[:AI_FLOW_EVENT]).to eq('mention') expect(variables[:AI_FLOW_DISCUSSION_ID]).to eq(existing_note.discussion_id) - expect(variables[:AI_FLOW_ID]).to be_present expect(variables[:AI_FLOW_GITLAB_TOKEN]).to be_nil original_method.call(**kwargs) end @@ -484,7 +480,6 @@ expect(variables[:AI_FLOW_INPUT]).to eq('test input') expect(variables[:AI_FLOW_EVENT]).to eq('mention') expect(variables[:AI_FLOW_DISCUSSION_ID]).to eq(existing_note.discussion_id) - expect(variables[:AI_FLOW_ID]).to be_present expect(variables[:AI_FLOW_GITLAB_TOKEN]).to be_nil original_method.call(**kwargs) end @@ -507,7 +502,6 @@ expect(variables[:AI_FLOW_INPUT]).to eq('test input') expect(variables[:AI_FLOW_EVENT]).to eq('mention') expect(variables[:AI_FLOW_DISCUSSION_ID]).to eq(existing_note.discussion_id) - expect(variables[:AI_FLOW_ID]).to be_present expect(variables[:AI_FLOW_GITLAB_TOKEN]).to be_present original_method.call(**kwargs) end -- GitLab