config: routines and quiet hours move to their own file (V-410)
RoutineConfig, MorningRoutineConfig, MorningRoutineItemConfig, QuietHoursConfig, the two mappers, and the severity-floor and cron-parse arms as normaliseRoutines and validateRoutines. config.go drops the morning and cron imports.
This commit is contained in:
+3
-111
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user