diff --git a/frontend/app/src/lib/utils.ts b/frontend/app/src/lib/utils.ts index ce8959432..0a9165b8c 100644 --- a/frontend/app/src/lib/utils.ts +++ b/frontend/app/src/lib/utils.ts @@ -157,5 +157,16 @@ export const extractCronTz = (cron: string): string => { export const formatCron = (cron: string) => { const cronExpression = cron.replace(/^CRON_TZ=([^\s]+)\s+/, ''); - return CronPrettifier.toString(cronExpression || ''); + // Validate field count before formatting (only 5 or 6 fields supported) + const fields = cronExpression.trim().split(/\s+/); + if (fields.length !== 5 && fields.length !== 6) { + return `Invalid cron expression: must have 5 or 6 fields, got ${fields.length}`; + } + + try { + // cronstrue automatically handles both 5 and 6-field cron expressions + return CronPrettifier.toString(cronExpression || ''); + } catch (error) { + return `Invalid cron expression: ${error}`; + } }; diff --git a/frontend/app/src/pages/main/workflows/$workflow/components/trigger-workflow-form.tsx b/frontend/app/src/pages/main/workflows/$workflow/components/trigger-workflow-form.tsx index 7d260f1e0..363d95f32 100644 --- a/frontend/app/src/pages/main/workflows/$workflow/components/trigger-workflow-form.tsx +++ b/frontend/app/src/pages/main/workflows/$workflow/components/trigger-workflow-form.tsx @@ -57,7 +57,7 @@ export function TriggerWorkflowForm({ const [scheduleTime, setScheduleTime] = useState( new Date(), ); - const [cronExpression, setCronExpression] = useState('* * * * *'); + const [cronExpression, setCronExpression] = useState('0 * * * *'); const [cronName, setCronName] = useState(''); const [selectedWorkflowId, setSelectedWorkflowId] = useState< @@ -66,8 +66,30 @@ export function TriggerWorkflowForm({ const cronPretty = useMemo(() => { try { + const expr = cronExpression || ''; + + // Remove timezone prefix if present (CRON_TZ= or TZ=) + let cleanCron = expr; + if (expr.startsWith('CRON_TZ=')) { + const parts = expr.split(' ', 2); + if (parts.length === 2) { + cleanCron = parts[1]; + } + } else if (expr.startsWith('TZ=')) { + const parts = expr.split(' ', 2); + if (parts.length === 2) { + cleanCron = parts[1]; + } + } + + // Validate field count first (only 5 or 6 fields supported) + const fields = cleanCron.trim().split(/\s+/); + if (fields.length !== 5 && fields.length !== 6) { + return { error: `Invalid: must have 5 or 6 fields, got ${fields.length}` }; + } + return { - pretty: CronPrettifier.toString(cronExpression || '').toLowerCase(), + pretty: CronPrettifier.toString(expr).toLowerCase(), }; } catch (e) { console.error(e); @@ -411,12 +433,15 @@ export function TriggerWorkflowForm({ type="text" value={cronExpression} onChange={(e) => setCronExpression(e.target.value)} - placeholder="e.g., 0 0 * * *" + placeholder="e.g., 0 0 * * *, 0 0 0 * * *, or CRON_TZ=America/New_York 0 0 * * *" className="w-full" />
{cronPretty?.error || `(runs ${cronPretty?.pretty} UTC)`}
+
+ Supports 5-field (minute hour day month weekday) or 6-field (second minute hour day month weekday) format. Timezone prefix (CRON_TZ= or TZ=) optional. +
diff --git a/internal/services/ticker/cron.go b/internal/services/ticker/cron.go index 2d7a3a032..aaf645a12 100644 --- a/internal/services/ticker/cron.go +++ b/internal/services/ticker/cron.go @@ -15,6 +15,7 @@ import ( "github.com/hatchet-dev/hatchet/pkg/repository" "github.com/hatchet-dev/hatchet/pkg/repository/postgres/dbsqlc" "github.com/hatchet-dev/hatchet/pkg/repository/postgres/sqlchelpers" + "github.com/hatchet-dev/hatchet/pkg/validator" ) // runPollCronSchedules acquires a list of cron schedules from the database and schedules any which are not @@ -87,8 +88,9 @@ func (t *TickerImpl) handleScheduleCron(ctx context.Context, cron *dbsqlc.PollCr cronUUID := uuid.New() // schedule the cron + withSeconds := validator.CronHasSeconds(cron.Cron) _, err := t.userCronScheduler.NewJob( - gocron.CronJob(cron.Cron, false), + gocron.CronJob(cron.Cron, withSeconds), gocron.NewTask( t.runCronWorkflow(tenantId, workflowVersionId, cron.Cron, cronParentId, &cron.Name.String, cron.Input, additionalMetadata, &cron.Priority), ), diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index 213f3401a..5219998bd 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -3,6 +3,7 @@ package validator import ( "encoding/json" "regexp" + "strings" "time" "unicode" @@ -36,7 +37,37 @@ func newValidator() *validator.Validate { }) _ = validate.RegisterValidation("cron", func(fl validator.FieldLevel) bool { - _, err := cronParser.Parse(fl.Field().String()) + var err error + cronExpr := fl.Field().String() + + // Extract just the cron part for field count validation + cronPart := cronExpr + if strings.HasPrefix(cronExpr, "CRON_TZ=") { + parts := strings.SplitN(cronExpr, " ", 2) + if len(parts) == 2 { + cronPart = parts[1] + } + } else if strings.HasPrefix(cronExpr, "TZ=") { + parts := strings.SplitN(cronExpr, " ", 2) + if len(parts) == 2 { + cronPart = parts[1] + } + } + + // Validate field count first - only allow 5 or 6 fields + fields := strings.Fields(cronPart) + if len(fields) != 5 && len(fields) != 6 { + return false + } + + // Parse the cron part only - robfig/cron doesn't support timezone prefixes + if len(fields) == 6 { + // uses same logic as gocron internally does + p := cron.NewParser(cron.SecondOptional | cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + _, err = p.Parse(cronPart) + } else { + _, err = cronParser.Parse(cronPart) + } return err == nil }) @@ -116,3 +147,25 @@ func isValidJSON(s string) bool { var js map[string]interface{} return json.Unmarshal([]byte(s), &js) == nil } + +// CronHasSeconds checks if a cron expression includes seconds (6 fields) or not (5 fields) +// Also validates that the expression has exactly 5 or 6 fields (rejects 7+ field expressions) +// Supports timezone prefixes like CRON_TZ= and TZ= +func CronHasSeconds(cronExpr string) bool { + // Extract just the cron part for field count analysis + cronPart := cronExpr + if strings.HasPrefix(cronExpr, "CRON_TZ=") { + parts := strings.SplitN(cronExpr, " ", 2) + if len(parts) == 2 { + cronPart = parts[1] + } + } else if strings.HasPrefix(cronExpr, "TZ=") { + parts := strings.SplitN(cronExpr, " ", 2) + if len(parts) == 2 { + cronPart = parts[1] + } + } + + fields := strings.Fields(cronPart) + return len(fields) == 6 +} diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go index 8aa6246d5..f51fa9da9 100644 --- a/pkg/validator/validator_test.go +++ b/pkg/validator/validator_test.go @@ -56,6 +56,92 @@ func TestValidatorInvalidCron(t *testing.T) { assert.ErrorContains(t, err, "validation for 'Cron' failed on the 'cron' tag", "should throw error on invalid cron") } +func TestValidatorValidCronWithSeconds(t *testing.T) { + v := newValidator() + + // Test 6-field cron expressions (with seconds) + testCases := []string{ + "*/30 * * * * *", // Every 30 seconds + "0 */2 * * * *", // Every 2 minutes + "15 30 10 * * *", // Every day at 10:30:15 + "0 0 0 1 * *", // First day of every month at midnight + "*/15 * * * * MON-FRI", // Every 15 seconds on weekdays + } + + for _, cronExpr := range testCases { + err := v.Struct(&cronResource{ + Cron: cronExpr, + }) + assert.NoError(t, err, "should accept valid 6-field cron: %s", cronExpr) + } +} + +func TestValidatorInvalidCronWithSeconds(t *testing.T) { + v := newValidator() + + // Test invalid cron expressions (both 5 and 6-field) + testCases := []string{ + "* * * *", // Too few fields + "* * * * * * *", // Too many fields + "invalid * * * *", // Invalid field value + "60 * * * * *", // Invalid seconds (60) + "* 60 * * * *", // Invalid minutes (60) + "* * 24 * * *", // Invalid hours (24) + } + + for _, cronExpr := range testCases { + err := v.Struct(&cronResource{ + Cron: cronExpr, + }) + assert.ErrorContains(t, err, "validation for 'Cron' failed on the 'cron' tag", "should reject invalid cron: %s", cronExpr) + } +} + +func TestCronHasSecondsDetection(t *testing.T) { + testCases := []struct { + expr string + hasSeconds bool + description string + }{ + {"0 * * * *", false, "5-field standard cron"}, + {"*/5 * * * *", false, "5-field with wildcard"}, + {"0 0 * * *", false, "5-field daily"}, + {"*/30 * * * * *", true, "6-field with seconds"}, + {"0 */2 * * * *", true, "6-field every 2 minutes"}, + {"15 30 10 * * *", true, "6-field specific time"}, + {"0 0 0 1 * *", true, "6-field monthly"}, + // Test timezone prefixes + {"CRON_TZ=America/New_York 0 * * * *", false, "5-field with CRON_TZ"}, + {"TZ=UTC */30 * * * * *", true, "6-field with TZ"}, + {"CRON_TZ=Europe/London 15 30 10 * * *", true, "6-field with CRON_TZ"}, + } + + for _, tc := range testCases { + result := CronHasSeconds(tc.expr) + assert.Equal(t, tc.hasSeconds, result, "Detection failed for %s: %s", tc.expr, tc.description) + } +} + +func TestValidatorValidCronWithTimezone(t *testing.T) { + v := newValidator() + + // Test cron expressions with timezone prefixes + testCases := []string{ + "CRON_TZ=America/New_York 0 * * * *", // 5-field with CRON_TZ + "TZ=UTC */15 * * * *", // 5-field with TZ + "CRON_TZ=Europe/London */30 * * * * *", // 6-field with CRON_TZ + "TZ=Asia/Tokyo 0 */2 * * * *", // 6-field with TZ + "CRON_TZ=America/Los_Angeles 15 30 10 * * *", // 6-field specific time with timezone + } + + for _, cronExpr := range testCases { + err := v.Struct(&cronResource{ + Cron: cronExpr, + }) + assert.NoError(t, err, "should accept valid cron with timezone: %s", cronExpr) + } +} + func TestValidatorValidDuration(t *testing.T) { v := newValidator() diff --git a/sdks/go/features/crons.go b/sdks/go/features/crons.go index ceebbfca4..ed85e2ff8 100644 --- a/sdks/go/features/crons.go +++ b/sdks/go/features/crons.go @@ -2,6 +2,7 @@ package features import ( "context" + "strings" "github.com/google/uuid" "github.com/robfig/cron/v3" @@ -62,9 +63,26 @@ func NewCronsClient( } // ValidateCronExpression validates that a string is a valid cron expression. +// Supports both 5-field (standard) and 6-field (with seconds) cron expressions. +// Also supports timezone prefixes like CRON_TZ= and TZ=. func ValidateCronExpression(expression string) bool { - parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) - _, err := parser.Parse(expression) + // Extract cron part for validation (strip timezone if present) + cronPart := expression + if strings.HasPrefix(expression, "CRON_TZ=") { + parts := strings.SplitN(expression, " ", 2) + if len(parts) == 2 { + cronPart = parts[1] + } + } else if strings.HasPrefix(expression, "TZ=") { + parts := strings.SplitN(expression, " ", 2) + if len(parts) == 2 { + cronPart = parts[1] + } + } + + // Validate using robfig/cron parser (which doesn't support timezone prefixes) + parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.SecondOptional) + _, err := parser.Parse(cronPart) return err == nil } diff --git a/sdks/python/hatchet_sdk/features/cron.py b/sdks/python/hatchet_sdk/features/cron.py index add83b065..965462937 100644 --- a/sdks/python/hatchet_sdk/features/cron.py +++ b/sdks/python/hatchet_sdk/features/cron.py @@ -38,6 +38,8 @@ class CreateCronTriggerConfig(BaseModel): def validate_cron_expression(cls, v: str) -> str: """ Validates the cron expression to ensure it adheres to the expected format. + Supports both 5-field (minute hour day month weekday) and 6-field (second minute hour day month weekday) formats. + Also supports timezone prefixes like CRON_TZ= and TZ=. :param v: The cron expression to validate. @@ -48,10 +50,21 @@ class CreateCronTriggerConfig(BaseModel): if not v: raise ValueError("Cron expression is required") - parts = v.split() - if len(parts) != 5: + # Extract cron part for field count validation (timezone is supported but we validate the cron part) + cron_part = v + if v.startswith("CRON_TZ="): + parts = v.split(" ", 1) + if len(parts) == 2: + cron_part = parts[1] + elif v.startswith("TZ="): + parts = v.split(" ", 1) + if len(parts) == 2: + cron_part = parts[1] + + parts = cron_part.split() + if len(parts) not in [5, 6]: raise ValueError( - "Cron expression must have 5 parts: minute hour day month weekday" + "Cron expression must have 5 parts (minute hour day month weekday) or 6 parts (second minute hour day month weekday)" ) for part in parts: diff --git a/sdks/typescript/src/v1/client/features/crons.ts b/sdks/typescript/src/v1/client/features/crons.ts index aae6013f3..624490976 100644 --- a/sdks/typescript/src/v1/client/features/crons.ts +++ b/sdks/typescript/src/v1/client/features/crons.ts @@ -8,12 +8,45 @@ import { applyNamespace } from '@hatchet/util/apply-namespace'; import { HatchetClient } from '../client'; import { workflowNameString, WorkflowsClient } from './workflows'; +/** + * Validates a cron expression to support both 5-field and 6-field formats. + * Also supports timezone prefixes like CRON_TZ= and TZ=. + * @param expression - The cron expression to validate. + * @returns True if valid, false otherwise. + */ +function validateCronExpression(expression: string): boolean { + if (!expression || typeof expression !== 'string') { + return false; + } + + // Extract cron part for field count validation (timezone is supported but we validate the cron part) + let cronPart = expression; + if (expression.startsWith('CRON_TZ=')) { + const parts = expression.split(' ', 2); + if (parts.length === 2) { + cronPart = parts[1]; + } + } else if (expression.startsWith('TZ=')) { + const parts = expression.split(' ', 2); + if (parts.length === 2) { + cronPart = parts[1]; + } + } + + const fields = cronPart.trim().split(/\s+/); + + // Only allow 5 or 6 fields (not 7 with year field) + return fields.length === 5 || fields.length === 6; +} + /** * Schema for creating a Cron Trigger. */ export const CreateCronTriggerSchema = z.object({ name: z.string(), - expression: z.string(), + expression: z.string().refine(validateCronExpression, { + message: 'Cron expression must have 5 fields (minute hour day month weekday) or 6 fields (second minute hour day month weekday)', + }), input: z.record(z.any()).optional(), additionalMetadata: z.record(z.string()).optional(), priority: z.number().optional(),