// Package schedule is the platform's answer to "run this later": the WHEN of // scheduled work, so an app that hibernates does not have to keep a process // alive to keep a timer. Spec: docs/superpowers/specs/2026-08-23-scheduled-work-design.md. // // This file is the cron dialect. Deliberately small (five fields, UTC, no // names, no @aliases — spec §4.1) and deliberately ours: the box binary is // built with GOPROXY=off, and a parser this size is cheaper to own than to // vendor. package schedule import ( "errors" "fmt" "strconv" "strings" "time" ) // CronExpr is a parsed five-field expression. Each field is a bitset over // its legal range (minute 0-59, hour 0-23, day-of-month 1-31, month 1-12, // day-of-week 0-6 with 7 folded onto 0). type CronExpr struct { min, hour, dom, month, dow uint64 raw string domStar, dowStar bool // "*" in a day field — the classic OR-vs-AND rule below } func (c *CronExpr) String() string { return c.raw } // ParseCron accepts "min hour dom month dow" with *, n, a-b, */n, a-b/n and // comma lists. Anything else — names, six fields, @daily — is an error with // the field named, because the member typing it has no box to debug on. func ParseCron(expr string) (*CronExpr, error) { fields := strings.Fields(expr) if len(fields) != 5 { return nil, fmt.Errorf("cron: want 5 fields (min hour day month weekday), got %d", len(fields)) } c := &CronExpr{raw: strings.Join(fields, " ")} var err error if c.min, _, err = parseField(fields[0], 0, 59, "minute"); err != nil { return nil, err } if c.hour, _, err = parseField(fields[1], 0, 23, "hour"); err != nil { return nil, err } if c.dom, c.domStar, err = parseField(fields[2], 1, 31, "day"); err != nil { return nil, err } if c.month, _, err = parseField(fields[3], 1, 12, "month"); err != nil { return nil, err } if c.dow, c.dowStar, err = parseField(fields[4], 0, 7, "weekday"); err != nil { return nil, err } if c.dow&(1<<7) != 0 { // 7 is Sunday too c.dow = (c.dow &^ (1 << 7)) | 1 } return c, nil } // parseField returns the bitset and whether the field was a bare "*". func parseField(f string, lo, hi int, name string) (uint64, bool, error) { var bits uint64 star := f == "*" for _, part := range strings.Split(f, ",") { rangePart, stepPart, hasStep := strings.Cut(part, "/") step := 1 if hasStep { n, err := strconv.Atoi(stepPart) if err != nil || n <= 0 { return 0, false, fmt.Errorf("cron %s: bad step %q", name, part) } step = n } a, b := lo, hi switch { case rangePart == "*": case strings.Contains(rangePart, "-"): as, bs, _ := strings.Cut(rangePart, "-") var err error if a, err = atoiIn(as, lo, hi); err != nil { return 0, false, fmt.Errorf("cron %s: %w", name, err) } if b, err = atoiIn(bs, lo, hi); err != nil { return 0, false, fmt.Errorf("cron %s: %w", name, err) } if a > b { return 0, false, fmt.Errorf("cron %s: range %q runs backwards", name, rangePart) } default: n, err := atoiIn(rangePart, lo, hi) if err != nil { return 0, false, fmt.Errorf("cron %s: %w", name, err) } a, b = n, n if hasStep { b = hi // "5/10" means from 5 to the end, stepping } } for v := a; v <= b; v += step { bits |= 1 << uint(v) } } if bits == 0 { return 0, false, errors.New("cron " + name + ": empty") } return bits, star, nil } func atoiIn(s string, lo, hi int) (int, error) { n, err := strconv.Atoi(s) if err != nil { return 0, fmt.Errorf("%q is not a number", s) } if n < lo || n > hi { return 0, fmt.Errorf("%d is outside %d-%d", n, lo, hi) } return n, nil } // Next is the first matching minute strictly after `after`, in UTC, or the // zero time if none falls within the search window (a "0 0 30 2 *" never // matches, and terminates quickly since each mismatched day is a single // jump, not a minute-by-minute walk). The window is 5 years, not 366 days: // "0 0 29 2 *" (Feb 29) can be up to ~4 years out depending on where `after` // falls in the leap-year cycle, and a 366-day window would wrongly report // "never" for it. func (c *CronExpr) Next(after time.Time) time.Time { t := after.UTC().Truncate(time.Minute).Add(time.Minute) limit := t.AddDate(5, 0, 0) for t.Before(limit) { if c.month&(1<