From bac93ec197b191a5929238ee7c11eddb9c7596fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Th=C3=A9o=20Grandin?= Date: Tue, 11 Aug 2026 17:19:47 +0200 Subject: [PATCH 1/3] feat(agentic-workflow): integrate agentic workflow experience --- apps/console/public/assets/ai-tools/jira.svg | 1 + .../lib/domains-organizations-data-access.ts | 7 + .../git-branch-settings.tsx | 3 + .../git-provider-setting.tsx | 9 +- .../git-repository-setting.tsx | 3 + .../src/lib/domains-services-data-access.ts | 9 + libs/domains/services/feature/src/index.ts | 1 + .../use-create-agentic-workflow.ts | 21 + .../agentic-workflow-configuration.spec.tsx | 91 +- .../agentic-workflow-configuration.tsx | 3693 ++++++++++++++--- .../instructions-editor.tsx | 56 + .../agentic-workflow-context.tsx | 130 +- .../agentic-workflow-summary.spec.tsx | 15 +- .../agentic-workflow-summary.tsx | 11 +- .../src/lib/service-icon/service-icon.tsx | 1 + .../src/lib/service-new/service-new.spec.tsx | 4 +- .../src/lib/service-new/service-new.tsx | 5 +- .../create-update-variable-modal.tsx | 10 +- .../lib/assets/services/agentic-workflow.svg | 167 + .../src/lib/components/modal/modal-root.tsx | 2 + .../ui/src/lib/components/modal/modal.tsx | 5 + .../components/modal/use-modal/use-modal.tsx | 1 + .../segmented-control/segmented-control.tsx | 25 +- package.json | 8 + yarn.lock | 554 +++ 25 files changed, 4241 insertions(+), 591 deletions(-) create mode 100644 apps/console/public/assets/ai-tools/jira.svg create mode 100644 libs/domains/services/feature/src/lib/hooks/use-create-agentic-workflow/use-create-agentic-workflow.ts create mode 100644 libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/instructions-editor.tsx create mode 100644 libs/shared/ui/src/lib/assets/services/agentic-workflow.svg diff --git a/apps/console/public/assets/ai-tools/jira.svg b/apps/console/public/assets/ai-tools/jira.svg new file mode 100644 index 00000000000..35971c006a5 --- /dev/null +++ b/apps/console/public/assets/ai-tools/jira.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts index 777c25b9c06..fbf9667ec24 100644 --- a/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts +++ b/libs/domains/organizations/data-access/src/lib/domains-organizations-data-access.ts @@ -80,6 +80,13 @@ export const organizations = createQueryKeys('organizations', { return response.data }, }), + environments: ({ organizationId }: { organizationId: string }) => ({ + queryKey: [organizationId], + async queryFn() { + const response = await organizationApi.listEnvironmentsByOrganizationId(organizationId) + return response.data.results ?? [] + }, + }), helmRepositories: ({ organizationId }: { organizationId: string }) => ({ queryKey: [organizationId], async queryFn() { diff --git a/libs/domains/organizations/feature/src/lib/git-branch-settings/git-branch-settings.tsx b/libs/domains/organizations/feature/src/lib/git-branch-settings/git-branch-settings.tsx index 034ff5d08b7..87271f7495e 100644 --- a/libs/domains/organizations/feature/src/lib/git-branch-settings/git-branch-settings.tsx +++ b/libs/domains/organizations/feature/src/lib/git-branch-settings/git-branch-settings.tsx @@ -10,6 +10,7 @@ export interface GitBranchSettingsProps { gitTokenId?: string disabled?: boolean hideRootPath?: boolean + portal?: boolean rootPathLabel?: string rootPathHint?: string } @@ -20,6 +21,7 @@ export function GitBranchSettings({ gitProvider, gitTokenId, hideRootPath, + portal, rootPathLabel = 'Application root folder path', rootPathHint = 'Provide the folder path in the repository where the application is located.', }: GitBranchSettingsProps) { @@ -99,6 +101,7 @@ export function GitBranchSettings({ disabled={disabled} isSearchable isCreatable + portal={portal} /> )} /> diff --git a/libs/domains/organizations/feature/src/lib/git-provider-setting/git-provider-setting.tsx b/libs/domains/organizations/feature/src/lib/git-provider-setting/git-provider-setting.tsx index 83a0e70e9dd..c9a795f157f 100644 --- a/libs/domains/organizations/feature/src/lib/git-provider-setting/git-provider-setting.tsx +++ b/libs/domains/organizations/feature/src/lib/git-provider-setting/git-provider-setting.tsx @@ -10,6 +10,7 @@ import { isGitTokenExpired, useGitTokens } from '../hooks/use-git-tokens/use-git export interface GitProviderSettingProps { organizationId: string disabled?: boolean + portal?: boolean showAuthProviders?: boolean } @@ -74,7 +75,12 @@ export const mergeProviders = (authProviders: GitAuthProvider[] = [], gitTokens: return [...currentAuthProviders, ...currentGitTokens] } -export function GitProviderSetting({ disabled, organizationId, showAuthProviders = true }: GitProviderSettingProps) { +export function GitProviderSetting({ + disabled, + organizationId, + portal, + showAuthProviders = true, +}: GitProviderSettingProps) { const { control, watch, setValue, clearErrors } = useFormContext() const { openModal, closeModal } = useModal() @@ -174,6 +180,7 @@ export function GitProviderSetting({ disabled, organizationId, showAuthProviders disabled={disabled} isSearchable filterOption={customFilterOption} + portal={portal} /> )} /> diff --git a/libs/domains/organizations/feature/src/lib/git-repository-setting/git-repository-setting.tsx b/libs/domains/organizations/feature/src/lib/git-repository-setting/git-repository-setting.tsx index 74b0561b1eb..4ebe1a3e2d3 100644 --- a/libs/domains/organizations/feature/src/lib/git-repository-setting/git-repository-setting.tsx +++ b/libs/domains/organizations/feature/src/lib/git-repository-setting/git-repository-setting.tsx @@ -9,6 +9,7 @@ export interface GitRepositorySettingProps { gitProvider: keyof typeof GitProviderEnum gitTokenId?: string disabled?: boolean + portal?: boolean urlRepository?: string } @@ -17,6 +18,7 @@ export function GitRepositorySetting({ disabled, gitProvider, gitTokenId, + portal, urlRepository, }: GitRepositorySettingProps) { const { control, setValue, watch } = useFormContext<{ @@ -86,6 +88,7 @@ export function GitRepositorySetting({ error={error?.message} disabled={disabled} isSearchable + portal={portal} /> {urlRepository && ( diff --git a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts index 4bc968deff7..58aa6feacec 100644 --- a/libs/domains/services/data-access/src/lib/domains-services-data-access.ts +++ b/libs/domains/services/data-access/src/lib/domains-services-data-access.ts @@ -829,6 +829,11 @@ type DeployBlueprintRequest = { blueprintId: string } +type CreateAgenticWorkflowRequest = { + environmentId: string + payload: AgenticWorkflowRequest +} + type EditServiceRequest = { serviceId: string payload: @@ -1129,6 +1134,10 @@ export const mutations = { const response = await blueprintApi.deployBlueprint(blueprintId) return response.data }, + async createAgenticWorkflow({ environmentId, payload }: CreateAgenticWorkflowRequest) { + const response = await agenticWorkflowsApi.createAgenticWorkflow(environmentId, payload) + return response.data + }, async editService({ serviceId, payload }: EditServiceRequest) { const { mutation } = match(payload) .with({ serviceType: 'APPLICATION' }, ({ serviceType, ...payload }) => ({ diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index 76e88f44842..8f4a5684377 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -110,6 +110,7 @@ export * from './lib/service-blueprint-update-flow/blueprint-update-flow' export * from './lib/service-creation-flow/agentic-workflow/agentic-workflow-context' export * from './lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration' export * from './lib/service-creation-flow/agentic-workflow/agentic-workflow-summary' +export * from './lib/hooks/use-create-agentic-workflow/use-create-agentic-workflow' export * from './lib/service-creation-flow/agentic-workflow/agentic-workflow-request' export * from './lib/application-container-healthchecks/application-container-healthchecks-form/application-container-healthchecks-form' export * from './lib/application-container-healthchecks/healthchecks-utils' diff --git a/libs/domains/services/feature/src/lib/hooks/use-create-agentic-workflow/use-create-agentic-workflow.ts b/libs/domains/services/feature/src/lib/hooks/use-create-agentic-workflow/use-create-agentic-workflow.ts new file mode 100644 index 00000000000..9428bb915d4 --- /dev/null +++ b/libs/domains/services/feature/src/lib/hooks/use-create-agentic-workflow/use-create-agentic-workflow.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { mutations } from '@qovery/domains/services/data-access' +import { queries } from '@qovery/state/util-queries' + +export function useCreateAgenticWorkflow({ environmentId }: { environmentId: string }) { + const queryClient = useQueryClient() + + return useMutation(mutations.createAgenticWorkflow, { + onSuccess() { + queryClient.invalidateQueries({ + queryKey: queries.services.list(environmentId).queryKey, + }) + }, + meta: { + notifyOnSuccess: { + title: 'Your agentic workflow has been created', + }, + notifyOnError: true, + }, + }) +} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx index 9c17beb8f0a..8dbcd6a25b0 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.spec.tsx @@ -1,4 +1,47 @@ -import { getJsonError, isGitRepositoryComplete, isOutputComplete } from './agentic-workflow-configuration' +import { useModal } from '@qovery/shared/ui' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { AgenticWorkflowCreationFlow } from '../agentic-workflow-context' +import { + AgentCreationActions, + ContextMenu, + DockerSettings, + getJsonError, + isGitRepositoryComplete, + isOutputComplete, +} from './agentic-workflow-configuration' + +jest.mock('./instructions-editor', () => ({ + InstructionsEditor: () => null, +})) + +function ContextMenuHarness() { + const { openModal } = useModal() + + return ( + + openModal({ + content:
Qovery environment popup
, + options: { fakeModal: true }, + }) + } + onAddGit={() => + openModal({ + content:
Git repository popup
, + options: { fakeModal: true }, + }) + } + /> + ) +} + +function DockerSettingsHarness() { + return ( + + + + ) +} describe('AgenticWorkflowConfiguration validation', () => { it('should require valid JSON only when a required JSON field is empty or invalid', () => { @@ -30,4 +73,50 @@ describe('AgenticWorkflowConfiguration validation', () => { expect(isOutputComplete({ url: 'https://hooks.example.com/workflow', headersJson: '{}', prompt: '' })).toBe(true) expect(isOutputComplete({ url: '', headersJson: '{}', prompt: 'Notify the team.' })).toBe(false) }) + + it.each([ + ['From Qovery environment', 'Qovery environment popup'], + ['From Git repository', 'Git repository popup'], + ])('should open the relevant context popup from %s', async (menuItem, popupContent) => { + const { userEvent } = renderWithProviders() + + await userEvent.click(screen.getByRole('button', { name: 'Add context' })) + await userEvent.click(await screen.findByText(menuItem)) + + expect(await screen.findByText(popupContent)).toBeInTheDocument() + }) + + it('should display create agent and agent dropdown actions', async () => { + const onCreateAgent = jest.fn() + const onCloneAgent = jest.fn() + const onDeleteAgent = jest.fn() + const { userEvent } = renderWithProviders( + + ) + + await userEvent.click(screen.getByRole('button', { name: 'Create agent' })) + expect(onCreateAgent).toHaveBeenCalledTimes(1) + + await userEvent.click(screen.getByRole('button', { name: 'Agent actions' })) + await userEvent.click(await screen.findByRole('menuitem', { name: 'Clone' })) + expect(onCloneAgent).toHaveBeenCalledTimes(1) + + await userEvent.click(screen.getByRole('button', { name: 'Agent actions' })) + await userEvent.click(await screen.findByRole('menuitem', { name: 'Delete agent' })) + expect(onDeleteAgent).toHaveBeenCalledTimes(1) + }) + + it('should import a txt file and display its title in the Dockerfile fragment card', async () => { + const { userEvent } = renderWithProviders() + const input = screen.getByLabelText('Dockerfile fragment file') + const fileInputClick = jest.spyOn(input, 'click') + const content = 'RUN apt-get update\nRUN apt-get install -y jq' + + await userEvent.click(screen.getByRole('button', { name: 'Add file' })) + await userEvent.upload(input, new File([content], 'fragment.txt', { type: 'text/plain' })) + + expect(fileInputClick).toHaveBeenCalled() + expect(await screen.findByText('fragment.txt')).toBeInTheDocument() + expect(screen.queryByText(content)).not.toBeInTheDocument() + }) }) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx index 1addec9bb63..554d2c8c28a 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-configuration/agentic-workflow-configuration.tsx @@ -1,37 +1,127 @@ -import { type IconName } from '@fortawesome/fontawesome-common-types' -import { useNavigate } from '@tanstack/react-router' -import { type ReactNode, useEffect, useRef } from 'react' +import { useQuery } from '@tanstack/react-query' +import { useNavigate, useParams } from '@tanstack/react-router' import { + APIVariableTypeEnum, + type EnvironmentModeEnum, + type GitProviderEnum, + type GitRepository, + type OrganizationEnvironmentResponse, + type VariableResponse, +} from 'qovery-typescript-axios' +import { + type ChangeEvent, + type ComponentPropsWithoutRef, + type ReactNode, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { createPortal } from 'react-dom' +import { FormProvider, useForm } from 'react-hook-form' +import { useClusters } from '@qovery/domains/clusters/feature' +import { + GitBranchSettings, + GitProviderSetting, + GitRepositorySetting, + useOrganization, +} from '@qovery/domains/organizations/feature' +import { useProjects } from '@qovery/domains/projects/feature' +import { type AnyService } from '@qovery/domains/services/data-access' +import { type CreateUpdateVariableModalSubmitData, VariableFormModal } from '@qovery/domains/variables/feature' +import { IconEnum } from '@qovery/shared/enums' +import { useUserAccount } from '@qovery/shared/iam/feature' +import { type VariableData } from '@qovery/shared/interfaces' +import { + Avatar, + Badge, Button, + Checkbox, CodeEditor, - FunnelFlowBody, - Heading, + DropdownMenu, Icon, - InputText, - InputTextArea, - InputToggle, - Section, + InputSelect, + LoaderSpinner, + Modal, + SegmentedControl, + Sheet, + Tooltip, + useModal, } from '@qovery/shared/ui' +import { twMerge } from '@qovery/shared/util-js' +import { queries } from '@qovery/state/util-queries' +import { useEnvironment } from '../../../hooks/use-environment/use-environment' +import { ServiceAvatar } from '../../../service-avatar/service-avatar' +import { ServiceIcons } from '../../../service-icon/service-icon' import { - type AgenticWorkflowConfigurationSection, + type AgenticWorkflowAutomation, + type AgenticWorkflowAutomationOutput, + type AgenticWorkflowAutomationOutputType, + type AgenticWorkflowAutomationTrigger, + type AgenticWorkflowAutomationTriggerType, + type AgenticWorkflowEnvironmentContext, type AgenticWorkflowGitRepository, + type AgenticWorkflowMcpAuthType, + type AgenticWorkflowMcpServer, type AgenticWorkflowOutput, + type AgenticWorkflowProvider, + type AgenticWorkflowSkill, + type AgenticWorkflowSkillSource, useAgenticWorkflowCreateContext, } from '../agentic-workflow-context' -import { AIModelCards } from './ai-model-cards' -import { GitRepositoryCard } from './git-repository-card' - -const sectionOrder: AgenticWorkflowConfigurationSection[] = [ - 'service-information', - 'ai-model', - 'connectors', - 'git-repositories', - 'governance', - 'docker-fragment', - 'outputs', - 'agent-prompt', +import { InstructionsEditor } from './instructions-editor' + +type SettingsSection = 'information' | 'resources' | 'environment' | 'network' | 'cost' | 'access' | 'docker' +type SheetView = 'provider' | 'mcp' | 'skills' | 'automation' | null + +function readFileAsText(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader() + + reader.onload = () => resolve(typeof reader.result === 'string' ? reader.result : '') + reader.onerror = () => reject(reader.error) + reader.readAsText(file) + }) +} + +const PROVIDERS = { + openai: { + name: 'OpenAI', + models: ['GPT-5.5', 'GPT-5.6', 'GPT-5.6 Luna'], + }, + anthropic: { + name: 'Anthropic', + models: ['Claude Sonnet 4', 'Claude Haiku 4'], + }, +} as const + +const MCP_AUTH_OPTIONS: { label: string; value: AgenticWorkflowMcpAuthType }[] = [ + { label: 'None', value: 'none' }, + { label: 'Headers', value: 'headers' }, + { label: 'Token', value: 'token' }, + { label: 'OAuth', value: 'oauth' }, ] +const SKILL_SOURCE_LABELS: Record = { + repository: 'Git repository', + import: 'Import', + scratch: 'Create', +} + +const AUTOMATION_APPLICATIONS = ['Linear', 'Slack', 'Jira', 'GitHub', 'Grafana', 'HubSpot', 'Figma'] +const AUTOMATION_APPLICATION_EVENTS = ['Qovery agent mention', 'New issue created', 'Comment added'] +const AUTOMATION_APPLICATION_CHANNELS = ['#eng-alerts', '#devops', '#product'] +const AUTOMATION_FREQUENCIES = ['Daily', 'Weekly', 'Monthly'] +const AUTOMATION_TIMES = ['8:00am', '9:00am', '12:00pm', '5:00pm'] +const AUTOMATION_TIMEZONES = ['UTC', 'Europe/Paris', 'America/New_York'] +const AUTOMATION_DAYS = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday'] +const AUTOMATION_MONTH_DAYS = Array.from({ length: 28 }, (_, index) => { + const day = index + 1 + const suffix = day === 1 || day === 21 ? 'st' : day === 2 || day === 22 ? 'nd' : day === 3 || day === 23 ? 'rd' : 'th' + + return `${day}${suffix}` +}) + export function getJsonError(value: string, required = false) { if (!value.trim()) return required ? 'Please enter a valid JSON configuration.' : undefined @@ -43,28 +133,6 @@ export function getJsonError(value: string, required = false) { } } -function isSectionCompleted( - section: AgenticWorkflowConfigurationSection, - activeSection: AgenticWorkflowConfigurationSection -) { - return sectionOrder.indexOf(section) < sectionOrder.indexOf(activeSection) -} - -function getSectionTitle(section: AgenticWorkflowConfigurationSection) { - const titles: Record = { - 'service-information': 'Service information', - 'ai-model': 'AI model', - connectors: 'MCPs', - 'git-repositories': 'Git repositories', - governance: 'Governance', - 'docker-fragment': 'Docker fragment', - outputs: 'Output webhooks', - 'agent-prompt': 'Agent prompt', - } - - return titles[section] -} - export function isGitRepositoryComplete(repository: AgenticWorkflowGitRepository) { return Boolean( (repository.gitTokenId || repository.provider || repository.isPublicRepository) && @@ -77,554 +145,3097 @@ export function isOutputComplete(output: AgenticWorkflowOutput) { return Boolean(output.url.trim()) } -function AgenticWorkflowSection({ - children, - headerAction, +function formatMcpJson(servers: AgenticWorkflowMcpServer[], linkedIds: string[]) { + const linkedServers = servers.filter((server) => linkedIds.includes(server.id)) + + if (linkedServers.length === 0) return '' + + return JSON.stringify( + { + mcpServers: Object.fromEntries( + linkedServers.map((server) => [ + server.name.toLowerCase().replace(/\s+/g, '-'), + { + type: 'http', + url: server.url, + ...(server.headers.trim() ? { headers: JSON.parse(server.headers) } : {}), + ...(server.authType === 'token' && server.token.trim() + ? { headers: { Authorization: 'Bearer {{MCP_TOKEN}}' } } + : {}), + }, + ]) + ), + }, + null, + 2 + ) +} + +interface FieldProps { + children?: ReactNode + hint?: string + label: string +} + +function Field({ children, hint, label }: FieldProps) { + return ( + + ) +} + +interface TextFieldProps extends Omit, 'className'> { + label: string +} + +function TextField({ label, ...props }: TextFieldProps) { + return ( + + + + ) +} + +interface TextAreaFieldProps extends Omit, 'className'> { + label: string +} + +function TextAreaField({ label, ...props }: TextAreaFieldProps) { + return ( + +