feat: Phase 2 — SQLite AllowanceStore backend + config wiring

Add persistent storage for agent allowance quotas using go-store (SQLite KV).
SQLiteStore implements all 11 AllowanceStore methods with JSON serialisation,
mutex-guarded read-modify-write for atomic increments, and proper
time.Duration handling via nanosecond int64 encoding.

- allowance_sqlite.go: full AllowanceStore implementation with 4 KV groups
- allowance_sqlite_test.go: 26 tests covering CRUD, persistence across
  close/reopen, concurrent access (10 goroutines), and AllowanceService
  integration
- config.go: AllowanceConfig struct + NewAllowanceStoreFromConfig factory
- go.mod: add forge.lthn.ai/core/go-store dependency

Co-Authored-By: Virgil <virgil@lethean.io>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-20 07:07:49 +00:00
parent 5d0269511e
commit 3e43233e0e
6 changed files with 885 additions and 4 deletions

View file

@ -10,10 +10,10 @@
## Phase 2: Allowance Persistence
- [ ] MemoryStore is in-memory only -- state lost on restart
- [x] MemoryStore is in-memory only -- state lost on restart
- [ ] Add Redis backend for `AllowanceStore` interface (multi-process safe)
- [ ] Add SQLite backend for `AllowanceStore` interface (single-node fallback)
- [ ] Config already supports YAML -- wire backend selection into config loader
- [x] Add SQLite backend for `AllowanceStore` interface (single-node fallback)
- [x] Config already supports YAML -- wire backend selection into config loader
## Phase 3: Multi-Agent Coordination

296
allowance_sqlite.go Normal file
View file

