diff --git a/internal/config/config.go b/internal/config/config.go index 07800f0..b6f83ce 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,7 +14,6 @@ package config import ( "encoding/base64" "encoding/json" - "errors" "fmt" "os" "path/filepath" @@ -22,9 +21,7 @@ import ( "github.com/kami/maven/internal/delivery/ntfysink" "github.com/kami/maven/internal/delivery/telegramsink" - "github.com/kami/maven/internal/morning" "github.com/kami/maven/internal/update" - "github.com/robfig/cron/v3" ) // Config — the daemon's whole config tree. Loaded once at startup. @@ -282,51 +279,6 @@ type Config struct { NetScan *NetScanConfig `json:"netscan,omitempty"` } -// RoutineConfig — one scheduled routine. Cron is a standard 5-field expression -// ("0 8 * * *" = 08:00 daily). Body is the RU text delivered verbatim (routines -// are not LLM-phrased). Severity (1-4, default 1) drives routing: care-class -// (≤2) is suppressed by quiet hours and drops when away; ops-class reaches away -// channels. -type RoutineConfig struct { - Name string `json:"name"` - Cron string `json:"cron"` - Body string `json:"body"` - Severity int `json:"severity,omitempty"` -} - -// MorningRoutineConfig — one daily checklist. WindowStart/WindowEnd/NudgeAt -// are "HH:MM" local time; NudgeAt empty defaults to WindowEnd. Weekdays are -// 0=Sunday..6=Saturday; empty means every day (set two routines under -// different names for weekday/weekend variants). -type MorningRoutineConfig struct { - Name string `json:"name"` - Weekdays []int `json:"weekdays,omitempty"` - WindowStart string `json:"window_start"` - WindowEnd string `json:"window_end"` - NudgeAt string `json:"nudge_at,omitempty"` - Severity int `json:"severity,omitempty"` - Items []MorningRoutineItemConfig `json:"items"` -} - -// MorningRoutineItemConfig — one checklist entry. FactKey is the fact whose -// presence within the window counts as completion evidence. -type MorningRoutineItemConfig struct { - Key string `json:"key"` - FactKey string `json:"fact_key"` - Label string `json:"label"` - // Optional — this one being skipped does not earn a nudge. Default false, - // so a routine written before 04-08-2026 keeps behaving as it did. - Optional bool `json:"optional,omitempty"` -} - -// QuietHoursConfig — a recurring daily quiet-window. Times are local to the -// server's wall clock. A window crossing midnight (Start > End) is handled: -// "23:00"-"08:00" means quiet from 23:00 to 08:00 the next day. -type QuietHoursConfig struct { - Start string `json:"start,omitempty"` // "HH:MM" local time, e.g. "23:00" - End string `json:"end,omitempty"` // "HH:MM" local time, e.g. "08:00" -} - // DigestConfig — notification batching / digest mode. When enabled, eligible // nudges (severity ≤ SeverityCeiling) are queued in memory instead of sent // immediately. Every Window duration (or when MaxItems reached), the queue is @@ -567,20 +519,7 @@ func (c *Config) applyDefaults() { c.normaliseVoice() - // routines: default severity to care-class (1) — the safe floor: a - // misconfigured routine can't blast an away channel at 3am. - for i := range c.Routines { - if c.Routines[i].Severity == 0 { - c.Routines[i].Severity = 1 - } - } - - // morning routines: same safe-floor default as cron routines. - for i := range c.MorningRoutines { - if c.MorningRoutines[i].Severity == 0 { - c.MorningRoutines[i].Severity = 1 - } - } + c.normaliseRoutines() } func (c *Config) validate() error { @@ -598,18 +537,8 @@ func (c *Config) validate() error { if err := c.validateVoice(); err != nil { return err } - // routines: name + body required, cron must parse. A typo here should fail - // at startup, not silently never fire. - for _, r := range c.Routines { - if r.Name == "" { - return errors.New("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) - } + if err := c.validateRoutines(); err != nil { + return err } if err := c.validateMCP(); err != nil { return err @@ -629,46 +558,9 @@ func (c *Config) validate() error { if err := c.validateCapture(); err != nil { return err } - if len(c.MorningRoutines) > 0 { - if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil { - return err - } - } return nil } -// morningRoutinesFromConfig maps the config's morning-routine blocks to the -// engine type. Shared with the daemon so config validation and daemon wiring -// can never drift on the mapping. -func morningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine { - out := make([]morning.Routine, len(mc)) - for i, r := range mc { - items := make([]morning.Item, len(r.Items)) - for j, it := range r.Items { - items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label, Optional: it.Optional} - } - weekdays := make([]time.Weekday, len(r.Weekdays)) - for j, w := range r.Weekdays { - weekdays[j] = time.Weekday(w) - } - out[i] = morning.Routine{ - Name: r.Name, - Weekdays: weekdays, - WindowStart: r.WindowStart, - WindowEnd: r.WindowEnd, - NudgeAt: r.NudgeAt, - Severity: r.Severity, - Items: items, - } - } - return out -} - -// MorningRoutinesFromConfig is the exported form daemon wiring uses. -func MorningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine { - return morningRoutinesFromConfig(mc) -} - // DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins // over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens // a plaintext store. A configured-but-invalid key is an error (fail closed, diff --git a/internal/config/routines.go b/internal/config/routines.go new file mode 100644 index 0000000..eb41110 --- /dev/null +++ b/internal/config/routines.go @@ -0,0 +1,131 @@ +package config + +import ( + "errors" + "fmt" + "time" + + "github.com/kami/maven/internal/morning" + "github.com/robfig/cron/v3" +) + +// The two scheduled things Maven says on her own, plus the window she does not +// say them in. Both routine kinds default to severity 1 — the care class, which +// quiet hours suppress and which never reaches an away channel. A routine that +// speaks at 3am has to be written that way on purpose. + +// RoutineConfig — one scheduled routine. Cron is a standard 5-field expression +// ("0 8 * * *" = 08:00 daily). Body is the RU text delivered verbatim (routines +// are not LLM-phrased). Severity (1-4, default 1) drives routing: care-class +// (≤2) is suppressed by quiet hours and drops when away; ops-class reaches away +// channels. +type RoutineConfig struct { + Name string `json:"name"` + Cron string `json:"cron"` + Body string `json:"body"` + Severity int `json:"severity,omitempty"` +} + +// MorningRoutineConfig — one daily checklist. WindowStart/WindowEnd/NudgeAt +// are "HH:MM" local time; NudgeAt empty defaults to WindowEnd. Weekdays are +// 0=Sunday..6=Saturday; empty means every day (set two routines under +// different names for weekday/weekend variants). +type MorningRoutineConfig struct { + Name string `json:"name"` + Weekdays []int `json:"weekdays,omitempty"` + WindowStart string `json:"window_start"` + WindowEnd string `json:"window_end"` + NudgeAt string `json:"nudge_at,omitempty"` + Severity int `json:"severity,omitempty"` + Items []MorningRoutineItemConfig `json:"items"` +} + +// MorningRoutineItemConfig — one checklist entry. FactKey is the fact whose +// presence within the window counts as completion evidence. +type MorningRoutineItemConfig struct { + Key string `json:"key"` + FactKey string `json:"fact_key"` + Label string `json:"label"` + // Optional — this one being skipped does not earn a nudge. Default false, + // so a routine written before 04-08-2026 keeps behaving as it did. + Optional bool `json:"optional,omitempty"` +} + +// QuietHoursConfig — a recurring daily quiet-window. Times are local to the +// server's wall clock. A window crossing midnight (Start > End) is handled: +// "23:00"-"08:00" means quiet from 23:00 to 08:00 the next day. +type QuietHoursConfig struct { + Start string `json:"start,omitempty"` // "HH:MM" local time, e.g. "23:00" + End string `json:"end,omitempty"` // "HH:MM" local time, e.g. "08:00" +} + +// normaliseRoutines defaults both routine kinds to severity 1, the care class: +// the safe floor, so a misconfigured routine cannot blast an away channel at +// 3am. +func (c *Config) normaliseRoutines() { + for i := range c.Routines { + if c.Routines[i].Severity == 0 { + c.Routines[i].Severity = 1 + } + } + for i := range c.MorningRoutines { + if c.MorningRoutines[i].Severity == 0 { + c.MorningRoutines[i].Severity = 1 + } + } +} + +// validateRoutines fails a routine that could never fire. A typo in a cron +// expression or a missing body should stop the daemon at startup, not go +// unnoticed as silence at 08:00 every day. +func (c *Config) validateRoutines() error { + for _, r := range c.Routines { + if r.Name == "" { + return errors.New("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) + } + } + if len(c.MorningRoutines) > 0 { + if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil { + return err + } + } + return nil +} + +// morningRoutinesFromConfig maps the config's morning-routine blocks to the +// engine type. Shared with the daemon so config validation and daemon wiring +// can never drift on the mapping. +func morningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine { + out := make([]morning.Routine, len(mc)) + for i, r := range mc { + items := make([]morning.Item, len(r.Items)) + for j, it := range r.Items { + items[j] = morning.Item{Key: it.Key, FactKey: it.FactKey, Label: it.Label, Optional: it.Optional} + } + weekdays := make([]time.Weekday, len(r.Weekdays)) + for j, w := range r.Weekdays { + weekdays[j] = time.Weekday(w) + } + out[i] = morning.Routine{ + Name: r.Name, + Weekdays: weekdays, + WindowStart: r.WindowStart, + WindowEnd: r.WindowEnd, + NudgeAt: r.NudgeAt, + Severity: r.Severity, + Items: items, + } + } + return out +} + +// MorningRoutinesFromConfig is the exported form daemon wiring uses. +func MorningRoutinesFromConfig(mc []MorningRoutineConfig) []morning.Routine { + return morningRoutinesFromConfig(mc) +}