package skill import ( "fmt" "strings" "time" "github.com/robfig/cron/v3" ) // scheduleParser is the cron parser shared across the skills package. It // accepts the standard 5-field syntax (minute hour dom month dow) plus // descriptors such as @daily, @hourly, etc. We do not enable the seconds // field — schedule cadence is governed in minutes, and a seconds field // would invite specs that fire below the min-interval floor without // surfacing as such in the spec text. // // Why standalone vs. cron.ParseStandard: ParseStandard rejects descriptors // (@daily, @hourly). Skills callers may want to write @daily as a // shorthand alongside the explicit "daily" / "weekly" forms we translate // below. var scheduleParser = cron.NewParser( cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor, ) // ParseSchedule turns a user-supplied schedule expression into a // cron.Schedule. The empty string returns (nil, nil) — callers should // treat that as "on-demand only". // // Why: Skill.Schedule is a string field stored verbatim; the validator, // the scheduler runner, and any future tooling all need to round-trip // through the same parser. Centralising it here avoids drift. // // Accepted shorthands: // - "daily" → "0 0 * * *" (midnight UTC every day) // - "weekly" → "0 0 * * 0" (midnight UTC every Sunday) // // Anything else is fed through robfig/cron/v3's standard parser // (descriptors enabled). // // Test: schedule_test.go covers shorthand expansion and invalid-spec // rejection. func ParseSchedule(expr string) (cron.Schedule, error) { expr = strings.TrimSpace(expr) if expr == "" { return nil, nil } switch strings.ToLower(expr) { case "daily": expr = "0 0 * * *" case "weekly": expr = "0 0 * * 0" } sched, err := scheduleParser.Parse(expr) if err != nil { return nil, fmt.Errorf("invalid schedule %q: %w", expr, err) } return sched, nil } // ScheduleMinInterval returns an estimate of the smallest gap between // consecutive fire times for a parsed schedule. It samples the next two // fire times from a couple of starting points and returns the smallest // observed gap. // // Why: cron.Schedule does not expose a "smallest interval" API. The // validator needs this to enforce a per-skill min-interval floor (so an // admin can't accidentally register "* * * * *" and burn GPU minutes). // Two probe points are enough to catch irregular schedules whose tightest // gap appears at a particular point in the week (e.g. "0 9 * * 1,5", // where Mon→Fri is 4d but Fri→Mon is 3d — both sampled). // // Returns 0 if sched is nil. // // Test: schedule_test.go covers a "* * * * *" minute-interval probe and // the irregular Mon/Fri case. func ScheduleMinInterval(sched cron.Schedule) time.Duration { if sched == nil { return 0 } // Probe from a fixed reference and from a midweek offset. Six fire // times across two starts catches weekly irregularities (the worst // case is a schedule that fires once a week — we still get one gap // per probe). Using a wall-clock-independent reference keeps the // test deterministic. starts := []time.Time{ time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), // Monday 00:00 time.Date(2024, 1, 4, 12, 30, 0, 0, time.UTC), // Thursday 12:30 time.Date(2024, 6, 15, 23, 59, 59, 0, time.UTC), // mid-year, late } var min time.Duration for _, t := range starts { // Sample three consecutive fires per start to capture two gaps. f1 := sched.Next(t) f2 := sched.Next(f1) f3 := sched.Next(f2) for _, gap := range []time.Duration{f2.Sub(f1), f3.Sub(f2)} { if gap <= 0 { continue } if min == 0 || gap < min { min = gap } } } return min }