From c8559676ed0fe2c26068f3a4b3e21ea218059839 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Sat, 27 Jun 2026 00:15:00 -0400 Subject: [PATCH] P4b: skill noun + contrib/store (SQLite for budget/persona/skill/audit) 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) --- .gitea/workflows/ci.yml | 12 + CLAUDE.md | 32 ++- contrib/store/audit_store.go | 356 +++++++++++++++++++++++ contrib/store/audit_store_test.go | 67 +++++ contrib/store/budget_store.go | 105 +++++++ contrib/store/budget_store_test.go | 65 +++++ contrib/store/go.mod | 54 ++++ contrib/store/go.sum | 105 +++++++ contrib/store/persona_store.go | 174 ++++++++++++ contrib/store/persona_store_test.go | 106 +++++++ contrib/store/skill_store.go | 280 ++++++++++++++++++ contrib/store/skill_store_test.go | 72 +++++ contrib/store/sqlite.go | 54 ++++ go.mod | 1 + go.sum | 2 + skill/consts.go | 6 + skill/inputs.go | 422 +++++++++++++++++++++++++++ skill/memory.go | 169 +++++++++++ skill/runnable.go | 35 +++ skill/schedule.go | 107 +++++++ skill/skill.go | 424 ++++++++++++++++++++++++++++ skill/skill_test.go | 57 ++++ skill/skill_version.go | 28 ++ skill/store.go | 44 +++ skill/validate.go | 374 ++++++++++++++++++++++++ 25 files changed, 3141 insertions(+), 10 deletions(-) create mode 100644 contrib/store/audit_store.go create mode 100644 contrib/store/audit_store_test.go create mode 100644 contrib/store/budget_store.go create mode 100644 contrib/store/budget_store_test.go create mode 100644 contrib/store/go.mod create mode 100644 contrib/store/go.sum create mode 100644 contrib/store/persona_store.go create mode 100644 contrib/store/persona_store_test.go create mode 100644 contrib/store/skill_store.go create mode 100644 contrib/store/skill_store_test.go create mode 100644 contrib/store/sqlite.go create mode 100644 skill/consts.go create mode 100644 skill/inputs.go create mode 100644 skill/memory.go create mode 100644 skill/runnable.go create mode 100644 skill/schedule.go create mode 100644 skill/skill.go create mode 100644 skill/skill_test.go create mode 100644 skill/skill_version.go create mode 100644 skill/store.go create mode 100644 skill/validate.go 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 67e4b6f..7afcf4c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,30 +58,42 @@ CORE (majordomo + stdlib): structured output — no separate structured/ pkg) llmmeta/ shared meta-LLM helper over model/ [P1 ✓] compact/ context compactor (WithCompactor hook) [P2 ✓] - tools/ generic tool library: Register (think/now/ [P3 wip] + tools/ generic tool library: Register (think/now/ [P3 ✓] cite, zero-config) + RegisterMeta (classify/ extract_entities/summarize) + RegisterStore (kv_*/file_*, default static quota); seams in research_providers.go/file_storage.go/ kv_storage.go/quota_provider.go. End-to-end - "agent calls a tool" test green. Remaining: - web/net/compose groups + default backends [P3] + "agent calls a tool" test green. Remaining + (deferred): web/net/compose groups + backends BATTERIES (opt-in siblings, each nil-safe + a default): - persona/ Agent noun + Storage seam + builtin loader [P4 ~] + persona/ Agent noun + Storage seam + builtin loader [P4 ✓] + ToRunnable() bridge to run.RunnableAgent + Memory default (host: chatbot/commands/personalization) - skill/ rich Skill + SkillStore seam + toml loader [P4] + skill/ Skill noun + LEAN SkillStore (lifecycle/ [P4 ✓] + versions/schedule, NOT mort's 60-method + monster) + ToRunnable + Memory default audit/ run.Audit Sink + Writer + queryable Memory [P4 ✓] default (skillaudit Storage iface; GORM stays in mort) - critic/ two-tier timeout state machine + Escalator [P4] - schedule/ cron runner cores [P4] - checkpoint/ durable resume seam [P4] + critic/ two-tier timeout watchdog (run.Critic) + [P4 ✓] + Escalator policy seam + ExtendOnce default + schedule/ generic cron Runner (Tick/Loop over a wired [P4 ✓] + Due/Run/Mark/Next; no cron grammar of its own) + checkpoint/ CheckpointStore + run.Checkpointer handle [P4 ✓] + (throttled Save/Complete/Fail) + Memory 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 ALL store seams: budget + + persona + skill + audit (JSON-blob+indexed cols, + round-trip tested). CI proves the driver lands HERE, + not in the core go.sum. + +NOTE: critic/checkpoint executor wiring (run.Ports.Critic / +.Checkpointer call sites) is a P2 follow-up — the batteries + +defaults exist ahead of that wiring. ``` ### The one architectural move diff --git a/contrib/store/audit_store.go b/contrib/store/audit_store.go new file mode 100644 index 0000000..96ee04a --- /dev/null +++ b/contrib/store/audit_store.go @@ -0,0 +1,356 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "gitea.stevedudenhoeffer.com/steve/executus/audit" +) + +// auditStore is the SQLite-backed audit.Storage: one row per run (+ a JSON +// `inputs` blob), one row per log event. The run-list/filter/walk queries are +// indexed on the columns they filter; the log payload is a JSON blob. +type auditStore struct{ db *sql.DB } + +// Audit returns a durable audit.Storage backed by this database. +func (d *DB) Audit() audit.Storage { return &auditStore{db: d.sql} } + +var _ audit.Storage = (*auditStore)(nil) + +func (s *auditStore) Initialize(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, ` +CREATE TABLE IF NOT EXISTS skill_runs ( + id TEXT PRIMARY KEY, + skill_id TEXT NOT NULL DEFAULT '', + caller_id TEXT NOT NULL DEFAULT '', + channel_id TEXT NOT NULL DEFAULT '', + parent_run_id TEXT NOT NULL DEFAULT '', + inputs TEXT NOT NULL DEFAULT '{}', + started_at INTEGER NOT NULL DEFAULT 0, + finished_at INTEGER NOT NULL DEFAULT 0, -- 0 = still running + status TEXT NOT NULL DEFAULT 'running', + output TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '', + tool_calls INTEGER NOT NULL DEFAULT 0, + runtime_seconds REAL NOT NULL DEFAULT 0, + total_input_tokens INTEGER NOT NULL DEFAULT 0, + total_output_tokens INTEGER NOT NULL DEFAULT 0, + total_thinking_tokens INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_runs_skill ON skill_runs(skill_id, started_at); +CREATE INDEX IF NOT EXISTS idx_runs_caller ON skill_runs(caller_id, started_at); +CREATE INDEX IF NOT EXISTS idx_runs_parent ON skill_runs(parent_run_id); +CREATE INDEX IF NOT EXISTS idx_runs_started ON skill_runs(started_at); +CREATE TABLE IF NOT EXISTS skill_run_logs ( + run_id TEXT NOT NULL, + seq INTEGER NOT NULL, + event_type TEXT NOT NULL, + payload TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + PRIMARY KEY (run_id, seq) +);`) + if err != nil { + return fmt.Errorf("auditStore.Initialize: %w", err) + } + return nil +} + +func unixOrZero(t time.Time) int64 { + if t.IsZero() { + return 0 + } + return t.Unix() +} + +func (s *auditStore) StartRun(ctx context.Context, r audit.SkillRun) error { + inputs, _ := json.Marshal(r.Inputs) + var fin int64 + if r.FinishedAt != nil { + fin = unixOrZero(*r.FinishedAt) + } + status := r.Status + if status == "" { + status = "running" + } + _, err := s.db.ExecContext(ctx, ` +INSERT INTO skill_runs (id, skill_id, caller_id, channel_id, parent_run_id, inputs, started_at, finished_at, + status, output, error, tool_calls, runtime_seconds, total_input_tokens, total_output_tokens, total_thinking_tokens) +VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + skill_id=excluded.skill_id, caller_id=excluded.caller_id, channel_id=excluded.channel_id, + parent_run_id=excluded.parent_run_id, inputs=excluded.inputs, started_at=excluded.started_at`, + r.ID, r.SkillID, r.CallerID, r.ChannelID, r.ParentRunID, string(inputs), unixOrZero(r.StartedAt), fin, + status, r.Output, r.Error, r.ToolCallsCount, r.RuntimeSeconds, + r.TotalInputTokens, r.TotalOutputTokens, r.TotalThinkingTokens) + if err != nil { + return fmt.Errorf("auditStore.StartRun: %w", err) + } + return nil +} + +func (s *auditStore) FinishRun(ctx context.Context, runID string, st audit.RunStats) error { + res, err := s.db.ExecContext(ctx, ` +UPDATE skill_runs SET finished_at=?, status=?, output=?, error=?, tool_calls=?, runtime_seconds=?, + total_input_tokens=?, total_output_tokens=?, total_thinking_tokens=? WHERE id=?`, + time.Now().Unix(), st.Status, st.Output, st.Error, st.ToolCalls, st.RuntimeSeconds, + st.InputTokens, st.OutputTokens, st.ThinkingTokens, runID) + if err != nil { + return fmt.Errorf("auditStore.FinishRun: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return audit.ErrNotFound + } + return nil +} + +func (s *auditStore) AppendLog(ctx context.Context, l audit.SkillRunLog) error { + payload, _ := json.Marshal(l.Payload) + created := unixOrZero(l.CreatedAt) + if created == 0 { + created = time.Now().Unix() + } + _, err := s.db.ExecContext(ctx, + `INSERT OR REPLACE INTO skill_run_logs (run_id, seq, event_type, payload, created_at) VALUES (?, ?, ?, ?, ?)`, + l.RunID, l.Sequence, l.EventType, string(payload), created) + if err != nil { + return fmt.Errorf("auditStore.AppendLog: %w", err) + } + return nil +} + +// runCols is the SELECT column list matching scanRun. +const runCols = `id, skill_id, caller_id, channel_id, parent_run_id, inputs, started_at, finished_at, + status, output, error, tool_calls, runtime_seconds, total_input_tokens, total_output_tokens, total_thinking_tokens` + +func scanRun(sc interface{ Scan(...any) error }) (*audit.SkillRun, error) { + var r audit.SkillRun + var inputs string + var started, finished int64 + if err := sc.Scan(&r.ID, &r.SkillID, &r.CallerID, &r.ChannelID, &r.ParentRunID, &inputs, + &started, &finished, &r.Status, &r.Output, &r.Error, &r.ToolCallsCount, &r.RuntimeSeconds, + &r.TotalInputTokens, &r.TotalOutputTokens, &r.TotalThinkingTokens); err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(inputs), &r.Inputs) + r.StartedAt = time.Unix(started, 0).UTC() + if finished > 0 { + t := time.Unix(finished, 0).UTC() + r.FinishedAt = &t + } + return &r, nil +} + +func (s *auditStore) GetRun(ctx context.Context, runID string) (*audit.SkillRun, error) { + row := s.db.QueryRowContext(ctx, `SELECT `+runCols+` FROM skill_runs WHERE id = ?`, runID) + r, err := scanRun(row) + if errors.Is(err, sql.ErrNoRows) { + return nil, audit.ErrNotFound + } + return r, err +} + +func (s *auditStore) queryRuns(ctx context.Context, tail string, args ...any) ([]audit.SkillRun, error) { + rows, err := s.db.QueryContext(ctx, `SELECT `+runCols+` FROM skill_runs `+tail, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var out []audit.SkillRun + for rows.Next() { + r, err := scanRun(rows) + if err != nil { + return nil, err + } + out = append(out, *r) + } + return out, rows.Err() +} + +func (s *auditStore) ListLogsByRun(ctx context.Context, runID string) ([]audit.SkillRunLog, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT run_id, seq, event_type, payload, created_at FROM skill_run_logs WHERE run_id = ? ORDER BY seq`, runID) + if err != nil { + return nil, fmt.Errorf("auditStore.ListLogsByRun: %w", err) + } + defer rows.Close() + var out []audit.SkillRunLog + for rows.Next() { + var l audit.SkillRunLog + var payload string + var created int64 + if err := rows.Scan(&l.RunID, &l.Sequence, &l.EventType, &payload, &created); err != nil { + return nil, err + } + _ = json.Unmarshal([]byte(payload), &l.Payload) + l.CreatedAt = time.Unix(created, 0).UTC() + out = append(out, l) + } + return out, rows.Err() +} + +func (s *auditStore) ListRunsBySkill(ctx context.Context, skillID string, limit int) ([]audit.SkillRun, error) { + return s.ListRunsBySkillPaginated(ctx, skillID, 0, limit, false) +} + +func (s *auditStore) ListRunsBySkillPaginated(ctx context.Context, skillID string, offset, limit int, includeDryRun bool) ([]audit.SkillRun, error) { + w := `WHERE skill_id = ?` + args := []any{skillID} + if !includeDryRun { + w += ` AND status != 'dry_run'` + } + return s.queryRuns(ctx, w+` ORDER BY started_at DESC `+limitOffset(limit, offset), args...) +} + +func (s *auditStore) CountRunsBySkill(ctx context.Context, skillID string, includeDryRun bool) (int64, error) { + q := `SELECT COUNT(*) FROM skill_runs WHERE skill_id = ?` + if !includeDryRun { + q += ` AND status != 'dry_run'` + } + var n int64 + err := s.db.QueryRowContext(ctx, q, skillID).Scan(&n) + return n, err +} + +func (s *auditStore) ListRunsByCaller(ctx context.Context, callerID string, limit int) ([]audit.SkillRun, error) { + return s.queryRuns(ctx, `WHERE caller_id = ? AND status != 'dry_run' ORDER BY started_at DESC `+limitOffset(limit, 0), callerID) +} + +func (s *auditStore) buildFilter(f audit.RunFilter) (string, []any) { + var conds []string + var args []any + if !f.IncludeDryRun { + conds = append(conds, `status != 'dry_run'`) + } + if f.Status != "" { + conds = append(conds, `status = ?`) + args = append(args, f.Status) + } + if f.SkillID != "" { + conds = append(conds, `skill_id = ?`) + args = append(args, f.SkillID) + } + if f.CallerID != "" { + conds = append(conds, `caller_id = ?`) + args = append(args, f.CallerID) + } + if f.ChannelID != "" { + conds = append(conds, `channel_id = ?`) + args = append(args, f.ChannelID) + } + if f.TopLevelOnly { + conds = append(conds, `parent_run_id = ''`) + } + if !f.Since.IsZero() { + conds = append(conds, `started_at >= ?`) + args = append(args, f.Since.Unix()) + } + if !f.Until.IsZero() { + conds = append(conds, `started_at <= ?`) + args = append(args, f.Until.Unix()) + } + where := "" + if len(conds) > 0 { + where = `WHERE ` + strings.Join(conds, " AND ") + } + return where, args +} + +func (s *auditStore) ListRunsFiltered(ctx context.Context, f audit.RunFilter, offset, limit int) ([]audit.SkillRun, error) { + where, args := s.buildFilter(f) + return s.queryRuns(ctx, where+` ORDER BY started_at DESC `+limitOffset(limit, offset), args...) +} + +func (s *auditStore) CountRunsFiltered(ctx context.Context, f audit.RunFilter) (int64, error) { + where, args := s.buildFilter(f) + var n int64 + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM skill_runs `+where, args...).Scan(&n) + return n, err +} + +func (s *auditStore) PurgeOlderThan(ctx context.Context, t time.Time) (int64, error) { + res, err := s.db.ExecContext(ctx, `DELETE FROM skill_runs WHERE finished_at > 0 AND finished_at < ?`, t.Unix()) + if err != nil { + return 0, fmt.Errorf("auditStore.PurgeOlderThan: %w", err) + } + n, _ := res.RowsAffected() + // Best-effort orphan-log cleanup. + _, _ = s.db.ExecContext(ctx, `DELETE FROM skill_run_logs WHERE run_id NOT IN (SELECT id FROM skill_runs)`) + return n, nil +} + +func (s *auditStore) ListChildrenByParent(ctx context.Context, parentRunID string) ([]audit.SkillRun, error) { + return s.queryRuns(ctx, `WHERE parent_run_id = ? ORDER BY started_at DESC`, parentRunID) +} + +func (s *auditStore) WalkParentChain(ctx context.Context, runID string) ([]audit.SkillRun, error) { + var chain []audit.SkillRun + seen := map[string]bool{} + for id := runID; id != ""; { + if seen[id] { + break + } + seen[id] = true + r, err := s.GetRun(ctx, id) + if errors.Is(err, audit.ErrNotFound) { + break + } + if err != nil { + return nil, err + } + chain = append(chain, *r) + id = r.ParentRunID + } + return chain, nil +} + +func (s *auditStore) ListFinishedRunsBefore(ctx context.Context, cutoff time.Time, limit int) ([]audit.SkillRun, error) { + return s.queryRuns(ctx, + `WHERE finished_at > 0 AND finished_at < ? ORDER BY started_at DESC `+limitOffset(limit, 0), cutoff.Unix()) +} + +func (s *auditStore) LastRunBySkills(ctx context.Context, skillIDs []string, includeFailed bool) (map[string]time.Time, error) { + out := map[string]time.Time{} + if len(skillIDs) == 0 { + return out, nil + } + q := `SELECT skill_id, MAX(started_at) FROM skill_runs WHERE skill_id IN (` + + strings.TrimSuffix(strings.Repeat("?,", len(skillIDs)), ",") + `)` + args := make([]any, 0, len(skillIDs)) + for _, id := range skillIDs { + args = append(args, id) + } + if !includeFailed { + q += ` AND status NOT IN ('error','timeout')` + } + q += ` GROUP BY skill_id` + rows, err := s.db.QueryContext(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("auditStore.LastRunBySkills: %w", err) + } + defer rows.Close() + for rows.Next() { + var id string + var ts int64 + if err := rows.Scan(&id, &ts); err != nil { + return nil, err + } + out[id] = time.Unix(ts, 0).UTC() + } + return out, rows.Err() +} + +// limitOffset renders an optional LIMIT/OFFSET clause (limit<=0 = no limit). +func limitOffset(limit, offset int) string { + if limit <= 0 { + return "" + } + if offset > 0 { + return fmt.Sprintf("LIMIT %d OFFSET %d", limit, offset) + } + return fmt.Sprintf("LIMIT %d", limit) +} diff --git a/contrib/store/audit_store_test.go b/contrib/store/audit_store_test.go new file mode 100644 index 0000000..1ea7039 --- /dev/null +++ b/contrib/store/audit_store_test.go @@ -0,0 +1,67 @@ +package store + +import ( + "context" + "testing" + "time" + + "gitea.stevedudenhoeffer.com/steve/executus/audit" +) + +func TestSQLiteAuditStore(t *testing.T) { + ctx := context.Background() + db, err := Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + st := db.Audit() + if err := st.Initialize(ctx); err != nil { + t.Fatal(err) + } + + now := time.Now().UTC() + // parent run + if err := st.StartRun(ctx, audit.SkillRun{ID: "r1", SkillID: "agent-x", CallerID: "c1", + Inputs: map[string]any{"q": "hi"}, StartedAt: now}); err != nil { + t.Fatal(err) + } + // child run + st.StartRun(ctx, audit.SkillRun{ID: "r2", SkillID: "skill-y", CallerID: "c1", ParentRunID: "r1", StartedAt: now.Add(time.Second)}) + + st.AppendLog(ctx, audit.SkillRunLog{RunID: "r1", Sequence: 1, EventType: "step", Payload: map[string]any{"i": 1}, CreatedAt: now}) + if err := st.FinishRun(ctx, "r1", audit.RunStats{Status: "ok", Output: "done", ToolCalls: 2, InputTokens: 10, OutputTokens: 5}); err != nil { + t.Fatal(err) + } + + got, err := st.GetRun(ctx, "r1") + if err != nil || got.Status != "ok" || got.Output != "done" || got.FinishedAt == nil || + got.Inputs["q"] != "hi" || got.TotalInputTokens != 10 { + t.Fatalf("GetRun: %v %+v", err, got) + } + if logs, _ := st.ListLogsByRun(ctx, "r1"); len(logs) != 1 || logs[0].EventType != "step" { + t.Errorf("ListLogsByRun = %+v", logs) + } + if kids, _ := st.ListChildrenByParent(ctx, "r1"); len(kids) != 1 || kids[0].ID != "r2" { + t.Errorf("ListChildrenByParent = %+v", kids) + } + if chain, _ := st.WalkParentChain(ctx, "r2"); len(chain) != 2 || chain[1].ID != "r1" { + t.Errorf("WalkParentChain = %+v", chain) + } + if byCaller, _ := st.ListRunsByCaller(ctx, "c1", 10); len(byCaller) != 2 { + t.Errorf("ListRunsByCaller = %d, want 2", len(byCaller)) + } + // filter: top-level only + tl, _ := st.ListRunsFiltered(ctx, audit.RunFilter{TopLevelOnly: true}, 0, 10) + if len(tl) != 1 || tl[0].ID != "r1" { + t.Errorf("TopLevelOnly filter = %+v", tl) + } + // last-run map + last, _ := st.LastRunBySkills(ctx, []string{"agent-x", "skill-y"}, true) + if _, ok := last["agent-x"]; !ok { + t.Errorf("LastRunBySkills missing agent-x: %+v", last) + } + if n, _ := st.CountRunsBySkill(ctx, "agent-x", false); n != 1 { + t.Errorf("CountRunsBySkill = %d, want 1", n) + } +} diff --git a/contrib/store/budget_store.go b/contrib/store/budget_store.go new file mode 100644 index 0000000..be2fbb7 --- /dev/null +++ b/contrib/store/budget_store.go @@ -0,0 +1,105 @@ +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 +} 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..a4630f3 --- /dev/null +++ b/contrib/store/go.mod @@ -0,0 +1,54 @@ +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 ( + 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 + 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..f620e95 --- /dev/null +++ b/contrib/store/go.sum @@ -0,0 +1,105 @@ +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= +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= +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= +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/persona_store.go b/contrib/store/persona_store.go new file mode 100644 index 0000000..74187b1 --- /dev/null +++ b/contrib/store/persona_store.go @@ -0,0 +1,174 @@ +package store + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" + + "gitea.stevedudenhoeffer.com/steve/executus/persona" +) + +// personaStore is the SQLite-backed persona.Storage. It stores each Agent as a +// JSON blob in `data` with a handful of extracted, indexed columns for the +// query methods — so the FULL struct round-trips (no domain↔GORM↔DB field-loss +// footgun) while owner/name/webhook/schedule lookups stay indexable. +type personaStore struct{ db *sql.DB } + +// Personas returns a durable persona.Storage backed by this database. +func (d *DB) Personas() persona.Storage { return &personaStore{db: d.sql} } + +var _ persona.Storage = (*personaStore)(nil) + +func (s *personaStore) InitializeAgentStorage(ctx context.Context) error { + _, err := s.db.ExecContext(ctx, ` +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + owner_id TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL DEFAULT '', + webhook_secret TEXT NOT NULL DEFAULT '', + chatbot_channel_filter TEXT NOT NULL DEFAULT '', + schedule TEXT NOT NULL DEFAULT '', + next_run_at INTEGER NOT NULL DEFAULT 0, -- unix seconds; 0 = unset + data TEXT NOT NULL -- full Agent as JSON +); +CREATE INDEX IF NOT EXISTS idx_agents_owner ON agents(owner_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_agents_owner_name ON agents(owner_id, name); +CREATE INDEX IF NOT EXISTS idx_agents_sched ON agents(schedule, next_run_at);`) + if err != nil { + return fmt.Errorf("personaStore.Initialize: %w", err) + } + return nil +} + +func (s *personaStore) SaveAgent(ctx context.Context, a *persona.Agent) error { + blob, err := json.Marshal(a) + if err != nil { + return fmt.Errorf("personaStore.SaveAgent: marshal: %w", err) + } + var next int64 + if a.NextRunAt != nil && !a.NextRunAt.IsZero() { + next = a.NextRunAt.Unix() + } + _, err = s.db.ExecContext(ctx, ` +INSERT INTO agents (id, owner_id, name, webhook_secret, chatbot_channel_filter, schedule, next_run_at, data) +VALUES (?, ?, ?, ?, ?, ?, ?, ?) +ON CONFLICT(id) DO UPDATE SET + owner_id=excluded.owner_id, name=excluded.name, webhook_secret=excluded.webhook_secret, + chatbot_channel_filter=excluded.chatbot_channel_filter, schedule=excluded.schedule, + next_run_at=excluded.next_run_at, data=excluded.data`, + a.ID, a.OwnerID, a.Name, a.WebhookSecret, a.ChatbotChannelFilter, a.Schedule, next, string(blob)) + if err != nil { + return fmt.Errorf("personaStore.SaveAgent: %w", err) + } + return nil +} + +// scanAgents unmarshals the `data` column of every row in rows. +func scanAgents(rows *sql.Rows) ([]*persona.Agent, error) { + defer rows.Close() + var out []*persona.Agent + for rows.Next() { + var blob string + if err := rows.Scan(&blob); err != nil { + return nil, err + } + var a persona.Agent + if err := json.Unmarshal([]byte(blob), &a); err != nil { + return nil, err + } + out = append(out, &a) + } + return out, rows.Err() +} + +func (s *personaStore) getOne(ctx context.Context, where string, arg ...any) (*persona.Agent, error) { + var blob string + err := s.db.QueryRowContext(ctx, `SELECT data FROM agents WHERE `+where, arg...).Scan(&blob) + switch { + case errors.Is(err, sql.ErrNoRows): + return nil, persona.ErrNotFound + case err != nil: + return nil, err + } + var a persona.Agent + if err := json.Unmarshal([]byte(blob), &a); err != nil { + return nil, err + } + return &a, nil +} + +func (s *personaStore) GetAgent(ctx context.Context, id string) (*persona.Agent, error) { + return s.getOne(ctx, "id = ?", id) +} + +func (s *personaStore) GetAgentByName(ctx context.Context, ownerID, name string) (*persona.Agent, error) { + return s.getOne(ctx, "owner_id = ? AND name = ?", ownerID, name) +} + +func (s *personaStore) GetAgentByWebhookSecret(ctx context.Context, secret string) (*persona.Agent, error) { + if secret == "" { + return nil, persona.ErrNotFound + } + return s.getOne(ctx, "webhook_secret = ?", secret) +} + +func (s *personaStore) ListAgents(ctx context.Context, ownerID string) ([]*persona.Agent, error) { + rows, err := s.db.QueryContext(ctx, `SELECT data FROM agents WHERE owner_id = ? ORDER BY name`, ownerID) + if err != nil { + return nil, fmt.Errorf("personaStore.ListAgents: %w", err) + } + return scanAgents(rows) +} + +func (s *personaStore) ListAllAgents(ctx context.Context) ([]*persona.Agent, error) { + rows, err := s.db.QueryContext(ctx, `SELECT data FROM agents ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("personaStore.ListAllAgents: %w", err) + } + return scanAgents(rows) +} + +func (s *personaStore) DeleteAgent(ctx context.Context, id string) error { + if _, err := s.db.ExecContext(ctx, `DELETE FROM agents WHERE id = ?`, id); err != nil { + return fmt.Errorf("personaStore.DeleteAgent: %w", err) + } + return nil +} + +func (s *personaStore) ListAgentsByChatbotChannelFilter(ctx context.Context) ([]*persona.Agent, error) { + rows, err := s.db.QueryContext(ctx, `SELECT data FROM agents WHERE chatbot_channel_filter != '' ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("personaStore.ListAgentsByChatbotChannelFilter: %w", err) + } + return scanAgents(rows) +} + +func (s *personaStore) ListScheduledAgents(ctx context.Context, dueBefore time.Time) ([]*persona.Agent, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT data FROM agents WHERE schedule != '' AND next_run_at > 0 AND next_run_at <= ? ORDER BY next_run_at`, + dueBefore.Unix()) + if err != nil { + return nil, fmt.Errorf("personaStore.ListScheduledAgents: %w", err) + } + return scanAgents(rows) +} + +func (s *personaStore) MarkAgentScheduledRun(ctx context.Context, agentID string, ranAt, nextAt time.Time) error { + // Single atomic statement, not Get→mutate→Save: closes the lost-update + // window a concurrent Mark/edit would otherwise open. json_set keeps the + // blob's *time.Time fields consistent with the next_run_at column (Go + // encodes time.Time as RFC3339Nano, so it round-trips through GetAgent). + res, err := s.db.ExecContext(ctx, + `UPDATE agents SET next_run_at=?, data=json_set(data,'$.NextRunAt',?,'$.LastScheduledRunAt',?) WHERE id=?`, + nextAt.Unix(), nextAt.Format(time.RFC3339Nano), ranAt.Format(time.RFC3339Nano), agentID) + if err != nil { + return fmt.Errorf("personaStore.MarkAgentScheduledRun: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return persona.ErrNotFound + } + return nil +} diff --git a/contrib/store/persona_store_test.go b/contrib/store/persona_store_test.go new file mode 100644 index 0000000..e04fa0e --- /dev/null +++ b/contrib/store/persona_store_test.go @@ -0,0 +1,106 @@ +package store + +import ( + "context" + "testing" + "time" + + "gitea.stevedudenhoeffer.com/steve/executus/persona" +) + +func TestSQLitePersonaStore(t *testing.T) { + ctx := context.Background() + db, err := Open(":memory:") + if err != nil { + t.Fatal(err) + } + defer db.Close() + st := db.Personas() + if err := st.InitializeAgentStorage(ctx); err != nil { + t.Fatal(err) + } + + // Full struct round-trips through the JSON blob (incl. nested + map fields). + a := &persona.Agent{ + ID: "a1", Name: "helper", OwnerID: "o1", SystemPrompt: "be nice", + ModelTier: "fast", SkillPalette: []string{"animate"}, + StateReactEmoji: map[string]string{"running": "⏳"}, + ChatbotChannelFilter: "general", + } + if err := st.SaveAgent(ctx, a); err != nil { + t.Fatal(err) + } + got, err := st.GetAgent(ctx, "a1") + if err != nil || got.SystemPrompt != "be nice" || len(got.SkillPalette) != 1 || + got.StateReactEmoji["running"] != "⏳" { + t.Fatalf("round-trip lost fields: %+v (err %v)", got, err) + } + if byName, err := st.GetAgentByName(ctx, "o1", "helper"); err != nil || byName.ID != "a1" { + t.Fatalf("GetAgentByName: %v %+v", err, byName) + } + if cf, _ := st.ListAgentsByChatbotChannelFilter(ctx); len(cf) != 1 { + t.Errorf("ListAgentsByChatbotChannelFilter = %d, want 1", len(cf)) + } + + // Scheduling: due query + MarkAgentScheduledRun round-trip. + now := time.Now().UTC() + sched := &persona.Agent{ID: "s1", Name: "cron", OwnerID: "o1", Schedule: "0 * * * *"} + due := now.Add(-time.Minute) + sched.NextRunAt = &due + if err := st.SaveAgent(ctx, sched); err != nil { + t.Fatal(err) + } + dueList, _ := st.ListScheduledAgents(ctx, now) + if len(dueList) != 1 || dueList[0].ID != "s1" { + t.Fatalf("ListScheduledAgents = %+v", dueList) + } + next := now.Add(time.Hour) + if err := st.MarkAgentScheduledRun(ctx, "s1", now, next); err != nil { + t.Fatal(err) + } + if again, _ := st.ListScheduledAgents(ctx, now); len(again) != 0 { + t.Errorf("after MarkAgentScheduledRun, nothing should be due before now: %+v", again) + } + + if err := st.DeleteAgent(ctx, "a1"); err != nil { + t.Fatal(err) + } + if _, err := st.GetAgent(ctx, "a1"); err != persona.ErrNotFound { + t.Errorf("GetAgent after delete = %v, want ErrNotFound", err) + } +} + +// TestMarkAgentScheduledRunBlobRoundTrips guards the json_set atomic update: +// the JSON blob must stay parseable and reflect the new scheduled times. +func TestMarkAgentScheduledRunBlobRoundTrips(t *testing.T) { + ctx := context.Background() + db, _ := Open(":memory:") + defer db.Close() + st := db.Personas() + st.InitializeAgentStorage(ctx) + start := time.Now().UTC() + a := &persona.Agent{ID: "m1", Name: "n", OwnerID: "o", Schedule: "0 * * * *"} + a.NextRunAt = &start + if err := st.SaveAgent(ctx, a); err != nil { + t.Fatal(err) + } + ran := start + next := start.Add(time.Hour) + if err := st.MarkAgentScheduledRun(ctx, "m1", ran, next); err != nil { + t.Fatal(err) + } + got, err := st.GetAgent(ctx, "m1") // blob must still unmarshal + if err != nil { + t.Fatalf("GetAgent after json_set Mark failed (blob corrupt?): %v", err) + } + if got.NextRunAt == nil || !got.NextRunAt.Equal(next) { + t.Errorf("blob NextRunAt = %v, want %v", got.NextRunAt, next) + } + if got.LastScheduledRunAt == nil || !got.LastScheduledRunAt.Equal(ran) { + t.Errorf("blob LastScheduledRunAt = %v, want %v", got.LastScheduledRunAt, ran) + } + // Unknown id -> ErrNotFound. + if err := st.MarkAgentScheduledRun(ctx, "nope", ran, next); err != persona.ErrNotFound { + t.Errorf("Mark(unknown) = %v, want ErrNotFound", err) + } +} diff --git a/contrib/store/skill_store.go b/contrib/store/skill_store.go new file mode 100644 index 0000000..0933edd --- /dev/null +++ b/contrib/store/skill_store.go @@ -0,0 +1,280 @@ +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 UNIQUE 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 { + // Single atomic statement instead of Get→mutate→Save: a concurrent Mark or + // admin edit can't lose this update (no read-modify-write window). json_set + // keeps the JSON blob's NextRunAt/LastScheduledRunAt consistent with the + // indexed next_run_at column; RFC3339Nano matches Go's time JSON encoding so + // the blob still round-trips through Get. + var next int64 + if !nextAt.IsZero() { + next = nextAt.Unix() + } + res, err := s.db.ExecContext(ctx, + `UPDATE skills SET next_run_at=?, data=json_set(data,'$.NextRunAt',?,'$.LastScheduledRunAt',?) WHERE id=?`, + next, nextAt.Format(time.RFC3339Nano), ranAt.Format(time.RFC3339Nano), skillID) + if err != nil { + return fmt.Errorf("skillStore.MarkScheduledRun: %w", err) + } + if n, _ := res.RowsAffected(); n == 0 { + return skill.ErrNotFound + } + return nil +} + +func (s *skillStore) AppendVersion(ctx context.Context, sv skill.SkillVersion) error { + if sv.SkillID == "" { + return fmt.Errorf("skillStore.AppendVersion: skill_id is required") + } + 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). The + // MAX-then-INSERT runs in ONE transaction and the (skill_id, seq) index is + // UNIQUE, so two concurrent appends can't both land the same seq: the loser + // fails loudly on commit instead of silently corrupting the ordering. The + // Scan error is propagated (was swallowed, leaving seq=0 on failure). + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("skillStore.AppendVersion: begin: %w", err) + } + defer tx.Rollback() //nolint:errcheck // no-op after Commit + var seq int64 + if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(seq),0)+1 FROM skill_versions WHERE skill_id = ?`, sv.SkillID).Scan(&seq); err != nil { + return fmt.Errorf("skillStore.AppendVersion: seq: %w", err) + } + if _, err := tx.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: insert: %w", err) + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("skillStore.AppendVersion: commit: %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) + } +} diff --git a/contrib/store/sqlite.go b/contrib/store/sqlite.go new file mode 100644 index 0000000..e8548e6 --- /dev/null +++ b/contrib/store/sqlite.go @@ -0,0 +1,54 @@ +// 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) + } + // A contended writer should WAIT for the lock, not fail immediately — set a + // busy_timeout so concurrent stores don't see spurious SQLITE_BUSY. (The + // doc example advertised this; it's now actually applied for every DSN.) + if _, err := sqldb.Exec("PRAGMA busy_timeout=5000"); err != nil { + sqldb.Close() + return nil, fmt.Errorf("store: set busy_timeout %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 } diff --git a/go.mod b/go.mod index 7463def..8e3f4fe 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.2 require ( gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260626223738-1fd7109a42f3 github.com/google/uuid v1.6.0 + github.com/robfig/cron/v3 v3.0.1 golang.org/x/crypto v0.53.0 gopkg.in/yaml.v3 v3.0.1 ) diff --git a/go.sum b/go.sum index a7e5c65..7f34796 100644 --- a/go.sum +++ b/go.sum @@ -50,6 +50,8 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +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= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= diff --git a/skill/consts.go b/skill/consts.go new file mode 100644 index 0000000..0396d46 --- /dev/null +++ b/skill/consts.go @@ -0,0 +1,6 @@ +package skill + +// DefaultChatbotInputName is the input-param name a chatbot-exposed skill +// receives the user's message under when its schema doesn't name one. Moved +// from mort's chatbot_provider.go (a host concern) as a host-agnostic default. +const DefaultChatbotInputName = "request" diff --git a/skill/inputs.go b/skill/inputs.go new file mode 100644 index 0000000..a328940 --- /dev/null +++ b/skill/inputs.go @@ -0,0 +1,422 @@ +package skill + +import ( + "fmt" + "strings" +) + +// This file holds the shared input-parsing primitives used by both the +// chatbot exposure adapter (chatbot_provider.go) and the .skill Discord +// command handler (commands.go) to construct a SkillInputs map from +// caller-supplied raw values. Centralising here avoids the two paths +// drifting in their type-coercion or required-check semantics. +// +// Two layers: +// +// - CoerceInputValue: per-param-type coercion (int/float/bool/string). +// Accepts loosely-typed values (LLM-stringified numbers, JSON +// float64s for ints) and returns a value in the target Go shape. +// +// - CoerceInputs: per-skill validation. Walks the InputSchema, coerces +// each declared param via CoerceInputValue, drops extras silently, +// errors on missing required. +// +// Why exported (capital): both consumers live in the same package, but +// the names are also referenced in test files and the symbols are +// genuinely useful API for any future consumer (webui form handler, +// scheduler in v2). Keep the surface small. + +// CoerceInputValue coerces a single raw value to the target InputParam +// type. JSON numbers arrive from json.Unmarshal as float64; bools as +// bool; strings as string. Type-mismatched strings are accepted ("3" → +// int 3, "true" → bool true) because both LLM tool calls and Discord +// command args frequently surface scalars as strings. +// +// Why: LLM tool-call args come through json.Unmarshal of a plain +// map[string]any, which forces every JSON number into float64 and every +// JSON string into string. Without this coerce step, an int parameter +// would arrive in SkillInputs as a float64, a bool sent as "true" would +// arrive as a string, etc. — confusing the skill agent's prompt +// renderer and any tool-side logic that switches on Go type. The +// .skill command handler benefits identically: arg tokens arrive as +// strings, but downstream tools may expect typed values. +// +// Test: TestCoerceInputValue in inputs_test.go covers each branch. +func CoerceInputValue(paramType string, v any) (any, error) { + switch paramType { + case "int": + switch x := v.(type) { + case float64: + return int(x), nil + case int: + return x, nil + case string: + var i int + if _, err := fmt.Sscanf(x, "%d", &i); err != nil { + return nil, fmt.Errorf("not an int: %q", x) + } + return i, nil + default: + return nil, fmt.Errorf("not an int: %T", v) + } + case "float": + switch x := v.(type) { + case float64: + return x, nil + case int: + return float64(x), nil + case string: + var f float64 + if _, err := fmt.Sscanf(x, "%f", &f); err != nil { + return nil, fmt.Errorf("not a float: %q", x) + } + return f, nil + default: + return nil, fmt.Errorf("not a float: %T", v) + } + case "bool": + switch x := v.(type) { + case bool: + return x, nil + case string: + switch x { + case "true", "True", "TRUE", "1": + return true, nil + case "false", "False", "FALSE", "0": + return false, nil + default: + return nil, fmt.Errorf("not a bool: %q", x) + } + default: + return nil, fmt.Errorf("not a bool: %T", v) + } + default: + // "string", "user", "channel", "url", and unknown — coerce to + // string. JSON numbers/bools are stringified so the executor's + // validateInputs (which strips e.g. <@!123> wrappers) gets a + // uniform string input. + switch x := v.(type) { + case string: + return x, nil + case float64: + return fmt.Sprintf("%v", x), nil + case bool: + return fmt.Sprintf("%v", x), nil + default: + return fmt.Sprintf("%v", v), nil + } + } +} + +// CoerceInputs validates and coerces a map of raw caller-supplied values +// against the declared parameter set: +// +// - Extra keys (not in params) are dropped silently. +// - Missing required keys return an error so the caller can surface +// usage information. +// - Per-param type coercion handles int/float/bool sent as strings. +// +// Returns a fresh map containing only declared params; never mutates the +// input map. +// +// Why: see CoerceInputValue. Both callers (chatbot exposure adapter, +// .skill command handler) need the same required-check + extra-drop +// semantics; previously only the chatbot path implemented them, which +// is exactly why .skill dropped its arguments entirely. +// +// Test: TestCoerceInputs in inputs_test.go. +func CoerceInputs(params []InputParam, raw map[string]any) (map[string]any, error) { + out := make(map[string]any, len(params)) + for _, p := range params { + v, present := raw[p.Name] + if !present { + if p.Required { + return nil, fmt.Errorf("missing required parameter %q", p.Name) + } + continue + } + typed, err := CoerceInputValue(p.Type, v) + if err != nil { + return nil, fmt.Errorf("parameter %q: %w", p.Name, err) + } + out[p.Name] = typed + } + return out, nil +} + +// ParseCommandInputs parses a free-form command argument string into a +// raw map[string]any keyed by InputSchema parameter names. Three modes +// are supported, picked by the shape of `schema`: +// +// CASE A — empty schema: +// The whole string becomes {"request": ""}. Mirrors the +// chatbot exposure default (DefaultChatbotInputName) so a skill with +// no declared inputs can still receive its trigger text uniformly +// across both surfaces. +// +// CASE B — exactly one required param (with optional non-required +// tail): +// If the user passed any --key=value or --key value flags they're +// parsed as flags (Case C). Otherwise the WHOLE rest-of-message +// becomes that single required param's value. This is the +// "single-arg convenience" pattern that lets `.skill weather Boston +// today` work without the user typing --city=. +// +// CASE C — multiple params, OR any --flag style input: +// Tokens are parsed as `--name=value` or `--name value`. Bare +// positional tokens after a flag are collected as that flag's value. +// Trailing positional tokens with no preceding flag are dropped +// (the caller's usage string should mention the flag form). +// +// The returned map values are RAW strings (or bool true for +// presence-only flags); type coercion is the caller's job via +// CoerceInputs. +// +// Why this signature instead of returning the typed map directly: the +// caller wants to distinguish "missing required" (→ usage reply) from +// "type coercion failed" (→ explicit error). Splitting parse from +// coerce keeps the message specific. +func ParseCommandInputs(schema []InputParam, raw string) map[string]any { + out := map[string]any{} + raw = strings.TrimSpace(raw) + if raw == "" { + return out + } + + // Detect flag-style input regardless of schema shape — even a single + // required-param schema may be invoked via `.skill x --name value` + // for forward compat. + hasFlag := strings.Contains(raw, "--") + + switch { + case len(schema) == 0: + // Empty schema: mirror the chatbot exposure adapter's default + // "request" pseudo-param so executor.composePrompt can render + // it uniformly. + out[DefaultChatbotInputName] = raw + + case !hasFlag && countRequired(schema) == 1: + // Single-required-param convenience: whole rest-of-message is the + // value, regardless of any other (non-required) params declared. + // They can be supplied via --flag form if needed. + req := firstRequired(schema) + out[req.Name] = raw + + default: + // Flag-style parse. Walk tokens looking for --name[=value] or + // --name . + parseFlagStyle(out, schema, raw) + } + return out +} + +// countRequired returns the number of params marked Required. +func countRequired(schema []InputParam) int { + n := 0 + for _, p := range schema { + if p.Required { + n++ + } + } + return n +} + +// firstRequired returns the first required param. Caller must have +// already verified at least one exists. +func firstRequired(schema []InputParam) *InputParam { + for i := range schema { + if schema[i].Required { + return &schema[i] + } + } + return nil +} + +// parseFlagStyle walks tokens for --name=value and --name value forms. +// Unknown flags (not in schema) are still accepted into the output map +// so the caller can detect and warn about them; CoerceInputs will drop +// extras when constructing the final SkillInputs. +// +// Tokens not preceded by a --flag are dropped. v1 is intentionally +// strict-ish here: we don't try to guess which positional token belongs +// to which param when there are several. The single-required-param +// convenience handles the common ambiguity-free case in the caller. +func parseFlagStyle(out map[string]any, schema []InputParam, raw string) { + tokens := tokeniseCommandLine(raw) + declared := map[string]bool{} + for _, p := range schema { + declared[p.Name] = true + } + + i := 0 + for i < len(tokens) { + t := tokens[i] + if !strings.HasPrefix(t, "--") { + // Bare positional token outside a flag context — drop. The + // caller's usage string should steer users to flag form. + i++ + continue + } + key := t[2:] + // --name=value form + if eq := strings.IndexByte(key, '='); eq >= 0 { + out[key[:eq]] = key[eq+1:] + i++ + continue + } + // --name form: take the next token IF it doesn't itself + // start with --. Otherwise treat as a presence-only boolean flag. + if i+1 < len(tokens) && !strings.HasPrefix(tokens[i+1], "--") { + out[key] = tokens[i+1] + i += 2 + continue + } + out[key] = "true" + i++ + } + _ = declared // reserved for v2 unknown-flag warnings +} + +// tokeniseCommandLine splits a free-form Discord command argument +// string into tokens. Quoted spans (single or double quotes) are kept +// as one token so users can pass values with spaces: +// +// .skill weather --city="New York" +// .skill summarise --text 'a long sentence here' +// +// Mirrors the user's intuition without introducing a full shell +// parser. Newlines split as whitespace. +func tokeniseCommandLine(s string) []string { + var out []string + var cur strings.Builder + var quote rune + flush := func() { + if cur.Len() > 0 { + out = append(out, cur.String()) + cur.Reset() + } + } + for _, r := range s { + switch { + case quote != 0: + if r == quote { + quote = 0 + continue + } + cur.WriteRune(r) + case r == '"' || r == '\'': + quote = r + case r == ' ' || r == '\t' || r == '\n': + flush() + default: + cur.WriteRune(r) + } + } + flush() + return out +} + +// ResolveCommandInputs is the one-call helper a Discord .skill handler +// uses to turn a free-form rest-of-message into a coerced +// SkillInputs map ready to hand to the executor. It is the single +// production entry point for command-side input resolution: every +// caller must use it (do NOT chain ParseCommandInputs + CoerceInputs +// directly). +// +// Why this exists as a single function: chaining +// ParseCommandInputs + CoerceInputs at the call site is what broke +// `.skill echo hello world` in production. ParseCommandInputs Case A +// (empty schema) writes the user's text into out["request"], but +// CoerceInputs(emptySchema, …) iterates the DECLARED params and +// silently drops every key not in the schema — so "request" is +// dropped before reaching the executor, and the agent's user-prompt +// renders "(no input provided)". The fix is to mirror the chatbot +// exposure adapter: derive the EFFECTIVE param set (which inflates +// an empty schema to a single required "request" param) and coerce +// against that, not the original empty schema. +// +// What: +// - Empty input_schema → effective params = [{request, required, string}], +// so ParseCommandInputs Case A's "request" key survives Coerce. +// - Non-empty input_schema → effective params = the schema as-is, so +// Case B / Case C parse-and-coerce semantics are unchanged. +// +// Returns the coerced SkillInputs map, or an error suitable for +// surfacing to the user (e.g. via FormatUsage). Never mutates +// `schema`. +// +// Test: TestResolveCommandInputs_* in inputs_test.go cover the three +// cases plus the empty-schema regression. +func ResolveCommandInputs(schema []InputParam, raw string) (map[string]any, error) { + rawInputs := ParseCommandInputs(schema, raw) + effective := effectiveCommandParams(schema) + return CoerceInputs(effective, rawInputs) +} + +// effectiveCommandParams returns the parameter set the .skill command +// path should use for coercion. Mirrors chatbotToolParams in +// chatbot_provider.go: an empty input_schema is inflated to a single +// required "request" string param so the user's free-text trigger +// survives CoerceInputs's drop-extras semantics. +// +// Why a separate helper (vs reusing chatbotToolParams): keeping the +// helper local to inputs.go avoids dragging chatbot_provider.go into +// the .skill command path's import surface and makes the intent +// (Discord-side parameter inflation) explicit at the call site. +func effectiveCommandParams(schema []InputParam) []InputParam { + if len(schema) > 0 { + return schema + } + return []InputParam{{ + Name: DefaultChatbotInputName, + Description: "The user's free-text trigger.", + Type: "string", + Required: true, + }} +} + +// FormatUsage renders a human-readable usage string for the .skill +// invocation form. Used by command handlers when required params are +// missing or coercion fails. +// +// Why: keep the usage message in one place so both the missing-required +// and coercion-failed paths produce identical output. +func FormatUsage(name string, schema []InputParam) string { + var sb strings.Builder + fmt.Fprintf(&sb, "usage: `.skill %s", name) + if len(schema) == 0 { + sb.WriteString(" `") + return sb.String() + } + if countRequired(schema) == 1 { + req := firstRequired(schema) + fmt.Fprintf(&sb, " <%s>`", req.Name) + // Show optional flags (if any). + var optional []InputParam + for _, p := range schema { + if !p.Required { + optional = append(optional, p) + } + } + if len(optional) > 0 { + sb.WriteString("\n optional:") + for _, p := range optional { + fmt.Fprintf(&sb, " --%s=<%s>", p.Name, p.Type) + } + } + return sb.String() + } + // Multi-param: full --flag form. + for _, p := range schema { + if p.Required { + fmt.Fprintf(&sb, " --%s=<%s>", p.Name, p.Type) + } + } + for _, p := range schema { + if !p.Required { + fmt.Fprintf(&sb, " [--%s=<%s>]", p.Name, p.Type) + } + } + sb.WriteString("`") + return sb.String() +} diff --git a/skill/memory.go b/skill/memory.go new file mode 100644 index 0000000..3159b37 --- /dev/null +++ b/skill/memory.go @@ -0,0 +1,169 @@ +package skill + +import ( + "context" + "sort" + "sync" + "time" +) + +// Memory is a zero-dependency in-process SkillStore — a light host or test gets +// saved-skill persistence with no DB. Mort backs SkillStore with GORM/MySQL; +// contrib/store adds durable SQLite. +type Memory struct { + mu sync.RWMutex + skills map[string]*Skill // by ID + versions map[string][]SkillVersion // by skill ID, append order + byVerID map[string]SkillVersion // by version ID +} + +// NewMemory returns an empty in-memory SkillStore. +func NewMemory() *Memory { + return &Memory{ + skills: map[string]*Skill{}, + versions: map[string][]SkillVersion{}, + byVerID: map[string]SkillVersion{}, + } +} + +var _ SkillStore = (*Memory)(nil) + +func (m *Memory) Initialize(context.Context) error { return nil } + +func (m *Memory) Save(_ context.Context, s *Skill) error { + m.mu.Lock() + defer m.mu.Unlock() + cp := *s + m.skills[s.ID] = &cp + return nil +} + +func (m *Memory) Get(_ context.Context, id string) (*Skill, error) { + m.mu.RLock() + defer m.mu.RUnlock() + s, ok := m.skills[id] + if !ok { + return nil, ErrNotFound + } + cp := *s + return &cp, nil +} + +func (m *Memory) GetByName(_ context.Context, ownerID, name string) (*Skill, error) { + m.mu.RLock() + defer m.mu.RUnlock() + for _, s := range m.skills { + if s.OwnerID == ownerID && s.Name == name { + cp := *s + return &cp, nil + } + } + return nil, ErrNotFound +} + +func (m *Memory) Delete(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.skills, id) + return nil +} + +func (m *Memory) listWhere(keep func(*Skill) bool) []Skill { + m.mu.RLock() + defer m.mu.RUnlock() + out := make([]Skill, 0, len(m.skills)) + for _, s := range m.skills { + if keep == nil || keep(s) { + out = append(out, *s) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func (m *Memory) ListByOwner(_ context.Context, ownerID string) ([]Skill, error) { + return m.listWhere(func(s *Skill) bool { return s.OwnerID == ownerID }), nil +} + +func (m *Memory) ListPublic(context.Context) ([]Skill, error) { + return m.listWhere(func(s *Skill) bool { return s.Visibility == VisibilityPublic }), nil +} + +func (m *Memory) ListSharedWith(_ context.Context, memberID string) ([]Skill, error) { + return m.listWhere(func(s *Skill) bool { + if s.Visibility != VisibilityShared { + return false + } + for _, id := range s.SharedWith { + if id == memberID { + return true + } + } + return false + }), nil +} + +func (m *Memory) ListBuiltinByName(_ context.Context, name string) (*Skill, error) { + m.mu.RLock() + defer m.mu.RUnlock() + for _, s := range m.skills { + if s.Source == SourceBuiltin && s.Name == name { + cp := *s + return &cp, nil + } + } + return nil, ErrNotFound +} + +func (m *Memory) ListChatbotExposed(context.Context) ([]Skill, error) { + return m.listWhere(func(s *Skill) bool { return s.ExposeAsChatbotTool }), nil +} + +func (m *Memory) ListDueScheduled(_ context.Context, now time.Time) ([]Skill, error) { + return m.listWhere(func(s *Skill) bool { return s.DueAt(now) }), nil +} + +func (m *Memory) MarkScheduledRun(_ context.Context, skillID string, ranAt, nextAt time.Time) error { + m.mu.Lock() + defer m.mu.Unlock() + s, ok := m.skills[skillID] + if !ok { + return ErrNotFound + } + s.LastScheduledRunAt = ranAt + s.NextRunAt = nextAt + return nil +} + +func (m *Memory) AppendVersion(_ context.Context, sv SkillVersion) error { + m.mu.Lock() + defer m.mu.Unlock() + m.versions[sv.SkillID] = append(m.versions[sv.SkillID], sv) + m.byVerID[sv.ID] = sv + return nil +} + +func (m *Memory) ListVersionsBySkill(_ context.Context, skillID string, limit int) ([]SkillVersion, error) { + m.mu.RLock() + defer m.mu.RUnlock() + all := m.versions[skillID] + // newest first + out := make([]SkillVersion, 0, len(all)) + for i := len(all) - 1; i >= 0; i-- { + out = append(out, all[i]) + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + +func (m *Memory) GetVersionByID(_ context.Context, versionID string) (*SkillVersion, error) { + m.mu.RLock() + defer m.mu.RUnlock() + sv, ok := m.byVerID[versionID] + if !ok { + return nil, ErrNotFound + } + return &sv, nil +} diff --git a/skill/runnable.go b/skill/runnable.go new file mode 100644 index 0000000..de61576 --- /dev/null +++ b/skill/runnable.go @@ -0,0 +1,35 @@ +package skill + +import ( + "time" + + "gitea.stevedudenhoeffer.com/steve/executus/run" +) + +// ToRunnable lowers a saved Skill into the kernel's run.RunnableAgent DTO, so +// run.Executor can run a skill WITHOUT importing this battery (the inversion of +// mort's skillexec running a skills.Skill). Maps the static shape only; the +// skill's input schema → prompt rendering, palette resolution, audit, etc. are +// supplied separately (the host renders inputs into the input string and wires +// run.Ports). A skill exposes a flat tool list (no SkillPalette/SubAgentPalette +// — composition is a host concern), so those stay empty. +func (s *Skill) ToRunnable() run.RunnableAgent { + return run.RunnableAgent{ + ID: s.ID, + Name: s.Name, + SystemPrompt: s.SystemPrompt, + ModelTier: s.ModelTier, + MaxIterations: s.MaxIterations, + MaxRuntime: s.MaxRuntime, + LowLevelTools: s.Tools, + } +} + +// DueAt reports whether a scheduled skill is due at now (cron empty => never). +// Convenience for a host scheduler that doesn't want to re-parse the cron. +func (s *Skill) DueAt(now time.Time) bool { + if s.Schedule == "" || s.NextRunAt.IsZero() { + return false + } + return !s.NextRunAt.After(now) +} diff --git a/skill/schedule.go b/skill/schedule.go new file mode 100644 index 0000000..033e348 --- /dev/null +++ b/skill/schedule.go @@ -0,0 +1,107 @@ +package skill + +import ( + "fmt" + "strings" + "time" + + "github.com/robfig/cron/v3" +) + +// scheduleParser is the cron parser shared across the skills package. It +// accepts the standard 5-field syntax (minute hour dom month dow) plus +// descriptors such as @daily, @hourly, etc. We do not enable the seconds +// field — schedule cadence is governed in minutes, and a seconds field +// would invite specs that fire below the min-interval floor without +// surfacing as such in the spec text. +// +// Why standalone vs. cron.ParseStandard: ParseStandard rejects descriptors +// (@daily, @hourly). Skills callers may want to write @daily as a +// shorthand alongside the explicit "daily" / "weekly" forms we translate +// below. +var scheduleParser = cron.NewParser( + cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor, +) + +// ParseSchedule turns a user-supplied schedule expression into a +// cron.Schedule. The empty string returns (nil, nil) — callers should +// treat that as "on-demand only". +// +// Why: Skill.Schedule is a string field stored verbatim; the validator, +// the scheduler runner, and any future tooling all need to round-trip +// through the same parser. Centralising it here avoids drift. +// +// Accepted shorthands: +// - "daily" → "0 0 * * *" (midnight UTC every day) +// - "weekly" → "0 0 * * 0" (midnight UTC every Sunday) +// +// Anything else is fed through robfig/cron/v3's standard parser +// (descriptors enabled). +// +// Test: schedule_test.go covers shorthand expansion and invalid-spec +// rejection. +func ParseSchedule(expr string) (cron.Schedule, error) { + expr = strings.TrimSpace(expr) + if expr == "" { + return nil, nil + } + switch strings.ToLower(expr) { + case "daily": + expr = "0 0 * * *" + case "weekly": + expr = "0 0 * * 0" + } + sched, err := scheduleParser.Parse(expr) + if err != nil { + return nil, fmt.Errorf("invalid schedule %q: %w", expr, err) + } + return sched, nil +} + +// ScheduleMinInterval returns an estimate of the smallest gap between +// consecutive fire times for a parsed schedule. It samples the next two +// fire times from a couple of starting points and returns the smallest +// observed gap. +// +// Why: cron.Schedule does not expose a "smallest interval" API. The +// validator needs this to enforce a per-skill min-interval floor (so an +// admin can't accidentally register "* * * * *" and burn GPU minutes). +// Two probe points are enough to catch irregular schedules whose tightest +// gap appears at a particular point in the week (e.g. "0 9 * * 1,5", +// where Mon→Fri is 4d but Fri→Mon is 3d — both sampled). +// +// Returns 0 if sched is nil. +// +// Test: schedule_test.go covers a "* * * * *" minute-interval probe and +// the irregular Mon/Fri case. +func ScheduleMinInterval(sched cron.Schedule) time.Duration { + if sched == nil { + return 0 + } + // Probe from a fixed reference and from a midweek offset. Six fire + // times across two starts catches weekly irregularities (the worst + // case is a schedule that fires once a week — we still get one gap + // per probe). Using a wall-clock-independent reference keeps the + // test deterministic. + starts := []time.Time{ + time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC), // Monday 00:00 + time.Date(2024, 1, 4, 12, 30, 0, 0, time.UTC), // Thursday 12:30 + time.Date(2024, 6, 15, 23, 59, 59, 0, time.UTC), // mid-year, late + } + var min time.Duration + for _, t := range starts { + // Sample three consecutive fires per start to capture two gaps. + f1 := sched.Next(t) + f2 := sched.Next(f1) + f3 := sched.Next(f2) + for _, gap := range []time.Duration{f2.Sub(f1), f3.Sub(f2)} { + if gap <= 0 { + continue + } + if min == 0 || gap < min { + min = gap + } + } + } + return min +} diff --git a/skill/skill.go b/skill/skill.go new file mode 100644 index 0000000..10b71ff --- /dev/null +++ b/skill/skill.go @@ -0,0 +1,424 @@ +// Package skills implements the agentic skills platform: user-creatable +// agent definitions (system prompt + tool whitelist + I/O spec) that run +// in-process via majordomo's agent loop. +// +// A Skill is a saved agent definition. It can be invoked from Discord +// (.skill ), exposed to the chatbot as a tool (via the +// SkillsToolProvider), and (in v2) scheduled. Skills compose tools from +// the skilltools registry, gated by a three-stage permission model: +// save-time AuthoringRequirement, share-time SafeForShare, execute-time +// SkillNameGate. +// +// This file declares the domain types only. Storage lives in storage.go; +// validation lives in validate.go. The grand storage pattern documented in +// pkg/logic/storage/CLAUDE.md applies — when adding a field to Skill, you +// MUST also update pkg/logic/skills/gorm_model.go (gormSkill, fromStorage, +// toStorage) or persistence will silently break. +package skill + +import "time" + +// Skill is the domain definition of an agentic skill. +// +// Why: a skill is a saved agent definition reusable across invocations +// (Discord, chatbot tool, scheduled run in v2). The struct is intentionally +// flat — every field lives on its own column on the skills table; there is +// no JSON-blob spec column. This keeps queries (e.g. "list all skills with +// chatbot exposure") indexable and avoids opaque migration headaches. +// +// What: identity + authoring + agent spec + visibility + chatbot exposure +// fields, all on one struct. +// +// Test: see validate_test.go and integration_test.go for round-trip and +// validation coverage. +type Skill struct { + // Identity + ID string // UUID + OwnerID string // Discord member ID; empty for builtin + Name string // unique per (owner, builtin namespace) + Description string + Source Source // SourceBuiltin | SourceManual + CreatedAt time.Time + UpdatedAt time.Time + + // Authoring (copied at save time from the user) + AuthoredBy string // member ID at time of last edit (audit; may differ from owner over time) + + // Versioning (for builtins; user skills typically stay at 1.0.0) + Version string // semver; used by builtin loader to decide re-seed + + // Spec — agent definition + SystemPrompt string + Tools []string // registry tool names + ModelTier string // "fast" | "standard" | "thinking" | explicit "provider/model" + InputSchema []InputParam + OutputTarget OutputTarget + Schedule string // cron; empty = on-demand only; rejected in v1 (ships in v2) + Visibility Visibility // VisibilityPrivate | VisibilityShared | VisibilityPublic + SharedWith []string // member IDs for visibility=shared + MaxIterations int // 0 → use convar default + MaxToolCalls int // 0 → use convar default + MaxRuntime time.Duration // 0 → use convar default + InitialMessage string + + // Chatbot exposure (v1 — proves out the platform via mortventure) + ExposeAsChatbotTool bool + ChatbotToolName string + ChatbotToolDescription string + ChatbotChannelFilter string // named filter from the channel-filter registry + + // Admin gating (v2 — public scheduled channel skills require approval). + // DEPRECATED in v3: PinnedVersionID subsumes this flag for non-owner + // invocation gating. CanInvoke no longer references this column. + // Drop in v4. + PendingApproval bool + + // Pinned version (v3 — admin-curated invocation gate). + // + // Why: in v3, non-owner invocation requires that an admin explicitly + // pin a known snapshot. This replaces v2's PendingApproval flag — + // pinning is the explicit "approved for general use" signal, and the + // pinned snapshot is what executes for non-owner callers (so an owner + // editing a public skill never accidentally exposes work-in-progress + // to other users). + // + // PinnedVersionID is the SkillVersion.ID (UUID) of the snapshot that + // non-owner invocations resolve to. Empty means "no pin yet" — only + // the owner and admins can invoke. + // + // Schema column is `pinned_version` per the design spec but the field + // name in the domain struct is explicit about the kind of value it + // holds (a snapshot row's UUID, NOT a semver string), which avoids + // the spec ambiguity around "pin to v1.0.5" potentially mapping to + // multiple snapshot rows over time. + PinnedVersionID string + + // PinnedAt is the wall-clock time the pin was set. Zero means + // PinnedVersionID is empty (never pinned). + PinnedAt time.Time + + // PinnedBy is the admin member ID who set the current pin. Empty + // when PinnedVersionID is empty. + PinnedBy string + + // Scheduler bookkeeping (v2). Updated by the scheduler runner after + // a successful (or failed-but-counted) scheduled execution. + // + // LastScheduledRunAt records the wall-clock time of the most recent + // scheduled invocation; zero means "never run on schedule". + // + // NextRunAt is the precomputed wake-up time the scheduler polls for + // (`WHERE next_run_at <= NOW()`). It is recomputed by feeding + // LastScheduledRunAt (or NOW() on first scheduling) through + // ParseSchedule(Schedule).Next(...). Manual / on-demand invocations + // MUST NOT touch these fields. + LastScheduledRunAt time.Time + NextRunAt time.Time + + // ExtendedBounds, when true, lets a non-admin author save the skill + // with bounds (MaxIterations / MaxToolCalls / MaxRuntime) above the + // default tier (12/30/60s) up to the extended tier (50/150/600s). + // Set by an admin via `.skill admin grant-extended `. Cleared + // by `.skill admin revoke-extended `. Builtins and admin- + // authored skills bypass the cap entirely (the tier resolution in + // Validate treats AuthorIsAdmin and ExtendedBounds equivalently). + // + // Why a per-skill flag vs a per-user grant: governance is per-skill + // — an admin reviews a specific skill's bounds and decides those + // resource limits are justified for THAT skill. A user grant would + // blanket-allow expensive bounds on every skill they author. + ExtendedBounds bool + + // ParallelCompositionAllowed gates whether this skill may use the + // skill_invoke_parallel tool. Default false. + // + // Why a per-skill admin gate: parallel fan-out multiplies blast + // radius (one bad skill spawns N concurrent runs). Admins approve + // each skill that's allowed to use parallel composition; granting + // is per-skill via `.skill admin grant-parallel `. Builtins + // may set this directly in skill.yml (the loader bypasses + // save-time gates by design). + // + // Checked AT INVOCATION TIME (every skill_invoke_parallel call), so + // admins can grant or revoke without redeploying. The check lives + // in the tool handler (pkg/skilltools/tools/skill_invoke_parallel.go) + // via the SkillInvokerProvider.IsParallelAllowed extension. + ParallelCompositionAllowed bool + + // ExecutionLane is the named lane the skill's runs are submitted to + // when the executor routes through pkg/lane (v6). Default + // "skill-default"; admin overrides per-skill via + // `.skill admin set-lane `. + // + // Why per-skill (vs a single global skill lane): different skills + // have different concurrency profiles. A long-running web-research + // skill might warrant a dedicated 1-slot lane to avoid starving + // quick chatbot-exposed skills; an admin should be able to isolate + // it without a code change. + // + // Empty string falls through to "skill-default" at executor time + // — keeping the field nullable lets a future schema change + // distinguish "explicit skill-default" from "never set". + ExecutionLane string + + // WebhookSecret enables inbound webhooks (v7). Empty = disabled + // (the default). Non-empty = the random secret URL path segment + // for POST /webhooks/. Generated by EnableWebhook; + // rotated by RegenerateWebhookSecret. Storage is varchar(64) and + // the secret is 32 random bytes (64 hex chars), so the column + // holds a fully unique secret per skill. + // + // Why store the secret directly (not a hash): the webhook handler + // must look up the skill by the secret on every POST, which would + // require comparing every stored hash against the supplied secret + // — a per-call O(n_skills) operation. The secret is treated as a + // long random URL key (like a paste UUID); compromise is mitigated + // via RegenerateWebhookSecret rotation, not via storage hashing. + WebhookSecret string + + // WebhookSignatureRequired controls whether the inbound webhook + // handler verifies HMAC against the X-Mort-Signature header. Default + // true (the storage column default). Toggling to false skips HMAC + // verification — useful for low-stakes integrations behind an IP + // allowlist where the caller can't easily compute HMAC. Owners + // flip this on the management page; admins can also force it + // back on if a leaked allowlist becomes a concern. + WebhookSignatureRequired bool + + // WebhookIPAllowlist is a newline-separated list of CIDR blocks + // (or bare IPs). Empty string = no allowlist (accept any source + // IP). The handler parses the list at request time so updates take + // effect immediately without a redeploy. Invalid CIDR entries + // are silently dropped at parse time (the management page form + // shows a parse-error preview before save). + WebhookIPAllowlist string + + // EncryptionEnabled (v8) opts the skill into per-skill envelope + // encryption for KV values and file blob content. Default false + // (plaintext storage; matches the legacy default). When true, new + // writes go through the AES-256-GCM helpers in pkg/skilltools and + // the corresponding skill_kv / skill_file_blobs row stamps + // encryption_key_version=1; reads transparently decrypt rows whose + // version > 0 and pass through rows whose version == 0 (mixed + // storage is supported indefinitely). + // + // !!!!! OPERATIONAL WARNING !!!!! This flag is a write-side switch + // only. Disabling encryption for an already-encrypted skill does + // NOT decrypt existing rows — they remain reachable as long as + // the master key is intact. Losing SKILLS_ENCRYPTION_MASTER_KEY + // renders every encrypted row unreadable; back the master key up + // separately from database backups. See pkg/skilltools/encryption.go + // for the full operational rules. + EncryptionEnabled bool + + // Preemptible (v9) opts the skill into preemption: when a higher- + // priority job arrives at a full lane, this skill's running job may + // be cancelled mid-flight to free a slot. Default false. + // + // !!!!! OPERATIONAL WARNING !!!!! Preemption means the skill's + // scaddy.Agent context is cancelled mid-step; any partial side + // effects (file writes, KV updates, sent emails, etc.) remain + // committed. Only mark a skill preemptible when it is idempotent + // or read-only — otherwise the user-visible state may be + // inconsistent with the run's "preempted" terminal status. + // + // The lane scheduler will not preempt jobs younger than + // `skills.lane.preemption_min_runtime_seconds` (default 30s) to + // prevent thrashing. The preempted run is recorded with + // status="preempted". + Preemptible bool + + // DefaultPriority (v9) is the per-skill default priority used by + // the lane scheduler's fair-share queue ordering. Higher numbers + // run first within a single user's sub-queue. Default 0. + // + // Per-invocation overrides (skill_invoke priority arg, webhook + // X-Mort-Priority header) win over this default. Owners may set + // values in the range [-`skills.priority_max_per_user`, + // +`skills.priority_max_per_user`] (default cap 5); admins may + // exceed the cap. + DefaultPriority int + + // Tags is a free-form set of short labels owners attach to a skill + // for organisation + discovery. The list page renders each tag as a + // chip and offers a dropdown filter populated from all visible + // skills' tags. + // + // Why a separate field (vs reusing Description / Tools): tags are a + // curatorial signal, not part of the agent spec — they only matter + // to humans browsing the list. Storing them on the skill row (vs a + // side table) keeps lookups index-only and matches how the rest of + // the skill's flat fields are persisted. + // + // Validate enforces: each tag is trimmed + lowercased; max 32 chars + // per tag; max 16 tags per skill; duplicates within a single skill + // are deduped. + Tags []string + + // DeprecatedByAgentID is the Phase 7 soft-retire pointer: when + // non-empty, the Skill is "soft retired" — hidden from default + // listings (`.skill list`, the webui index, chatbot tool exposure) + // but STILL invokable via `.skill ` and via `skill_invoke` + // tool calls. The string is the agents.Agent.ID of the replacement + // Agent that supersedes this Skill. + // + // Why a pointer (not a bool): a future audit / migration tool needs + // to follow the soft-retire link back to the replacement. An admin + // browsing the deprecated-skills page wants to see "what should I + // use instead?" without a separate lookup table. + // + // Why keep the Skill row (not drop it): existing skill_invoke calls + // in user-authored skills, scheduled jobs, and webhook integrations + // would break if the row vanished. Soft-retire preserves the + // callable surface while signalling "this is the old name; the + // replacement Agent is the curated version." + // + // Set by the Phase 7 boot migration (pkg/logic/agents/migrate_phase7.go); + // admins may also flip it manually via storage tooling. Listing + // methods filter on this field by default but explicit GetByName / + // GetForInvocation lookups bypass the filter so direct invocation + // continues to work. + DeprecatedByAgentID string + + // DefaultEmoji is an optional identity emoji for the skill, shown + // as the __start__ fallback when StateReactEmoji has no __start__ + // entry. Also forwarded to the invoking Discord message when a + // parent agent calls this skill via skill_invoke, so the user sees + // the child skill's identity emoji during execution. + DefaultEmoji string + + // StateReactEmoji maps tool names (and reserved keys "__start__", + // "__end__", "__error__") to Discord emoji that the bot reacts to + // the invoking message with as the skill progresses. Empty map + // (the default) disables state-react reactions for this skill. + // + // Why: the legacy `.query` agent surfaced live progress via emoji + // reactions on the invoking message (magnifying glass on search, + // page on read, …). Skills inherit the same UX without each + // author having to wire `update_status` for trivial signalling — + // the emoji map is declarative and the executor calls inv.OnEvent + // at the relevant boundaries. update_status remains for richer + // interim text; emoji reactions are an additive lightweight signal. + // + // Reserved keys: + // - __start__: reacted right before agent.Run starts + // - __end__: reacted on successful completion + // - __error__: reacted on terminal error + // + // Tool keys: react fires on each tool dispatch. Repeated reactions + // of the same emoji are no-ops at Discord (idempotent), so a skill + // that calls web_search 5x just leaves one 🔍. + // + // Map values are arbitrary Discord emoji strings (unicode emoji, + // custom emoji `<:name:id>`, animated ``). Validate does + // not enforce a format — Discord rejects invalid emoji at react + // time and the executor swallows that with a log line. + StateReactEmoji map[string]string +} + +// ThreadIDInputKey is the magic key under skilltools.Invocation.SkillInputs +// that the v2 .skill new / .skill edit wizard handlers use to thread a +// pre-created thread channel ID through to delivery. When +// OutputTarget.Kind == "thread" and this key is present in +// inv.SkillInputs, delivery posts directly to that thread channel; +// otherwise it falls back to OutputTarget.Target / inv.ChannelID. +// +// Why a magic input key vs an OutputTarget override field: keeps the +// wire shape (Skill struct) unchanged and keeps the override scoped +// to a single invocation. Wizard commands set this immediately after +// MessageThreadStartComplex; nothing else writes it. +// +// Why defined here vs in skillexec: wizard command handlers in this +// package need to write the key, and skillexec imports skills (so +// the reverse import would cycle). Skillexec aliases this constant. +const ThreadIDInputKey = "__thread_id__" + +// Source distinguishes builtins (loaded from skills//skill.yml on +// boot) from user-authored manual skills. +// +// Why: builtin skills bypass save-time authoring and share-time safety +// checks because the loader is trusted infrastructure. +type Source string + +const ( + SourceBuiltin Source = "builtin" + SourceManual Source = "manual" +) + +// InputParam declares a typed input slot on a skill, populated at +// invocation time from positional/flag args (Discord) or form fields +// (webui). +// +// Why: skills are invoked from heterogeneous surfaces and need a uniform +// schema for input collection and validation. The Type drives string→typed +// coercion in skillexec.validateInputs; Choices restricts to an enum set. +type InputParam struct { + Name string + Description string + Type string // "string"|"int"|"float"|"bool"|"user"|"channel"|"url" + Required bool + Default string // string-encoded; parsed per Type at invocation + Choices []string +} + +// OutputTarget controls where the executor delivers a skill's output. +// +// Why: skills run in many contexts and the user shouldn't have to think +// about delivery — the spec encodes it once. The Discord delivery +// implementation in pkg/logic/skillexec/delivery.go reads this struct. +type OutputTarget struct { + Kind string // "channel"|"dm"|"thread"|"webui_only"|"channel_with_summary" + Target string // channel/member/thread ID, or empty for caller-context +} + +// Visibility controls who may invoke a skill. +// +// Why: separates *invocation* gating (this struct) from *tool authoring* +// gating (skilltools.Permission) — they are orthogonal. A non-admin can +// invoke an admin-authored public skill that uses db_select; the permission +// model for the underlying tool only fires at save time, not invocation. +type Visibility string + +const ( + VisibilityPrivate Visibility = "private" + VisibilityShared Visibility = "shared" + VisibilityPublic Visibility = "public" +) + +// IsKnownVisibility reports whether v is a recognised visibility value. +// Used by Validate. +func IsKnownVisibility(v Visibility) bool { + switch v { + case VisibilityPrivate, VisibilityShared, VisibilityPublic: + return true + } + return false +} + +// IsKnownOutputKind reports whether kind is a recognised OutputTarget.Kind. +// Used by Validate and by the Discord delivery switch. +// +// "channel_with_summary" is the v-research delivery kind: full output +// posts to a configured spam channel (skills.research.spam_channel_id) +// while a generated summary posts in the original channel as a reply +// linking back. Falls through to plain "channel" behaviour when the +// spam channel convar is unset or matches the invocation channel. +// Validate accepts this kind here; the Discord delivery switch in +// pkg/logic/skillexec/delivery_discord.go is the consumer side. +func IsKnownOutputKind(kind string) bool { + switch kind { + case "channel", "dm", "thread", "webui_only", "channel_with_summary": + return true + } + return false +} + +// IsKnownInputType reports whether t is a recognised InputParam.Type. +// Used by Validate and by skillexec.validateInputs for coercion dispatch. +func IsKnownInputType(t string) bool { + switch t { + case "string", "int", "float", "bool", "user", "channel", "url": + return true + } + return false +} diff --git a/skill/skill_test.go b/skill/skill_test.go new file mode 100644 index 0000000..8b7d389 --- /dev/null +++ b/skill/skill_test.go @@ -0,0 +1,57 @@ +package skill + +import ( + "context" + "testing" + "time" +) + +func TestSkillToRunnable(t *testing.T) { + s := &Skill{ + ID: "s1", Name: "summarizer", SystemPrompt: "summarize well", ModelTier: "fast", + MaxIterations: 4, MaxRuntime: 20 * time.Second, Tools: []string{"summarize", "now"}, + } + r := s.ToRunnable() + if r.ID != "s1" || r.ModelTier != "fast" || r.MaxIterations != 4 || len(r.LowLevelTools) != 2 { + t.Fatalf("ToRunnable mapping wrong: %+v", r) + } + // A skill exposes a flat tool list, not a palette. + if len(r.SkillPalette) != 0 || len(r.SubAgentPalette) != 0 { + t.Errorf("skill should have empty palettes, got %+v", r) + } +} + +func TestMemoryStoreVisibilityAndVersions(t *testing.T) { + ctx := context.Background() + m := NewMemory() + pub := &Skill{ID: "a", Name: "pub", OwnerID: "o1", Visibility: VisibilityPublic} + shared := &Skill{ID: "b", Name: "shr", OwnerID: "o1", Visibility: VisibilityShared, SharedWith: []string{"bob"}} + priv := &Skill{ID: "c", Name: "prv", OwnerID: "o1", Visibility: VisibilityPrivate} + for _, s := range []*Skill{pub, shared, priv} { + if err := m.Save(ctx, s); err != nil { + t.Fatal(err) + } + } + if ps, _ := m.ListPublic(ctx); len(ps) != 1 || ps[0].ID != "a" { + t.Errorf("ListPublic = %+v", ps) + } + if ss, _ := m.ListSharedWith(ctx, "bob"); len(ss) != 1 || ss[0].ID != "b" { + t.Errorf("ListSharedWith(bob) = %+v", ss) + } + if ss, _ := m.ListSharedWith(ctx, "carol"); len(ss) != 0 { + t.Errorf("ListSharedWith(carol) should be empty, got %+v", ss) + } + if all, _ := m.ListByOwner(ctx, "o1"); len(all) != 3 { + t.Errorf("ListByOwner = %d, want 3", len(all)) + } + // Versions: newest-first, fetchable by id. + m.AppendVersion(ctx, SkillVersion{ID: "v1", SkillID: "a", Version: "1.0.0"}) + m.AppendVersion(ctx, SkillVersion{ID: "v2", SkillID: "a", Version: "1.1.0"}) + vs, _ := m.ListVersionsBySkill(ctx, "a", 10) + if len(vs) != 2 || vs[0].ID != "v2" { + t.Errorf("versions newest-first wrong: %+v", vs) + } + if got, err := m.GetVersionByID(ctx, "v1"); err != nil || got.Version != "1.0.0" { + t.Errorf("GetVersionByID: %v %+v", err, got) + } +} diff --git a/skill/skill_version.go b/skill/skill_version.go new file mode 100644 index 0000000..607d29d --- /dev/null +++ b/skill/skill_version.go @@ -0,0 +1,28 @@ +package skill + +import "time" + +// SkillVersion is one immutable snapshot of a Skill at the moment it +// was saved. The skill_versions table is append-only; pruning is by +// retention policy in PruneOldVersions. +// +// Why: edit history with rollback (v3) and the admin pin gate (v3 Phase 4) +// both need a stable snapshot of the skill at a known version. The Snapshot +// field carries the FULL Skill struct so a later restore or pin produces +// the exact agent definition that was saved — system_prompt, tools, +// schedule, every field — not a synthesized partial snapshot. +// +// What: identity (UUID per snapshot) + skill ref + version-string copy + +// the full Skill payload + audit fields (saved_by, saved_at, edit_summary). +// +// Test: see skill_version_test.go for round-trip, list ordering, prune +// retention, and version-by-number disambiguation coverage. +type SkillVersion struct { + ID string // UUID per snapshot (NOT the skill's ID) + SkillID string // FK to skills.id (conceptually; not enforced by GORM) + Version string // Skill.Version at save time (semver) + Snapshot Skill // full Skill struct embedded; serialised as JSON + SavedBy string // caller member ID (or "" for builtin loader / pre-v3) + SavedAt time.Time // wall-clock save time + EditSummary string // optional human-readable note ("changed model tier", "...") +} diff --git a/skill/store.go b/skill/store.go new file mode 100644 index 0000000..865d260 --- /dev/null +++ b/skill/store.go @@ -0,0 +1,44 @@ +package skill + +import ( + "context" + "errors" + "time" +) + +// ErrNotFound is returned when a skill (or version) lookup misses. +var ErrNotFound = errors.New("skill not found") + +// SkillStore is the persistence seam for saved skills. This is the DELIBERATELY +// LEAN redesign of mort's 60-method skills.Storage: it carries only skill +// lifecycle (CRUD + visibility), versioning, and scheduling. The KV/file/quota +// sub-stores that were fused into mort's interface are NOT here — they are the +// tools/ store seams (KVStorage / FileStorage / QuotaProvider); email recipients +// and channel grants stay host concerns. A host backs this with its DB; Memory() +// is the zero-dependency default; contrib/store adds durable SQLite. +type SkillStore interface { + // Initialize prepares storage (idempotent). + Initialize(ctx context.Context) error + + // --- lifecycle --- + Save(ctx context.Context, s *Skill) error + Get(ctx context.Context, id string) (*Skill, error) + GetByName(ctx context.Context, ownerID, name string) (*Skill, error) + Delete(ctx context.Context, id string) error + + // --- listing / visibility --- + ListByOwner(ctx context.Context, ownerID string) ([]Skill, error) + ListPublic(ctx context.Context) ([]Skill, error) + ListSharedWith(ctx context.Context, memberID string) ([]Skill, error) + ListBuiltinByName(ctx context.Context, name string) (*Skill, error) + ListChatbotExposed(ctx context.Context) ([]Skill, error) + + // --- scheduling --- + ListDueScheduled(ctx context.Context, now time.Time) ([]Skill, error) + MarkScheduledRun(ctx context.Context, skillID string, ranAt, nextAt time.Time) error + + // --- versioning --- + AppendVersion(ctx context.Context, sv SkillVersion) error + ListVersionsBySkill(ctx context.Context, skillID string, limit int) ([]SkillVersion, error) + GetVersionByID(ctx context.Context, versionID string) (*SkillVersion, error) +} diff --git a/skill/validate.go b/skill/validate.go new file mode 100644 index 0000000..586db47 --- /dev/null +++ b/skill/validate.go @@ -0,0 +1,374 @@ +package skill + +import ( + "fmt" + "strings" + "time" + + "gitea.stevedudenhoeffer.com/steve/executus/model" +) + +// ChannelFilterChecker is the subset of ChannelFilterRegistry used by +// Validate to check that a skill references a registered channel filter. +// +// Why: kept narrow so tests can pass a tiny stub; full registry is +// declared in channel_filters.go. +type ChannelFilterChecker interface { + Has(name string) bool +} + +// ModelTierChecker reports whether the given model tier or +// "provider/model" spec is recognised. Validate uses this to reject +// typos at save time. +// +// Why: tiers come from llms.tier.* convars (fast/standard/thinking by +// default) but admins may add custom tiers; explicit "provider/model" +// is also valid. Validate accepts anything non-empty matching either +// pattern — finer correctness is the LLM call's job. +type ModelTierChecker interface { + IsValid(spec string) bool +} + +// defaultModelTierChecker accepts all registered tier names (via +// model.IsTierName) plus any "provider/model" form (string contains "/"). +// Tests can substitute a strict checker via ValidateOpts.ModelTierChecker. +type defaultModelTierChecker struct{} + +func (defaultModelTierChecker) IsValid(spec string) bool { + if spec == "" { + return false + } + if model.IsTierName(spec) { + return true + } + // Accept tier-with-reasoning (e.g. "thinking:high") + if i := strings.IndexByte(spec, ':'); i > 0 { + if model.IsTierName(spec[:i]) { + return true + } + } + // Accept explicit "provider/model" or "provider/model:reasoning" + return strings.ContainsRune(spec, '/') +} + +// ValidateOpts customises what Validate accepts. All fields are optional; +// nil checkers fall back to permissive defaults. +// +// Why: Validate is called from save paths (which know the registries) and +// from tests (which want to control acceptance). Bundling the deps here +// keeps the Skill API stable. +type ValidateOpts struct { + // Filters is consulted when the skill declares a chatbot channel + // filter. nil → channel-filter validity is not checked (use only in + // tests). + Filters ChannelFilterChecker + // ModelTier checks the ModelTier spec. nil → defaultModelTierChecker. + ModelTier ModelTierChecker + // MinIntervalMinutes is the floor on the smallest gap between + // consecutive fires of a skill's cron schedule. Zero → use the + // package default (defaultMinScheduleIntervalMinutes). Tests pass an + // explicit value to exercise the boundary. + MinIntervalMinutes int + + // AuthorIsAdmin tells Validate the author has admin privileges and + // may save with extended-tier bounds without ExtendedBounds=true. + // SaveUserSkill passes this from s.admin.IsAdmin(sk.AuthoredBy). + // Builtin loader sets this true to bypass the per-skill flag check + // (builtins are trusted infrastructure). + AuthorIsAdmin bool + + // DefaultMaxIterations / DefaultMaxToolCalls / DefaultMaxRuntimeSecs + // override the package-default tier-1 caps. Zero → fall back to the + // constants below. Production wiring populates these from convars + // (skills.default_max_iterations etc.) so admins can adjust the + // default tier without a redeploy. + DefaultMaxIterations int + DefaultMaxToolCalls int + DefaultMaxRuntimeSecs int + + // ExtendedMaxIterations / ExtendedMaxToolCalls / ExtendedMaxRuntimeSecs + // override the package-default tier-2 caps (the ceilings allowed when + // ExtendedBounds=true OR AuthorIsAdmin=true). Zero → fall back to the + // constants below. + ExtendedMaxIterations int + ExtendedMaxToolCalls int + ExtendedMaxRuntimeSecs int +} + +// Tiered cap defaults. The DEFAULT tier is what a non-admin author sees +// without an explicit grant; the EXTENDED tier is what admin authors and +// admin-granted skills may use. Values are tuned in the v3 spec +// "Governance: tiered resource caps" section. +// +// The package's existing absolute ceilings (maxIterationsLimit=50 and +// maxRuntime=10m) act as outer floors / sanity bounds; the tier caps +// are the active gate at save time. Extended caps respect the absolute +// ceilings naturally (50 iter, 600s = 10min runtime). +const ( + // Default tier — non-admin authors of skills without ExtendedBounds. + DefaultMaxIterations = 12 + DefaultMaxToolCalls = 30 + DefaultMaxRuntimeSecs = 60 + + // Extended tier — admin authors OR ExtendedBounds=true. + ExtendedMaxIterations = 50 + ExtendedMaxToolCalls = 150 + ExtendedMaxRuntimeSecs = 600 // 10m + + maxIterationsLimit = 50 + minRuntime = time.Second + maxRuntime = 10 * time.Minute + defaultMinScheduleIntervalMinutes = 30 + + // MaxTagsPerSkill caps the number of organisation tags any single + // skill may carry. Generous compared to typical taxonomies (GitHub + // allows ~10 topics/repo). The cap exists to prevent the list + // page's chip rendering from becoming unmanageable. + MaxTagsPerSkill = 16 + + // MaxTagLength is the per-tag character ceiling. Long enough for + // hyphenated phrases ("retro-gaming") but short enough that the + // list-page tag dropdown stays readable. + MaxTagLength = 32 +) + +// Validate enforces the skill spec invariants documented in the design +// spec ("Skill domain model" section). It is called at save time; the +// builtin loader skips authoring/share-safety checks but still runs +// Validate, so all callers can rely on a saved skill being well-formed. +// +// Why: spec rules are easy to violate by hand and silently break +// downstream (e.g. an unknown channel filter never exposes the skill to +// the chatbot). Every rule fails loudly here. +// +// What: returns the first error found; callers may surface it directly to +// users. opts may be the zero value, in which case channel-filter +// validation is skipped (tests). +// +// Test: each rejection branch has a dedicated unit test in +// validate_test.go. +func (s *Skill) Validate(opts ValidateOpts) error { + if s == nil { + return fmt.Errorf("skill is nil") + } + if strings.TrimSpace(s.Name) == "" { + return fmt.Errorf("skill name is required") + } + if strings.TrimSpace(s.SystemPrompt) == "" { + return fmt.Errorf("skill system prompt is required") + } + + // ModelTier + tierCheck := opts.ModelTier + if tierCheck == nil { + tierCheck = defaultModelTierChecker{} + } + if !tierCheck.IsValid(s.ModelTier) { + return fmt.Errorf("unknown model tier %q (expected a tier alias or provider/model)", s.ModelTier) + } + + // Schedule — empty means on-demand only. A non-empty value must be + // a valid cron expression (or one of the "daily" / "weekly" + // shorthands) AND have a smallest fire-gap >= the configured + // min-interval floor. Both checks share the package-level + // ParseSchedule helper so the scheduler runner uses the same parser. + if expr := strings.TrimSpace(s.Schedule); expr != "" { + sched, err := ParseSchedule(expr) + if err != nil { + return fmt.Errorf("schedule: %w", err) + } + minMinutes := opts.MinIntervalMinutes + if minMinutes == 0 { + minMinutes = defaultMinScheduleIntervalMinutes + } + floor := time.Duration(minMinutes) * time.Minute + if interval := ScheduleMinInterval(sched); interval < floor { + return fmt.Errorf( + "schedule %q runs more often than the minimum (every %s, floor is %s)", + expr, interval.Round(time.Second), floor) + } + } + + // Iteration / call / runtime budgets. Zero is allowed — the executor + // substitutes a convar-backed default. Negative is always wrong. + // The absolute ceilings (maxIterationsLimit=50, maxRuntime=10m) are + // outer sanity bounds; the tier caps below are the active gate. + // + // Why admin bypass on the outer ceilings: builtins are trusted + // infrastructure (per the v2 "Builtin loader must bypass save-time + // gates" lesson). The builtin loader passes AuthorIsAdmin=true so + // trusted skills like `deepresearch` (max_iterations=100, + // max_runtime=45m) and `research` (max_runtime=15m) can validate + // without re-tuning the package-wide outer floor for everyone. + // Non-admin authors still hit the original ceilings AND the + // tier-based cap (default 12 iter / 60s runtime, extended 50 iter / + // 600s runtime) — both layers stay intact for the untrusted path. + if s.MaxIterations < 0 { + return fmt.Errorf("max_iterations must be >= 0, got %d", s.MaxIterations) + } + if !opts.AuthorIsAdmin && s.MaxIterations > maxIterationsLimit { + return fmt.Errorf("max_iterations must be 0..%d, got %d", maxIterationsLimit, s.MaxIterations) + } + if s.MaxToolCalls < 0 { + return fmt.Errorf("max_tool_calls must be >= 0, got %d", s.MaxToolCalls) + } + if s.MaxRuntime < 0 { + return fmt.Errorf("max_runtime must be 0 or positive, got %s", s.MaxRuntime) + } + if s.MaxRuntime > 0 && s.MaxRuntime < minRuntime { + return fmt.Errorf("max_runtime must be 0 or >= %s, got %s", minRuntime, s.MaxRuntime) + } + if !opts.AuthorIsAdmin && s.MaxRuntime > maxRuntime { + return fmt.Errorf("max_runtime must be 0 or in [%s..%s], got %s", minRuntime, maxRuntime, s.MaxRuntime) + } + + // Tiered cap resolution: a skill saved by an admin OR a skill with + // ExtendedBounds=true (admin-granted) may use the extended tier; + // everything else saturates at the default tier. Builtins go through + // the loader's bypass path (AuthorIsAdmin=true). + defIter := opts.DefaultMaxIterations + if defIter == 0 { + defIter = DefaultMaxIterations + } + defCalls := opts.DefaultMaxToolCalls + if defCalls == 0 { + defCalls = DefaultMaxToolCalls + } + defRuntime := opts.DefaultMaxRuntimeSecs + if defRuntime == 0 { + defRuntime = DefaultMaxRuntimeSecs + } + extIter := opts.ExtendedMaxIterations + if extIter == 0 { + extIter = ExtendedMaxIterations + } + extCalls := opts.ExtendedMaxToolCalls + if extCalls == 0 { + extCalls = ExtendedMaxToolCalls + } + extRuntime := opts.ExtendedMaxRuntimeSecs + if extRuntime == 0 { + extRuntime = ExtendedMaxRuntimeSecs + } + maxIter := defIter + maxCalls := defCalls + maxRuntimeSecs := defRuntime + tier := "default" + hint := "; ask an admin to grant extended_bounds for higher" + if s.ExtendedBounds || opts.AuthorIsAdmin { + maxIter = extIter + maxCalls = extCalls + maxRuntimeSecs = extRuntime + tier = "extended" + hint = "" // already at the highest tier — no upgrade path + } + // Admin bypass on the tier cap: trusted infrastructure (builtins, + // admin-authored skills) may exceed the extended tier. The + // non-admin author still hits the tier cap above. See the + // "trusted infrastructure" rationale on the outer-ceiling block. + if !opts.AuthorIsAdmin { + if s.MaxIterations > maxIter { + return fmt.Errorf("max_iterations %d exceeds %s cap (%d)%s", + s.MaxIterations, tier, maxIter, hint) + } + if s.MaxToolCalls > maxCalls { + return fmt.Errorf("max_tool_calls %d exceeds %s cap (%d)%s", + s.MaxToolCalls, tier, maxCalls, hint) + } + if s.MaxRuntime > 0 && s.MaxRuntime > time.Duration(maxRuntimeSecs)*time.Second { + return fmt.Errorf("max_runtime %s exceeds %s cap (%ds)%s", + s.MaxRuntime, tier, maxRuntimeSecs, hint) + } + } + + // Output target + if !IsKnownOutputKind(s.OutputTarget.Kind) { + return fmt.Errorf("unknown output_target.kind %q", s.OutputTarget.Kind) + } + + // Input schema + seenInput := map[string]struct{}{} + for i, p := range s.InputSchema { + if strings.TrimSpace(p.Name) == "" { + return fmt.Errorf("input_schema[%d]: Name is required", i) + } + if !IsKnownInputType(p.Type) { + return fmt.Errorf("input_schema[%d] (%q): unknown type %q", i, p.Name, p.Type) + } + if _, dup := seenInput[p.Name]; dup { + return fmt.Errorf("input_schema: duplicate parameter name %q", p.Name) + } + seenInput[p.Name] = struct{}{} + } + + // Tools + seenTool := map[string]struct{}{} + for _, t := range s.Tools { + if strings.TrimSpace(t) == "" { + return fmt.Errorf("tools: empty tool name") + } + if _, dup := seenTool[t]; dup { + return fmt.Errorf("tools: duplicate tool name %q", t) + } + seenTool[t] = struct{}{} + } + + // Tags — normalise + bounds-check. The caller may pass user input + // directly; we trim, lowercase, dedup, and bound count + per-tag + // length. Mutating the slice in place is intentional so callers + // don't need a separate normalise pass. + // + // Why caps (16 tags / 32 chars): both are generous for human- + // curated organisation labels (compare to GitHub's 10 topics/repo + // + ~50 chars). The aim is rejecting accidental data dumps and + // keeping the list-page chip rendering manageable, not strict + // taxonomy enforcement. + if len(s.Tags) > MaxTagsPerSkill { + return fmt.Errorf("tags: too many (max %d, got %d)", MaxTagsPerSkill, len(s.Tags)) + } + if len(s.Tags) > 0 { + seenTag := map[string]struct{}{} + out := make([]string, 0, len(s.Tags)) + for _, raw := range s.Tags { + t := strings.ToLower(strings.TrimSpace(raw)) + if t == "" { + continue + } + if len(t) > MaxTagLength { + return fmt.Errorf("tags: %q exceeds %d chars", t, MaxTagLength) + } + if _, dup := seenTag[t]; dup { + continue + } + seenTag[t] = struct{}{} + out = append(out, t) + } + s.Tags = out + } + + // Visibility + if !IsKnownVisibility(s.Visibility) { + return fmt.Errorf("unknown visibility %q", s.Visibility) + } + if s.Visibility == VisibilityShared && len(s.SharedWith) == 0 { + return fmt.Errorf("visibility=shared requires non-empty shared_with") + } + + // Chatbot exposure + if s.ExposeAsChatbotTool { + if strings.TrimSpace(s.ChatbotToolName) == "" { + return fmt.Errorf("expose_as_chatbot_tool=true requires chatbot_tool_name") + } + if strings.TrimSpace(s.ChatbotToolDescription) == "" { + return fmt.Errorf("expose_as_chatbot_tool=true requires chatbot_tool_description") + } + if strings.TrimSpace(s.ChatbotChannelFilter) == "" { + return fmt.Errorf("expose_as_chatbot_tool=true requires chatbot_channel_filter") + } + if opts.Filters != nil && !opts.Filters.Has(s.ChatbotChannelFilter) { + return fmt.Errorf("unknown chatbot_channel_filter %q (not registered)", s.ChatbotChannelFilter) + } + } + + return nil +}