Files
goclaw/internal/http/skills_evolution.go

584 lines
18 KiB
Go

package http
import (
"crypto/sha256"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/i18n"
"github.com/nextlevelbuilder/goclaw/internal/permissions"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
var (
errSkillEvolutionSkillNotFound = errors.New("skill not found")
errSkillEvolutionSystemMutation = errors.New("system skill mutation is blocked")
errSkillEvolutionInvalidDraftPatch = errors.New("invalid draft_patch")
errSkillEvolutionDraftPatchRequired = errors.New("draft_patch requires content or find/replace")
errSkillEvolutionFindTextNotFound = errors.New("find text not found in target file")
errSkillEvolutionGuardPolicyViolation = errors.New("skill guard policy violation")
)
type skillEvolutionPatchRequest struct {
Enabled *bool `json:"enabled"`
Mode string `json:"mode"`
}
type skillSuggestionCreateRequest struct {
SuggestionType string `json:"suggestion_type"`
Reason string `json:"reason"`
Evidence json.RawMessage `json:"evidence"`
DraftPatch json.RawMessage `json:"draft_patch"`
TargetFile string `json:"target_file"`
}
type skillSuggestionApplyRequest struct {
Approve bool `json:"approve"`
}
type skillDraftPatch struct {
Find string `json:"find"`
Replace string `json:"replace"`
Content *string `json:"content"`
}
func (h *SkillsHandler) evolutionConfigured(w http.ResponseWriter, r *http.Request) bool {
if h.evolutionStore == nil {
writeSkillEvolutionError(w, r, http.StatusServiceUnavailable, i18n.MsgSkillEvolutionNotConfigured)
return false
}
return true
}
func writeSkillEvolutionError(w http.ResponseWriter, r *http.Request, status int, key string, args ...any) {
writeJSON(w, status, map[string]string{"error": i18n.T(store.LocaleFromContext(r.Context()), key, args...)})
}
func (h *SkillsHandler) skillIDFromRequest(w http.ResponseWriter, r *http.Request) (uuid.UUID, store.SkillInfo, bool) {
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidID, "skill")
return uuid.Nil, store.SkillInfo{}, false
}
info, ok := h.skills.GetSkillByID(r.Context(), id)
if !ok {
writeSkillEvolutionError(w, r, http.StatusNotFound, i18n.MsgNotFound, "skill", id.String())
return uuid.Nil, store.SkillInfo{}, false
}
return id, info, true
}
func (h *SkillsHandler) handleGetEvolution(w http.ResponseWriter, r *http.Request) {
if !h.evolutionConfigured(w, r) {
return
}
skillID, _, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
settings, err := h.evolutionStore.GetSettings(r.Context(), skillID)
if err != nil {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
return
}
writeJSON(w, http.StatusOK, settings)
}
func (h *SkillsHandler) handlePatchEvolution(w http.ResponseWriter, r *http.Request) {
if !h.evolutionConfigured(w, r) {
return
}
skillID, _, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
var req skillEvolutionPatchRequest
if !bindJSON(w, r, store.LocaleFromContext(r.Context()), &req) {
return
}
current, err := h.evolutionStore.GetSettings(r.Context(), skillID)
if err != nil {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
return
}
enabled := current.Enabled
if req.Enabled != nil {
enabled = *req.Enabled
}
mode := strings.TrimSpace(req.Mode)
if mode == "" {
mode = current.Mode
}
if mode == "" {
mode = store.SkillEvolutionModeSuggestOnly
}
if mode != store.SkillEvolutionModeSuggestOnly && mode != store.SkillEvolutionModeAutoAnalyze {
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidEvolutionMode)
return
}
updated, err := h.evolutionStore.UpsertSettings(r.Context(), store.SkillEvolutionSettings{
SkillID: skillID,
Enabled: enabled,
Mode: mode,
})
if err != nil {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
return
}
action := "skill.evolve.mode_updated"
if req.Enabled != nil {
if enabled {
action = "skill.evolve.enabled"
} else {
action = "skill.evolve.disabled"
}
}
h.logSkillActivity(r, action, skillID, map[string]any{"enabled": enabled, "mode": mode})
writeJSON(w, http.StatusOK, updated)
}
func (h *SkillsHandler) handleGetSkillMetrics(w http.ResponseWriter, r *http.Request) {
if !h.evolutionConfigured(w, r) {
return
}
skillID, _, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
var since *time.Time
if raw := strings.TrimSpace(r.URL.Query().Get("since")); raw != "" {
if t, err := time.Parse(time.RFC3339, raw); err == nil {
since = &t
}
}
stats, err := h.evolutionStore.AggregateUsage(r.Context(), skillID, since)
if err != nil {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
return
}
if !permissions.HasMinRole(resolveAuth(r).Role, permissions.RoleAdmin) {
stats.TopFailureReasons = nil
}
writeJSON(w, http.StatusOK, stats)
}
func (h *SkillsHandler) handleGetSkillActivity(w http.ResponseWriter, r *http.Request) {
if h.activityStore == nil {
writeSkillEvolutionError(w, r, http.StatusServiceUnavailable, i18n.MsgActivityStoreNotConfigured)
return
}
skillID, _, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
items, err := h.activityStore.List(r.Context(), store.ActivityListOpts{
EntityType: "skill",
EntityID: skillID.String(),
Limit: clampLimit(r.URL.Query().Get("limit"), 50, 200),
})
if err != nil {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"activity": items})
}
func (h *SkillsHandler) handleListSkillSuggestions(w http.ResponseWriter, r *http.Request) {
if !h.evolutionConfigured(w, r) {
return
}
skillID, _, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
items, err := h.evolutionStore.ListSuggestions(r.Context(), skillID, r.URL.Query().Get("status"), clampLimit(r.URL.Query().Get("limit"), 50, 200))
if err != nil {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
return
}
if !permissions.HasMinRole(resolveAuth(r).Role, permissions.RoleAdmin) {
for i := range items {
items[i].Evidence = nil
items[i].DraftPatch = nil
items[i].CreatedByActorID = ""
items[i].ReviewedByActorID = ""
}
}
writeJSON(w, http.StatusOK, map[string]any{"suggestions": items})
}
func (h *SkillsHandler) handleCreateSkillSuggestion(w http.ResponseWriter, r *http.Request) {
if !h.evolutionConfigured(w, r) {
return
}
skillID, _, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
var req skillSuggestionCreateRequest
if !bindJSON(w, r, store.LocaleFromContext(r.Context()), &req) {
return
}
if strings.TrimSpace(req.SuggestionType) == "" {
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgRequired, "suggestion_type")
return
}
target := strings.TrimSpace(req.TargetFile)
if target != "" {
clean, err := skills.ValidateSkillTargetPath(target, true)
if err != nil {
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidRequest, err.Error())
return
}
target = clean
}
created, err := h.evolutionStore.CreateSuggestion(r.Context(), store.SkillImprovementSuggestion{
SkillID: skillID,
SuggestionType: req.SuggestionType,
Reason: req.Reason,
Evidence: req.Evidence,
DraftPatch: req.DraftPatch,
TargetFile: target,
CreatedByActorType: "user",
CreatedByActorID: store.ActorIDFromContext(r.Context()),
})
if err != nil {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
return
}
h.logSkillActivity(r, "skill.suggestion.created", skillID, map[string]any{"suggestion_id": created.ID.String(), "type": created.SuggestionType})
writeJSON(w, http.StatusCreated, created)
}
func (h *SkillsHandler) handleApproveSkillSuggestion(w http.ResponseWriter, r *http.Request) {
h.handleSuggestionStatus(w, r, store.SkillSuggestionStatusApproved, "skill.suggestion.approved")
}
func (h *SkillsHandler) handleRejectSkillSuggestion(w http.ResponseWriter, r *http.Request) {
h.handleSuggestionStatus(w, r, store.SkillSuggestionStatusRejected, "skill.suggestion.rejected")
}
func (h *SkillsHandler) handleSuggestionStatus(w http.ResponseWriter, r *http.Request, status, action string) {
if !h.evolutionConfigured(w, r) {
return
}
skillID, _, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
suggestionID, err := uuid.Parse(r.PathValue("suggestionID"))
if err != nil {
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidID, "suggestion")
return
}
sg, err := h.evolutionStore.GetSuggestion(r.Context(), suggestionID)
if err != nil {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
return
}
if sg == nil || sg.SkillID != skillID {
writeSkillEvolutionError(w, r, http.StatusNotFound, i18n.MsgNotFound, "suggestion", suggestionID.String())
return
}
updated, err := h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, status, "user", store.ActorIDFromContext(r.Context()))
if err != nil {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
return
}
h.logSkillActivity(r, action, skillID, map[string]any{"suggestion_id": suggestionID.String()})
writeJSON(w, http.StatusOK, updated)
}
func (h *SkillsHandler) handleApplySkillSuggestion(w http.ResponseWriter, r *http.Request) {
if !h.evolutionConfigured(w, r) {
return
}
skillID, info, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
if info.IsSystem {
writeSkillEvolutionError(w, r, http.StatusForbidden, i18n.MsgSystemSkillMutationBlocked)
return
}
suggestionID, err := uuid.Parse(r.PathValue("suggestionID"))
if err != nil {
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgInvalidID, "suggestion")
return
}
var req skillSuggestionApplyRequest
_ = json.NewDecoder(r.Body).Decode(&req)
sg, err := h.evolutionStore.GetSuggestion(r.Context(), suggestionID)
if err != nil {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
return
}
if sg == nil || sg.SkillID != skillID {
writeSkillEvolutionError(w, r, http.StatusNotFound, i18n.MsgNotFound, "suggestion", suggestionID.String())
return
}
if sg.Status == store.SkillSuggestionStatusApplied && sg.AppliedVersion != nil {
writeJSON(w, http.StatusOK, sg)
return
}
if sg.Status != store.SkillSuggestionStatusApproved {
if !req.Approve {
writeSkillEvolutionError(w, r, http.StatusBadRequest, i18n.MsgSuggestionMustBeApproved)
return
}
sg, err = h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, store.SkillSuggestionStatusApproved, "user", store.ActorIDFromContext(r.Context()))
if err != nil {
writeSkillEvolutionError(w, r, http.StatusInternalServerError, i18n.MsgInternalError, err.Error())
return
}
}
applied, err := h.applySkillSuggestionPatch(r, skillID, sg)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": h.skillEvolutionApplyErrorMessage(r, skillID, err)})
return
}
writeJSON(w, http.StatusOK, applied)
}
func (h *SkillsHandler) applySkillSuggestionPatch(r *http.Request, skillID uuid.UUID, sg *store.SkillImprovementSuggestion) (*store.SkillImprovementSuggestion, error) {
currentDir, slug, oldVersion, isSystem, ok := h.skills.GetSkillFilePath(r.Context(), skillID)
if !ok {
return nil, errSkillEvolutionSkillNotFound
}
if isSystem {
return nil, errSkillEvolutionSystemMutation
}
target := sg.TargetFile
if strings.TrimSpace(target) == "" {
target = "SKILL.md"
}
cleanTarget, err := skills.ValidateSkillTargetPath(target, true)
if err != nil {
return nil, err
}
var patch skillDraftPatch
if len(sg.DraftPatch) > 0 {
if err := json.Unmarshal(sg.DraftPatch, &patch); err != nil {
return nil, fmt.Errorf("%w: %v", errSkillEvolutionInvalidDraftPatch, err)
}
}
if patch.Content == nil && patch.Find == "" {
return nil, errSkillEvolutionDraftPatchRequired
}
newVersion, commitLock, err := h.skills.GetNextVersionLocked(r.Context(), slug)
if err != nil {
return nil, err
}
defer commitLock() //nolint:errcheck
destDir := filepath.Join(h.tenantSkillsDir(r), slug, fmt.Sprintf("%d", newVersion))
tmpDir := destDir + ".tmp-" + uuid.NewString()
if err := copyDir(currentDir, tmpDir); err != nil {
return nil, fmt.Errorf("stage version: %w", err)
}
removeDestOnError := true
defer func() {
_ = os.RemoveAll(tmpDir)
if removeDestOnError {
_ = os.RemoveAll(destDir)
}
}()
targetPath := filepath.Join(tmpDir, filepath.FromSlash(cleanTarget))
var nextContent string
if patch.Content != nil {
nextContent = *patch.Content
} else {
currentBytes, err := os.ReadFile(targetPath)
if err != nil {
return nil, fmt.Errorf("read target file: %w", err)
}
nextContent = string(currentBytes)
replaced := strings.Replace(nextContent, patch.Find, patch.Replace, 1)
if replaced == nextContent {
return nil, errSkillEvolutionFindTextNotFound
}
nextContent = replaced
}
if cleanTarget == "SKILL.md" {
violations, safe := skills.GuardSkillContent(nextContent)
if !safe {
return nil, fmt.Errorf("%w: %s", errSkillEvolutionGuardPolicyViolation, skills.FormatGuardViolations(violations))
}
}
if err := os.MkdirAll(filepath.Dir(targetPath), 0755); err != nil {
return nil, fmt.Errorf("create target directory: %w", err)
}
if err := os.WriteFile(targetPath, []byte(nextContent), 0644); err != nil {
return nil, fmt.Errorf("write target file: %w", err)
}
if err := os.Rename(tmpDir, destDir); err != nil {
return nil, fmt.Errorf("commit version files: %w", err)
}
hash, size, err := hashSkillDir(destDir)
if err != nil {
return nil, err
}
if err := h.skills.UpdateSkill(r.Context(), skillID, map[string]any{
"version": newVersion,
"file_path": destDir,
"file_size": size,
"file_hash": &hash,
"updated_at": time.Now(),
}); err != nil {
return nil, fmt.Errorf("update skill: %w", err)
}
// The active skill row now points at destDir; keep files even if
// version history or suggestion metadata fails after this point.
removeDestOnError = false
changedFiles, _ := json.Marshal([]string{cleanTarget})
if _, err := h.evolutionStore.CreateSkillVersion(r.Context(), store.SkillVersion{
SkillID: skillID,
Version: newVersion,
ContentHash: hash,
ChangedFiles: changedFiles,
CreatedByActorType: "user",
CreatedByActorID: store.ActorIDFromContext(r.Context()),
CreatedFromSuggestionID: &sg.ID,
}); err != nil {
return nil, fmt.Errorf("record skill version: %w", err)
}
applied, err := h.evolutionStore.MarkSuggestionApplied(r.Context(), sg.ID, newVersion, "user", store.ActorIDFromContext(r.Context()))
if err != nil {
return nil, err
}
h.logSkillActivity(r, "skill.suggestion.applied", skillID, map[string]any{
"suggestion_id": sg.ID.String(),
"changed_files": []string{cleanTarget},
"old_version": oldVersion,
"new_version": newVersion,
"content_hash": hash,
})
return applied, nil
}
func (h *SkillsHandler) skillEvolutionApplyErrorMessage(r *http.Request, skillID uuid.UUID, err error) string {
locale := store.LocaleFromContext(r.Context())
switch {
case errors.Is(err, errSkillEvolutionSkillNotFound):
return i18n.T(locale, i18n.MsgNotFound, "skill", skillID.String())
case errors.Is(err, errSkillEvolutionSystemMutation):
return i18n.T(locale, i18n.MsgSystemSkillMutationBlocked)
case errors.Is(err, errSkillEvolutionInvalidDraftPatch):
return i18n.T(locale, i18n.MsgInvalidDraftPatch, strings.TrimPrefix(err.Error(), errSkillEvolutionInvalidDraftPatch.Error()+": "))
case errors.Is(err, errSkillEvolutionDraftPatchRequired):
return i18n.T(locale, i18n.MsgDraftPatchRequired)
case errors.Is(err, errSkillEvolutionFindTextNotFound):
return i18n.T(locale, i18n.MsgFindTextNotFound)
case errors.Is(err, errSkillEvolutionGuardPolicyViolation):
return i18n.T(locale, i18n.MsgInvalidRequest, strings.TrimPrefix(err.Error(), errSkillEvolutionGuardPolicyViolation.Error()+": "))
default:
return i18n.T(locale, i18n.MsgInvalidRequest, err.Error())
}
}
func (h *SkillsHandler) logSkillActivity(r *http.Request, action string, skillID uuid.UUID, details map[string]any) {
if h.activityStore == nil {
return
}
raw, _ := json.Marshal(details)
_ = h.activityStore.Log(r.Context(), &store.ActivityLog{
ActorType: "user",
ActorID: store.ActorIDFromContext(r.Context()),
Action: action,
EntityType: "skill",
EntityID: skillID.String(),
Details: raw,
IPAddress: r.RemoteAddr,
})
}
func clampLimit(raw string, fallback, max int) int {
if raw == "" {
return fallback
}
var n int
if _, err := fmt.Sscanf(raw, "%d", &n); err != nil || n <= 0 {
return fallback
}
if n > max {
return max
}
return n
}
func copyDir(src, dst string) error {
return filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
target := filepath.Join(dst, rel)
if d.IsDir() {
return os.MkdirAll(target, 0755)
}
if d.Type()&os.ModeSymlink != 0 {
return nil
}
in, err := os.Open(path)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil {
return err
}
out, err := os.OpenFile(target, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
})
}
func hashSkillDir(dir string) (string, int64, error) {
var size int64
h := sha256.New()
err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || d.Type()&os.ModeSymlink != 0 {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
size += info.Size()
rel, _ := filepath.Rel(dir, path)
h.Write([]byte(filepath.ToSlash(rel)))
data, err := os.ReadFile(path)
if err != nil {
return err
}
h.Write(data)
return nil
})
if err != nil {
return "", 0, err
}
return fmt.Sprintf("%x", h.Sum(nil)), size, nil
}