mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 20:17:20 +00:00
174 lines
5.9 KiB
Go
174 lines
5.9 KiB
Go
package pg
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
)
|
|
|
|
const defaultSkillsCacheTTL = 5 * time.Minute
|
|
|
|
// PGSkillStore implements store.SkillStore backed by Postgres.
|
|
// Skills metadata lives in DB; content files on filesystem.
|
|
// ListSkills() is cached with version-based invalidation + TTL safety net.
|
|
// Also implements store.EmbeddingSkillSearcher for vector-based skill search.
|
|
type PGSkillStore struct {
|
|
db *sql.DB
|
|
baseDir string // filesystem base for skill content
|
|
mu sync.RWMutex
|
|
cache map[string]*store.SkillInfo
|
|
version atomic.Int64
|
|
|
|
// List cache: per-tenant cached result of ListSkills() with version + TTL validation.
|
|
// Key is tenant UUID; uuid.Nil = cross-tenant (system admin).
|
|
listCache map[uuid.UUID]*listCacheEntry
|
|
ttl time.Duration
|
|
|
|
// Embedding provider for vector-based skill search
|
|
embProvider store.EmbeddingProvider
|
|
}
|
|
|
|
// listCacheEntry holds per-tenant cached skill list with version + TTL.
|
|
type listCacheEntry struct {
|
|
skills []store.SkillInfo
|
|
ver int64
|
|
time time.Time
|
|
}
|
|
|
|
func NewPGSkillStore(db *sql.DB, baseDir string) *PGSkillStore {
|
|
return &PGSkillStore{
|
|
db: db,
|
|
baseDir: baseDir,
|
|
cache: make(map[string]*store.SkillInfo),
|
|
listCache: make(map[uuid.UUID]*listCacheEntry),
|
|
ttl: defaultSkillsCacheTTL,
|
|
}
|
|
}
|
|
|
|
func (s *PGSkillStore) Version() int64 { return s.version.Load() }
|
|
func (s *PGSkillStore) BumpVersion() { s.version.Store(time.Now().UnixMilli()) }
|
|
func (s *PGSkillStore) Dirs() []string { return []string{s.baseDir} }
|
|
|
|
func (s *PGSkillStore) ListSkills(ctx context.Context) []store.SkillInfo {
|
|
currentVer := s.version.Load()
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
tid = store.MasterTenantID
|
|
}
|
|
|
|
// Check per-tenant cache
|
|
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()
|
|
|
|
// Cache miss or TTL expired → query DB
|
|
// Returns active + archived + system skills. Archived skills are shown dimmed in the UI
|
|
// so admins can see missing deps and re-activate after installing them.
|
|
// Tenant filter: system skills visible globally, custom skills scoped to tenant.
|
|
var scanned []skillInfoRowWithFrontmatter
|
|
if err := pkgSqlxDB.SelectContext(ctx, &scanned,
|
|
`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 = true) AND (is_system = true OR tenant_id = $1)
|
|
ORDER BY name`, tid); err != nil {
|
|
return nil
|
|
}
|
|
|
|
result := make([]store.SkillInfo, 0, len(scanned))
|
|
for i := range scanned {
|
|
result = append(result, scanned[i].toSkillInfo(s.baseDir))
|
|
}
|
|
s.attachSkillAgentMetadata(ctx, result)
|
|
|
|
s.mu.Lock()
|
|
s.listCache[tid] = &listCacheEntry{skills: result, ver: currentVer, time: time.Now()}
|
|
s.mu.Unlock()
|
|
|
|
return result
|
|
}
|
|
|
|
// ListAllSkills returns system skills + custom skills for the given tenant.
|
|
// Cross-tenant callers get every enabled non-deleted skill for global admin operations like rescan-deps.
|
|
// Disabled skills are excluded — no point scanning or updating them.
|
|
func (s *PGSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo {
|
|
var scanned []skillInfoRow
|
|
if store.IsCrossTenant(ctx) {
|
|
if err := pkgSqlxDB.SelectContext(ctx, &scanned,
|
|
`SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
|
|
FROM skills WHERE enabled = true AND status != 'deleted'
|
|
ORDER BY name`); err != nil {
|
|
return nil
|
|
}
|
|
} else {
|
|
tid := store.TenantIDFromContext(ctx)
|
|
if tid == uuid.Nil {
|
|
tid = store.MasterTenantID
|
|
}
|
|
if err := pkgSqlxDB.SelectContext(ctx, &scanned,
|
|
`SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
|
|
FROM skills WHERE enabled = true AND status != 'deleted' AND (is_system = true OR tenant_id = $1)
|
|
ORDER BY name`, tid); err != nil {
|
|
return nil
|
|
}
|
|
}
|
|
return skillInfoRowsToSlice(scanned, s.baseDir)
|
|
}
|
|
|
|
// ListAllSystemSkills returns only system skills (for startup dependency scanning).
|
|
// No tenant filter — system skills belong to MasterTenantID and are globally visible.
|
|
func (s *PGSkillStore) ListAllSystemSkills(ctx context.Context) []store.SkillInfo {
|
|
var scanned []skillInfoRow
|
|
if err := pkgSqlxDB.SelectContext(ctx, &scanned,
|
|
`SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
|
|
FROM skills WHERE is_system = true AND enabled = true AND status != 'deleted'
|
|
ORDER BY name`); err != nil {
|
|
return nil
|
|
}
|
|
return skillInfoRowsToSlice(scanned, s.baseDir)
|
|
}
|
|
|
|
// skillInfoRowsToSlice converts a slice of skillInfoRow to []store.SkillInfo. Shared by list methods.
|
|
func skillInfoRowsToSlice(rows []skillInfoRow, baseDir string) []store.SkillInfo {
|
|
result := make([]store.SkillInfo, len(rows))
|
|
for i := range rows {
|
|
result[i] = rows[i].toSkillInfo(baseDir)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// StoreMissingDeps persists the missing_deps list for a skill into the deps JSONB column.
|
|
// Works for both system and custom skills. System skills bypass tenant filter;
|
|
// custom skills require tenant_id match for cross-tenant safety.
|
|
func (s *PGSkillStore) StoreMissingDeps(ctx context.Context, id uuid.UUID, missing []string) error {
|
|
encoded, err := marshalMissingDeps(missing)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tid := tenantIDForInsert(ctx)
|
|
_, err = s.db.ExecContext(ctx,
|
|
`UPDATE skills SET deps = $1, updated_at = NOW() WHERE id = $2 AND (is_system = true OR tenant_id = $3)`,
|
|
encoded, id, tid,
|
|
)
|
|
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})
|
|
}
|