Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
Expand Down Expand Up @@ -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')
Expand All @@ -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(<AgenticWorkflowSettings page="general" />)

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(<AgenticWorkflowSettings page="general" />)

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(<AgenticWorkflowSettings page="ai-configuration" />)

Expand Down
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -25,6 +27,9 @@ interface FormValues {
name: string
description: string
enabled: boolean
scheduleEnabled: boolean
scheduleCronExpression: string
timezone: string
modelApiKey: string
modelSettings: string
agentPrompt: string
Expand Down Expand Up @@ -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 : '',
Expand Down Expand Up @@ -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,
Expand All @@ -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),
Expand Down Expand Up @@ -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 })

Expand Down Expand Up @@ -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 })}
/>
<div className="pt-6">
<h2 className="mb-1 text-base font-medium text-neutral">Schedule</h2>
<p className="mb-4 text-sm text-neutral-subtle">
Configure when this agent task runs automatically in addition to webhook requests.
</p>
<FormProvider {...form}>
<AgenticWorkflowScheduleFields />
</FormProvider>
{nextRunAt ? (
<p className="mt-2 text-xs text-neutral-subtle">
Next run: {new Date(nextRunAt).toLocaleString(undefined, { timeZone: values.timezone })}
</p>
) : null}
</div>
<div className="pt-6">
<h2 className="mb-1 text-base font-medium text-neutral">Resources</h2>
<p className="mb-4 text-sm text-neutral-subtle">
Expand Down
1 change: 1 addition & 0 deletions libs/domains/services/feature/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<AgenticWorkflowConfigurationSection, boolean> = {
'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,
Expand All @@ -254,7 +255,8 @@ export function AgenticWorkflowConfiguration() {
!mcpJsonError &&
outputHeadersErrors.every((error) => !error) &&
!modelSettingsJsonError &&
variablesValid
variablesValid &&
isAgenticWorkflowScheduleValid(values)

useEffect(() => {
setCurrentStep(1)
Expand Down Expand Up @@ -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 })}
/>
<ContinueButton disabled={!values.name.trim()} onClick={goToNextSection} />
<AgenticWorkflowScheduleFields />
<ContinueButton
disabled={!values.name.trim() || !isAgenticWorkflowScheduleValid(values)}
onClick={goToNextSection}
/>
</AgenticWorkflowSection>

<AgenticWorkflowSection
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ export interface AgenticWorkflowFormData {
memory: string
storage: string
workflowEnabled: boolean
scheduleEnabled: boolean
scheduleCronExpression: string
timezone: string
aiModel: AgenticWorkflowModelType
webhookEnabled: boolean
mcpServerIds: string[]
Expand Down Expand Up @@ -147,6 +150,9 @@ export function AgenticWorkflowCreationFlow({ children, creationFlowUrl, onExit
memory: '2048',
storage: '10',
workflowEnabled: true,
scheduleEnabled: false,
scheduleCronExpression: '0 8 * * 1-5',
timezone: 'Etc/UTC',
aiModel: AgenticWorkflowModelType.CLAUDE,
webhookEnabled: true,
mcpServerIds: [],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ const values: AgenticWorkflowFormData = {
memory: '2048',
storage: '10',
workflowEnabled: true,
scheduleEnabled: false,
scheduleCronExpression: '0 8 * * 1-5',
timezone: 'Europe/Paris',
aiModel: AgenticWorkflowModelType.CLAUDE,
webhookEnabled: true,
mcpServerIds: ['mcp-1', 'mcp-2'],
Expand All @@ -27,6 +30,14 @@ describe('formatAgenticWorkflowRequest', () => {
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) => ({
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<AgenticWorkflowScheduleFields />, {
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)
})
})
Original file line number Diff line number Diff line change
@@ -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<AgenticWorkflowScheduleFormValues>()
const scheduleEnabled = watch('scheduleEnabled')
const scheduleCronExpression = watch('scheduleCronExpression')
const timezone = watch('timezone')
const formattedSchedule = formatCronExpression(scheduleCronExpression)

return (
<div className="flex flex-col gap-4">
<InputToggle
small
align="top"
value={scheduleEnabled}
title="Schedule agent task"
description="Run this agent task automatically on top of its webhook."
onChange={(value) => setValue('scheduleEnabled', value, { shouldDirty: true, shouldValidate: true })}
/>
{scheduleEnabled ? (
<div className="flex flex-col gap-3">
<div className="flex justify-end">
<ExternalLink href="https://crontab.guru/" size="sm">
CRON expression builder
</ExternalLink>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<Controller
name="scheduleCronExpression"
control={control}
rules={{
required: 'Please enter a cron expression.',
validate: (value) => Boolean(formatCronExpression(value)) || 'Invalid cron expression.',
}}
render={({ field, fieldState: { error } }) => (
<InputText
name={field.name}
label="Cron expression"
value={field.value}
hint={formattedSchedule ? `${formattedSchedule} (${timezone})` : undefined}
error={error?.message}
onChange={field.onChange}
/>
)}
/>
<TimezoneSetting />
</div>
</div>
) : null}
</div>
)
}
Loading
Loading