9116abcae2
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>
57 lines
1.4 KiB
Go
57 lines
1.4 KiB
Go
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
|
|
}
|