d52f60c54e
- Add CalendarEvents method to recordingAPI in auth_test.go - Add CalendarEvents method to fakeCore in handlers_test.go Co-Authored-By: opencode <opencode@anthropic.com>
141 lines
3.8 KiB
Go
141 lines
3.8 KiB
Go
package loop
|
|
|
|
import (
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// TickTrace — snapshot of one tick's rule evaluations.
|
|
type TickTrace struct {
|
|
Now time.Time `json:"now"`
|
|
Winner string `json:"winner"` // empty if nothing fired
|
|
RuleTraces []RuleTrace `json:"rules"`
|
|
}
|
|
|
|
// RuleTrace — per-rule evaluation result for one tick.
|
|
type RuleTrace struct {
|
|
RuleName string `json:"rule_name"`
|
|
Severity Severity `json:"severity"`
|
|
PredicateResult bool `json:"predicate_result"`
|
|
GateResult bool `json:"gate_result"`
|
|
GateBlockedBy string `json:"gate_blocked_by,omitempty"` // "snooze"|"cooldown"|"quiet_hours"|"calendar_busy"|"presence"|"inert_no_data"|"" (passed)
|
|
GateDetail GateDetail `json:"gate_detail,omitempty"`
|
|
WasSelected bool `json:"was_selected"`
|
|
LostTo string `json:"lost_to,omitempty"` // rule that won instead
|
|
}
|
|
|
|
// GateDetail — snapshot of the values the gate checked.
|
|
type GateDetail struct {
|
|
SnoozeUntil *time.Time `json:"snooze_until,omitempty"`
|
|
CooldownUntil *time.Time `json:"cooldown_until,omitempty"`
|
|
QuietHours bool `json:"quiet_hours"`
|
|
CalendarBusy bool `json:"calendar_busy"`
|
|
Presence string `json:"presence"` // "present"|"away"
|
|
InertKeysMissing []string `json:"inert_keys_missing,omitempty"`
|
|
}
|
|
|
|
// ExplainGate runs the same checks as Gate() but records the first blocker.
|
|
// Returns (passed, blockedBy, detail).
|
|
func ExplainGate(s State, r Rule) (bool, string, GateDetail) {
|
|
var d GateDetail
|
|
d.QuietHours = s.QuietHours
|
|
d.CalendarBusy = s.CalendarBusy
|
|
d.Presence = "present"
|
|
if s.Presence == store.Away {
|
|
d.Presence = "away"
|
|
}
|
|
|
|
// Snooze
|
|
if until, ok := s.SnoozeUntil[r.Name]; ok && s.Now.Before(until) {
|
|
d.SnoozeUntil = &until
|
|
return false, "snooze", d
|
|
}
|
|
// Cooldown
|
|
if until, ok := s.CooldownUntil[r.Name]; ok && s.Now.Before(until) {
|
|
d.CooldownUntil = &until
|
|
return false, "cooldown", d
|
|
}
|
|
// Quiet hours (care only)
|
|
if s.QuietHours && r.Severity.IsCare() {
|
|
return false, "quiet_hours", d
|
|
}
|
|
// Calendar busy (care only)
|
|
if s.CalendarBusy && r.Severity.IsCare() {
|
|
return false, "calendar_busy", d
|
|
}
|
|
// Presence away (care only)
|
|
if s.Presence == store.Away && r.Severity.IsCare() {
|
|
return false, "presence", d
|
|
}
|
|
// InertWhenNoData
|
|
for _, k := range r.InertWhenNoData {
|
|
if _, ok := s.Fact(k); !ok {
|
|
d.InertKeysMissing = append(d.InertKeysMissing, k)
|
|
}
|
|
}
|
|
if len(d.InertKeysMissing) > 0 {
|
|
return false, "inert_no_data", d
|
|
}
|
|
|
|
return true, "", d
|
|
}
|
|
|
|
// ExplainTick evaluates all rules and returns both the best Candidate and
|
|
// a full TickTrace explaining every rule's result. Pure: no I/O.
|
|
func ExplainTick(s State, rules []Rule) (*Candidate, *TickTrace) {
|
|
trace := &TickTrace{
|
|
Now: s.Now,
|
|
}
|
|
var best *Candidate
|
|
|
|
for _, r := range rules {
|
|
tr := RuleTrace{
|
|
RuleName: r.Name,
|
|
Severity: r.Severity,
|
|
}
|
|
|
|
// Predicate
|
|
tr.PredicateResult = r.Predicate(s)
|
|
if !tr.PredicateResult {
|
|
tr.GateBlockedBy = "predicate"
|
|
trace.RuleTraces = append(trace.RuleTraces, tr)
|
|
continue
|
|
}
|
|
|
|
// Gate
|
|
var passed bool
|
|
passed, tr.GateBlockedBy, tr.GateDetail = ExplainGate(s, r)
|
|
tr.GateResult = passed
|
|
if !passed {
|
|
trace.RuleTraces = append(trace.RuleTraces, tr)
|
|
continue
|
|
}
|
|
|
|
// Candidate comparison
|
|
cand := &Candidate{Rule: r, Severity: r.Severity, State: s}
|
|
if best == nil || cand.Severity > best.Severity ||
|
|
(cand.Severity == best.Severity && cand.Rule.Name < best.Rule.Name) {
|
|
if best != nil {
|
|
for i := range trace.RuleTraces {
|
|
if trace.RuleTraces[i].RuleName == best.Rule.Name {
|
|
trace.RuleTraces[i].LostTo = r.Name
|
|
break
|
|
}
|
|
}
|
|
}
|
|
best = cand
|
|
tr.WasSelected = true
|
|
} else {
|
|
tr.LostTo = best.Rule.Name
|
|
}
|
|
|
|
trace.RuleTraces = append(trace.RuleTraces, tr)
|
|
}
|
|
|
|
if best != nil {
|
|
trace.Winner = best.Rule.Name
|
|
}
|
|
return best, trace
|
|
}
|