@ -0,0 +1,296 @@
package agentic
import (
"encoding/json"
"errors"
"sync"
"time"
"forge.lthn.ai/core/go-store"
)
// SQLite group names for namespacing data in the KV store.
const (
groupAllowances = "allowances"
groupUsage = "usage"
groupModelQuota = "model_quotas"
groupModelUsage = "model_usage"
)
// SQLiteStore implements AllowanceStore using go-store (SQLite KV).
// It provides persistent storage that survives process restarts.
type SQLiteStore struct {
db *store.Store
mu sync.Mutex // serialises read-modify-write operations
}
// NewSQLiteStore creates a new SQLite-backed allowance store at the given path.
// Use ":memory:" for tests that do not need persistence.
func NewSQLiteStore(dbPath string) (*SQLiteStore, error) {
db, err := store.New(dbPath)
if err != nil {
return nil, &APIError{Code: 500, Message: "failed to open SQLite store: " + err.Error()}
}
return &SQLiteStore{db: db}, nil
}
// Close releases the underlying SQLite database.
func (s *SQLiteStore) Close() error {
return s.db.Close()
}
// GetAllowance returns the quota limits for an agent.
func (s *SQLiteStore) GetAllowance(agentID string) (*AgentAllowance, error) {
val, err := s.db.Get(groupAllowances, agentID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, &APIError{Code: 404, Message: "allowance not found for agent: " + agentID}
}
return nil, &APIError{Code: 500, Message: "failed to get allowance: " + err.Error()}
}
var a allowanceJSON
if err := json.Unmarshal([]byte(val), &a); err != nil {
return nil, &APIError{Code: 500, Message: "failed to unmarshal allowance: " + err.Error()}
}
return a.toAgentAllowance(), nil
}
// SetAllowance persists quota limits for an agent.
func (s *SQLiteStore) SetAllowance(a *AgentAllowance) error {
aj := newAllowanceJSON(a)
data, err := json.Marshal(aj)
if err != nil {
return &APIError{Code: 500, Message: "failed to marshal allowance: " + err.Error()}
}
if err := s.db.Set(groupAllowances, a.AgentID, string(data)); err != nil {
return &APIError{Code: 500, Message: "failed to set allowance: " + err.Error()}
}
return nil
}
// GetUsage returns the current usage record for an agent.
func (s *SQLiteStore) GetUsage(agentID string) (*UsageRecord, error) {
val, err := s.db.Get(groupUsage, agentID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return &UsageRecord{
AgentID: agentID,
PeriodStart: startOfDay(time.Now().UTC()),
}, nil
}
return nil, &APIError{Code: 500, Message: "failed to get usage: " + err.Error()}
}
var u UsageRecord
if err := json.Unmarshal([]byte(val), &u); err != nil {
return nil, &APIError{Code: 500, Message: "failed to unmarshal usage: " + err.Error()}
}
return &u, nil
}
// IncrementUsage atomically adds to an agent's usage counters.
func (s *SQLiteStore) IncrementUsage(agentID string, tokens int64, jobs int) error {
s.mu.Lock()
defer s.mu.Unlock()
u, err := s.getUsageLocked(agentID)
if err != nil {
return err
}
u.TokensUsed += tokens
u.JobsStarted += jobs
if jobs > 0 {
u.ActiveJobs += jobs
}
return s.putUsageLocked(u)
}
// DecrementActiveJobs reduces the active job count by 1.
func (s *SQLiteStore) DecrementActiveJobs(agentID string) error {
s.mu.Lock()
defer s.mu.Unlock()
u, err := s.getUsageLocked(agentID)
if err != nil {
return err
}
if u.ActiveJobs > 0 {
u.ActiveJobs--
}
return s.putUsageLocked(u)
}
// ReturnTokens adds tokens back to the agent's remaining quota.
func (s *SQLiteStore) ReturnTokens(agentID string, tokens int64) error {
s.mu.Lock()
defer s.mu.Unlock()
u, err := s.getUsageLocked(agentID)
if err != nil {
return err
}
u.TokensUsed -= tokens
if u.TokensUsed < 0 {
u.TokensUsed = 0
}
return s.putUsageLocked(u)
}
// ResetUsage clears usage counters for an agent.
func (s *SQLiteStore) ResetUsage(agentID string) error {
s.mu.Lock()
defer s.mu.Unlock()
u := &UsageRecord{
AgentID: agentID,
PeriodStart: startOfDay(time.Now().UTC()),
}
return s.putUsageLocked(u)
}
// GetModelQuota returns global limits for a model.
func (s *SQLiteStore) GetModelQuota(model string) (*ModelQuota, error) {
val, err := s.db.Get(groupModelQuota, model)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, &APIError{Code: 404, Message: "model quota not found: " + model}
}
return nil, &APIError{Code: 500, Message: "failed to get model quota: " + err.Error()}
}
var q ModelQuota
if err := json.Unmarshal([]byte(val), &q); err != nil {
return nil, &APIError{Code: 500, Message: "failed to unmarshal model quota: " + err.Error()}
}
return &q, nil
}
// GetModelUsage returns current token usage for a model.
func (s *SQLiteStore) GetModelUsage(model string) (int64, error) {
val, err := s.db.Get(groupModelUsage, model)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return 0, nil
}
return 0, &APIError{Code: 500, Message: "failed to get model usage: " + err.Error()}
}
var tokens int64
if err := json.Unmarshal([]byte(val), &tokens); err != nil {
return 0, &APIError{Code: 500, Message: "failed to unmarshal model usage: " + err.Error()}
}
return tokens, nil
}
// IncrementModelUsage atomically adds to a model's usage counter.
func (s *SQLiteStore) IncrementModelUsage(model string, tokens int64) error {
s.mu.Lock()
defer s.mu.Unlock()
current, err := s.getModelUsageLocked(model)
if err != nil {
return err
}
current += tokens
data, err := json.Marshal(current)
if err != nil {
return &APIError{Code: 500, Message: "failed to marshal model usage: " + err.Error()}
}
if err := s.db.Set(groupModelUsage, model, string(data)); err != nil {
return &APIError{Code: 500, Message: "failed to set model usage: " + err.Error()}
}
return nil
}
// SetModelQuota persists global limits for a model.
func (s *SQLiteStore) SetModelQuota(q *ModelQuota) error {
data, err := json.Marshal(q)
if err != nil {
return &APIError{Code: 500, Message: "failed to marshal model quota: " + err.Error()}
}
if err := s.db.Set(groupModelQuota, q.Model, string(data)); err != nil {
return &APIError{Code: 500, Message: "failed to set model quota: " + err.Error()}
}
return nil
}
// --- internal helpers (must be called with mu held) ---
// getUsageLocked reads a UsageRecord from the store. Caller must hold s.mu.
func (s *SQLiteStore) getUsageLocked(agentID string) (*UsageRecord, error) {
val, err := s.db.Get(groupUsage, agentID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return &UsageRecord{
AgentID: agentID,
PeriodStart: startOfDay(time.Now().UTC()),
}, nil
}
return nil, &APIError{Code: 500, Message: "failed to get usage: " + err.Error()}
}
var u UsageRecord
if err := json.Unmarshal([]byte(val), &u); err != nil {
return nil, &APIError{Code: 500, Message: "failed to unmarshal usage: " + err.Error()}
}
return &u, nil
}
// putUsageLocked writes a UsageRecord to the store. Caller must hold s.mu.
func (s *SQLiteStore) putUsageLocked(u *UsageRecord) error {
data, err := json.Marshal(u)
if err != nil {
return &APIError{Code: 500, Message: "failed to marshal usage: " + err.Error()}
}
if err := s.db.Set(groupUsage, u.AgentID, string(data)); err != nil {
return &APIError{Code: 500, Message: "failed to set usage: " + err.Error()}
}
return nil
}
// getModelUsageLocked reads model usage from the store. Caller must hold s.mu.
func (s *SQLiteStore) getModelUsageLocked(model string) (int64, error) {
val, err := s.db.Get(groupModelUsage, model)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return 0, nil
}
return 0, &APIError{Code: 500, Message: "failed to get model usage: " + err.Error()}
}
var tokens int64
if err := json.Unmarshal([]byte(val), &tokens); err != nil {
return 0, &APIError{Code: 500, Message: "failed to unmarshal model usage: " + err.Error()}
}
return tokens, nil
}
// --- JSON serialisation helper for AgentAllowance ---
// time.Duration does not have a stable JSON representation. We serialise it
// as an int64 (nanoseconds) to avoid locale-dependent string parsing.
type allowanceJSON struct {
AgentID string `json:"agent_id"`
DailyTokenLimit int64 `json:"daily_token_limit"`
DailyJobLimit int `json:"daily_job_limit"`
ConcurrentJobs int `json:"concurrent_jobs"`
MaxJobDurationNs int64 `json:"max_job_duration_ns"`
ModelAllowlist []string `json:"model_allowlist,omitempty"`
}
func newAllowanceJSON(a *AgentAllowance) *allowanceJSON {
return &allowanceJSON{
AgentID: a.AgentID,
DailyTokenLimit: a.DailyTokenLimit,
DailyJobLimit: a.DailyJobLimit,
ConcurrentJobs: a.ConcurrentJobs,
MaxJobDurationNs: int64(a.MaxJobDuration),
ModelAllowlist: a.ModelAllowlist,
}
}
func (aj *allowanceJSON) toAgentAllowance() *AgentAllowance {
return &AgentAllowance{
AgentID: aj.AgentID,
DailyTokenLimit: aj.DailyTokenLimit,
DailyJobLimit: aj.DailyJobLimit,
ConcurrentJobs: aj.ConcurrentJobs,
MaxJobDuration: time.Duration(aj.MaxJobDurationNs),
ModelAllowlist: aj.ModelAllowlist,
}
}

