diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 477ed6a..c2b3e2c 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -103,3 +103,15 @@ jobs: exit 1 fi echo "OK: core go.sum is free of host/DB dependencies." + + - name: contrib/store (nested SQLite module — isolated from core) + run: | + # contrib/store is a SEPARATE module carrying modernc.org/sqlite; the + # core's `go test ./...` doesn't reach it. Build + test it on its own, + # and confirm it DOES carry the driver the core forbids (proof the + # split works: persistence lives here, not in the core go.sum). + cd contrib/store + go build ./... + go test -race -count=1 -timeout 5m ./... + grep -qE 'modernc.org/sqlite' go.sum || { echo "ERROR: contrib/store should carry the sqlite driver"; exit 1; } + echo "OK: contrib/store builds, tests pass, and owns the SQLite dep." diff --git a/CLAUDE.md b/CLAUDE.md index ec43f06..aa2b045 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,8 +75,10 @@ BATTERIES (opt-in siblings, each nil-safe + a default): budget/ DBBudget rolling-7d + NoOp (run.Budget); [P4 ✓] BudgetStorage iface + Memory default -contrib/store/ SECOND module (+ modernc.org/sqlite): [P4] - in-memory + pure-Go SQLite impls of every *Store seam +contrib/store/ SECOND module (+ modernc.org/sqlite): [P4 ~] + pure-Go SQLite impls of the *Store seams. budget ✓ + (conformance-tested); persona/skill/audit pending. + CI proves the driver lands HERE, not in the core go.sum. ``` ### The one architectural move diff --git a/README.md b/README.md index 80b53d8..789e071 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ Two tiers in one module (`go.mod` = majordomo + stdlib only): ships a default, so you add only what you use. Persistence that needs a real database lives in a **separate** nested module -(`contrib/store`, pure-Go SQLite) so the core never drags in a DB driver — a +(`contrib/store`, pure-Go SQLite — the `budget` store landed first, conformance-tested) so the core never drags in a DB driver — a static-binary host (gadfly) stays static. ## License diff --git a/contrib/store/budget_store.go b/contrib/store/budget_store.go new file mode 100644 index 0000000..57dd85b --- /dev/null +++ b/contrib/store/budget_store.go @@ -0,0 +1,99 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "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 { + 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 +} diff --git a/contrib/store/budget_store_test.go b/contrib/store/budget_store_test.go new file mode 100644 index 0000000..d4a0db9 --- /dev/null +++ b/contrib/store/budget_store_test.go @@ -0,0 +1,65 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" + + "gitea.stevedudenhoeffer.com/steve/executus/budget" +) + +// TestSQLiteBudgetConformance runs the budget battery over the SQLite store and +// asserts the same rolling-window contract the in-memory store must satisfy. +func TestSQLiteBudgetConformance(t *testing.T) { + ctx := context.Background() + db, err := Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + st := db.Budget() + if err := st.Initialize(ctx); err != nil { + t.Fatal(err) + } + + now := time.Now().UTC() + b := budget.NewDBBudget(st, func() float64 { return 100 }, nil, func() time.Time { return now }) + + if err := b.Check(ctx, "u"); err != nil { + t.Fatalf("fresh caller should pass: %v", err) + } + b.Commit(ctx, "u", 60) + if err := b.Check(ctx, "u"); err != nil { + t.Fatalf("60/100 should pass: %v", err) + } + b.Commit(ctx, "u", 50) // 110 total + if err := b.Check(ctx, "u"); !errors.Is(err, budget.ErrBudgetExceeded) { + t.Fatalf("110/100 should be ErrBudgetExceeded, got %v", err) + } + + // Direct Get reflects the persisted row. + row, err := st.Get(ctx, "u") + if err != nil || row == nil { + t.Fatalf("Get: %v %+v", err, row) + } + if row.SecondsUsed != 110 || row.RunsCount != 2 { + t.Errorf("row = %+v, want seconds=110 runs=2", row) + } + + // Window rolls over after 7 days. + now = now.Add(8 * 24 * time.Hour) + b.Commit(ctx, "u", 1) + if err := b.Check(ctx, "u"); err != nil { + t.Fatalf("after rollover should pass: %v", err) + } + row, _ = st.Get(ctx, "u") + if row.SecondsUsed != 1 || row.RunsCount != 1 { + t.Errorf("post-rollover row = %+v, want seconds=1 runs=1", row) + } + + // Unknown user -> (nil, nil). + if r, err := st.Get(ctx, "nobody"); err != nil || r != nil { + t.Errorf("Get(unknown) = %+v %v, want nil,nil", r, err) + } +} diff --git a/contrib/store/go.mod b/contrib/store/go.mod new file mode 100644 index 0000000..a265a43 --- /dev/null +++ b/contrib/store/go.mod @@ -0,0 +1,29 @@ +module gitea.stevedudenhoeffer.com/steve/executus/contrib/store + +go 1.26.2 + +require ( + gitea.stevedudenhoeffer.com/steve/executus v0.0.0 + modernc.org/sqlite v1.34.4 +) + +require ( + gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sys v0.46.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) + +// Co-developed against the local checkout; dropped (pinned) at executus v0.1.0. +replace gitea.stevedudenhoeffer.com/steve/executus => ../../ diff --git a/contrib/store/go.sum b/contrib/store/go.sum new file mode 100644 index 0000000..6a8744b --- /dev/null +++ b/contrib/store/go.sum @@ -0,0 +1,53 @@ +gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3 h1:KYKIFFRsXzbbBJVDa99+Fhy0zxl9G0xV/MCrLipsLL4= +gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY= +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-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +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/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 v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= +modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/contrib/store/sqlite.go b/contrib/store/sqlite.go new file mode 100644 index 0000000..3fd8893 --- /dev/null +++ b/contrib/store/sqlite.go @@ -0,0 +1,47 @@ +// Package store provides durable, pure-Go SQLite implementations of executus's +// battery store seams (audit, budget, persona, skill). It is a SEPARATE nested +// module so the SQLite driver (modernc.org/sqlite — pure Go, no cgo) never +// enters the executus core go.sum: a static-binary host (gadfly) that imports +// only the core stays static, while a host that wants turnkey persistence +// imports this module and gets every *Store seam backed by one SQLite file. +// +// db, _ := store.Open("file:executus.db?_pragma=busy_timeout(5000)") +// defer db.Close() +// budgetStore := db.Budget() // satisfies budget.BudgetStorage +package store + +import ( + "database/sql" + "fmt" + + _ "modernc.org/sqlite" // pure-Go driver, registered as "sqlite" +) + +// DB is a handle to one SQLite database backing the executus store seams. Each +// accessor (Budget(), …) returns a seam implementation sharing this connection. +// Safe for concurrent use (SQLite serializes writes; busy_timeout handles +// contention). Construct with Open; close with Close. +type DB struct { + sql *sql.DB +} + +// Open opens (creating if absent) a SQLite database at dsn and returns a DB. A +// dsn of ":memory:" yields an ephemeral in-memory database. The caller owns the +// returned DB and must Close it. +func Open(dsn string) (*DB, error) { + sqldb, err := sql.Open("sqlite", dsn) + if err != nil { + return nil, fmt.Errorf("store: open %q: %w", dsn, err) + } + if err := sqldb.Ping(); err != nil { + sqldb.Close() + return nil, fmt.Errorf("store: ping %q: %w", dsn, err) + } + return &DB{sql: sqldb}, nil +} + +// Close closes the underlying database. +func (d *DB) Close() error { return d.sql.Close() } + +// SQL exposes the underlying *sql.DB for hosts that need direct access. +func (d *DB) SQL() *sql.DB { return d.sql }