P4: budget battery — DBBudget (rolling 7-day) over run.Budget
Second Tier-2 battery, plugging into run.Ports.Budget: - budget.go: skillexec's BudgetTracker / NoOpBudget / DBBudget moved clean (stdlib only). Check/Commit match run.Budget exactly (compile-time proof in run.go: NoOpBudget and *DBBudget are run.Budget). - storage.go: the BudgetStorage seam + SkillBudget domain, split out of mort's GORM file (the GORM impl stays in mort). - memory.go: NewMemory() — zero-dependency in-process BudgetStorage with the 7-day rolling-window rollover in Add. Tests: per-user cap enforced, window rolls over after 7 days, NoOp always allows. CI invariant: core imports ZERO from the budget battery. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,56 @@
|
||||
package budget
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Memory is a zero-dependency in-process BudgetStorage: per-user rolling-window
|
||||
// usage held in memory (lost on restart). The default behind DBBudget for a
|
||||
// light host or tests; mort uses its GORM Storage, contrib/store adds SQLite.
|
||||
type Memory struct {
|
||||
mu sync.Mutex
|
||||
rows map[string]*SkillBudget
|
||||
}
|
||||
|
||||
// NewMemory returns an empty in-memory BudgetStorage.
|
||||
func NewMemory() *Memory { return &Memory{rows: map[string]*SkillBudget{}} }
|
||||
|
||||
var _ BudgetStorage = (*Memory)(nil)
|
||||
|
||||
func (m *Memory) Initialize(context.Context) error { return nil }
|
||||
|
||||
func (m *Memory) Get(_ context.Context, userID string) (*SkillBudget, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
r, ok := m.rows[userID]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
cp := *r // copy out so callers can't mutate our row
|
||||
return &cp, nil
|
||||
}
|
||||
|
||||
func (m *Memory) Add(_ context.Context, userID string, secondsUsed float64, now time.Time) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
r, ok := m.rows[userID]
|
||||
if !ok {
|
||||
m.rows[userID] = &SkillBudget{
|
||||
UserID: userID, WindowStart: now,
|
||||
SecondsUsed: secondsUsed, RunsCount: 1, UpdatedAt: now,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
// Roll the window over if it's older than the window length.
|
||||
if now.Sub(r.WindowStart) >= budgetWindow {
|
||||
r.WindowStart = now
|
||||
r.SecondsUsed = 0
|
||||
r.RunsCount = 0
|
||||
}
|
||||
r.SecondsUsed += secondsUsed
|
||||
r.RunsCount++
|
||||
r.UpdatedAt = now
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user