// 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 }