465
allowance_sqlite_test.go Normal file
View file

@ -0,0 +1,465 @@
package agentic
import (
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newTestSQLiteStore creates a SQLiteStore in a temp directory.
func newTestSQLiteStore(t *testing.T) *SQLiteStore {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
s, err := NewSQLiteStore(dbPath)
require.NoError(t, err)
t.Cleanup(func() { _ = s.Close() })
return s
}
// --- SetAllowance / GetAllowance ---
func TestSQLiteStore_SetGetAllowance_Good(t *testing.T) {
s := newTestSQLiteStore(t)
a := &AgentAllowance{
AgentID: "agent-1",
DailyTokenLimit: 100000,
DailyJobLimit: 10,
ConcurrentJobs: 2,
MaxJobDuration: 30 * time.Minute,
ModelAllowlist: []string{"claude-sonnet-4-5-20250929"},
}
err := s.SetAllowance(a)
require.NoError(t, err)
got, err := s.GetAllowance("agent-1")
require.NoError(t, err)
assert.Equal(t, a.AgentID, got.AgentID)
assert.Equal(t, a.DailyTokenLimit, got.DailyTokenLimit)
assert.Equal(t, a.DailyJobLimit, got.DailyJobLimit)
assert.Equal(t, a.ConcurrentJobs, got.ConcurrentJobs)
assert.Equal(t, a.MaxJobDuration, got.MaxJobDuration)
assert.Equal(t, a.ModelAllowlist, got.ModelAllowlist)
}
func TestSQLiteStore_GetAllowance_Bad_NotFound(t *testing.T) {
s := newTestSQLiteStore(t)
_, err := s.GetAllowance("nonexistent")
require.Error(t, err)
assert.Contains(t, err.Error(), "allowance not found")
}
func TestSQLiteStore_SetAllowance_Good_Overwrite(t *testing.T) {
s := newTestSQLiteStore(t)
_ = s.SetAllowance(&AgentAllowance{AgentID: "agent-1", DailyTokenLimit: 100})
_ = s.SetAllowance(&AgentAllowance{AgentID: "agent-1", DailyTokenLimit: 200})
got, err := s.GetAllowance("agent-1")
require.NoError(t, err)
assert.Equal(t, int64(200), got.DailyTokenLimit)
}
// --- GetUsage / IncrementUsage ---
func TestSQLiteStore_GetUsage_Good_Default(t *testing.T) {
s := newTestSQLiteStore(t)
u, err := s.GetUsage("agent-1")
require.NoError(t, err)
assert.Equal(t, "agent-1", u.AgentID)
assert.Equal(t, int64(0), u.TokensUsed)
assert.Equal(t, 0, u.JobsStarted)
assert.Equal(t, 0, u.ActiveJobs)
}
func TestSQLiteStore_IncrementUsage_Good(t *testing.T) {
s := newTestSQLiteStore(t)
err := s.IncrementUsage("agent-1", 5000, 1)
require.NoError(t, err)
u, err := s.GetUsage("agent-1")
require.NoError(t, err)
assert.Equal(t, int64(5000), u.TokensUsed)
assert.Equal(t, 1, u.JobsStarted)
assert.Equal(t, 1, u.ActiveJobs)
}
func TestSQLiteStore_IncrementUsage_Good_Accumulates(t *testing.T) {
s := newTestSQLiteStore(t)
_ = s.IncrementUsage("agent-1", 1000, 1)
_ = s.IncrementUsage("agent-1", 2000, 1)
_ = s.IncrementUsage("agent-1", 3000, 0)
u, err := s.GetUsage("agent-1")
require.NoError(t, err)
assert.Equal(t, int64(6000), u.TokensUsed)
assert.Equal(t, 2, u.JobsStarted)
assert.Equal(t, 2, u.ActiveJobs)
}
// --- DecrementActiveJobs ---
func TestSQLiteStore_DecrementActiveJobs_Good(t *testing.T) {
s := newTestSQLiteStore(t)
_ = s.IncrementUsage("agent-1", 0, 2)
_ = s.DecrementActiveJobs("agent-1")
u, _ := s.GetUsage("agent-1")
assert.Equal(t, 1, u.ActiveJobs)
}
func TestSQLiteStore_DecrementActiveJobs_Good_FloorAtZero(t *testing.T) {
s := newTestSQLiteStore(t)
_ = s.DecrementActiveJobs("agent-1") // no usage record yet
_ = s.IncrementUsage("agent-1", 0, 0)
_ = s.DecrementActiveJobs("agent-1") // should stay at 0
u, _ := s.GetUsage("agent-1")
assert.Equal(t, 0, u.ActiveJobs)
}
// --- ReturnTokens ---
func TestSQLiteStore_ReturnTokens_Good(t *testing.T) {
s := newTestSQLiteStore(t)
_ = s.IncrementUsage("agent-1", 10000, 0)
err := s.ReturnTokens("agent-1", 5000)
require.NoError(t, err)
u, _ := s.GetUsage("agent-1")
assert.Equal(t, int64(5000), u.TokensUsed)
}
func TestSQLiteStore_ReturnTokens_Good_FloorAtZero(t *testing.T) {
s := newTestSQLiteStore(t)
_ = s.IncrementUsage("agent-1", 1000, 0)
_ = s.ReturnTokens("agent-1", 5000) // more than used
u, _ := s.GetUsage("agent-1")
assert.Equal(t, int64(0), u.TokensUsed)
}
func TestSQLiteStore_ReturnTokens_Good_NoRecord(t *testing.T) {
s := newTestSQLiteStore(t)
// Return tokens for agent with no usage record -- should create one at 0
err := s.ReturnTokens("agent-1", 500)
require.NoError(t, err)
u, _ := s.GetUsage("agent-1")
assert.Equal(t, int64(0), u.TokensUsed)
}
// --- ResetUsage ---
func TestSQLiteStore_ResetUsage_Good(t *testing.T) {
s := newTestSQLiteStore(t)
_ = s.IncrementUsage("agent-1", 50000, 5)
err := s.ResetUsage("agent-1")
require.NoError(t, err)
u, _ := s.GetUsage("agent-1")
assert.Equal(t, int64(0), u.TokensUsed)
assert.Equal(t, 0, u.JobsStarted)
assert.Equal(t, 0, u.ActiveJobs)
}
// --- ModelQuota ---
func TestSQLiteStore_GetModelQuota_Bad_NotFound(t *testing.T) {
s := newTestSQLiteStore(t)
_, err := s.GetModelQuota("nonexistent")
require.Error(t, err)
assert.Contains(t, err.Error(), "model quota not found")
}
func TestSQLiteStore_SetGetModelQuota_Good(t *testing.T) {
s := newTestSQLiteStore(t)
q := &ModelQuota{
Model: "claude-opus-4-6",
DailyTokenBudget: 500000,
HourlyRateLimit: 100,
CostCeiling: 10000,
}
err := s.SetModelQuota(q)
require.NoError(t, err)
got, err := s.GetModelQuota("claude-opus-4-6")
require.NoError(t, err)
assert.Equal(t, q.Model, got.Model)
assert.Equal(t, q.DailyTokenBudget, got.DailyTokenBudget)
assert.Equal(t, q.HourlyRateLimit, got.HourlyRateLimit)
assert.Equal(t, q.CostCeiling, got.CostCeiling)
}
// --- ModelUsage ---
func TestSQLiteStore_ModelUsage_Good(t *testing.T) {
s := newTestSQLiteStore(t)
_ = s.IncrementModelUsage("claude-sonnet", 10000)
_ = s.IncrementModelUsage("claude-sonnet", 5000)
usage, err := s.GetModelUsage("claude-sonnet")
require.NoError(t, err)
assert.Equal(t, int64(15000), usage)
}
func TestSQLiteStore_GetModelUsage_Good_Default(t *testing.T) {
s := newTestSQLiteStore(t)
usage, err := s.GetModelUsage("unknown-model")
require.NoError(t, err)
assert.Equal(t, int64(0), usage)
}
// --- Persistence: close and reopen ---
func TestSQLiteStore_Persistence_Good(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "persist.db")
// Phase 1: write data
s1, err := NewSQLiteStore(dbPath)
require.NoError(t, err)
_ = s1.SetAllowance(&AgentAllowance{
AgentID: "agent-1",
DailyTokenLimit: 100000,
MaxJobDuration: 15 * time.Minute,
})
_ = s1.IncrementUsage("agent-1", 25000, 3)
_ = s1.SetModelQuota(&ModelQuota{Model: "claude-opus-4-6", DailyTokenBudget: 500000})
_ = s1.IncrementModelUsage("claude-opus-4-6", 42000)
require.NoError(t, s1.Close())
// Phase 2: reopen and verify
s2, err := NewSQLiteStore(dbPath)
require.NoError(t, err)
defer func() { _ = s2.Close() }()
a, err := s2.GetAllowance("agent-1")
require.NoError(t, err)
assert.Equal(t, int64(100000), a.DailyTokenLimit)
assert.Equal(t, 15*time.Minute, a.MaxJobDuration)
u, err := s2.GetUsage("agent-1")
require.NoError(t, err)
assert.Equal(t, int64(25000), u.TokensUsed)
assert.Equal(t, 3, u.JobsStarted)
assert.Equal(t, 3, u.ActiveJobs)
q, err := s2.GetModelQuota("claude-opus-4-6")
require.NoError(t, err)
assert.Equal(t, int64(500000), q.DailyTokenBudget)
mu, err := s2.GetModelUsage("claude-opus-4-6")
require.NoError(t, err)
assert.Equal(t, int64(42000), mu)
}
// --- Concurrent access ---
func TestSQLiteStore_ConcurrentIncrementUsage_Good(t *testing.T) {
s := newTestSQLiteStore(t)
const goroutines = 10
const tokensEach = 1000
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
err := s.IncrementUsage("agent-1", tokensEach, 1)
assert.NoError(t, err)
}()
}
wg.Wait()
u, err := s.GetUsage("agent-1")
require.NoError(t, err)
assert.Equal(t, int64(goroutines*tokensEach), u.TokensUsed)
assert.Equal(t, goroutines, u.JobsStarted)
assert.Equal(t, goroutines, u.ActiveJobs)
}
func TestSQLiteStore_ConcurrentModelUsage_Good(t *testing.T) {
s := newTestSQLiteStore(t)
const goroutines = 10
const tokensEach int64 = 500
var wg sync.WaitGroup
wg.Add(goroutines)
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
err := s.IncrementModelUsage("claude-opus-4-6", tokensEach)
assert.NoError(t, err)
}()
}
wg.Wait()
usage, err := s.GetModelUsage("claude-opus-4-6")
require.NoError(t, err)
assert.Equal(t, goroutines*tokensEach, usage)
}
func TestSQLiteStore_ConcurrentMixed_Good(t *testing.T) {
s := newTestSQLiteStore(t)
_ = s.SetAllowance(&AgentAllowance{
AgentID: "agent-1",
DailyTokenLimit: 1000000,
DailyJobLimit: 100,
ConcurrentJobs: 50,
})
const goroutines = 10
var wg sync.WaitGroup
wg.Add(goroutines * 3)
// Increment usage
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
_ = s.IncrementUsage("agent-1", 100, 1)
}()
}
// Decrement active jobs
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
_ = s.DecrementActiveJobs("agent-1")
}()
}
// Return tokens
for i := 0; i < goroutines; i++ {
go func() {
defer wg.Done()
_ = s.ReturnTokens("agent-1", 10)
}()
}
wg.Wait()
// Just verify no panics and data is consistent
u, err := s.GetUsage("agent-1")
require.NoError(t, err)
assert.GreaterOrEqual(t, u.TokensUsed, int64(0))
assert.GreaterOrEqual(t, u.ActiveJobs, 0)
}
// --- AllowanceService integration via SQLiteStore ---
func TestSQLiteStore_AllowanceServiceCheck_Good(t *testing.T) {
s := newTestSQLiteStore(t)
svc := NewAllowanceService(s)
_ = s.SetAllowance(&AgentAllowance{
AgentID: "agent-1",
DailyTokenLimit: 100000,
DailyJobLimit: 10,
ConcurrentJobs: 2,
})
result, err := svc.Check("agent-1", "")
require.NoError(t, err)
assert.True(t, result.Allowed)
assert.Equal(t, AllowanceOK, result.Status)
}
func TestSQLiteStore_AllowanceServiceRecordUsage_Good(t *testing.T) {
s := newTestSQLiteStore(t)
svc := NewAllowanceService(s)
_ = s.SetAllowance(&AgentAllowance{
AgentID: "agent-1",
DailyTokenLimit: 100000,
})
// Start job
err := svc.RecordUsage(UsageReport{
AgentID: "agent-1",
JobID: "job-1",
Event: QuotaEventJobStarted,
})
require.NoError(t, err)
// Complete job
err = svc.RecordUsage(UsageReport{
AgentID: "agent-1",
JobID: "job-1",
Model: "claude-sonnet",
TokensIn: 1000,
TokensOut: 500,
Event: QuotaEventJobCompleted,
})
require.NoError(t, err)
u, _ := s.GetUsage("agent-1")
assert.Equal(t, int64(1500), u.TokensUsed)
assert.Equal(t, 0, u.ActiveJobs)
}
// --- Config-based factory ---
func TestNewAllowanceStoreFromConfig_Good_Memory(t *testing.T) {
cfg := AllowanceConfig{StoreBackend: "memory"}
s, err := NewAllowanceStoreFromConfig(cfg)
require.NoError(t, err)
_, ok := s.(*MemoryStore)
assert.True(t, ok, "expected MemoryStore")
}
func TestNewAllowanceStoreFromConfig_Good_Default(t *testing.T) {
cfg := AllowanceConfig{} // empty defaults to memory
s, err := NewAllowanceStoreFromConfig(cfg)
require.NoError(t, err)
_, ok := s.(*MemoryStore)
assert.True(t, ok, "expected MemoryStore for empty config")
}
func TestNewAllowanceStoreFromConfig_Good_SQLite(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "factory.db")
cfg := AllowanceConfig{
StoreBackend: "sqlite",
StorePath: dbPath,
}
s, err := NewAllowanceStoreFromConfig(cfg)
require.NoError(t, err)
ss, ok := s.(*SQLiteStore)
assert.True(t, ok, "expected SQLiteStore")
_ = ss.Close()
}
func TestNewAllowanceStoreFromConfig_Bad_UnknownBackend(t *testing.T) {
cfg := AllowanceConfig{StoreBackend: "redis"}
_, err := NewAllowanceStoreFromConfig(cfg)
require.Error(t, err)
assert.Contains(t, err.Error(), "unsupported store backend")
}
// --- NewSQLiteStore error case ---
func TestNewSQLiteStore_Bad_InvalidPath(t *testing.T) {
_, err := NewSQLiteStore("/nonexistent/deeply/nested/dir/test.db")
require.Error(t, err)
}

