feat(schedule): support KOL subscription targets and random time windows
Extend schedule tasks to dispatch KOL subscription prompts in addition to prompt rules, add daily/weekly scheduling with fixed or random time windows, and track dispatch outcomes (last run, status, consecutive failures). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,177 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
TimeModeFixed = "fixed"
|
||||
TimeModeRandomWindow = "random_window"
|
||||
|
||||
maxRandomRunSearchDays = 370
|
||||
)
|
||||
|
||||
type RandomPolicy struct {
|
||||
Days []time.Weekday
|
||||
TimeMode string
|
||||
WindowStart time.Duration
|
||||
WindowEnd time.Duration
|
||||
Seed int64
|
||||
}
|
||||
|
||||
func ParseDayKey(value string) (time.Weekday, bool) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "sun", "sunday", "0", "7":
|
||||
return time.Sunday, true
|
||||
case "mon", "monday", "1":
|
||||
return time.Monday, true
|
||||
case "tue", "tuesday", "2":
|
||||
return time.Tuesday, true
|
||||
case "wed", "wednesday", "3":
|
||||
return time.Wednesday, true
|
||||
case "thu", "thursday", "4":
|
||||
return time.Thursday, true
|
||||
case "fri", "friday", "5":
|
||||
return time.Friday, true
|
||||
case "sat", "saturday", "6":
|
||||
return time.Saturday, true
|
||||
default:
|
||||
return time.Sunday, false
|
||||
}
|
||||
}
|
||||
|
||||
func DayKey(day time.Weekday) string {
|
||||
switch day {
|
||||
case time.Sunday:
|
||||
return "sun"
|
||||
case time.Monday:
|
||||
return "mon"
|
||||
case time.Tuesday:
|
||||
return "tue"
|
||||
case time.Wednesday:
|
||||
return "wed"
|
||||
case time.Thursday:
|
||||
return "thu"
|
||||
case time.Friday:
|
||||
return "fri"
|
||||
case time.Saturday:
|
||||
return "sat"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func ParseClockDuration(value string) (time.Duration, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 0, fmt.Errorf("time value is required")
|
||||
}
|
||||
parsed, err := time.Parse("15:04:05", trimmed)
|
||||
if err != nil {
|
||||
parsed, err = time.Parse("15:04", trimmed)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("parse time %q: %w", trimmed, err)
|
||||
}
|
||||
return time.Duration(parsed.Hour())*time.Hour +
|
||||
time.Duration(parsed.Minute())*time.Minute +
|
||||
time.Duration(parsed.Second())*time.Second, nil
|
||||
}
|
||||
|
||||
func FormatClockDuration(value time.Duration) string {
|
||||
if value < 0 {
|
||||
value = 0
|
||||
}
|
||||
value = value.Truncate(time.Second)
|
||||
hour := int(value / time.Hour)
|
||||
value -= time.Duration(hour) * time.Hour
|
||||
minute := int(value / time.Minute)
|
||||
value -= time.Duration(minute) * time.Minute
|
||||
second := int(value / time.Second)
|
||||
return fmt.Sprintf("%02d:%02d:%02d", hour, minute, second)
|
||||
}
|
||||
|
||||
func ComputeNextRandomRun(policy RandomPolicy, startAt, endAt *time.Time, now time.Time) (*time.Time, error) {
|
||||
return computeNextRandomRun(policy, startAt, endAt, now, maxRandomRunSearchDays)
|
||||
}
|
||||
|
||||
func computeNextRandomRun(policy RandomPolicy, startAt, endAt *time.Time, now time.Time, maxSearchDays int) (*time.Time, error) {
|
||||
days := normalizePolicyDays(policy.Days)
|
||||
if len(days) == 0 {
|
||||
return nil, fmt.Errorf("schedule days are required")
|
||||
}
|
||||
if strings.TrimSpace(policy.TimeMode) == "" {
|
||||
policy.TimeMode = TimeModeRandomWindow
|
||||
}
|
||||
if policy.TimeMode != TimeModeRandomWindow {
|
||||
return nil, fmt.Errorf("unsupported random policy time mode: %s", policy.TimeMode)
|
||||
}
|
||||
if policy.WindowEnd <= policy.WindowStart {
|
||||
return nil, fmt.Errorf("random window end must be after start")
|
||||
}
|
||||
|
||||
notBefore := now.In(time.Local)
|
||||
if startAt != nil {
|
||||
start := startAt.In(time.Local)
|
||||
if start.After(notBefore) {
|
||||
notBefore = start
|
||||
}
|
||||
}
|
||||
|
||||
allowed := make(map[time.Weekday]struct{}, len(days))
|
||||
for _, day := range days {
|
||||
allowed[day] = struct{}{}
|
||||
}
|
||||
|
||||
baseDate := time.Date(notBefore.Year(), notBefore.Month(), notBefore.Day(), 0, 0, 0, 0, notBefore.Location())
|
||||
for offset := 0; offset <= maxSearchDays; offset++ {
|
||||
dayStart := baseDate.AddDate(0, 0, offset)
|
||||
if _, ok := allowed[dayStart.Weekday()]; !ok {
|
||||
continue
|
||||
}
|
||||
runAt := dayStart.Add(policy.WindowStart + stableWindowOffset(policy.Seed, dayStart, policy.WindowEnd-policy.WindowStart))
|
||||
if runAt.Before(notBefore) || runAt.Equal(notBefore) {
|
||||
continue
|
||||
}
|
||||
if endAt != nil && runAt.After(endAt.In(time.Local)) {
|
||||
return nil, nil
|
||||
}
|
||||
return &runAt, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func normalizePolicyDays(days []time.Weekday) []time.Weekday {
|
||||
seen := make(map[time.Weekday]struct{}, len(days))
|
||||
result := make([]time.Weekday, 0, len(days))
|
||||
for _, day := range days {
|
||||
if day < time.Sunday || day > time.Saturday {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[day]; ok {
|
||||
continue
|
||||
}
|
||||
seen[day] = struct{}{}
|
||||
result = append(result, day)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func stableWindowOffset(seed int64, day time.Time, width time.Duration) time.Duration {
|
||||
widthSeconds := int64(width / time.Second)
|
||||
if widthSeconds <= 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var buf [16]byte
|
||||
binary.BigEndian.PutUint64(buf[:8], uint64(seed))
|
||||
binary.BigEndian.PutUint64(buf[8:], uint64(day.Year()*10000+int(day.Month())*100+day.Day()))
|
||||
sum := sha256.Sum256(buf[:])
|
||||
value := binary.BigEndian.Uint64(sum[:8])
|
||||
return time.Duration(int64(value%uint64(widthSeconds))) * time.Second
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package schedule
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestComputeNextRandomRunStaysInsideWindowAndIsStable(t *testing.T) {
|
||||
loc := time.Local
|
||||
now := time.Date(2026, 5, 18, 0, 30, 0, 0, loc) // Monday
|
||||
policy := RandomPolicy{
|
||||
Days: []time.Weekday{time.Monday},
|
||||
TimeMode: TimeModeRandomWindow,
|
||||
WindowStart: time.Hour,
|
||||
WindowEnd: 5 * time.Hour,
|
||||
Seed: 42,
|
||||
}
|
||||
|
||||
first, err := ComputeNextRandomRun(policy, nil, nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ComputeNextRandomRun returned error: %v", err)
|
||||
}
|
||||
second, err := ComputeNextRandomRun(policy, nil, nil, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ComputeNextRandomRun returned error on second call: %v", err)
|
||||
}
|
||||
if first == nil || second == nil {
|
||||
t.Fatal("expected next run")
|
||||
}
|
||||
if !first.Equal(*second) {
|
||||
t.Fatalf("expected stable run for same seed/day, got %v and %v", first, second)
|
||||
}
|
||||
if first.Weekday() != time.Monday {
|
||||
t.Fatalf("expected Monday run, got %s", first.Weekday())
|
||||
}
|
||||
clock := time.Duration(first.Hour())*time.Hour +
|
||||
time.Duration(first.Minute())*time.Minute +
|
||||
time.Duration(first.Second())*time.Second
|
||||
if clock < policy.WindowStart || clock >= policy.WindowEnd {
|
||||
t.Fatalf("run time %s outside window [%s, %s)", clock, policy.WindowStart, policy.WindowEnd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeNextRandomRunSkipsEqualNotBeforeBoundary(t *testing.T) {
|
||||
loc := time.Local
|
||||
day := time.Date(2026, 5, 18, 0, 0, 0, 0, loc) // Monday
|
||||
offset := stableWindowOffset(7, day, time.Hour)
|
||||
runAt := day.Add(time.Hour + offset)
|
||||
policy := RandomPolicy{
|
||||
Days: []time.Weekday{time.Monday},
|
||||
TimeMode: TimeModeRandomWindow,
|
||||
WindowStart: time.Hour,
|
||||
WindowEnd: 2 * time.Hour,
|
||||
Seed: 7,
|
||||
}
|
||||
|
||||
next, err := ComputeNextRandomRun(policy, nil, nil, runAt)
|
||||
if err != nil {
|
||||
t.Fatalf("ComputeNextRandomRun returned error: %v", err)
|
||||
}
|
||||
if next == nil {
|
||||
t.Fatal("expected next weekly run")
|
||||
}
|
||||
if !next.After(runAt) {
|
||||
t.Fatalf("expected equal boundary to roll forward after %v, got %v", runAt, next)
|
||||
}
|
||||
if next.Weekday() != time.Monday {
|
||||
t.Fatalf("expected next allowed weekday Monday, got %s", next.Weekday())
|
||||
}
|
||||
if next.Year() == runAt.Year() && next.YearDay() == runAt.YearDay() {
|
||||
t.Fatalf("expected equal boundary to skip same day, got %v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeNextRandomRunHonorsStartAtAndEndAt(t *testing.T) {
|
||||
loc := time.Local
|
||||
now := time.Date(2026, 5, 18, 0, 0, 0, 0, loc) // Monday
|
||||
startAt := time.Date(2026, 5, 20, 0, 0, 0, 0, loc) // Wednesday
|
||||
endAt := time.Date(2026, 5, 20, 5, 0, 0, 0, loc)
|
||||
policy := RandomPolicy{
|
||||
Days: []time.Weekday{time.Monday, time.Wednesday},
|
||||
TimeMode: TimeModeRandomWindow,
|
||||
WindowStart: time.Hour,
|
||||
WindowEnd: 2 * time.Hour,
|
||||
Seed: 99,
|
||||
}
|
||||
|
||||
next, err := ComputeNextRandomRun(policy, &startAt, &endAt, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ComputeNextRandomRun returned error: %v", err)
|
||||
}
|
||||
if next == nil {
|
||||
t.Fatal("expected run inside endAt")
|
||||
}
|
||||
if next.Weekday() != time.Wednesday {
|
||||
t.Fatalf("expected Wednesday after startAt, got %s", next.Weekday())
|
||||
}
|
||||
if next.Before(startAt) || next.After(endAt) {
|
||||
t.Fatalf("run %v outside [%v, %v]", next, startAt, endAt)
|
||||
}
|
||||
|
||||
endBeforeNextWindow := time.Date(2026, 5, 20, 0, 30, 0, 0, loc)
|
||||
next, err = ComputeNextRandomRun(policy, &startAt, &endBeforeNextWindow, now)
|
||||
if err != nil {
|
||||
t.Fatalf("ComputeNextRandomRun returned error with endAt: %v", err)
|
||||
}
|
||||
if next != nil {
|
||||
t.Fatalf("expected nil when next run exceeds endAt, got %v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeNextRandomRunRejectsInvalidPolicy(t *testing.T) {
|
||||
_, err := ComputeNextRandomRun(RandomPolicy{}, nil, nil, time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("expected missing days error")
|
||||
}
|
||||
|
||||
_, err = ComputeNextRandomRun(RandomPolicy{
|
||||
Days: []time.Weekday{time.Monday},
|
||||
TimeMode: TimeModeRandomWindow,
|
||||
WindowStart: 2 * time.Hour,
|
||||
WindowEnd: time.Hour,
|
||||
}, nil, nil, time.Now())
|
||||
if err == nil {
|
||||
t.Fatal("expected invalid window error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeNextRandomRunReturnsNilAfterSearchHorizon(t *testing.T) {
|
||||
loc := time.Local
|
||||
now := time.Date(2026, 5, 18, 23, 0, 0, 0, loc) // Monday after the random window
|
||||
policy := RandomPolicy{
|
||||
Days: []time.Weekday{time.Monday},
|
||||
TimeMode: TimeModeRandomWindow,
|
||||
WindowStart: time.Hour,
|
||||
WindowEnd: 2 * time.Hour,
|
||||
Seed: 1,
|
||||
}
|
||||
|
||||
next, err := computeNextRandomRun(policy, nil, nil, now, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("computeNextRandomRun returned error: %v", err)
|
||||
}
|
||||
if next != nil {
|
||||
t.Fatalf("expected nil after exhausting search horizon, got %v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStableWindowOffsetIsDeterministicAndBounded(t *testing.T) {
|
||||
day := time.Date(2026, 5, 18, 0, 0, 0, 0, time.Local)
|
||||
width := 4 * time.Hour
|
||||
|
||||
first := stableWindowOffset(123, day, width)
|
||||
second := stableWindowOffset(123, day, width)
|
||||
if first != second {
|
||||
t.Fatalf("expected deterministic offset, got %s and %s", first, second)
|
||||
}
|
||||
if first < 0 || first >= width {
|
||||
t.Fatalf("offset %s outside width %s", first, width)
|
||||
}
|
||||
if got := stableWindowOffset(123, day, 0); got != 0 {
|
||||
t.Fatalf("expected zero width offset to be 0, got %s", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user