// Package routine is maven's scheduled-behavior engine: things she does on a // cron schedule, independent of any reactive request or care predicate. // // A Routine is the third proactive class, distinct from the two that already // exist: // // - a Reminder (internal/store) is a USER-stated one-off/recurring intent — // "напомни завтра позвонить маме". It exists because the user asked. // - a care Rule (internal/loop) fires on WORLD-STATE predicates — water/meal/ // break/service_down. It exists because a snapshot crossed a threshold. // - a Routine is OPERATOR-declared clockwork — an 08:00 morning briefing, a // 22:00 wind-down. It exists purely because the clock said so. // // This package is pure: no store, no clock of its own, no I/O. The daemon's tick // driver owns the impurity (it holds the last-fired map and calls Due each tick), // exactly as it does for the loop package. That keeps the schedule logic here // unit-testable without time side effects. package routine import ( "fmt" "time" "github.com/robfig/cron/v3" ) // Routine — one scheduled behavior. Cron is a standard 5-field expression // (minute hour dom month dow). Body is the RU text delivered verbatim through // the normal dispatcher (routines are NOT LLM-phrased: the operator writes the // line, so it's deterministic and can't hallucinate). Severity (1-4) drives // routing the same way a nudge's does — care-class (≤2) is suppressed by quiet // hours and drops when away; ops-class (3-4) reaches away channels. type Routine struct { Name string Cron string Body string Severity int } // Validate reports the first structural problem with a routine set: a missing // name/cron/body or an unparseable cron expression. Called at config load so a // typo surfaces at startup, not as a silently-never-firing routine at runtime. func Validate(routines []Routine) error { for _, r := range routines { if r.Name == "" { return fmt.Errorf("routine: name is required") } if r.Body == "" { return fmt.Errorf("routine %q: body is required", r.Name) } if _, err := cron.ParseStandard(r.Cron); err != nil { return fmt.Errorf("routine %q: bad cron %q: %w", r.Name, r.Cron, err) } } return nil } // Accepted — an accepted routine proposal as the tick driver sees it. This is a // different shape from Routine: the schedule is a plain interval the pattern // detector measured, not an operator-written cron expression. Accepted is when // the human said yes; LastFired is nil until the first nudge. type Accepted struct { ID int64 Name string IntervalDays float64 Accepted time.Time LastFired *time.Time } // DueAccepted returns the accepted routines whose interval has passed. It does // not mutate anything — the caller persists the new last-fired time, because // that has to survive a restart (unlike Due's in-memory map). // // The clock starts at LastFired, or at Accepted for a routine that has never // nudged. A routine with a non-positive interval never fires: a bad interval // should mean silence, not a nudge every tick. // // One occurrence per call, no catch-up: the caller stamps the fire time as now, // so a routine that was silent for a month nudges once and then waits a full // interval. Never a backlog. func DueAccepted(rs []Accepted, now time.Time) []Accepted { var out []Accepted for _, r := range rs { if r.IntervalDays <= 0 { continue } since := r.Accepted if r.LastFired != nil { since = *r.LastFired } gap := time.Duration(r.IntervalDays * 24 * float64(time.Hour)) if !now.Before(since.Add(gap)) { out = append(out, r) } } return out } // Due returns the routines whose schedule crossed since their last fire and // records now as the new last-fire time for each one returned. The caller owns // `last` (the tick driver holds it across ticks); Due mutates it in place. // // Cold-start guard: a routine absent from `last` (never seen — fresh daemon, or // a newly-added routine) is SEEDED to now WITHOUT firing. Without this, a daemon // restart at 12:00 would replay the 08:00 briefing, because Next() computed from // the zero time is always in the past. The cost is that a routine can't fire in // the same tick the daemon booted — an acceptable trade for never replaying a // missed schedule on restart. // // A routine whose cron fails to parse is skipped (Validate rejects those at // load; this is defence in depth so a bad expression can't fire every tick). func Due(routines []Routine, last map[string]time.Time, now time.Time) []Routine { var out []Routine for _, r := range routines { sched, err := cron.ParseStandard(r.Cron) if err != nil { continue } prev, seen := last[r.Name] if !seen { last[r.Name] = now // seed on first sight — don't fire (cold-start guard) continue } if next := sched.Next(prev); !next.After(now) { last[r.Name] = now out = append(out, r) } } return out }