c8559676ed
executus CI / test (push) Has been cancelled
Merges the skill half of the persona/skill pair plus the second nested module. (Squashed onto main from phase-4b-skill; the audit/budget/persona batteries it was stacked on already landed via the P4 merge.) - skill/: clean-redesign Skill noun + LEAN SkillStore (lifecycle/versions/ schedule only) + ToRunnable + Memory default. - contrib/store/: separate go.mod carrying modernc.org/sqlite, so the driver never enters the core go.sum. db.Budget()/Personas()/Skills()/Audit() back all four store seams (JSON-blob + indexed columns; round-trip tested). Includes the verified gadfly #5 fixes (AppendVersion tx+UNIQUE+error, Mark*ScheduledRun atomic json_set, busy_timeout, NaN guard). - CI: builds + tests the nested module and asserts it owns the sqlite driver. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
106 lines
3.2 KiB
Go
106 lines
3.2 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"time"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/executus/budget"
|
|
)
|
|
|
|
// budgetStore is the SQLite-backed budget.BudgetStorage.
|
|
type budgetStore struct{ db *sql.DB }
|
|
|
|
// Budget returns a durable budget.BudgetStorage backed by this database.
|
|
func (d *DB) Budget() budget.BudgetStorage { return &budgetStore{db: d.sql} }
|
|
|
|
var _ budget.BudgetStorage = (*budgetStore)(nil)
|
|
|
|
func (s *budgetStore) Initialize(ctx context.Context) error {
|
|
_, err := s.db.ExecContext(ctx, `
|
|
CREATE TABLE IF NOT EXISTS skill_budgets (
|
|
user_id TEXT PRIMARY KEY,
|
|
window_start INTEGER NOT NULL, -- unix seconds
|
|
seconds_used REAL NOT NULL,
|
|
runs_count INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
)`)
|
|
if err != nil {
|
|
return fmt.Errorf("budgetStore.Initialize: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *budgetStore) Get(ctx context.Context, userID string) (*budget.SkillBudget, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT window_start, seconds_used, runs_count, updated_at FROM skill_budgets WHERE user_id = ?`, userID)
|
|
var ws, ua int64
|
|
var used float64
|
|
var runs int
|
|
switch err := row.Scan(&ws, &used, &runs, &ua); {
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
return nil, nil // no row yet — documented (nil, nil)
|
|
case err != nil:
|
|
return nil, fmt.Errorf("budgetStore.Get: %w", err)
|
|
}
|
|
return &budget.SkillBudget{
|
|
UserID: userID,
|
|
WindowStart: time.Unix(ws, 0).UTC(),
|
|
SecondsUsed: used,
|
|
RunsCount: runs,
|
|
UpdatedAt: time.Unix(ua, 0).UTC(),
|
|
}, nil
|
|
}
|
|
|
|
// Add increments usage atomically, rolling the 7-day window over inside one
|
|
// transaction so concurrent Adds can't race the read-modify-write.
|
|
func (s *budgetStore) Add(ctx context.Context, userID string, secondsUsed float64, now time.Time) error {
|
|
// A NaN/Inf would poison the seconds_used column irrecoverably (NaN
|
|
// propagates through every later add), so reject it at the boundary.
|
|
if math.IsNaN(secondsUsed) || math.IsInf(secondsUsed, 0) {
|
|
return fmt.Errorf("budgetStore.Add: invalid secondsUsed %v", secondsUsed)
|
|
}
|
|
tx, err := s.db.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("budgetStore.Add: begin: %w", err)
|
|
}
|
|
defer tx.Rollback() //nolint:errcheck // no-op after Commit
|
|
|
|
var ws int64
|
|
var used float64
|
|
var runs int
|
|
err = tx.QueryRowContext(ctx,
|
|
`SELECT window_start, seconds_used, runs_count FROM skill_budgets WHERE user_id = ?`, userID).
|
|
Scan(&ws, &used, &runs)
|
|
switch {
|
|
case errors.Is(err, sql.ErrNoRows):
|
|
ws, used, runs = now.Unix(), 0, 0
|
|
case err != nil:
|
|
return fmt.Errorf("budgetStore.Add: select: %w", err)
|
|
}
|
|
// Roll the window over if older than 7 days.
|
|
if now.Sub(time.Unix(ws, 0)) >= 7*24*time.Hour {
|
|
ws, used, runs = now.Unix(), 0, 0
|
|
}
|
|
used += secondsUsed
|
|
runs++
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO skill_budgets (user_id, window_start, seconds_used, runs_count, updated_at)
|
|
VALUES (?, ?, ?, ?, ?)
|
|
ON CONFLICT(user_id) DO UPDATE SET
|
|
window_start = excluded.window_start,
|
|
seconds_used = excluded.seconds_used,
|
|
runs_count = excluded.runs_count,
|
|
updated_at = excluded.updated_at`,
|
|
userID, ws, used, runs, now.Unix()); err != nil {
|
|
return fmt.Errorf("budgetStore.Add: upsert: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return fmt.Errorf("budgetStore.Add: commit: %w", err)
|
|
}
|
|
return nil
|
|
}
|