package httpapi import ( "context" "encoding/json" "fmt" "log/slog" "github.com/dtoro/oikos/internal/db/sqlcgen" "github.com/dtoro/oikos/internal/domain" "github.com/dtoro/oikos/internal/httpapi/gen" "github.com/dtoro/oikos/internal/observability" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) // ─── Skills ──────────────────────────────────────────────────────────── func (s *Server) ListSkills(ctx context.Context, req gen.ListSkillsRequestObject) (gen.ListSkillsResponseObject, error) { limit := clampLimit(req.Params.Limit) rows, err := s.pool.Query(ctx, ` SELECT s.entity_id, s.version, s.name, s.procedure, s.applies_type, s.action, s.pattern_ids, s.status, s.success_rate, s.changed_by::text, s.change_reason, s.last_used_at FROM skills s WHERE ($1::text IS NULL OR s.status = $1) AND ($2::text IS NULL OR s.applies_type = $2) AND ($3::text IS NULL OR s.action = $3) ORDER BY s.name, s.version DESC`, req.Params.Status, req.Params.AppliesTo, req.Params.Action) if err != nil { return nil, err } defer rows.Close() // Deduplicate to latest version per skill (the ORDER BY name, version DESC // means the first row per name is the latest). seen := map[string]bool{} items := []gen.Skill{} for rows.Next() { var s gen.Skill var procBytes []byte var patternIDs []uuid.UUID if err := rows.Scan(&s.Id, &s.Version, &s.Name, &procBytes, &s.AppliesType, &s.Action, &patternIDs, &s.Status, &s.SuccessRate, &s.ChangedBy, &s.ChangeReason, &s.LastUsedAt); err != nil { return nil, err } if seen[s.Id.String()] { continue } seen[s.Id.String()] = true if err := json.Unmarshal(procBytes, &s.Procedure); err != nil { slog.Warn("phase3: unmarshal skill procedure", "skill", s.Name, "error", err) } if len(patternIDs) > 0 { pids := make([]string, len(patternIDs)) for i, pid := range patternIDs { pids[i] = pid.String() } s.PatternIds = &pids } items = append(items, s) if len(items) > limit { break } } if rows.Err() != nil { return nil, rows.Err() } if items == nil { items = []gen.Skill{} } return gen.ListSkills200JSONResponse{Items: items}, nil } func (s *Server) PatchSkill(ctx context.Context, req gen.PatchSkillRequestObject) (gen.PatchSkillResponseObject, error) { if req.Body == nil { return nil, fmt.Errorf("%w: request body is required", domain.ErrInvalidInput) } id, err := s.resolveEntityID(ctx, req.Id) if err != nil { return nil, err } tx, err := s.pool.Begin(ctx) if err != nil { return nil, err } defer tx.Rollback(ctx) q := sqlcgen.New(tx) if req.Body.Status != nil { if err := q.UpdateSkillStatus(ctx, sqlcgen.UpdateSkillStatusParams{ EntityID: id, Status: string(*req.Body.Status), }); err != nil { if err == pgx.ErrNoRows { return nil, fmt.Errorf("%w: skill %s", domain.ErrNotFound, req.Id) } return nil, err } } // Re-read skill. var skill gen.Skill var procBytes []byte var patternIDs []uuid.UUID err = tx.QueryRow(ctx, ` SELECT entity_id, version, name, procedure, applies_type, action, pattern_ids, status, success_rate, changed_by::text, change_reason, last_used_at FROM skills WHERE entity_id = $1 ORDER BY version DESC LIMIT 1`, id). Scan(&skill.Id, &skill.Version, &skill.Name, &procBytes, &skill.AppliesType, &skill.Action, &patternIDs, &skill.Status, &skill.SuccessRate, &skill.ChangedBy, &skill.ChangeReason, &skill.LastUsedAt) if err != nil { if err == pgx.ErrNoRows { return nil, fmt.Errorf("%w: skill %s", domain.ErrNotFound, req.Id) } return nil, err } if err := json.Unmarshal(procBytes, &skill.Procedure); err != nil { slog.Warn("phase3: unmarshal skill proc", "error", err) } if len(patternIDs) > 0 { pids := make([]string, len(patternIDs)) for i, pid := range patternIDs { pids[i] = pid.String() } skill.PatternIds = &pids } actorType, actor := actorInfo(ctx) if auditErr := observability.Audit(ctx, q, actorType, actor, "patch", &id, "PATCH", "/api/v1/skills/"+req.Id, "", map[string]any{"status": req.Body.Status, "pinned_version": req.Body.PinnedVersion}); auditErr != nil { return nil, auditErr } if err := tx.Commit(ctx); err != nil { return nil, err } return gen.PatchSkill200JSONResponse(skill), nil } func (s *Server) ListSkillVersions(ctx context.Context, req gen.ListSkillVersionsRequestObject) (gen.ListSkillVersionsResponseObject, error) { id, err := s.resolveEntityID(ctx, req.Id) if err != nil { return nil, err } rows, err := s.pool.Query(ctx, ` SELECT entity_id, version, name, procedure, applies_type, action, pattern_ids, status, success_rate, changed_by::text, change_reason, last_used_at FROM skills WHERE entity_id = $1 ORDER BY version DESC`, id) if err != nil { return nil, err } defer rows.Close() items := []gen.Skill{} for rows.Next() { var skill gen.Skill var procBytes []byte var patternIDs []uuid.UUID if err := rows.Scan(&skill.Id, &skill.Version, &skill.Name, &procBytes, &skill.AppliesType, &skill.Action, &patternIDs, &skill.Status, &skill.SuccessRate, &skill.ChangedBy, &skill.ChangeReason, &skill.LastUsedAt); err != nil { return nil, err } if err := json.Unmarshal(procBytes, &skill.Procedure); err != nil { slog.Warn("phase3: unmarshal skill proc", "error", err) } if len(patternIDs) > 0 { pids := make([]string, len(patternIDs)) for i, pid := range patternIDs { pids[i] = pid.String() } skill.PatternIds = &pids } items = append(items, skill) } if rows.Err() != nil { return nil, rows.Err() } if items == nil { items = []gen.Skill{} } return gen.ListSkillVersions200JSONResponse{Items: items}, nil }