Files

506 lines
17 KiB
Go

//go:build sqlite || sqliteonly
package sqlitestore
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type SQLiteSkillEvolutionStore struct {
db *sql.DB
}
func NewSQLiteSkillEvolutionStore(db *sql.DB) *SQLiteSkillEvolutionStore {
return &SQLiteSkillEvolutionStore{db: db}
}
func (s *SQLiteSkillEvolutionStore) resolveSkill(ctx context.Context, skillID uuid.UUID) (string, int, error) {
tenantID := tenantIDForInsert(ctx)
var slug, skillTenant string
var version int
var isSystem bool
err := s.db.QueryRowContext(ctx,
`SELECT slug, version, tenant_id, is_system FROM skills WHERE id = ? AND status != 'deleted'`,
skillID.String(),
).Scan(&slug, &version, &skillTenant, &isSystem)
if errors.Is(err, sql.ErrNoRows) {
return "", 0, fmt.Errorf("skill not found")
}
if err != nil {
return "", 0, err
}
if !store.IsCrossTenant(ctx) && !isSystem && skillTenant != tenantID.String() {
return "", 0, fmt.Errorf("skill not found")
}
return slug, version, nil
}
func (s *SQLiteSkillEvolutionStore) GetSettings(ctx context.Context, skillID uuid.UUID) (*store.SkillEvolutionSettings, error) {
if _, _, err := s.resolveSkill(ctx, skillID); err != nil {
return nil, err
}
tenantID := tenantIDForInsert(ctx)
var out store.SkillEvolutionSettings
var tenantStr, skillStr string
var last nullSqliteTime
var created, updated sqliteTime
err := s.db.QueryRowContext(ctx,
`SELECT tenant_id, skill_id, enabled, mode, last_analyzed_at, created_at, updated_at
FROM skill_evolution_settings WHERE tenant_id = ? AND skill_id = ?`,
tenantID.String(), skillID.String(),
).Scan(&tenantStr, &skillStr, &out.Enabled, &out.Mode, &last, &created, &updated)
if errors.Is(err, sql.ErrNoRows) {
return &store.SkillEvolutionSettings{
TenantID: tenantID,
SkillID: skillID,
Enabled: false,
Mode: store.SkillEvolutionModeSuggestOnly,
}, nil
}
if err != nil {
return nil, err
}
out.TenantID, _ = uuid.Parse(tenantStr)
out.SkillID, _ = uuid.Parse(skillStr)
if last.Valid {
t := last.Time
out.LastAnalyzedAt = &t
}
out.CreatedAt = created.Time
out.UpdatedAt = updated.Time
return &out, nil
}
func (s *SQLiteSkillEvolutionStore) UpsertSettings(ctx context.Context, settings store.SkillEvolutionSettings) (*store.SkillEvolutionSettings, error) {
if _, _, err := s.resolveSkill(ctx, settings.SkillID); err != nil {
return nil, err
}
tenantID := tenantIDForInsert(ctx)
mode := strings.TrimSpace(settings.Mode)
if mode == "" {
mode = store.SkillEvolutionModeSuggestOnly
}
now := time.Now().UTC().Format(time.RFC3339Nano)
var last any
if settings.LastAnalyzedAt != nil {
last = settings.LastAnalyzedAt.UTC().Format(time.RFC3339Nano)
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO skill_evolution_settings (tenant_id, skill_id, enabled, mode, last_analyzed_at, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(tenant_id, skill_id) DO UPDATE SET
enabled = excluded.enabled,
mode = excluded.mode,
last_analyzed_at = excluded.last_analyzed_at,
updated_at = excluded.updated_at`,
tenantID.String(), settings.SkillID.String(), settings.Enabled, mode, last, now, now,
)
if err != nil {
return nil, err
}
return s.GetSettings(ctx, settings.SkillID)
}
func (s *SQLiteSkillEvolutionStore) RecordUsage(ctx context.Context, metric store.SkillUsageMetric) error {
slug, version, err := s.resolveSkill(ctx, metric.SkillID)
if err != nil {
return err
}
tenantID := tenantIDForInsert(ctx)
if metric.ID == uuid.Nil {
metric.ID = uuid.New()
}
if metric.SkillSlug == "" {
metric.SkillSlug = slug
}
if metric.SkillVersion == 0 {
metric.SkillVersion = version
}
if metric.Status == "" {
metric.Status = store.SkillUsageStatusStarted
}
agentID := ""
if metric.AgentID != uuid.Nil {
agentID = metric.AgentID.String()
}
_, err = s.db.ExecContext(ctx,
`INSERT INTO skill_usage_metrics
(id, tenant_id, skill_id, skill_slug, skill_version, agent_id, user_id, session_key,
trace_id, invocation_id, invocation_source, status, failure_reason, tool_calls_count, duration_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
metric.ID.String(), tenantID.String(), metric.SkillID.String(), metric.SkillSlug, metric.SkillVersion,
agentID, metric.UserID, metric.SessionKey, metric.TraceID, metric.InvocationID, metric.InvocationSource,
metric.Status, metric.FailureReason, metric.ToolCallsCount, metric.DurationMs,
)
return err
}
func (s *SQLiteSkillEvolutionStore) AggregateUsage(ctx context.Context, skillID uuid.UUID, since *time.Time) (*store.SkillUsageStats, error) {
if _, _, err := s.resolveSkill(ctx, skillID); err != nil {
return nil, err
}
tenantID := tenantIDForInsert(ctx)
where := "tenant_id = ? AND skill_id = ?"
args := []any{tenantID.String(), skillID.String()}
if since != nil {
where += " AND created_at >= ?"
args = append(args, since.UTC().Format(time.RFC3339Nano))
}
query := fmt.Sprintf(
`SELECT COUNT(*),
SUM(CASE WHEN status = 'started' THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END),
SUM(CASE WHEN status = 'abandoned' THEN 1 ELSE 0 END),
MAX(created_at)
FROM skill_usage_metrics WHERE %s`,
where,
)
var out store.SkillUsageStats
out.SkillID = skillID
var last nullSqliteTime
if err := s.db.QueryRowContext(ctx, query, args...).Scan(&out.TotalCalls, &out.Started, &out.Succeeded, &out.Failed, &out.Abandoned, &last); err != nil {
return nil, err
}
if last.Valid {
t := last.Time
out.LastUsedAt = &t
}
if out.TotalCalls > 0 {
out.SuccessRate = float64(out.Succeeded) / float64(out.TotalCalls)
out.FailureRate = float64(out.Failed) / float64(out.TotalCalls)
}
reasonQuery := fmt.Sprintf(
`SELECT failure_reason, COUNT(*), MAX(created_at)
FROM skill_usage_metrics
WHERE %s AND status = 'failed' AND COALESCE(failure_reason, '') != ''
GROUP BY failure_reason ORDER BY COUNT(*) DESC, MAX(created_at) DESC LIMIT 5`,
where,
)
rows, err := s.db.QueryContext(ctx, reasonQuery, args...)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var r store.SkillFailureReason
var lastSeen sqliteTime
if err := rows.Scan(&r.Reason, &r.Count, &lastSeen); err != nil {
return nil, err
}
r.LastSeen = lastSeen.Time
out.TopFailureReasons = append(out.TopFailureReasons, r)
}
return &out, rows.Err()
}
func (s *SQLiteSkillEvolutionStore) ListUsage(ctx context.Context, skillID uuid.UUID, limit int) ([]store.SkillUsageMetric, error) {
if _, _, err := s.resolveSkill(ctx, skillID); err != nil {
return nil, err
}
if limit <= 0 {
limit = 50
}
tenantID := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT id, tenant_id, skill_id, skill_slug, skill_version, COALESCE(agent_id, ''),
COALESCE(user_id,''), COALESCE(session_key,''), COALESCE(trace_id,''), COALESCE(invocation_id,''),
invocation_source, status, COALESCE(failure_reason,''), tool_calls_count, duration_ms, created_at
FROM skill_usage_metrics
WHERE tenant_id = ? AND skill_id = ?
ORDER BY created_at DESC LIMIT ?`,
tenantID.String(), skillID.String(), limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []store.SkillUsageMetric
for rows.Next() {
var m store.SkillUsageMetric
var idStr, tenantStr, skillStr, agentStr string
var created sqliteTime
if err := rows.Scan(&idStr, &tenantStr, &skillStr, &m.SkillSlug, &m.SkillVersion, &agentStr,
&m.UserID, &m.SessionKey, &m.TraceID, &m.InvocationID, &m.InvocationSource, &m.Status,
&m.FailureReason, &m.ToolCallsCount, &m.DurationMs, &created); err != nil {
return nil, err
}
m.ID, _ = uuid.Parse(idStr)
m.TenantID, _ = uuid.Parse(tenantStr)
m.SkillID, _ = uuid.Parse(skillStr)
if agentStr != "" {
m.AgentID, _ = uuid.Parse(agentStr)
}
m.CreatedAt = created.Time
out = append(out, m)
}
return out, rows.Err()
}
func (s *SQLiteSkillEvolutionStore) CreateSuggestion(ctx context.Context, sg store.SkillImprovementSuggestion) (*store.SkillImprovementSuggestion, error) {
slug, _, err := s.resolveSkill(ctx, sg.SkillID)
if err != nil {
return nil, err
}
if sg.ID == uuid.Nil {
sg.ID = uuid.New()
}
if sg.SkillSlug == "" {
sg.SkillSlug = slug
}
if sg.Status == "" {
sg.Status = store.SkillSuggestionStatusPending
}
tenantID := tenantIDForInsert(ctx)
now := time.Now().UTC().Format(time.RFC3339Nano)
_, err = s.db.ExecContext(ctx,
`INSERT INTO skill_improvement_suggestions
(id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence,
draft_patch, target_file, created_by_actor_type, created_by_actor_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
sg.ID.String(), tenantID.String(), sg.SkillID.String(), sg.SkillSlug, sg.SuggestionType,
sg.Status, sg.Reason, string(jsonOrDefault(sg.Evidence, "{}")), string(jsonOrDefault(sg.DraftPatch, "{}")),
sg.TargetFile, sg.CreatedByActorType, sg.CreatedByActorID, now, now,
)
if err != nil {
return nil, err
}
return s.GetSuggestion(ctx, sg.ID)
}
func (s *SQLiteSkillEvolutionStore) ListSuggestions(ctx context.Context, skillID uuid.UUID, status string, limit int) ([]store.SkillImprovementSuggestion, error) {
if _, _, err := s.resolveSkill(ctx, skillID); err != nil {
return nil, err
}
if limit <= 0 {
limit = 50
}
tenantID := tenantIDForInsert(ctx)
q := `SELECT id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, draft_patch,
COALESCE(target_file,''), COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''),
COALESCE(reviewed_by_actor_type,''), COALESCE(reviewed_by_actor_id,''), reviewed_at, applied_version,
created_at, updated_at
FROM skill_improvement_suggestions WHERE tenant_id = ? AND skill_id = ?`
args := []any{tenantID.String(), skillID.String()}
if status != "" {
q += " AND status = ?"
args = append(args, status)
}
q += " ORDER BY created_at DESC LIMIT ?"
args = append(args, limit)
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanSQLiteSuggestions(rows)
}
func (s *SQLiteSkillEvolutionStore) GetSuggestion(ctx context.Context, id uuid.UUID) (*store.SkillImprovementSuggestion, error) {
tenantID := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT id, tenant_id, skill_id, skill_slug, suggestion_type, status, reason, evidence, draft_patch,
COALESCE(target_file,''), COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''),
COALESCE(reviewed_by_actor_type,''), COALESCE(reviewed_by_actor_id,''), reviewed_at, applied_version,
created_at, updated_at
FROM skill_improvement_suggestions WHERE tenant_id = ? AND id = ?`,
tenantID.String(), id.String())
if err != nil {
return nil, err
}
defer rows.Close()
items, err := scanSQLiteSuggestions(rows)
if err != nil || len(items) == 0 {
return nil, err
}
if _, _, err := s.resolveSkill(ctx, items[0].SkillID); err != nil {
return nil, err
}
return &items[0], nil
}
func (s *SQLiteSkillEvolutionStore) UpdateSuggestionStatus(ctx context.Context, id uuid.UUID, status, actorType, actorID string) (*store.SkillImprovementSuggestion, error) {
tenantID := tenantIDForInsert(ctx)
now := time.Now().UTC().Format(time.RFC3339Nano)
res, err := s.db.ExecContext(ctx,
`UPDATE skill_improvement_suggestions
SET status = ?, reviewed_by_actor_type = ?, reviewed_by_actor_id = ?, reviewed_at = ?, updated_at = ?
WHERE tenant_id = ? AND id = ?`,
status, actorType, actorID, now, now, tenantID.String(), id.String())
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, fmt.Errorf("suggestion not found")
}
return s.GetSuggestion(ctx, id)
}
func (s *SQLiteSkillEvolutionStore) MarkSuggestionApplied(ctx context.Context, id uuid.UUID, version int, actorType, actorID string) (*store.SkillImprovementSuggestion, error) {
tenantID := tenantIDForInsert(ctx)
now := time.Now().UTC().Format(time.RFC3339Nano)
res, err := s.db.ExecContext(ctx,
`UPDATE skill_improvement_suggestions
SET status = 'applied', applied_version = ?, reviewed_by_actor_type = ?,
reviewed_by_actor_id = ?, reviewed_at = COALESCE(reviewed_at, ?), updated_at = ?
WHERE tenant_id = ? AND id = ?`,
version, actorType, actorID, now, now, tenantID.String(), id.String())
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, fmt.Errorf("suggestion not found")
}
return s.GetSuggestion(ctx, id)
}
func (s *SQLiteSkillEvolutionStore) CreateSkillVersion(ctx context.Context, v store.SkillVersion) (*store.SkillVersion, error) {
if _, _, err := s.resolveSkill(ctx, v.SkillID); err != nil {
return nil, err
}
if v.ID == uuid.Nil {
v.ID = uuid.New()
}
tenantID := tenantIDForInsert(ctx)
suggestionID := ""
if v.CreatedFromSuggestionID != nil {
suggestionID = v.CreatedFromSuggestionID.String()
}
now := time.Now().UTC().Format(time.RFC3339Nano)
_, err := s.db.ExecContext(ctx,
`INSERT INTO skill_versions
(id, tenant_id, skill_id, version, content_hash, changed_files, created_by_actor_type,
created_by_actor_id, created_from_suggestion_id, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULLIF(?, ''), ?)`,
v.ID.String(), tenantID.String(), v.SkillID.String(), v.Version, v.ContentHash,
string(jsonOrDefault(v.ChangedFiles, "[]")), v.CreatedByActorType, v.CreatedByActorID, suggestionID, now,
)
if err != nil {
return nil, err
}
return s.GetSkillVersion(ctx, v.SkillID, v.Version)
}
func (s *SQLiteSkillEvolutionStore) ListSkillVersions(ctx context.Context, skillID uuid.UUID, limit int) ([]store.SkillVersion, error) {
if _, _, err := s.resolveSkill(ctx, skillID); err != nil {
return nil, err
}
if limit <= 0 {
limit = 50
}
tenantID := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT id, tenant_id, skill_id, version, content_hash, changed_files,
COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''),
created_from_suggestion_id, created_at
FROM skill_versions WHERE tenant_id = ? AND skill_id = ?
ORDER BY version DESC LIMIT ?`,
tenantID.String(), skillID.String(), limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanSQLiteSkillVersions(rows)
}
func (s *SQLiteSkillEvolutionStore) GetSkillVersion(ctx context.Context, skillID uuid.UUID, version int) (*store.SkillVersion, error) {
if _, _, err := s.resolveSkill(ctx, skillID); err != nil {
return nil, err
}
tenantID := tenantIDForInsert(ctx)
rows, err := s.db.QueryContext(ctx,
`SELECT id, tenant_id, skill_id, version, content_hash, changed_files,
COALESCE(created_by_actor_type,''), COALESCE(created_by_actor_id,''),
created_from_suggestion_id, created_at
FROM skill_versions WHERE tenant_id = ? AND skill_id = ? AND version = ?`,
tenantID.String(), skillID.String(), version)
if err != nil {
return nil, err
}
defer rows.Close()
items, err := scanSQLiteSkillVersions(rows)
if err != nil || len(items) == 0 {
return nil, err
}
return &items[0], nil
}
func scanSQLiteSuggestions(rows *sql.Rows) ([]store.SkillImprovementSuggestion, error) {
var out []store.SkillImprovementSuggestion
for rows.Next() {
var sg store.SkillImprovementSuggestion
var idStr, tenantStr, skillStr string
var evidence, draft []byte
var reviewedAt nullSqliteTime
var applied sql.NullInt64
var created, updated sqliteTime
if err := rows.Scan(&idStr, &tenantStr, &skillStr, &sg.SkillSlug, &sg.SuggestionType, &sg.Status,
&sg.Reason, &evidence, &draft, &sg.TargetFile, &sg.CreatedByActorType, &sg.CreatedByActorID,
&sg.ReviewedByActorType, &sg.ReviewedByActorID, &reviewedAt, &applied,
&created, &updated); err != nil {
return nil, err
}
sg.ID, _ = uuid.Parse(idStr)
sg.TenantID, _ = uuid.Parse(tenantStr)
sg.SkillID, _ = uuid.Parse(skillStr)
sg.Evidence = evidence
sg.DraftPatch = draft
if reviewedAt.Valid {
t := reviewedAt.Time
sg.ReviewedAt = &t
}
if applied.Valid {
v := int(applied.Int64)
sg.AppliedVersion = &v
}
sg.CreatedAt = created.Time
sg.UpdatedAt = updated.Time
out = append(out, sg)
}
return out, rows.Err()
}
func scanSQLiteSkillVersions(rows *sql.Rows) ([]store.SkillVersion, error) {
var out []store.SkillVersion
for rows.Next() {
var v store.SkillVersion
var idStr, tenantStr, skillStr string
var changed []byte
var suggestion sql.NullString
var created sqliteTime
if err := rows.Scan(&idStr, &tenantStr, &skillStr, &v.Version, &v.ContentHash, &changed,
&v.CreatedByActorType, &v.CreatedByActorID, &suggestion, &created); err != nil {
return nil, err
}
v.ID, _ = uuid.Parse(idStr)
v.TenantID, _ = uuid.Parse(tenantStr)
v.SkillID, _ = uuid.Parse(skillStr)
v.ChangedFiles = changed
if suggestion.Valid && suggestion.String != "" {
parsed, _ := uuid.Parse(suggestion.String)
v.CreatedFromSuggestionID = &parsed
}
v.CreatedAt = created.Time
out = append(out, v)
}
return out, rows.Err()
}
func jsonOrDefault(raw json.RawMessage, fallback string) json.RawMessage {
if len(raw) == 0 {
return json.RawMessage(fallback)
}
return raw
}
var _ store.SkillEvolutionStore = (*SQLiteSkillEvolutionStore)(nil)