diff --git a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.spec.tsx b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.spec.tsx index 22abe0e55f9..03265dcb69d 100644 --- a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.spec.tsx +++ b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.spec.tsx @@ -56,6 +56,11 @@ const service = { name: 'Incident assistant', description: 'Investigates production incidents', enabled: true, + schedule: { + cron_expression: '0 8 * * 1-5', + timezone: 'Europe/Paris', + next_run_at: '2026-08-28T06:00:00Z', + }, model: { type: AgenticWorkflowModelType.BEDROCK, settings: '{"temperature":0.2}', @@ -154,6 +159,9 @@ describe('AgenticWorkflowSettings views', () => { expect(screen.getByRole('spinbutton', { name: 'Memory (MiB)' })).toHaveValue(1024) expect(screen.getByRole('spinbutton', { name: 'GPU' })).toHaveValue(0) expect(screen.getByRole('spinbutton', { name: 'Storage (GiB)' })).toHaveValue(20) + expect(screen.getByText('Schedule agent task')).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: 'Cron expression' })).toHaveValue('0 8 * * 1-5') + expect(screen.getByText('Europe/Paris')).toBeInTheDocument() await userEvent.clear(screen.getByRole('textbox', { name: 'Description' })) await userEvent.type(screen.getByRole('textbox', { name: 'Description' }), 'Updated description') @@ -169,11 +177,34 @@ describe('AgenticWorkflowSettings views', () => { model: { type: AgenticWorkflowModelType.BEDROCK, settings: '{"temperature":0.2}' }, outputs: [{ name: 'Audit log', url: null }], mcp_server_ids: ['mcp-1'], + schedule: { + cron_expression: '0 8 * * 1-5', + timezone: 'Europe/Paris', + }, }), }) ) }) + it('hides the saved next run when scheduling is disabled in the form', async () => { + const { userEvent } = renderWithProviders() + + expect(screen.getByText(/^Next run:/)).toBeInTheDocument() + + await userEvent.click(screen.getByRole('switch', { name: 'Schedule agent task' })) + + expect(screen.queryByText(/^Next run:/)).not.toBeInTheDocument() + }) + + it('hides the saved next run when the schedule no longer matches the saved value', async () => { + const { userEvent } = renderWithProviders() + + await userEvent.clear(screen.getByRole('textbox', { name: 'Cron expression' })) + await userEvent.type(screen.getByRole('textbox', { name: 'Cron expression' }), '0 9 * * 1-5') + + expect(screen.queryByText(/^Next run:/)).not.toBeInTheDocument() + }) + it('renders AI configuration without exposing the write-only API key', () => { renderWithProviders() diff --git a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.tsx b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.tsx index 17ac517f938..a56727adba7 100644 --- a/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.tsx +++ b/libs/domains/service-settings/feature/src/lib/agentic-workflow-settings/agentic-workflow-settings.tsx @@ -1,12 +1,14 @@ import { useParams } from '@tanstack/react-router' import { type AgenticWorkflowOutput, type AgenticWorkflowRequest } from 'qovery-typescript-axios' -import { useForm } from 'react-hook-form' +import { FormProvider, useForm } from 'react-hook-form' import { McpServerSetting, useGitTokens, useMcpServers } from '@qovery/domains/organizations/feature' import { isAgenticWorkflow } from '@qovery/domains/services/data-access' import { AgenticWorkflowCodeEditorField, type AgenticWorkflowGitRepository, + AgenticWorkflowScheduleFields, GitRepositoryCard, + isAgenticWorkflowScheduleValid, isGitRepositoryComplete, useEditService, useService, @@ -25,6 +27,9 @@ interface FormValues { name: string description: string enabled: boolean + scheduleEnabled: boolean + scheduleCronExpression: string + timezone: string modelApiKey: string modelSettings: string agentPrompt: string @@ -129,6 +134,10 @@ export function AgenticWorkflowSettings({ page }: AgenticWorkflowSettingsProps) name: service?.name ?? '', description: service && isAgenticWorkflow(service) ? service.description : '', enabled: service && isAgenticWorkflow(service) ? service.enabled : false, + scheduleEnabled: Boolean(service && isAgenticWorkflow(service) && service.schedule), + scheduleCronExpression: + service && isAgenticWorkflow(service) ? service.schedule?.cron_expression ?? '0 8 * * 1-5' : '0 8 * * 1-5', + timezone: service && isAgenticWorkflow(service) ? service.schedule?.timezone ?? 'Etc/UTC' : 'Etc/UTC', modelApiKey: '', modelSettings: service && isAgenticWorkflow(service) ? service.model.settings : '', agentPrompt: service && isAgenticWorkflow(service) ? service.agent_prompt : '', @@ -169,6 +178,14 @@ export function AgenticWorkflowSettings({ page }: AgenticWorkflowSettingsProps) if (!service || !isAgenticWorkflow(service)) return null const values = form.watch() + const savedSchedule = service.schedule + const nextRunAt = + values.scheduleEnabled && + savedSchedule && + values.scheduleCronExpression === savedSchedule.cron_expression && + values.timezone === savedSchedule.timezone + ? savedSchedule.next_run_at + : null const submit = form.handleSubmit((data) => { const model: AgenticWorkflowRequest['model'] = { type: service.model.type, @@ -180,6 +197,12 @@ export function AgenticWorkflowSettings({ page }: AgenticWorkflowSettingsProps) name: data.name, description: data.description, enabled: data.enabled, + schedule: data.scheduleEnabled + ? { + cron_expression: data.scheduleCronExpression, + timezone: data.timezone, + } + : null, model, agent_prompt: data.agentPrompt, project_repositories: formatAgenticWorkflowRepositories(data.repositories), @@ -221,13 +244,15 @@ export function AgenticWorkflowSettings({ page }: AgenticWorkflowSettingsProps) const repositoriesValid = values.repositories.every(isGitRepositoryComplete) const pageValid = - page === 'ai-configuration' - ? agenticWorkflowJsonValidation(values.modelSettings) === true - : page === 'connections' - ? repositoriesValid && (!values.mcp.trim() || agenticWorkflowJsonValidation(values.mcp) === true) - : page === 'outputs' - ? agenticWorkflowOutputsValidation(values.outputs) === true - : true + page === 'general' + ? isAgenticWorkflowScheduleValid(values) + : page === 'ai-configuration' + ? agenticWorkflowJsonValidation(values.modelSettings) === true + : page === 'connections' + ? repositoriesValid && (!values.mcp.trim() || agenticWorkflowJsonValidation(values.mcp) === true) + : page === 'outputs' + ? agenticWorkflowOutputsValidation(values.outputs) === true + : true const addRepository = () => form.setValue('repositories', [...values.repositories, { repository: '', branch: '' }], { shouldDirty: true }) @@ -258,6 +283,20 @@ export function AgenticWorkflowSettings({ page }: AgenticWorkflowSettingsProps) description="Allow this agent task to listen for and process incoming requests." onChange={(value) => form.setValue('enabled', value, { shouldDirty: true })} /> +
+

Schedule

+

+ Configure when this agent task runs automatically in addition to webhook requests. +

+ + + + {nextRunAt ? ( +

+ Next run: {new Date(nextRunAt).toLocaleString(undefined, { timeZone: values.timezone })} +

+ ) : null} +

Resources

diff --git a/libs/domains/services/feature/src/index.ts b/libs/domains/services/feature/src/index.ts index 7a518746fce..62a3e404f84 100644 --- a/libs/domains/services/feature/src/index.ts +++ b/libs/domains/services/feature/src/index.ts @@ -8,6 +8,7 @@ export * from './lib/service-advanced-settings/service-advanced-settings' export * from './lib/general-setting/general-setting' export * from './lib/build-settings/build-settings' export * from './lib/timezone-setting/timezone-setting' +export * from './lib/service-creation-flow/agentic-workflow/agentic-workflow-schedule-fields' export * from './lib/general-container-settings/general-container-settings' export * from './lib/job-general-settings/job-general-settings' export * from './lib/entrypoint-cmd-inputs/entrypoint-cmd-inputs' 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 edee1c7bb26..e929b3725ad 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 @@ -23,6 +23,7 @@ import { type AgenticWorkflowOutput, useAgenticWorkflowCreateContext, } from '../agentic-workflow-context' +import { AgenticWorkflowScheduleFields, isAgenticWorkflowScheduleValid } from '../agentic-workflow-schedule-fields' import { AIModelCards } from './ai-model-cards' import { GitRepositoryCard } from './git-repository-card' @@ -235,7 +236,7 @@ export function AgenticWorkflowConfiguration() { const showNameError = Boolean(dirtyFields.name) && !values.name.trim() const showModelApiKeyError = Boolean(dirtyFields.modelApiKey) && !values.modelApiKey.trim() const sectionInvalid: Record = { - 'service-information': !values.name.trim(), + 'service-information': !values.name.trim() || !isAgenticWorkflowScheduleValid(values), 'ai-model': !values.modelApiKey.trim() || Boolean(modelSettingsJsonError), connectors: Boolean(mcpJsonError), 'git-repositories': !gitRepositoriesValid, @@ -254,7 +255,8 @@ export function AgenticWorkflowConfiguration() { !mcpJsonError && outputHeadersErrors.every((error) => !error) && !modelSettingsJsonError && - variablesValid + variablesValid && + isAgenticWorkflowScheduleValid(values) useEffect(() => { setCurrentStep(1) @@ -378,7 +380,11 @@ export function AgenticWorkflowConfiguration() { description="Start listening and executing this agent task as soon as it is created." onChange={(value) => form.setValue('workflowEnabled', value, { shouldDirty: true })} /> - + + { expect(formatAgenticWorkflowRequest(values).mcp_server_ids).toEqual(['mcp-1', 'mcp-2']) }) + it('sends an optional schedule', () => { + expect(formatAgenticWorkflowRequest(values).schedule).toBeNull() + expect(formatAgenticWorkflowRequest({ ...values, scheduleEnabled: true }).schedule).toEqual({ + cron_expression: '0 8 * * 1-5', + timezone: 'Europe/Paris', + }) + }) + it('uses the full URL of a selected Git repository', () => { const request = formatAgenticWorkflowRequest({ ...values, diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts index 209bf0e1b37..e767820a6b7 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-request.ts @@ -30,6 +30,12 @@ export function formatAgenticWorkflowRequest(values: AgenticWorkflowFormData): A description: values.description, docker_fragment: values.dockerFragment, enabled: values.workflowEnabled, + schedule: values.scheduleEnabled + ? { + cron_expression: values.scheduleCronExpression, + timezone: values.timezone, + } + : null, mcp: values.mcpJson.trim() || undefined, mcp_server_ids: values.mcpServerIds, outputs: values.outputs.map((output, index) => ({ diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-schedule-fields.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-schedule-fields.spec.tsx new file mode 100644 index 00000000000..45a41c3d3e2 --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-schedule-fields.spec.tsx @@ -0,0 +1,39 @@ +import { wrapWithReactHookForm } from '__tests__/utils/wrap-with-react-hook-form' +import { renderWithProviders, screen } from '@qovery/shared/util-tests' +import { AgenticWorkflowScheduleFields, isAgenticWorkflowScheduleValid } from './agentic-workflow-schedule-fields' + +describe('AgenticWorkflowScheduleFields', () => { + it('links to the CRON expression builder when scheduling is enabled', () => { + renderWithProviders( + wrapWithReactHookForm(, { + defaultValues: { + scheduleEnabled: true, + scheduleCronExpression: '0 8 * * 1-5', + timezone: 'Etc/UTC', + }, + }) + ) + + expect(screen.getByRole('link', { name: 'CRON expression builder' })).toHaveAttribute( + 'href', + 'https://crontab.guru/' + ) + }) + + it('requires a valid cron expression only when scheduling is enabled', () => { + expect( + isAgenticWorkflowScheduleValid({ + scheduleEnabled: false, + scheduleCronExpression: 'invalid', + timezone: 'Etc/UTC', + }) + ).toBe(true) + expect( + isAgenticWorkflowScheduleValid({ + scheduleEnabled: true, + scheduleCronExpression: 'invalid', + timezone: 'Etc/UTC', + }) + ).toBe(false) + }) +}) diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-schedule-fields.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-schedule-fields.tsx new file mode 100644 index 00000000000..7b20cdaebac --- /dev/null +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-schedule-fields.tsx @@ -0,0 +1,65 @@ +import { Controller, useFormContext } from 'react-hook-form' +import { ExternalLink, InputText, InputToggle } from '@qovery/shared/ui' +import { formatCronExpression } from '@qovery/shared/util-js' +import { TimezoneSetting } from '../../timezone-setting/timezone-setting' + +export interface AgenticWorkflowScheduleFormValues { + scheduleEnabled: boolean + scheduleCronExpression: string + timezone: string +} + +export function isAgenticWorkflowScheduleValid(values: AgenticWorkflowScheduleFormValues) { + return !values.scheduleEnabled || Boolean(formatCronExpression(values.scheduleCronExpression)) +} + +export function AgenticWorkflowScheduleFields() { + const { control, setValue, watch } = useFormContext() + const scheduleEnabled = watch('scheduleEnabled') + const scheduleCronExpression = watch('scheduleCronExpression') + const timezone = watch('timezone') + const formattedSchedule = formatCronExpression(scheduleCronExpression) + + return ( +

+ setValue('scheduleEnabled', value, { shouldDirty: true, shouldValidate: true })} + /> + {scheduleEnabled ? ( +
+
+ + CRON expression builder + +
+
+ Boolean(formatCronExpression(value)) || 'Invalid cron expression.', + }} + render={({ field, fieldState: { error } }) => ( + + )} + /> + +
+
+ ) : null} +
+ ) +} diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-summary.spec.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-summary.spec.tsx index 1174f192c1a..69c6733d44c 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-summary.spec.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-summary.spec.tsx @@ -126,6 +126,7 @@ describe('AgenticWorkflowSummary', () => { expect(screen.getByText(validValues.mcpJson ?? '')).toBeInTheDocument() expect(screen.getByText('Documentation, Tickets')).toBeInTheDocument() expect(screen.getByText('1 webhook')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Edit Schedule' })).not.toBeInTheDocument() expect(mockNavigate).not.toHaveBeenCalledWith({ to: '/create/agentic-workflow/configuration' }) }) @@ -137,6 +138,15 @@ describe('AgenticWorkflowSummary', () => { expect(mockNavigate).toHaveBeenCalledWith({ to: '/create/agentic-workflow/configuration' }) }) + it('should redirect an invalid enabled schedule back to configuration', () => { + renderSummary({ + scheduleEnabled: true, + scheduleCronExpression: 'invalid', + }) + + expect(mockNavigate).toHaveBeenCalledWith({ to: '/create/agentic-workflow/configuration' }) + }) + it('should create the agentic workflow and navigate back to the environment overview', async () => { const { userEvent } = renderSummary() diff --git a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-summary.tsx b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-summary.tsx index 0d3c7c678b2..c10c72218e2 100644 --- a/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-summary.tsx +++ b/libs/domains/services/feature/src/lib/service-creation-flow/agentic-workflow/agentic-workflow-summary.tsx @@ -17,6 +17,7 @@ import { useAgenticWorkflowCreateContext, } from './agentic-workflow-context' import { formatAgenticWorkflowRequest } from './agentic-workflow-request' +import { isAgenticWorkflowScheduleValid } from './agentic-workflow-schedule-fields' function truncateSummary(value: string) { if (!value.trim()) return '-' @@ -106,6 +107,7 @@ export function AgenticWorkflowSummary() { !values.agentPrompt.trim() || hasIncompleteGitRepository(values) || hasIncompleteOutput(values) || + !isAgenticWorkflowScheduleValid(values) || !variablesValid ) { navigate({ to: `${creationFlowUrl}/configuration` }) @@ -168,6 +170,13 @@ export function AgenticWorkflowSummary() { + + {values.scheduleEnabled ? ( + <> + + + + ) : null} handleEditSection('ai-model')}> diff --git a/package.json b/package.json index f8a30b0b7f6..5b310991000 100644 --- a/package.json +++ b/package.json @@ -77,7 +77,7 @@ "mermaid": "11.6.0", "monaco-editor": "0.53.0", "posthog-js": "1.345.1", - "qovery-typescript-axios": "1.1.958", + "qovery-typescript-axios": "1.1.962", "react": "18.3.1", "react-country-flag": "3.0.2", "react-datepicker": "4.12.0", diff --git a/yarn.lock b/yarn.lock index 4bcd007c6e8..772e780d114 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6343,7 +6343,7 @@ __metadata: prettier: 3.2.5 prettier-plugin-tailwindcss: 0.5.14 pretty-quick: 4.0.0 - qovery-typescript-axios: 1.1.958 + qovery-typescript-axios: 1.1.962 qovery-ws-typescript-axios: 0.1.644 react: 18.3.1 react-country-flag: 3.0.2 @@ -25863,12 +25863,12 @@ __metadata: languageName: node linkType: hard -"qovery-typescript-axios@npm:1.1.958": - version: 1.1.958 - resolution: "qovery-typescript-axios@npm:1.1.958" +"qovery-typescript-axios@npm:1.1.962": + version: 1.1.962 + resolution: "qovery-typescript-axios@npm:1.1.962" dependencies: axios: 1.18.1 - checksum: 91757e4ceb42ef4ec005a4d6b97ee39ec77a44958f4225292b38ed78cdbd52da373fed9cc688608da967147af2246d1b508067d1580a172af2caf2a21d6f57df + checksum: d26915192af2b90c62fd4f559ab5ba3ca9b7bc0ed9f9e3a8c8d7613de3b006d3e46699c07b83239967e60f6e40149e1c2259ee385bc689e3d851df8ca5ecaabe languageName: node linkType: hard