mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 14:16:08 +00:00
311 lines
8.1 KiB
Go
311 lines
8.1 KiB
Go
//go:build sqlite || sqliteonly
|
|
|
|
package sqlitestore
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
const defaultSkillsCacheTTL = 5 * time.Minute
|
|
|
|
// skillFrontmatterRe matches YAML frontmatter (--- delimited) at the start of a file.
|
|
var skillFrontmatterRe = regexp.MustCompile(`(?s)^---\n(.*?)\n---\n?`)
|
|
|
|
// SQLiteSkillStore implements store.SkillStore backed by SQLite.
|
|
type SQLiteSkillStore struct {
|
|
db *sql.DB
|
|
baseDir string
|
|
mu sync.RWMutex
|
|
version atomic.Int64
|
|
|
|
listCache map[uuid.UUID]*skillListCacheEntry
|
|
ttl time.Duration
|
|
}
|
|
|
|
type skillListCacheEntry struct {
|
|
skills []store.SkillInfo
|
|
ver int64
|
|
time time.Time
|
|
}
|
|
|
|
func NewSQLiteSkillStore(db *sql.DB, baseDir string) *SQLiteSkillStore {
|
|
return &SQLiteSkillStore{
|
|
db: db,
|
|
baseDir: baseDir,
|
|
listCache: make(map[uuid.UUID]*skillListCacheEntry),
|
|
ttl: defaultSkillsCacheTTL,
|
|
}
|
|
}
|
|
|
|
func (s *SQLiteSkillStore) Version() int64 { return s.version.Load() }
|
|
func (s *SQLiteSkillStore) BumpVersion() { s.version.Store(time.Now().UnixMilli()) }
|
|
func (s *SQLiteSkillStore) Dirs() []string { return []string{s.baseDir} }
|
|
|
|
func (s *SQLiteSkillStore) ListSkills(ctx context.Context) []store.SkillInfo {
|
|
currentVer := s.version.Load()
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
tid = store.MasterTenantID
|
|
}
|
|
|
|
s.mu.RLock()
|
|
if entry := s.listCache[tid]; entry != nil && entry.ver == currentVer && time.Since(entry.time) < s.ttl {
|
|
result := entry.skills
|
|
s.mu.RUnlock()
|
|
return result
|
|
}
|
|
s.mu.RUnlock()
|
|
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, frontmatter, file_path
|
|
FROM skills WHERE (status IN ('active', 'archived') OR is_system = 1) AND (is_system = 1 OR tenant_id = ?)
|
|
ORDER BY name`, tid)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
|
|
var result []store.SkillInfo
|
|
for rows.Next() {
|
|
var id uuid.UUID
|
|
var name, slug, visibility, ownerID, status string
|
|
var desc *string
|
|
var tagsJSON []byte
|
|
var version int
|
|
var isSystem, enabled bool
|
|
var depsRaw, fmRaw []byte
|
|
var filePath *string
|
|
if err := rows.Scan(&id, &name, &slug, &desc, &visibility, &ownerID, &tagsJSON, &version,
|
|
&isSystem, &status, &enabled, &depsRaw, &fmRaw, &filePath); err != nil {
|
|
continue
|
|
}
|
|
info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath)
|
|
info.Visibility = visibility
|
|
info.OwnerID = ownerID
|
|
scanJSONStringArray(tagsJSON, &info.Tags)
|
|
info.IsSystem = isSystem
|
|
info.Status = status
|
|
info.Enabled = enabled
|
|
info.MissingDeps = parseDepsColumn(depsRaw)
|
|
info.Author = parseFrontmatterAuthor(fmRaw)
|
|
info.CreatorAgent = parseFrontmatterCreatorAgent(fmRaw)
|
|
result = append(result, info)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Warn("ListSkills: rows iteration error", "error", err)
|
|
return nil
|
|
}
|
|
s.attachSkillAgentMetadata(ctx, result)
|
|
|
|
s.mu.Lock()
|
|
s.listCache[tid] = &skillListCacheEntry{skills: result, ver: currentVer, time: time.Now()}
|
|
s.mu.Unlock()
|
|
|
|
return result
|
|
}
|
|
|
|
func (s *SQLiteSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo {
|
|
var rows *sql.Rows
|
|
var err error
|
|
if store.IsCrossTenant(ctx) {
|
|
rows, err = s.db.QueryContext(ctx,
|
|
`SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
|
|
FROM skills WHERE enabled = 1 AND status != 'deleted'
|
|
ORDER BY name`)
|
|
} else {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
tid = store.MasterTenantID
|
|
}
|
|
rows, err = s.db.QueryContext(ctx,
|
|
`SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
|
|
FROM skills WHERE enabled = 1 AND status != 'deleted' AND (is_system = 1 OR tenant_id = ?)
|
|
ORDER BY name`, tid)
|
|
}
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
return s.scanSkillInfoList(rows)
|
|
}
|
|
|
|
func (s *SQLiteSkillStore) ListAllSystemSkills(ctx context.Context) []store.SkillInfo {
|
|
rows, err := s.db.QueryContext(ctx,
|
|
`SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
|
|
FROM skills WHERE is_system = 1 AND enabled = 1 AND status != 'deleted'
|
|
ORDER BY name`)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
defer rows.Close()
|
|
return s.scanSkillInfoList(rows)
|
|
}
|
|
|
|
func (s *SQLiteSkillStore) scanSkillInfoList(rows *sql.Rows) []store.SkillInfo {
|
|
var result []store.SkillInfo
|
|
for rows.Next() {
|
|
var id uuid.UUID
|
|
var tenantID uuid.UUID
|
|
var name, slug, visibility, ownerID, status string
|
|
var desc *string
|
|
var tagsJSON []byte
|
|
var version int
|
|
var isSystem, enabled bool
|
|
var depsRaw []byte
|
|
var filePath *string
|
|
if err := rows.Scan(&id, &tenantID, &name, &slug, &desc, &visibility, &ownerID, &tagsJSON, &version,
|
|
&isSystem, &status, &enabled, &depsRaw, &filePath); err != nil {
|
|
continue
|
|
}
|
|
info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath)
|
|
info.TenantID = tenantID.String()
|
|
info.Visibility = visibility
|
|
info.OwnerID = ownerID
|
|
scanJSONStringArray(tagsJSON, &info.Tags)
|
|
info.IsSystem = isSystem
|
|
info.Status = status
|
|
info.Enabled = enabled
|
|
info.MissingDeps = parseDepsColumn(depsRaw)
|
|
result = append(result, info)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
slog.Warn("scanSkillInfoList: rows iteration error", "error", err)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *SQLiteSkillStore) StoreMissingDeps(ctx context.Context, id uuid.UUID, missing []string) error {
|
|
encoded, err := marshalMissingDeps(missing)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.db.ExecContext(ctx,
|
|
`UPDATE skills SET deps = ?, updated_at = ? WHERE id = ?`,
|
|
encoded, time.Now().UTC(), id,
|
|
)
|
|
if err == nil {
|
|
s.BumpVersion()
|
|
}
|
|
return err
|
|
}
|
|
|
|
func marshalMissingDeps(missing []string) ([]byte, error) {
|
|
if missing == nil {
|
|
missing = []string{}
|
|
}
|
|
return json.Marshal(map[string]any{"missing": missing})
|
|
}
|
|
|
|
// --- helpers shared with other skill files ---
|
|
|
|
func buildSkillInfo(id, name, slug string, desc *string, version int, baseDir string, filePath *string) store.SkillInfo {
|
|
d := ""
|
|
if desc != nil {
|
|
d = *desc
|
|
}
|
|
skillDir := fmt.Sprintf("%s/%s/%d", baseDir, slug, version)
|
|
if filePath != nil && *filePath != "" {
|
|
skillDir = store.SkillBaseDir(*filePath)
|
|
}
|
|
return store.SkillInfo{
|
|
ID: id,
|
|
Name: name,
|
|
Slug: slug,
|
|
Path: store.SkillMarkdownPath(skillDir),
|
|
BaseDir: skillDir,
|
|
Source: "managed",
|
|
Description: d,
|
|
Version: version,
|
|
}
|
|
}
|
|
|
|
func readSkillFile(path string) (string, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
content := strings.ReplaceAll(string(data), "\r\n", "\n")
|
|
content = strings.ReplaceAll(content, "\r", "\n")
|
|
content = skillFrontmatterRe.ReplaceAllString(content, "")
|
|
return content, nil
|
|
}
|
|
|
|
func parseDepsColumn(raw []byte) []string {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var d struct {
|
|
Missing []string `json:"missing"`
|
|
}
|
|
if err := json.Unmarshal(raw, &d); err != nil {
|
|
return nil
|
|
}
|
|
if len(d.Missing) == 0 {
|
|
return nil
|
|
}
|
|
return d.Missing
|
|
}
|
|
|
|
func parseFrontmatterAuthor(raw []byte) string {
|
|
if len(raw) == 0 {
|
|
return ""
|
|
}
|
|
var fm map[string]string
|
|
if err := json.Unmarshal(raw, &fm); err != nil {
|
|
return ""
|
|
}
|
|
return fm["author"]
|
|
}
|
|
|
|
func parseFrontmatterCreatorAgent(raw []byte) *store.SkillAgentRef {
|
|
if len(raw) == 0 {
|
|
return nil
|
|
}
|
|
var fm map[string]string
|
|
if err := json.Unmarshal(raw, &fm); err != nil {
|
|
return nil
|
|
}
|
|
ref := store.SkillAgentRef{
|
|
ID: fm["created_by_agent_id"],
|
|
AgentKey: firstNonEmpty(fm["created_by_agent_key"], fm["creator_agent_key"]),
|
|
}
|
|
if ref.ID == "" && ref.AgentKey == "" {
|
|
return nil
|
|
}
|
|
return &ref
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func marshalFrontmatter(fm map[string]string) []byte {
|
|
if len(fm) == 0 {
|
|
return []byte("{}")
|
|
}
|
|
b, err := json.Marshal(fm)
|
|
if err != nil {
|
|
return []byte("{}")
|
|
}
|
|
return b
|
|
}
|