View file

@ -195,3 +195,45 @@ func ConfigPath() (string, error) {
}
return filepath.Join(homeDir, ".core", configFileName), nil
}
// AllowanceConfig controls allowance store backend selection.
type AllowanceConfig struct {
// StoreBackend is the storage backend: "memory" or "sqlite". Default: "memory".
StoreBackend string `yaml:"store_backend" json:"store_backend"`
// StorePath is the file path for the SQLite database.
// Default: ~/.config/agentic/allowance.db (only used when StoreBackend is "sqlite").
StorePath string `yaml:"store_path" json:"store_path"`
}
// DefaultAllowanceStorePath returns the default SQLite path for allowance data.
func DefaultAllowanceStorePath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", errors.E("agentic.DefaultAllowanceStorePath", "failed to get home directory", err)
}
return filepath.Join(homeDir, ".config", "agentic", "allowance.db"), nil
}
// NewAllowanceStoreFromConfig creates an AllowanceStore based on the given config.
// It returns a MemoryStore for "memory" (or empty) backend and a SQLiteStore for "sqlite".
func NewAllowanceStoreFromConfig(cfg AllowanceConfig) (AllowanceStore, error) {
switch cfg.StoreBackend {
case "", "memory":
return NewMemoryStore(), nil
case "sqlite":
dbPath := cfg.StorePath
if dbPath == "" {
var err error
dbPath, err = DefaultAllowanceStorePath()
if err != nil {
return nil, err
}
}
return NewSQLiteStore(dbPath)
default:
return nil, &APIError{
Code: 400,
Message: "unsupported store backend: " + cfg.StoreBackend,
}
}
}

15
go.mod
View file

@ -4,13 +4,28 @@ go 1.25.5
require (
forge.lthn.ai/core/go v0.0.0
forge.lthn.ai/core/go-store v0.0.0-00010101000000-000000000000
github.com/stretchr/testify v1.11.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
golang.org/x/sys v0.41.0 // indirect
modernc.org/libc v1.67.7 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect
)
replace forge.lthn.ai/core/go => ../go
replace forge.lthn.ai/core/go-store => ../go-store

65
go.sum
View file

@ -1,10 +1,73 @@
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.7 h1:H+gYQw2PyidyxwxQsGTwQw6+6H+xUk+plvOKW7+d3TI=
modernc.org/libc v1.67.7/go.mod h1:UjCSJFl2sYbJbReVQeVpq/MgzlbmDM4cRHIYFelnaDk=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=