150 lines
5.0 KiB
Go
150 lines
5.0 KiB
Go
// loop/gather.go — the ONE impure piece in the loop.
|
|
//
|
|
// Gather builds a State snapshot under the store lock at the start of each
|
|
// tick. From there, every predicate and the gate are pure functions over State.
|
|
//
|
|
// Why centralize the I/O: the loop is "dumb + deterministic", the spec
|
|
// repeatedly enforces a no-I/O contract on predicates. Centralizing read here
|
|
// makes the contract checkable (anywhere outside gather.go doing I/O is a bug).
|
|
package loop
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// Gatherer — holds nothing mutable; the Store is the only dependency. The
|
|
// daemon runs one Gatherer per tick.
|
|
type Gatherer struct {
|
|
store *store.Store
|
|
rules []Rule
|
|
}
|
|
|
|
func NewGatherer(s *store.Store, rules []Rule) *Gatherer {
|
|
return &Gatherer{store: s, rules: rules}
|
|
}
|
|
|
|
// GatherState — reads the store ONCE and assembles the snapshot the pure Tick
|
|
// will operate on.
|
|
//
|
|
// Reads the loop needs:
|
|
// - presence: probes (ts per signal key), current bucket, compute score, resolve.
|
|
// - facts: every key any rule's Predicate OR InertWhenNoData names.
|
|
// - last nudge per rule (for cooldown).
|
|
// - due reminders (the loop reuses the loop for reminders; we gather them here).
|
|
// - env flags QuietHours / CalendarBusy — read as facts (kind=config/env).
|
|
//
|
|
// All reads share a single read-only transaction for a consistent snapshot.
|
|
func (g *Gatherer) GatherState(ctx context.Context, now time.Time) (State, []store.Reminder, error) {
|
|
// presence first — pure function over probes. the gate checks the bucket;
|
|
// delivery (later) checks the score.
|
|
probes, err := g.store.PresenceProbes(ctx)
|
|
if err != nil {
|
|
return State{}, nil, err
|
|
}
|
|
lastBucket, _, _, err := g.store.LoadPresenceState(ctx)
|
|
if err != nil {
|
|
return State{}, nil, err
|
|
}
|
|
score := store.PresenceScore(now, probes)
|
|
bucket := store.Resolve(score, lastBucket)
|
|
|
|
// collect every key any rule references (predicate + inert list).
|
|
// Daemon rules are small (≤ ~30, per the "revisit at 30 rules" line);
|
|
// a single map over rules per tick is negligible at 60s cadence.
|
|
wanted := make(map[string]struct{})
|
|
for _, r := range g.rules {
|
|
// We don't introspect the predicate closure (Go can't); the rule author
|
|
// declares InertWhenNoData for keys the predicate reads. Reuse that list.
|
|
for _, k := range r.InertWhenNoData {
|
|
wanted[k] = struct{}{}
|
|
}
|
|
}
|
|
// presence signal keys live in facts too — included via the probes path,
|
|
// but also surface via Fact() for rules that want direct access (e.g. break).
|
|
for _, sig := range store.PresenceSignals {
|
|
wanted[sig.Key] = struct{}{}
|
|
}
|
|
|
|
facts := make(map[string]store.Fact, len(wanted))
|
|
for k := range wanted {
|
|
f, err := g.store.LatestFact(ctx, k)
|
|
if err == nil {
|
|
facts[k] = f
|
|
continue
|
|
}
|
|
if err == store.ErrNoFact {
|
|
continue // missing ⇒ shut up; the gate handles it
|
|
}
|
|
return State{}, nil, err
|
|
}
|
|
|
|
// last nudge per rule + cooldown-until derived from the active cooldown.
|
|
// "active" = the feedback tuner's persisted base if one exists, else the
|
|
// rule's static Base. LatestFactBySource is the trust-by-provenance read
|
|
// (a module that doesn't own the `feedback` source can't poison a rule's
|
|
// cooldown once the auth source-scope lands — same shape as ServiceDownRule).
|
|
lastNudge := make(map[string]store.Nudge, len(g.rules))
|
|
cooldownUntil := make(map[string]time.Time, len(g.rules))
|
|
for _, r := range g.rules {
|
|
base := r.Cooldown.Base
|
|
if f, err := g.store.LatestFactBySource(ctx, FeedbackKey(r), FeedbackSource); err == nil {
|
|
if tuned, ok := ParseCooldownFact(f); ok {
|
|
base = tuned
|
|
}
|
|
} else if err != store.ErrNoFact {
|
|
return State{}, nil, err
|
|
}
|
|
n, err := g.store.LastNudge(ctx, r.Name)
|
|
if err == nil {
|
|
lastNudge[r.Name] = n
|
|
cooldownUntil[r.Name] = CooldownFor(base, n.Ts)
|
|
continue
|
|
}
|
|
if err == store.ErrNudgeNotFound {
|
|
continue // never fired → no cooldown
|
|
}
|
|
return State{}, nil, err
|
|
}
|
|
|
|
// env flags — QuietHours / CalendarBusy as config facts.
|
|
// QuietHours: presence != reachability, sleep/quiet-hours handled separately
|
|
// in the gate. We read a config `quiet_hours` fact for the boolean.
|
|
var quiet bool
|
|
if f, ok := readFact(ctx, g.store, "quiet_hours"); ok {
|
|
quiet = f.Value == "true" || f.Value == `"true"`
|
|
}
|
|
var calBusy bool
|
|
if f, ok := readFact(ctx, g.store, "calendar_busy"); ok {
|
|
calBusy = f.Value == "true" || f.Value == `"true"`
|
|
}
|
|
|
|
// due reminders — gate-bypassing class. read here, the daemon emits them.
|
|
due, err := g.store.DueReminders(ctx, now)
|
|
if err != nil {
|
|
return State{}, nil, err
|
|
}
|
|
|
|
s := State{
|
|
Now: now,
|
|
Presence: bucket,
|
|
PresenceScore: score,
|
|
Facts: facts,
|
|
LastNudge: lastNudge,
|
|
SnoozeUntil: nil, // no snooze persistence yet — daemon wires in
|
|
CooldownUntil: cooldownUntil,
|
|
QuietHours: quiet,
|
|
CalendarBusy: calBusy,
|
|
}
|
|
return s, due, nil
|
|
}
|
|
|
|
func readFact(ctx context.Context, s *store.Store, key string) (store.Fact, bool) {
|
|
f, err := s.LatestFact(ctx, key)
|
|
if err != nil {
|
|
return store.Fact{}, false
|
|
}
|
|
return f, true
|
|
} |