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 (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -22,9 +21,7 @@ import (
|
|||||||
|
|
||||||
"github.com/kami/maven/internal/delivery/ntfysink"
|
"github.com/kami/maven/internal/delivery/ntfysink"
|
||||||
"github.com/kami/maven/internal/delivery/telegramsink"
|
"github.com/kami/maven/internal/delivery/telegramsink"
|
||||||
"github.com/kami/maven/internal/morning"
|
|
||||||
"github.com/kami/maven/internal/update"
|
"github.com/kami/maven/internal/update"
|
||||||
"github.com/robfig/cron/v3"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config — the daemon's whole config tree. Loaded once at startup.
|
// Config — the daemon's whole config tree. Loaded once at startup.
|
||||||
@@ -282,51 +279,6 @@ type Config struct {
|
|||||||
NetScan *NetScanConfig `json:"netscan,omitempty"`
|
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
|
// DigestConfig — notification batching / digest mode. When enabled, eligible
|
||||||
// nudges (severity ≤ SeverityCeiling) are queued in memory instead of sent
|
// nudges (severity ≤ SeverityCeiling) are queued in memory instead of sent
|
||||||
// immediately. Every Window duration (or when MaxItems reached), the queue is
|
// immediately. Every Window duration (or when MaxItems reached), the queue is
|
||||||
@@ -567,20 +519,7 @@ func (c *Config) applyDefaults() {
|
|||||||
|
|
||||||
c.normaliseVoice()
|
c.normaliseVoice()
|
||||||
|
|
||||||
// routines: default severity to care-class (1) — the safe floor: a
|
c.normaliseRoutines()
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Config) validate() error {
|
func (c *Config) validate() error {
|
||||||
@@ -598,18 +537,8 @@ func (c *Config) validate() error {
|
|||||||
if err := c.validateVoice(); err != nil {
|
if err := c.validateVoice(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// routines: name + body required, cron must parse. A typo here should fail
|
if err := c.validateRoutines(); err != nil {
|
||||||
// at startup, not silently never fire.
|
return err
|
||||||
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.validateMCP(); err != nil {
|
if err := c.validateMCP(); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -629,46 +558,9 @@ func (c *Config) validate() error {
|
|||||||
if err := c.validateCapture(); err != nil {
|
if err := c.validateCapture(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(c.MorningRoutines) > 0 {
|
|
||||||
if err := morning.Validate(morningRoutinesFromConfig(c.MorningRoutines)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
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
|
// DBEncryptionKey resolves the at-rest encryption key: DBKeyEnv (if set) wins
|
||||||
// over DBKeyB64. Returns (nil, nil) when neither is set — the caller then opens
|
// 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,
|
// 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