Skip to content
Open
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
52 changes: 52 additions & 0 deletions api/projects/schedules.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/semaphoreui/semaphore/api/helpers"
"github.com/semaphoreui/semaphore/db"
"github.com/semaphoreui/semaphore/services/schedules"
"github.com/semaphoreui/semaphore/util"
)

// SchedulesMiddleware ensures a template exists and loads it to the context
Expand Down Expand Up @@ -126,6 +127,57 @@ func ValidateScheduleCronFormat(w http.ResponseWriter, r *http.Request) {
_ = validateCronFormat(schedule.CronFormat, w)
}

func GetNextRunTime(w http.ResponseWriter, r *http.Request) {
var req struct {
CronFormat string `json:"cron_format"`
}
if !helpers.Bind(w, r, &req) {
return
}

if req.CronFormat == "" {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "cron_format is required",
})
return
}

schedule, err := schedules.ParseCronAndSemantics(req.CronFormat)
if err != nil {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "Invalid cron format: " + err.Error(),
})
return
}

timezone := util.Config.Schedule.Timezone
if timezone == "" {
timezone = "UTC"
}

loc, err := time.LoadLocation(timezone)
if err != nil {
helpers.WriteJSON(w, http.StatusBadRequest, map[string]string{
"error": "Invalid timezone: " + err.Error(),
})
return
}

now := time.Now().In(loc)
nextRun := schedule.Next(now)

if nextRun.IsZero() {
helpers.WriteJSON(w, http.StatusOK, map[string]string{
"next_run_time": "",
})
return
}

helpers.WriteJSON(w, http.StatusOK, map[string]string{
"next_run_time": nextRun.UTC().Format(time.RFC3339),
})
}

// AddSchedule adds a template to the database
func AddSchedule(w http.ResponseWriter, r *http.Request) {
project := helpers.GetFromContext(r, "project").(db.Project)
Expand Down
1 change: 1 addition & 0 deletions api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,6 +320,7 @@ func Route(
projectUserAPI.Path("/schedules").HandlerFunc(projects.GetProjectSchedules).Methods("GET", "HEAD")
projectUserAPI.Path("/schedules").HandlerFunc(projects.AddSchedule).Methods("POST")
projectUserAPI.Path("/schedules/validate").HandlerFunc(projects.ValidateScheduleCronFormat).Methods("POST")
projectUserAPI.Path("/schedules/next").HandlerFunc(projects.GetNextRunTime).Methods("POST")

projectUserAPI.Path("/views").HandlerFunc(projects.GetViews).Methods("GET", "HEAD")
projectUserAPI.Path("/views").HandlerFunc(projects.AddView).Methods("POST")
Expand Down
57 changes: 55 additions & 2 deletions services/schedules/SchedulePool.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,12 +350,12 @@ func (p *SchedulePool) Refresh() {
}

func (p *SchedulePool) addRunner(runner ScheduleRunner, cronFormat string) (int, error) {
id, err := p.cron.AddJob(cronFormat, runner)

schedule, err := ParseCronAndSemantics(cronFormat)
if err != nil {
return 0, err
}

id := p.cron.Schedule(schedule, runner)
return int(id), nil
}

Expand Down Expand Up @@ -405,3 +405,56 @@ func ValidateCronFormat(cronFormat string) error {
_, err := cron.ParseStandard(cronFormat)
return err
}

// andSchedule wraps a cron.SpecSchedule so that day-of-month and day-of-week
// are combined with AND instead of the POSIX OR that robfig/cron implements.
// For example "0 6 25-31 * 6" fires only on Saturdays within the 25-31 range
// (the last Saturday of each month), not on every Saturday OR every 25th-31st.
//
// When either field is * (starBit set), robfig/cron already uses AND, so the
// wrapper delegates directly.
type andSchedule struct {
spec *cron.SpecSchedule
}

const cronStarBit = 1 << 63

func (s *andSchedule) Next(t time.Time) time.Time {
if s.spec.Dom&cronStarBit != 0 || s.spec.Dow&cronStarBit != 0 {
return s.spec.Next(t)
}

limit := t.AddDate(4, 0, 0)
candidate := t
for candidate.Before(limit) {
Comment on lines +427 to +429
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove 4-year cutoff when searching AND cron matches

andSchedule.Next stops scanning at t.AddDate(4, 0, 0), but valid day-of-month/day-of-week intersections can be much farther away (for example, 0 0 29 2 1 only matches when Feb 29 is Monday, which is often >4 years out). In those cases this returns zero and both /schedules/next and the real scheduler path (SchedulePool.addRunner) treat the schedule as having no future run, so valid cron jobs are silently never scheduled.

Useful? React with 👍 / 👎.

next := s.spec.Next(candidate)
if next.IsZero() || next.After(limit) {
break
}

domMatch := s.spec.Dom&(1<<uint(next.Day())) != 0
dowMatch := s.spec.Dow&(1<<uint(next.Weekday())) != 0

if domMatch && dowMatch {
return next
}
candidate = next
}
return time.Time{}
}

// ParseCronAndSemantics parses a standard 5-field cron expression and returns
// a Schedule that uses AND semantics for day-of-month + day-of-week.
func ParseCronAndSemantics(cronFormat string) (cron.Schedule, error) {
schedule, err := cron.ParseStandard(cronFormat)
if err != nil {
return nil, err
}

spec, ok := schedule.(*cron.SpecSchedule)
if !ok {
return schedule, nil
}

return &andSchedule{spec: spec}, nil
}
30 changes: 23 additions & 7 deletions web/src/components/ScheduleForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,7 @@ export default {
showInfo: true,
cronFormatError: null,
runAtInput: '',
cronNextRunTime: null,
};
},

Expand Down Expand Up @@ -598,13 +599,7 @@ export default {
return parsed.toDate();
}

try {
return CronExpressionParser.parse(this.item.cron_format, {
tz: this.timezone,
}).next().toDate();
} catch {
return null;
}
return this.cronNextRunTime;
},

refreshCheckboxes() {
Expand Down Expand Up @@ -675,6 +670,27 @@ export default {
this.months = fields.month.values;
this.timing = 'yearly';
}

this.fetchCronNextRunTime();
},

async fetchCronNextRunTime() {
if (!this.item.cron_format || this.type === 'run_at') {
this.cronNextRunTime = null;
return;
}

try {
const resp = await axios({
method: 'post',
url: `/api/project/${this.projectId}/schedules/next`,
data: { cron_format: this.item.cron_format },
responseType: 'json',
});
this.cronNextRunTime = new Date(resp.data.next_run_time);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Handle empty next_run_time before constructing Date

The API explicitly returns next_run_time: "" when no next occurrence exists, but this code always does new Date(resp.data.next_run_time), which yields Invalid Date for an empty string. Downstream formatters only guard null and call Intl.DateTimeFormat(...).formatToParts(date), which throws RangeError on invalid dates, so the schedule form can crash for cron expressions with no computed next run.

Useful? React with 👍 / 👎.

} catch {
this.cronNextRunTime = null;
}
},

afterLoadData() {
Expand Down
Loading