返回 DeepSeek-Reasonix
heartbeat_schedule.go
根目录 / desktop / heartbeat_schedule.go
1 package main
2
3 import (
4 "strconv"
5 "strings"
6 "time"
7 )
8
9 // parseInterval converts a string like "5m", "1h", "30s" to time.Duration.
10 // Suffix after '|' is stripped (e.g. "24h|daily@09:00" -> "24h").
11 // Empty or invalid strings return 0, nil (task will be skipped).
12 func parseInterval(s string) (time.Duration, error) {
13 if idx := strings.Index(s, "|"); idx >= 0 {
14 s = s[:idx]
15 }
16 if len(s) == 0 {
17 return 0, nil
18 }
19 // Support common suffixed intervals
20 switch s[len(s)-1] {
21 case 's', 'm', 'h':
22 return time.ParseDuration(s)
23 default:
24 // Try "Xm" as default assumption
25 return time.ParseDuration(s + "m")
26 }
27 }
28
29 // isCronExpr returns true when s looks like a valid 5-field cron expression
30 // (e.g. "0 * * * *", "*/15 * * * *", "0 9 * * 1-5"). Rejects expressions with
31 // out-of-range values (e.g. "99 * * * *") that would never match.
32 func isCronExpr(s string) bool {
33 fields := strings.Fields(s)
34 if len(fields) != 5 {
35 return false
36 }
37 // min, hour, dom, month, dow — dom/month are 1-based (0 can never match
38 // time.Day()/time.Month()), dow is 0-7 (7 = Sunday alias, matched in cronDue).
39 limits := []int{59, 23, 31, 12, 7}
40 mins := []int{0, 0, 1, 1, 0}
41 for i, f := range fields {
42 if f == "" {
43 return false
44 }
45 for _, c := range f {
46 if !strings.ContainsRune("0123456789*/-,", c) {
47 return false
48 }
49 }
50 // Reject out-of-range values in each field, plus zero/empty step
51 // values ("*/0" never fires) and descending ranges ("5-1" never matches).
52 for part := range strings.SplitSeq(f, ",") {
53 part = strings.TrimSpace(part)
54 base := part
55 if stepBase, stepText, ok := strings.Cut(part, "/"); ok {
56 step, err := strconv.Atoi(stepText)
57 if err != nil || step < 1 {
58 return false
59 }
60 base = stepBase
61 }
62 if base == "*" {
63 continue
64 }
65 if loText, hiText, ok := strings.Cut(base, "-"); ok {
66 lo, err1 := strconv.Atoi(loText)
67 hi, err2 := strconv.Atoi(hiText)
68 if err1 != nil || err2 != nil || lo < mins[i] || hi > limits[i] || lo > hi {
69 return false
70 }
71 } else {
72 v, err := strconv.Atoi(base)
73 if err != nil || v < mins[i] || v > limits[i] {
74 return false
75 }
76 }
77 }
78 }
79 return true
80 }
81
82 // cronMatchField checks whether a single value matches a cron field pattern.
83 func cronMatchField(pattern string, value, minValue, maxValue int) bool {
84 // Handle comma-separated lists
85 for part := range strings.SplitSeq(pattern, ",") {
86 part = strings.TrimSpace(part)
87 if cronMatchSingle(part, value, minValue, maxValue) {
88 return true
89 }
90 }
91 return false
92 }
93
94 func cronMatchSingle(pattern string, value, minValue, maxValue int) bool {
95 // Handle step values: */15, 1-10/2, 1/2. Wildcard steps are
96 // anchored at the field minimum, which matters for 1-based fields.
97 if rangePart, stepStr, ok := strings.Cut(pattern, "/"); ok {
98 step, err := strconv.Atoi(stepStr)
99 if err != nil || step <= 0 {
100 return false
101 }
102 if rangePart == "*" {
103 return value >= minValue && value <= maxValue && (value-minValue)%step == 0
104 }
105 // Range with step: 1-10/2
106 if lowText, highText, ok := strings.Cut(rangePart, "-"); ok {
107 low, _ := strconv.Atoi(lowText)
108 high, _ := strconv.Atoi(highText)
109 if value < low || value > high {
110 return false
111 }
112 return (value-low)%step == 0
113 }
114 low, err := strconv.Atoi(rangePart)
115 if err != nil || value < low || value > maxValue {
116 return false
117 }
118 return (value-low)%step == 0
119 }
120 // Handle ranges: 1-5
121 if lowText, highText, ok := strings.Cut(pattern, "-"); ok {
122 low, err1 := strconv.Atoi(lowText)
123 high, err2 := strconv.Atoi(highText)
124 if err1 != nil || err2 != nil {
125 return false
126 }
127 return value >= low && value <= high
128 }
129 // Handle wildcard
130 if pattern == "*" {
131 return true
132 }
133 // Handle literal value
134 v, err := strconv.Atoi(pattern)
135 if err != nil {
136 return false
137 }
138 return v == value
139 }
140
141 // cronDue checks whether a 5-field cron expression should fire at the given time.
142 func cronDue(expr string, t time.Time) bool {
143 if !isCronExpr(expr) {
144 return false
145 }
146 fields := strings.Fields(expr)
147 if len(fields) != 5 {
148 return false
149 }
150 // Minute/hour/month must all match. When both day fields are restricted,
151 // standard cron ORs them: "0 9 1 * 1" fires on the 1st or on Mondays,
152 // rather than only when the 1st happens to be a Monday.
153 domRestricted := fields[2] != "*"
154 dowRestricted := fields[4] != "*"
155 domMatch := cronMatchField(fields[2], t.Day(), 1, 31)
156 // Standard cron allows 7 as a Sunday alias in the dow field (weekday is
157 // 0-6 in Go); a pattern like "0 9 * * 7" must still fire on Sundays.
158 weekday := int(t.Weekday())
159 dowMatch := cronMatchField(fields[4], weekday, 0, 7) || (weekday == 0 && cronMatchField(fields[4], 7, 0, 7))
160 dayMatch := !domRestricted || !dowRestricted
161 if domRestricted && dowRestricted {
162 dayMatch = domMatch || dowMatch
163 } else if domRestricted {
164 dayMatch = domMatch
165 } else if dowRestricted {
166 dayMatch = dowMatch
167 }
168 return cronMatchField(fields[0], t.Minute(), 0, 59) &&
169 cronMatchField(fields[1], t.Hour(), 0, 23) &&
170 dayMatch &&
171 cronMatchField(fields[3], int(t.Month()), 1, 12)
172 }
173
174 func heartbeatTaskDueAt(t HeartbeatTask, now time.Time) bool {
175 // Try cron expression first
176 if isCronExpr(t.Interval) {
177 if cronDue(t.Interval, now) {
178 if t.LastRunAt == 0 || time.UnixMilli(t.LastRunAt).Before(now.Truncate(time.Minute)) {
179 return true
180 }
181 }
182 return false
183 }
184
185 if scheduled, ok := previousHeartbeatScheduleAt(t, now); ok {
186 if t.CreatedAt != 0 && scheduled.Before(time.UnixMilli(t.CreatedAt)) {
187 return false
188 }
189 if t.LastRunAt != 0 && !time.UnixMilli(t.LastRunAt).Before(scheduled) {
190 return false
191 }
192 return !scheduled.After(now)
193 }
194
195 d, err := parseInterval(t.Interval)
196 if err != nil || d <= 0 {
197 return false
198 }
199 baseMillis := t.LastRunAt
200 if baseMillis == 0 {
201 baseMillis = t.CreatedAt
202 }
203 hasTimeWindow := t.TimeWindowStart != "" || t.TimeWindowEnd != ""
204 if baseMillis == 0 {
205 if hasTimeWindow {
206 return heartbeatWithinTimeWindow(t, now)
207 }
208 return true
209 }
210 if now.Sub(time.UnixMilli(baseMillis)) < d {
211 return false
212 }
213
214 // For interval-based tasks with a time window, check if current time
215 // falls within the configured window. If outside, defer until the next
216 // tick that falls within the window.
217 if hasTimeWindow {
218 return heartbeatWithinTimeWindow(t, now)
219 }
220
221 return true
222 }
223
224 // heartbeatWithinTimeWindow returns true when now falls within the task's
225 // configured time window. If the window is empty it returns true.
226 // Format: "HH:MM" in 24-hour clock; start inclusive, end exclusive.
227 func heartbeatWithinTimeWindow(t HeartbeatTask, now time.Time) bool {
228 startH, startM, startOK := parseHeartbeatClock(t.TimeWindowStart)
229 endH, endM, endOK := parseHeartbeatClock(t.TimeWindowEnd)
230
231 if !startOK && !endOK {
232 return true // no window configured
233 }
234
235 minutes := now.Hour()*60 + now.Minute()
236
237 // If only start is set: allow from start to end of day
238 if startOK && !endOK {
239 return minutes >= startH*60+startM
240 }
241
242 // If only end is set: allow from midnight to end
243 if !startOK && endOK {
244 return minutes < endH*60+endM
245 }
246
247 startMin := startH*60 + startM
248 endMin := endH*60 + endM
249
250 if startMin < endMin {
251 // Normal window: 09:00-17:00
252 return minutes >= startMin && minutes < endMin
253 }
254 // Cross-midnight window: 22:00-06:00
255 return minutes >= startMin || minutes < endMin
256 }
257
258 type heartbeatSchedule struct {
259 kind string
260 days []time.Weekday
261 month int
262 day int
263 hour int
264 minute int
265 hasRules bool
266 }
267
268 func parseHeartbeatSchedule(interval string) (heartbeatSchedule, bool) {
269 _, after, ok0 := strings.Cut(interval, "|")
270 if !ok0 {
271 return heartbeatSchedule{}, false
272 }
273 raw := strings.TrimSpace(after)
274 if raw == "" {
275 return heartbeatSchedule{}, false
276 }
277 at := "09:00"
278 if parts := strings.SplitN(raw, "@", 2); len(parts) == 2 {
279 raw = parts[0]
280 at = parts[1]
281 }
282 hour, minute, ok := parseHeartbeatClock(at)
283 if !ok {
284 return heartbeatSchedule{}, false
285 }
286 kind := raw
287 rule := ""
288 if parts := strings.SplitN(raw, ":", 2); len(parts) == 2 {
289 kind = parts[0]
290 rule = parts[1]
291 }
292 s := heartbeatSchedule{kind: kind, hour: hour, minute: minute, hasRules: true}
293 switch kind {
294 case "daily":
295 return s, true
296 case "weekly", "biweekly":
297 for part := range strings.SplitSeq(rule, ",") {
298 if wd, ok := parseHeartbeatWeekday(part); ok {
299 s.days = append(s.days, wd)
300 }
301 }
302 return s, len(s.days) > 0
303 case "monthly":
304 s.day = parsePositiveInt(rule, 1)
305 return s, true
306 case "yearly":
307 parts := strings.SplitN(rule, "-", 2)
308 s.month = parsePositiveInt(firstString(parts), 1)
309 s.day = 1
310 if len(parts) == 2 {
311 s.day = parsePositiveInt(parts[1], 1)
312 }
313 if s.month < 1 {
314 s.month = 1
315 }
316 if s.month > 12 {
317 s.month = 12
318 }
319 return s, true
320 default:
321 return heartbeatSchedule{}, false
322 }
323 }
324
325 func previousHeartbeatScheduleAt(t HeartbeatTask, now time.Time) (time.Time, bool) {
326 s, ok := parseHeartbeatSchedule(t.Interval)
327 if !ok || !s.hasRules {
328 return time.Time{}, false
329 }
330 switch s.kind {
331 case "daily":
332 candidate := dateAt(now.Year(), now.Month(), now.Day(), s.hour, s.minute, now.Location())
333 if candidate.After(now) {
334 candidate = candidate.AddDate(0, 0, -1)
335 }
336 return candidate, true
337 case "weekly":
338 return previousHeartbeatWeeklyAt(s, now, 7, time.Time{})
339 case "biweekly":
340 anchor := heartbeatScheduleAnchor(t, now)
341 return previousHeartbeatWeeklyAt(s, now, 14, anchor)
342 case "monthly":
343 return previousHeartbeatMonthlyAt(s, now), true
344 case "yearly":
345 return previousHeartbeatYearlyAt(s, now), true
346 default:
347 return time.Time{}, false
348 }
349 }
350
351 func previousHeartbeatWeeklyAt(s heartbeatSchedule, now time.Time, windowDays int, anchor time.Time) (time.Time, bool) {
352 var best time.Time
353 for offset := range windowDays {
354 day := now.AddDate(0, 0, -offset)
355 for _, wd := range s.days {
356 if day.Weekday() != wd {
357 continue
358 }
359 candidate := dateAt(day.Year(), day.Month(), day.Day(), s.hour, s.minute, now.Location())
360 if candidate.After(now) {
361 continue
362 }
363 if !anchor.IsZero() && weeksBetween(weekStart(anchor), weekStart(candidate))%2 != 0 {
364 continue
365 }
366 if best.IsZero() || candidate.After(best) {
367 best = candidate
368 }
369 }
370 }
371 return best, !best.IsZero()
372 }
373
374 func previousHeartbeatMonthlyAt(s heartbeatSchedule, now time.Time) time.Time {
375 candidate := monthlyCandidate(now.Year(), now.Month(), s.day, s.hour, s.minute, now.Location())
376 if candidate.After(now) {
377 prev := now.AddDate(0, -1, 0)
378 candidate = monthlyCandidate(prev.Year(), prev.Month(), s.day, s.hour, s.minute, now.Location())
379 }
380 return candidate
381 }
382
383 func previousHeartbeatYearlyAt(s heartbeatSchedule, now time.Time) time.Time {
384 month := time.Month(s.month)
385 candidate := monthlyCandidate(now.Year(), month, s.day, s.hour, s.minute, now.Location())
386 if candidate.After(now) {
387 candidate = monthlyCandidate(now.Year()-1, month, s.day, s.hour, s.minute, now.Location())
388 }
389 return candidate
390 }
391
392 func heartbeatScheduleAnchor(t HeartbeatTask, now time.Time) time.Time {
393 if t.CreatedAt != 0 {
394 return time.UnixMilli(t.CreatedAt)
395 }
396 if t.LastRunAt != 0 {
397 return time.UnixMilli(t.LastRunAt)
398 }
399 return now
400 }
401
402 func parseHeartbeatClock(s string) (int, int, bool) {
403 parts := strings.SplitN(strings.TrimSpace(s), ":", 2)
404 if len(parts) != 2 {
405 return 0, 0, false
406 }
407 hour := parsePositiveInt(parts[0], -1)
408 minute := parsePositiveInt(parts[1], -1)
409 if hour < 0 || hour > 23 || minute < 0 || minute > 59 {
410 return 0, 0, false
411 }
412 return hour, minute, true
413 }
414
415 func parseHeartbeatWeekday(s string) (time.Weekday, bool) {
416 switch strings.ToLower(strings.TrimSpace(s)) {
417 case "sun":
418 return time.Sunday, true
419 case "mon":
420 return time.Monday, true
421 case "tue":
422 return time.Tuesday, true
423 case "wed":
424 return time.Wednesday, true
425 case "thu":
426 return time.Thursday, true
427 case "fri":
428 return time.Friday, true
429 case "sat":
430 return time.Saturday, true
431 default:
432 return time.Sunday, false
433 }
434 }
435
436 func parsePositiveInt(s string, fallback int) int {
437 s = strings.TrimSpace(s)
438 if s == "" {
439 return fallback
440 }
441 n := 0
442 for _, r := range s {
443 if r < '0' || r > '9' {
444 return fallback
445 }
446 n = n*10 + int(r-'0')
447 }
448 return n
449 }
450
451 func firstString(values []string) string {
452 if len(values) == 0 {
453 return ""
454 }
455 return values[0]
456 }
457
458 func dateAt(year int, month time.Month, day, hour, minute int, loc *time.Location) time.Time {
459 return time.Date(year, month, day, hour, minute, 0, 0, loc)
460 }
461
462 func monthlyCandidate(year int, month time.Month, day, hour, minute int, loc *time.Location) time.Time {
463 if day < 1 {
464 day = 1
465 }
466 if max := daysInMonth(year, month, loc); day > max {
467 day = max
468 }
469 return dateAt(year, month, day, hour, minute, loc)
470 }
471
472 func daysInMonth(year int, month time.Month, loc *time.Location) int {
473 return time.Date(year, month+1, 0, 0, 0, 0, 0, loc).Day()
474 }
475
476 func weekStart(t time.Time) time.Time {
477 dayOffset := (int(t.Weekday()) + 6) % 7
478 base := dateAt(t.Year(), t.Month(), t.Day(), 0, 0, t.Location())
479 return base.AddDate(0, 0, -dayOffset)
480 }
481
482 func weeksBetween(a, b time.Time) int {
483 // Compare civil dates in UTC rather than elapsed local hours. Local Monday
484 // midnights spanning DST can be 167 or 169 hours apart despite being exactly
485 // one calendar week apart.
486 aDay := time.Date(a.Year(), a.Month(), a.Day(), 0, 0, 0, 0, time.UTC)
487 bDay := time.Date(b.Year(), b.Month(), b.Day(), 0, 0, 0, 0, time.UTC)
488 if bDay.Before(aDay) {
489 aDay, bDay = bDay, aDay
490 }
491 return int(bDay.Sub(aDay) / (7 * 24 * time.Hour))
492 }
493
493 lines GO