From 954efde4740f1502fa6215afef1330350af0e638 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Fri, 26 Jun 2026 22:47:35 -0400 Subject: [PATCH] =?UTF-8?q?P4:=20contrib/store=20=E2=80=94=20skill=20SQLit?= =?UTF-8?q?e=20store=20(lifecycle=20+=20versions)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit db.Skills() satisfies skill.SkillStore over SQLite, same JSON-blob + indexed columns pattern. Versions live in their own table (each SkillVersion embeds a full Skill snapshot as JSON), ordered newest-first by an append seq. Test: round-trip (Tools, ExposeAsChatbotTool), visibility listing (public/shared/private with SharedWith filtered in Go), chatbot-exposed, newest-first versions + GetVersionByID, scheduled-due query + MarkScheduledRun. contrib/store now covers budget + persona + skill; audit store next. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 4 +- contrib/store/go.mod | 24 +++ contrib/store/go.sum | 49 ++++++ contrib/store/skill_store.go | 251 ++++++++++++++++++++++++++++++ contrib/store/skill_store_test.go | 72 +++++++++ 5 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 contrib/store/skill_store.go create mode 100644 contrib/store/skill_store_test.go diff --git a/CLAUDE.md b/CLAUDE.md index acbcf5a..ce1de0d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,8 +77,8 @@ BATTERIES (opt-in siblings, each nil-safe + a default): contrib/store/ SECOND module (+ modernc.org/sqlite): [P4 ~] pure-Go SQLite impls of the *Store seams. budget + - persona ✓ (JSON-blob+indexed cols, round-trip tested); - skill/audit pending. + persona + skill ✓ (JSON-blob+indexed cols, round-trip + tested); audit pending. CI proves the driver lands HERE, not in the core go.sum. ``` diff --git a/contrib/store/go.mod b/contrib/store/go.mod index e3b7b5e..a4630f3 100644 --- a/contrib/store/go.mod +++ b/contrib/store/go.mod @@ -8,15 +8,39 @@ require ( ) require ( + cloud.google.com/go v0.123.0 // indirect + cloud.google.com/go/auth v0.18.1 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/google/s2a-go v0.1.9 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.11 // indirect + github.com/googleapis/gax-go/v2 v2.17.0 // indirect + github.com/gorilla/websocket v1.5.3 // 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 + github.com/robfig/cron/v3 v3.0.1 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect + go.opentelemetry.io/otel v1.40.0 // indirect + go.opentelemetry.io/otel/metric v1.40.0 // indirect + go.opentelemetry.io/otel/trace v1.40.0 // indirect golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + google.golang.org/genai v1.59.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/libc v1.55.3 // indirect diff --git a/contrib/store/go.sum b/contrib/store/go.sum index e786893..f620e95 100644 --- a/contrib/store/go.sum +++ b/contrib/store/go.sum @@ -1,11 +1,36 @@ +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.1 h1:IwTEx92GFUo2pJ6Qea0EU3zYvKnTAeRCODxfA/G5UWs= +cloud.google.com/go/auth v0.18.1/go.mod h1:GfTYoS9G3CWpRA3Va9doKN9mjPGRS+v41jmZAhBzbrA= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= 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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= 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/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= 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/googleapis/enterprise-certificate-proxy v0.3.11 h1:vAe81Msw+8tKUxi2Dqh/NZMz7475yUvmRIkXr4oN2ao= +github.com/googleapis/enterprise-certificate-proxy v0.3.11/go.mod h1:RFV7MUdlb7AgEq2v7FmMCfeSMCllAzWxFgRdusoGks8= +github.com/googleapis/gax-go/v2 v2.17.0 h1:RksgfBpxqff0EZkDWYuz9q/uWsTVz+kf43LsZ1J6SMc= +github.com/googleapis/gax-go/v2 v2.17.0/go.mod h1:mzaqghpQp4JDh3HvADwrat+6M3MOIDp5YKHhb9PAgDY= +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 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= @@ -16,15 +41,39 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI 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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= +go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= +go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= +go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= +go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= +go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= +go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= 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/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= 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/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +google.golang.org/genai v1.59.0 h1:xp+ydkJFW8hO0hTUaAkr8TrLM9HFP3NYAwFhPd0nDqA= +google.golang.org/genai v1.59.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20 h1:Jr5R2J6F6qWyzINc+4AM8t5pfUz6beZpHp678GNrMbE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260203192932-546029d2fa20/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/contrib/store/skill_store.go b/contrib/store/skill_store.go new file mode 100644 index 0000000..f7ea2c6 --- /dev/null +++ b/contrib/store/skill_store.go @@ -0,0 +1,251 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "gitea.stevedudenhoeffer.com/steve/executus/skill" +) + +// skillStore is the SQLite-backed skill.SkillStore. Same JSON-blob + indexed +// columns approach as personaStore: the full Skill round-trips, lookups stay +// indexed. Versions live in their own table (each SkillVersion embeds a full +// Skill snapshot, stored as a JSON blob). +type skillStore struct{ db *sql.DB } + +// Skills returns a durable skill.SkillStore backed by this database. +func (d *DB) Skills() skill.SkillStore { return &skillStore{db: d.sql} } + +var _ skill.SkillStore = (*skillStore)(nil) + +func (s *skillStore) Initialize(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, ` +CREATE TABLE IF NOT EXISTS skills ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + source TEXT NOT NULL DEFAULT '', + visibility TEXT NOT NULL DEFAULT '', + chatbot INTEGER NOT NULL DEFAULT 0, -- ExposeAsChatbotTool + schedule TEXT NOT NULL DEFAULT '', + next_run_at INTEGER NOT NULL DEFAULT 0, + data TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_skills_owner ON skills(owner_id); +CREATE INDEX IF NOT EXISTS idx_skills_vis ON skills(visibility); +CREATE INDEX IF NOT EXISTS idx_skills_sched ON skills(schedule, next_run_at); +CREATE TABLE IF NOT EXISTS skill_versions ( + id TEXT PRIMARY KEY, + skill_id TEXT NOT NULL, + version TEXT NOT NULL DEFAULT '', + seq INTEGER NOT NULL, -- append order, for newest-first + data TEXT NOT NULL +); +CREATE INDEX IF NOT EXISTS idx_skill_versions_skill ON skill_versions(skill_id, seq);`) + if err != nil { + return fmt.Errorf("skillStore.Initialize: %w", err) + } + return nil +} + +func (s *skillStore) Save(ctx context.Context, sk *skill.Skill) error { + blob, err := json.Marshal(sk) + if err != nil { + return fmt.Errorf("skillStore.Save: marshal: %w", err) + } + var next int64 + if !sk.NextRunAt.IsZero() { + next = sk.NextRunAt.Unix() + } + chatbot := 0 + if sk.ExposeAsChatbotTool { + chatbot = 1 + } + _, err = s.db.ExecContext(ctx, ` +INSERT INTO skills (id, owner_id, name, source, visibility, chatbot, schedule, next_run_at, data) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + owner_id=excluded.owner_id, name=excluded.name, source=excluded.source, + visibility=excluded.visibility, chatbot=excluded.chatbot, schedule=excluded.schedule, + next_run_at=excluded.next_run_at, data=excluded.data`, + sk.ID, sk.OwnerID, sk.Name, string(sk.Source), string(sk.Visibility), chatbot, + sk.Schedule, next, string(blob)) + if err != nil { + return fmt.Errorf("skillStore.Save: %w", err) + } + return nil +} + +func scanSkills(rows *sql.Rows) ([]skill.Skill, error) { + defer rows.Close() + var out []skill.Skill + for rows.Next() { + var blob string + if err := rows.Scan(&blob); err != nil { + return nil, err + } + var sk skill.Skill + if err := json.Unmarshal([]byte(blob), &sk); err != nil { + return nil, err + } + out = append(out, sk) + } + return out, rows.Err() +} + +func (s *skillStore) getOne(ctx context.Context, where string, arg ...any) (*skill.Skill, error) { + var blob string + err := s.db.QueryRowContext(ctx, `SELECT data FROM skills WHERE `+where, arg...).Scan(&blob) + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, skill.ErrNotFound + case err != nil: + return nil, err + } + var sk skill.Skill + if err := json.Unmarshal([]byte(blob), &sk); err != nil { + return nil, err + } + return &sk, nil +} + +func (s *skillStore) Get(ctx context.Context, id string) (*skill.Skill, error) { + return s.getOne(ctx, "id = ?", id) +} + +func (s *skillStore) GetByName(ctx context.Context, ownerID, name string) (*skill.Skill, error) { + return s.getOne(ctx, "owner_id = ? AND name = ?", ownerID, name) +} + +func (s *skillStore) ListBuiltinByName(ctx context.Context, name string) (*skill.Skill, error) { + return s.getOne(ctx, "source = ? AND name = ?", string(skill.SourceBuiltin), name) +} + +func (s *skillStore) Delete(ctx context.Context, id string) error { + if _, err := s.db.ExecContext(ctx, `DELETE FROM skills WHERE id = ?`, id); err != nil { + return fmt.Errorf("skillStore.Delete: %w", err) + } + return nil +} + +func (s *skillStore) query(ctx context.Context, where string, arg ...any) ([]skill.Skill, error) { + rows, err := s.db.QueryContext(ctx, `SELECT data FROM skills WHERE `+where+` ORDER BY name`, arg...) + if err != nil { + return nil, err + } + return scanSkills(rows) +} + +func (s *skillStore) ListByOwner(ctx context.Context, ownerID string) ([]skill.Skill, error) { + return s.query(ctx, "owner_id = ?", ownerID) +} + +func (s *skillStore) ListPublic(ctx context.Context) ([]skill.Skill, error) { + return s.query(ctx, "visibility = ?", string(skill.VisibilityPublic)) +} + +func (s *skillStore) ListChatbotExposed(ctx context.Context) ([]skill.Skill, error) { + return s.query(ctx, "chatbot = 1") +} + +// ListSharedWith loads visibility=shared rows and filters SharedWith in Go (the +// shared set per skill is small; avoids a JSON-array query). +func (s *skillStore) ListSharedWith(ctx context.Context, memberID string) ([]skill.Skill, error) { + shared, err := s.query(ctx, "visibility = ?", string(skill.VisibilityShared)) + if err != nil { + return nil, err + } + out := shared[:0] + for _, sk := range shared { + for _, id := range sk.SharedWith { + if id == memberID { + out = append(out, sk) + break + } + } + } + return out, nil +} + +func (s *skillStore) ListDueScheduled(ctx context.Context, now time.Time) ([]skill.Skill, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT data FROM skills WHERE schedule != '' AND next_run_at > 0 AND next_run_at <= ? ORDER BY next_run_at`, + now.Unix()) + if err != nil { + return nil, fmt.Errorf("skillStore.ListDueScheduled: %w", err) + } + return scanSkills(rows) +} + +func (s *skillStore) MarkScheduledRun(ctx context.Context, skillID string, ranAt, nextAt time.Time) error { + sk, err := s.Get(ctx, skillID) + if err != nil { + return err + } + sk.LastScheduledRunAt = ranAt + sk.NextRunAt = nextAt + return s.Save(ctx, sk) +} + +func (s *skillStore) AppendVersion(ctx context.Context, sv skill.SkillVersion) error { + blob, err := json.Marshal(sv) + if err != nil { + return fmt.Errorf("skillStore.AppendVersion: marshal: %w", err) + } + // seq = current max+1 for this skill (newest-first ordering key). + var seq int64 + _ = s.db.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq),0)+1 FROM skill_versions WHERE skill_id = ?`, sv.SkillID).Scan(&seq) + if _, err := s.db.ExecContext(ctx, + `INSERT INTO skill_versions (id, skill_id, version, seq, data) VALUES (?, ?, ?, ?, ?)`, + sv.ID, sv.SkillID, sv.Version, seq, string(blob)); err != nil { + return fmt.Errorf("skillStore.AppendVersion: %w", err) + } + return nil +} + +func (s *skillStore) ListVersionsBySkill(ctx context.Context, skillID string, limit int) ([]skill.SkillVersion, error) { + q := `SELECT data FROM skill_versions WHERE skill_id = ? ORDER BY seq DESC` + args := []any{skillID} + if limit > 0 { + q += ` LIMIT ?` + args = append(args, limit) + } + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("skillStore.ListVersionsBySkill: %w", err) + } + defer rows.Close() + var out []skill.SkillVersion + for rows.Next() { + var blob string + if err := rows.Scan(&blob); err != nil { + return nil, err + } + var sv skill.SkillVersion + if err := json.Unmarshal([]byte(blob), &sv); err != nil { + return nil, err + } + out = append(out, sv) + } + return out, rows.Err() +} + +func (s *skillStore) GetVersionByID(ctx context.Context, versionID string) (*skill.SkillVersion, error) { + var blob string + err := s.db.QueryRowContext(ctx, `SELECT data FROM skill_versions WHERE id = ?`, versionID).Scan(&blob) + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, skill.ErrNotFound + case err != nil: + return nil, err + } + var sv skill.SkillVersion + if err := json.Unmarshal([]byte(blob), &sv); err != nil { + return nil, err + } + return &sv, nil +} diff --git a/contrib/store/skill_store_test.go b/contrib/store/skill_store_test.go new file mode 100644 index 0000000..a534c6d --- /dev/null +++ b/contrib/store/skill_store_test.go @@ -0,0 +1,72 @@ +package store + +import ( + "context" + "testing" + "time" + + "gitea.stevedudenhoeffer.com/steve/executus/skill" +) + +func TestSQLiteSkillStore(t *testing.T) { + ctx := context.Background() + db, err := Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + st := db.Skills() + if err := st.Initialize(ctx); err != nil { + t.Fatal(err) + } + + pub := &skill.Skill{ID: "a", Name: "pub", OwnerID: "o1", Visibility: skill.VisibilityPublic, + Tools: []string{"summarize"}, ExposeAsChatbotTool: true} + shared := &skill.Skill{ID: "b", Name: "shr", OwnerID: "o1", Visibility: skill.VisibilityShared, SharedWith: []string{"bob"}} + if err := st.Save(ctx, pub); err != nil { + t.Fatal(err) + } + if err := st.Save(ctx, shared); err != nil { + t.Fatal(err) + } + + got, err := st.Get(ctx, "a") + if err != nil || len(got.Tools) != 1 || !got.ExposeAsChatbotTool { + t.Fatalf("round-trip: %v %+v", err, got) + } + if ps, _ := st.ListPublic(ctx); len(ps) != 1 || ps[0].ID != "a" { + t.Errorf("ListPublic = %+v", ps) + } + if ss, _ := st.ListSharedWith(ctx, "bob"); len(ss) != 1 || ss[0].ID != "b" { + t.Errorf("ListSharedWith(bob) = %+v", ss) + } + if ss, _ := st.ListSharedWith(ctx, "carol"); len(ss) != 0 { + t.Errorf("ListSharedWith(carol) should be empty: %+v", ss) + } + if ce, _ := st.ListChatbotExposed(ctx); len(ce) != 1 { + t.Errorf("ListChatbotExposed = %d, want 1", len(ce)) + } + + // Versions newest-first + by id. + st.AppendVersion(ctx, skill.SkillVersion{ID: "v1", SkillID: "a", Version: "1.0.0"}) + st.AppendVersion(ctx, skill.SkillVersion{ID: "v2", SkillID: "a", Version: "1.1.0"}) + vs, _ := st.ListVersionsBySkill(ctx, "a", 10) + if len(vs) != 2 || vs[0].ID != "v2" { + t.Errorf("versions newest-first: %+v", vs) + } + if gv, err := st.GetVersionByID(ctx, "v1"); err != nil || gv.Version != "1.0.0" { + t.Errorf("GetVersionByID: %v %+v", err, gv) + } + + // Scheduling. + now := time.Now().UTC() + cron := &skill.Skill{ID: "c", Name: "cron", OwnerID: "o1", Schedule: "0 * * * *", NextRunAt: now.Add(-time.Minute)} + st.Save(ctx, cron) + if due, _ := st.ListDueScheduled(ctx, now); len(due) != 1 || due[0].ID != "c" { + t.Fatalf("ListDueScheduled = %+v", due) + } + st.MarkScheduledRun(ctx, "c", now, now.Add(time.Hour)) + if due, _ := st.ListDueScheduled(ctx, now); len(due) != 0 { + t.Errorf("after MarkScheduledRun nothing due: %+v", due) + } +}