Files
Maven/internal/loop/gather.go
T
2026-07-03 00:32:48 +02:00

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
}