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 0000000000000000000000000000000000000000..d38831355686013756ec6300a0bd67bc6ae09371
--- /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 0000000000000000000000000000000000000000..9652ea270682b4028c92fded210c7dde5eefa686
--- /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 b86d63cff2aac0a2df9ec7908aa13aa6bc21aa1d..2d08ff4f4de14a7207de8c108e8a9dd08aa33b8d 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 2a776e60b2ab1d7337705a13a9b47d66239b2705..aca8bcab23b1a08a9a336c273edee819504f65c2 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 0000000000000000000000000000000000000000..86666e623087ea1cc44ae6b5ff603af045d46277
--- /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 0000000000000000000000000000000000000000..73c6a98f8e5bf97200eba95f97f5060341f6e1cb
--- /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 e59b45f0e461b670d754b69fcd98bd7e683ac953..e6a1308c0908b43cb97d2d1210200504dfa9fdea 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 77400a6a3ae714d5b74a0fd6260eb1231537cce0..99f7201ceda07dc81dc88e259bf2e45c42fbf2cd 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 c84dc6bfbdfcbdeeb818ec3109e3bb984b30d7d0..9d37fa052d6b334d14ae182f5b9c551423894e76 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 a676431376f1c12560a847c58a4c00bd93db8920..0f84286797a4a3f72924eb54ddf717d8a11aa63b 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 1f283229c69982f1f0b6ce6d83e6ee9edbad76a9..4b356e0e27e516b4cc1b85a66b8bfa67931549ce 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_note_service.rb b/ee/app/services/ai/flow_triggers/create_note_service.rb
index f524f1018a2ac1b06f169c6df946aa1d2e0d0302..155b4ded12f70f3268399b5944921e5005177ed8 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/create_service.rb b/ee/app/services/ai/flow_triggers/create_service.rb
index f0061eee00291e3f2c8a1cb2f3166fb32bbfcf3c..1c445cfbbf49f2c14364e34347047645d098d68a 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/run_service.rb b/ee/app/services/ai/flow_triggers/run_service.rb
index c00f3a006d806d9fd2513f87840fb972804eab2e..02b5b0f77926c17463cef0f52d8857520be67b88 100644
--- a/ee/app/services/ai/flow_triggers/run_service.rb
+++ b/ee/app/services/ai/flow_triggers/run_service.rb
@@ -14,28 +14,16 @@ def initialize(project:, current_user:, resource:, flow_trigger:)
end
def execute(params)
- # Create Duo Workflow Header
- 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
- }
- ).execute
-
- 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|
- run_workload(updated_params, workflow)
+ if flow_trigger.ai_catalog_item.present?
+ start_catalog_workflow
+ else
+ run_workload(updated_params)
+ end
end
end
@@ -43,7 +31,23 @@ def execute(params)
attr_reader :project, :current_user, :resource, :flow_trigger, :flow_trigger_user
- def run_workload(params, workflow)
+ def run_workload(params)
+ workflow_params = {
+ workflow_definition: "Trigger - #{flow_trigger.description}",
+ status: :running,
+ goal: params[:input],
+ environment: :web
+ }
+
+ wf_create_result = ::Ai::DuoWorkflows::CreateWorkflowService.new(
+ container: project,
+ current_user: current_user,
+ params: workflow_params
+ ).execute
+
+ return ServiceResponse.error(message: wf_create_result[:message]) if wf_create_result.error?
+
+ workflow = wf_create_result[:workflow]
flow_definition = fetch_flow_definition
return ServiceResponse.error(message: 'invalid or missing flow definition') unless flow_definition
@@ -75,9 +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, workflow]
+ end
+
+ 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]
- result
+ ::Ai::DuoWorkflows::UpdateWorkflowStatusService.new(
+ workflow: workflow, status_event: status_event, current_user: current_user
+ ).execute
+
+ [response, workflow]
end
def fetch_flow_definition
@@ -100,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]
}
@@ -131,15 +156,21 @@ def branch_args
def composite_identity_token
return unless can_use_composite_identity?
+ token = create_composite_oauth_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].plaintext_token
+ composite_oauth_token_result[:oauth_access_token]
end
def can_use_composite_identity?
diff --git a/ee/app/services/ai/flow_triggers/update_service.rb b/ee/app/services/ai/flow_triggers/update_service.rb
index 28a4a3413c936902814db841390ce8562cb4f77f..cac17f78c4a3f235fb53a005e3668281ceefe422 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 4271f775dcf81f4e29b1219266d4dc46bfaf82db..11cf395f9bfe49ef4a23aa54f9e09e89f5f8a82d 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 fbf8d8ed403a28a226f09a7289637aec37a2e1de..667c473b0bcf51c34d181069b306bbc1db66f08d 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 0c7bb51beb0132e37c726586faac0d0886224100..63ab5eeff05bcde03f678e94355755dd2b4aa4f8 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 ee6a4c231566d7fff57ac8aab3aaa48ec0b2ce72..98e35abe39c1faf1a77b3dad57b0ec5f9f5a5315 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 8b05bed10df6a4660aad2023fbb84a586657efbc..ef1a4c298cb5ae854627b002fa9edb6537e9104a 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 5394de47db9fd609eb545d2aa0548135d08c8ebb..e16e83cf11c871946d20db517911d522d04630a4 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 a95def74840be52082a9789f31e9ed0aa9f44539..c197d07f2ea8a262f5b2176cc8fba877efe1beb7 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/run_service_spec.rb b/ee/spec/services/ai/flow_triggers/run_service_spec.rb
index 1064cbe343b3a37ffb66159d4f22a15a5a180d7f..44ff75ef4277b26ef4220ccb89fbf77f12f8ad45 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
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 ca0f7c721ce9bf169105a5bc047f92ed58798710..39cdfb35acd160a93486301cdef9b5590dbc48d4 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 a0c2a978c59e122a57ecc34d59cf7fb9e1168921..c567deafbd6883ce0c8dd9288c7dca8896aa36c1 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 ""