feat(skills): add skill self-evolution metrics

This commit is contained in:
Goon committed 2026-06-12 14:47:51 +07:00
1 parent 95fd1f8f2d
commit f45bfa860c
41 files changed
+3008 -79

No files matched your search

+1
View File
@@ -41,6 +41,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir
if len(dirs) > 0 {
skillsH = httpapi.NewSkillsHandler(manageStore, dirs[0], dataDir, bundledSkillsDir, msgBus, stores.SkillTenantCfgs, stores.Tenants)
skillsH.SetDB(stores.DB)
skillsH.SetEvolutionStore(stores.SkillEvolution, stores.Activity)
skillsH.SetUploadLimitConfig(skillUploadConfig)
if stores.SystemConfigs != nil {
skillsH.SetSystemConfigStore(stores.SystemConfigs)
+2
View File
@@ -216,7 +216,9 @@ func wireExtras(
Tools: toolsReg,
ToolPolicy: toolPE,
Skills: skillsLoader,
SkillStore: stores.Skills,
SkillAccessStore: skillAccessStore,
SkillEvolutionStore: stores.SkillEvolution,
SkillSlashCommands: appCfg.Skills.SlashCommands,
HasMemory: hasMemory,
TraceCollector: traceCollector,
+4
View File
@@ -22,6 +22,10 @@ func skillsCmd() *cobra.Command {
}
cmd.AddCommand(skillsListCmd())
cmd.AddCommand(skillsShowCmd())
cmd.AddCommand(skillsEvolveCmd())
cmd.AddCommand(skillsMetricsCmd())
cmd.AddCommand(skillsActivityCmd())
cmd.AddCommand(skillsSuggestionsCmd())
return cmd
}
+222
View File
@@ -0,0 +1,222 @@
package cmd
import (
"bytes"
"encoding/json"
"fmt"
"net/url"
"os"
"text/tabwriter"
"github.com/spf13/cobra"
)
func skillsEvolveCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "evolve",
Short: "Manage per-skill self-evolution settings",
}
cmd.AddCommand(skillsEvolveStatusCmd())
cmd.AddCommand(skillsEvolveSetCmd("enable", true))
cmd.AddCommand(skillsEvolveSetCmd("disable", false))
cmd.AddCommand(skillsEvolveModeCmd())
return cmd
}
func skillsEvolveStatusCmd() *cobra.Command {
return &cobra.Command{
Use: "status [skill]",
Short: "Show self-evolution settings for a skill",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
requireRunningGatewayHTTP()
skillID := resolveGatewaySkillID(args[0])
resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/evolution")
exitOnErr(err)
printJSON(resp)
},
}
}
func skillsEvolveSetCmd(name string, enabled bool) *cobra.Command {
return &cobra.Command{
Use: name + " [skill]",
Short: name + " self-evolution for a skill",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
requireRunningGatewayHTTP()
skillID := resolveGatewaySkillID(args[0])
resp, err := gatewayHTTPDo("PATCH", "/v1/skills/"+url.PathEscape(skillID)+"/evolution", map[string]any{"enabled": enabled})
exitOnErr(err)
printJSON(resp)
},
}
}
func skillsEvolveModeCmd() *cobra.Command {
return &cobra.Command{
Use: "mode [skill] [suggest_only|auto_analyze]",
Short: "Set self-evolution mode for a skill",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
requireRunningGatewayHTTP()
skillID := resolveGatewaySkillID(args[0])
resp, err := gatewayHTTPDo("PATCH", "/v1/skills/"+url.PathEscape(skillID)+"/evolution", map[string]any{"mode": args[1]})
exitOnErr(err)
printJSON(resp)
},
}
}
func skillsMetricsCmd() *cobra.Command {
var jsonOutput bool
cmd := &cobra.Command{
Use: "metrics [skill]",
Short: "Show recorded usage metrics for a skill",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
requireRunningGatewayHTTP()
skillID := resolveGatewaySkillID(args[0])
resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/metrics")
exitOnErr(err)
if jsonOutput {
printJSON(resp)
return
}
fmt.Printf("Total: %.0f\nStarted: %.0f\nSucceeded: %.0f\nFailed: %.0f\nAbandoned: %.0f\nSuccess rate: %.2f\n",
num(resp["total_calls"]), num(resp["started"]), num(resp["succeeded"]), num(resp["failed"]), num(resp["abandoned"]), num(resp["success_rate"]))
},
}
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output as JSON")
return cmd
}
func skillsActivityCmd() *cobra.Command {
return &cobra.Command{
Use: "activity [skill]",
Short: "Show recent self-evolution activity for a skill",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
requireRunningGatewayHTTP()
skillID := resolveGatewaySkillID(args[0])
resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/activity")
exitOnErr(err)
printJSON(resp)
},
}
}
func skillsSuggestionsCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "suggestions",
Short: "Manage skill improvement suggestions",
}
cmd.AddCommand(skillsSuggestionsListCmd())
cmd.AddCommand(skillsSuggestionStatusCmd("approve"))
cmd.AddCommand(skillsSuggestionStatusCmd("reject"))
cmd.AddCommand(skillsSuggestionApplyCmd())
return cmd
}
func skillsSuggestionsListCmd() *cobra.Command {
return &cobra.Command{
Use: "list [skill]",
Short: "List suggestions for a skill",
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
requireRunningGatewayHTTP()
skillID := resolveGatewaySkillID(args[0])
resp, err := gatewayHTTPGet("/v1/skills/" + url.PathEscape(skillID) + "/evolution/suggestions")
exitOnErr(err)
printJSON(resp)
},
}
}
func skillsSuggestionStatusCmd(action string) *cobra.Command {
return &cobra.Command{
Use: action + " [skill] [suggestion-id]",
Short: action + " a skill improvement suggestion",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
requireRunningGatewayHTTP()
skillID := resolveGatewaySkillID(args[0])
path := fmt.Sprintf("/v1/skills/%s/evolution/suggestions/%s/%s", url.PathEscape(skillID), url.PathEscape(args[1]), action)
resp, err := gatewayHTTPPost(path, nil)
exitOnErr(err)
printJSON(resp)
},
}
}
func skillsSuggestionApplyCmd() *cobra.Command {
var approve bool
cmd := &cobra.Command{
Use: "apply [skill] [suggestion-id]",
Short: "Apply an approved skill improvement suggestion",
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
requireRunningGatewayHTTP()
skillID := resolveGatewaySkillID(args[0])
path := fmt.Sprintf("/v1/skills/%s/evolution/suggestions/%s/apply", url.PathEscape(skillID), url.PathEscape(args[1]))
resp, err := gatewayHTTPPost(path, map[string]any{"approve": approve})
exitOnErr(err)
printJSON(resp)
},
}
cmd.Flags().BoolVar(&approve, "approve", false, "approve pending suggestion before applying")
return cmd
}
func resolveGatewaySkillID(input string) string {
resp, err := gatewayHTTPGet("/v1/skills")
exitOnErr(err)
raw, _ := json.Marshal(resp["skills"])
var skills []struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
}
_ = json.Unmarshal(raw, &skills)
for _, sk := range skills {
if sk.ID == input || sk.Slug == input || sk.Name == input {
return sk.ID
}
}
return input
}
func printJSON(v any) {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetIndent("", " ")
_ = enc.Encode(v)
fmt.Print(buf.String())
}
func exitOnErr(err error) {
if err != nil {
fmt.Fprintln(os.Stderr, "Error:", err)
os.Exit(1)
}
}
func num(v any) float64 {
switch n := v.(type) {
case float64:
return n
case int:
return float64(n)
default:
return 0
}
}
func printSuggestionTable(items []map[string]any) {
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tSTATUS\tTYPE\tREASON")
for _, item := range items {
fmt.Fprintf(tw, "%v\t%v\t%v\t%v\n", item["id"], item["status"], item["suggestion_type"], item["reason"])
}
tw.Flush()
}
+30
View File
@@ -329,6 +329,36 @@ Use `direct_selection_count` plus the `selected_provider` sequence to verify rea
| `PUT` | `/v1/skills/{id}/tenant-config` | Set tenant-level skill config |
| `DELETE` | `/v1/skills/{id}/tenant-config` | Delete tenant-level skill config |
### Skill Self-Evolution
Skill self-evolution is tenant-scoped and off by default per skill. Usage
metrics are written only by trusted runtime paths such as `use_skill` tool
execution and slash-command activation. There is no public usage-write endpoint.
| Method | Path | Description |
|--------|------|-------------|
| `GET` | `/v1/skills/{id}/evolution` | Read self-evolution settings for a skill |
| `PATCH` | `/v1/skills/{id}/evolution` | Update enabled state or mode |
| `GET` | `/v1/skills/{id}/metrics` | Read aggregate usage metrics and status counts |
| `GET` | `/v1/skills/{id}/activity` | Read admin-only skill evolution activity |
| `GET` | `/v1/skills/{id}/evolution/suggestions` | List skill-scoped improvement suggestions |
| `POST` | `/v1/skills/{id}/evolution/suggestions` | Create a suggestion with evidence and draft patch |
| `POST` | `/v1/skills/{id}/evolution/suggestions/{suggestionID}/approve` | Approve a suggestion |
| `POST` | `/v1/skills/{id}/evolution/suggestions/{suggestionID}/reject` | Reject a suggestion |
| `POST` | `/v1/skills/{id}/evolution/suggestions/{suggestionID}/apply` | Apply an approved suggestion to a new skill version |
Supported modes:
| Mode | Behavior |
|------|----------|
| `suggest_only` | Collect metrics and manage suggestions; no automatic patching |
| `auto_analyze` | Reserved for analysis automation; patch application still requires explicit approval |
Viewer/operator callers can read aggregate metrics. Raw activity details,
actor IDs, failure evidence, draft patches, and suggestion apply actions remain
admin-controlled. System skill mutation is blocked; custom skill suggestions
write a new versioned directory and `skill_versions` record when applied.
### Skill Grants
Skill upload size is enforced per ZIP file. The effective limit resolves in this order:
@@ -354,7 +354,62 @@ All endpoints require authentication (`authMiddleware`). Mutation endpoints requ
| `POST` | `/v1/skills/install-deps` | Install all missing deps |
| `GET` | `/v1/skills/runtimes` | Check python3/node availability |
### 3.5 WebSocket RPC
### 3.5 Skill Self-Evolution
Skill self-evolution tracks how each existing skill performs over time. It is
separate from agent-level `skill_evolve`, which teaches agents when to create or
patch reusable skills.
**Runtime recording**
- `use_skill` tool calls record tenant-scoped usage with status `succeeded` or
`failed`, duration, session key, run/trace ID, agent ID, and user scope.
- Slash-command activation records a `started` event when `/<slug>` or
`/use <skill>` resolves to a skill.
- Usage writes are internal only. v1 intentionally has no public
`POST /v1/skills/{id}/usage` endpoint, so clients cannot forge success rates.
**Persistent tables**
| Table | Purpose |
|-------|---------|
| `skill_evolution_settings` | Per-tenant, per-skill enabled flag and mode |
| `skill_usage_metrics` | Runtime usage events and status counts |
| `skill_improvement_suggestions` | Skill-scoped suggestions with evidence and draft patches |
| `skill_versions` | Immutable applied-version records linked to changed files and suggestions |
**HTTP and CLI controls**
- HTTP: `GET/PATCH /v1/skills/{id}/evolution`,
`GET /v1/skills/{id}/metrics`,
`GET /v1/skills/{id}/activity`, and suggestion approve/reject/apply endpoints.
- CLI: `goclaw skills evolve`, `goclaw skills metrics`,
`goclaw skills suggestions`, and `goclaw skills activity`.
- Web UI: Skill detail has an `evolution` tab for settings, metrics,
suggestions, and admin-visible activity.
**Guardrails**
- Default mode is `suggest_only`; no automatic patching happens in v1.
- Applying a suggestion to a custom skill copies the current skill directory to
the next version, validates the target path, runs the SKILL.md guard scanner
when needed, updates the active skill, records `skill_versions`, and writes an
activity log entry.
- System/bundled skill mutation is refused by the apply path.
- Viewer surfaces are sanitized. Failure evidence, draft patches, actor IDs,
and activity details require admin visibility.
**Relationship to self-improving skills**
This v1 is the control-plane foundation for self-improving skills: runtime usage
events, evidence-backed suggestions, reference-file patches, version records,
and approval/audit surfaces. It does not yet run a consolidation extractor that
turns repeated corrections into learning notes or auto-applies user-scoped
reference overlays. That higher-level learning loop belongs above this
foundation and must keep scope separation, private-content filtering, evidence
thresholds, and owner/admin approval policies explicit.
### 3.6 WebSocket RPC
| Method | Description |
|--------|-------------|
@@ -362,7 +417,7 @@ All endpoints require authentication (`authMiddleware`). Mutation endpoints requ
| `skills.get` | Get skill content by name |
| `skills.update` | Update metadata (ownership-protected) |
### 3.6 Grants & Visibility
### 3.7 Grants & Visibility
```mermaid
stateDiagram-v2
+27
View File
@@ -6,6 +6,33 @@ Significant changes, features, and fixes in reverse chronological order.
## 2026-06-12
### Skill self-evolution metrics and upgrades (issue #161, issue #142 foundation)
**Features**
- Added per-skill self-evolution settings, usage metrics, suggestions, immutable
applied-version records, and activity logs.
- Added trusted runtime usage recording for `use_skill` and slash skill
activations without exposing a public usage-write endpoint.
- Added skill evolution HTTP, CLI, and Web UI surfaces for settings, metrics,
suggestions, approval/apply actions, and admin-visible activity.
- Added the shared foundation needed by self-improving skills: usage evidence,
reference-file patches, versioned apply, and approval/audit surfaces. The
consolidation learning extractor and auto user-scoped overlays remain out of
this v1 scope.
**Safety**
- Blocked direct system skill mutation in the suggestion apply path.
- Reused a shared target-path validator for skill companion/reference patches.
- Sanitized failure evidence, draft patches, actor IDs, and activity details
away from non-admin surfaces.
**Tests**
- Added focused skill path validator coverage and deep-link coverage for the new
Web UI evolution tab.
### Mid-flight request preservation (issue #137)
**Fixes**
+9 -1
View File
@@ -117,7 +117,15 @@ func (l *Loop) buildMessages(ctx context.Context, history []providers.Message, s
}
}
userMessage, extraSystemPrompt, skillFilter = l.applySkillSlashCommand(ctx, userMessage, extraSystemPrompt, skillFilter)
slashReq := &RunRequest{
SessionKey: sessionKey,
UserID: userID,
SenderID: store.SenderIDFromContext(ctx),
Channel: channel,
ChatID: chatID,
PeerKind: peerKind,
}
userMessage, extraSystemPrompt, skillFilter = l.applySkillSlashCommand(ctx, slashReq, userMessage, extraSystemPrompt, skillFilter)
// Build tool list, filtering out skill_manage when skill_evolve is off.
// Also applies ChannelAware filtering so channel-specific tools don't
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
@@ -58,6 +59,9 @@ func (l *Loop) makeExecuteToolCall(req *RunRequest, bridgeRS *runState) func(ctx
// v3 evolution metrics: record tool execution non-blocking (best-effort).
l.recordToolMetric(ctx, req.SessionKey, registryName, !result.IsError, toolDuration)
if registryName == "use_skill" {
l.recordSkillUsageFromTool(ctx, req, tc, result, toolDuration)
}
toolMsg, warningMsgs, action := l.processToolResult(ctx, bridgeRS, req, emitRun, tc, registryName, result, state.Context.HadBootstrap)
syncBridgeToState(bridgeRS, state, action)
@@ -168,6 +172,9 @@ func (l *Loop) makeProcessToolResult(req *RunRequest, bridgeRS *runState) func(c
// Record tool metrics (non-blocking, best-effort).
l.recordToolMetric(ctx, req.SessionKey, registryName, !result.IsError, dur)
if registryName == "use_skill" {
l.recordSkillUsageFromTool(ctx, req, tc, result, dur)
}
toolMsg, warningMsgs, action := l.processToolResult(ctx, bridgeRS, req, emitRun, tc, registryName, result, state.Context.HadBootstrap)
syncBridgeToState(bridgeRS, state, action)
@@ -246,6 +253,68 @@ func (l *Loop) recordToolMetric(ctx context.Context, sessionKey, toolName string
}()
}
func (l *Loop) recordSkillUsageFromTool(ctx context.Context, req *RunRequest, tc providers.ToolCall, result *tools.Result, duration time.Duration) {
name, _ := tc.Arguments["name"].(string)
name = strings.TrimSpace(name)
if name == "" {
return
}
status := store.SkillUsageStatusSucceeded
reason := ""
if result != nil && result.IsError {
status = store.SkillUsageStatusFailed
reason = strings.TrimSpace(result.ForLLM)
if len(reason) > 500 {
reason = reason[:500]
}
}
l.recordSkillUsage(ctx, req, name, tc.ID, "use_skill", status, reason, duration)
}
func (l *Loop) recordSkillUsage(ctx context.Context, req *RunRequest, skillName, invocationID, source, status, reason string, duration time.Duration) {
if l.skillEvolutionStore == nil || l.skillStore == nil {
return
}
info, ok := l.skillStore.GetSkill(ctx, skillName)
if !ok || info == nil || info.ID == "" {
return
}
skillID, err := uuid.Parse(info.ID)
if err != nil {
return
}
tenantID := store.TenantIDFromContext(ctx)
if tenantID == uuid.Nil {
tenantID = l.tenantID
}
metric := store.SkillUsageMetric{
ID: uuid.New(),
SkillID: skillID,
SkillSlug: info.Slug,
SkillVersion: info.Version,
AgentID: l.agentUUID,
InvocationID: invocationID,
InvocationSource: source,
Status: status,
FailureReason: reason,
DurationMs: duration.Milliseconds(),
}
if req != nil {
metric.UserID = req.UserID
metric.SessionKey = req.SessionKey
if req.RunID != "" {
metric.TraceID = req.RunID
}
}
go func() {
bgCtx, cancel := context.WithTimeout(store.WithTenantID(context.Background(), tenantID), 5*time.Second)
defer cancel()
if err := l.skillEvolutionStore.RecordUsage(bgCtx, metric); err != nil {
slog.Debug("skill.metric.record_failed", "skill", skillName, "source", source, "error", err)
}
}()
}
// makeToolEmitRun creates a tool event emitter with request context.
func makeToolEmitRun(l *Loop, req *RunRequest) func(AgentEvent) {
return func(event AgentEvent) {
+10
View File
@@ -254,6 +254,10 @@ type Loop struct {
// v3 evolution metrics store (nil = disabled)
evolutionMetricsStore store.EvolutionMetricsStore
// Skill self-evolution metrics store (nil = disabled)
skillEvolutionStore store.SkillEvolutionStore
skillStore store.SkillStore
// User identity resolver: maps channel contacts to merged tenant users for credential lookups.
userResolver UserIdentityResolver
@@ -454,6 +458,10 @@ type LoopConfig struct {
// V3 evolution metrics store for recording tool/retrieval/feedback metrics
EvolutionMetricsStore store.EvolutionMetricsStore
// Skill self-evolution metrics store for use_skill/slash activation metrics
SkillEvolutionStore store.SkillEvolutionStore
SkillStore store.SkillStore
// User identity resolver for credential lookups (maps channel contacts → tenant users)
UserResolver UserIdentityResolver
}
@@ -586,6 +594,8 @@ func NewLoop(cfg LoopConfig) *Loop {
orchMode: cfg.OrchMode,
delegateTargets: cfg.DelegateTargets,
evolutionMetricsStore: cfg.EvolutionMetricsStore,
skillEvolutionStore: cfg.SkillEvolutionStore,
skillStore: cfg.SkillStore,
userResolver: cfg.UserResolver,
}
}
+6 -2
View File
@@ -85,8 +85,10 @@ type ResolverDeps struct {
MCPGrantChecker mcpbridge.GrantChecker
// Skill access store — for per-agent skill visibility filtering
SkillAccessStore store.SkillAccessStore
SkillSlashCommands config.SkillSlashCommandConfig
SkillAccessStore store.SkillAccessStore
SkillStore store.SkillStore
SkillEvolutionStore store.SkillEvolutionStore
SkillSlashCommands config.SkillSlashCommandConfig
// Config permission store for group file writer checks
ConfigPermStore store.ConfigPermissionStore
@@ -542,6 +544,8 @@ func NewManagedResolver(deps ResolverDeps) ResolverFunc {
OrchMode: orchMode,
DelegateTargets: delegateTargets,
EvolutionMetricsStore: evoMetricsStore,
SkillEvolutionStore: deps.SkillEvolutionStore,
SkillStore: deps.SkillStore,
UserResolver: newContactResolver(deps.ContactStore),
})
+3 -1
View File
@@ -6,6 +6,7 @@ import (
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type skillSlashCommandKind int
@@ -27,7 +28,7 @@ type skillSlashCommandResult struct {
Suggestions []skills.Info
}
func (l *Loop) applySkillSlashCommand(ctx context.Context, message, extraPrompt string, skillFilter []string) (string, string, []string) {
func (l *Loop) applySkillSlashCommand(ctx context.Context, req *RunRequest, message, extraPrompt string, skillFilter []string) (string, string, []string) {
result := resolveSkillSlashCommand(ctx, l.skillsLoader, l.resolveSkillSlashCommandConfig(ctx), message)
if result.Kind == skillSlashCommandNone {
return message, extraPrompt, skillFilter
@@ -41,6 +42,7 @@ func (l *Loop) applySkillSlashCommand(ctx context.Context, message, extraPrompt
message = result.RemainingPrompt
}
skillFilter = []string{result.Skill.Slug}
l.recordSkillUsage(ctx, req, result.Skill.Slug, "", "slash", store.SkillUsageStatusStarted, "", 0)
case skillSlashCommandList:
message = "List the available skills shown in the system instructions."
case skillSlashCommandHelp:
+16
View File
@@ -36,12 +36,19 @@ type SkillsHandler struct {
msgBus *bus.MessageBus
tenantCfgStore store.SkillTenantConfigStore
tenantStore store.TenantStore
evolutionStore store.SkillEvolutionStore
activityStore store.ActivityStore
db *sql.DB // for export/import direct queries
uploadLocks sync.Map // per-slug mutex; bounded by validated slug set, entries are tiny (*sync.Mutex)
uploadLimitCfg config.SkillsConfig
systemConfigs store.SystemConfigStore
}
func (h *SkillsHandler) SetEvolutionStore(evolution store.SkillEvolutionStore, activity store.ActivityStore) {
h.evolutionStore = evolution
h.activityStore = activity
}
// NewSkillsHandler creates a handler for skill management endpoints.
func NewSkillsHandler(skills store.SkillManageStore, baseDir, dataDir, bundledDir string, msgBus *bus.MessageBus, tenantCfgStore store.SkillTenantConfigStore, tenantStore store.TenantStore) *SkillsHandler {
return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, msgBus: msgBus, tenantCfgStore: tenantCfgStore, tenantStore: tenantStore, uploadLimitCfg: config.SkillsConfig{MaxUploadSizeMB: config.DefaultSkillMaxUploadSizeMB}}
@@ -88,10 +95,19 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /v1/skills/{id}/versions", h.authMiddleware(h.handleListVersions))
mux.HandleFunc("GET /v1/skills/{id}/files/{path...}", h.authMiddleware(h.handleReadFile))
mux.HandleFunc("GET /v1/skills/{id}/files", h.authMiddleware(h.handleListFiles))
mux.HandleFunc("GET /v1/skills/{id}/evolution", h.authMiddleware(h.handleGetEvolution))
mux.HandleFunc("GET /v1/skills/{id}/metrics", h.authMiddleware(h.handleGetSkillMetrics))
mux.HandleFunc("GET /v1/skills/{id}/activity", h.adminMiddleware(h.handleGetSkillActivity))
mux.HandleFunc("GET /v1/skills/{id}/evolution/suggestions", h.authMiddleware(h.handleListSkillSuggestions))
// Skill writes (admin+)
mux.HandleFunc("POST /v1/skills/upload", h.adminMiddleware(h.handleUpload))
mux.HandleFunc("PUT /v1/skills/{id}", h.adminMiddleware(h.handleUpdate))
mux.HandleFunc("DELETE /v1/skills/{id}", h.adminMiddleware(h.handleDelete))
mux.HandleFunc("PATCH /v1/skills/{id}/evolution", h.adminMiddleware(h.handlePatchEvolution))
mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions", h.adminMiddleware(h.handleCreateSkillSuggestion))
mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/approve", h.adminMiddleware(h.handleApproveSkillSuggestion))
mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/reject", h.adminMiddleware(h.handleRejectSkillSuggestion))
mux.HandleFunc("POST /v1/skills/{id}/evolution/suggestions/{suggestionID}/apply", h.adminMiddleware(h.handleApplySkillSuggestion))
// Skill grants (admin+)
mux.HandleFunc("GET /v1/skills/{id}/grants/agent", h.adminMiddleware(h.handleListAgentGrants))
mux.HandleFunc("POST /v1/skills/{id}/grants/agent", h.adminMiddleware(h.handleGrantAgent))
+542
View File
@@ -0,0 +1,542 @@
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/permissions"
"github.com/nextlevelbuilder/goclaw/internal/skills"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
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) bool {
if h.evolutionStore == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "skill evolution store not configured"})
return false
}
return true
}
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 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid skill id"})
return uuid.Nil, store.SkillInfo{}, false
}
info, ok := h.skills.GetSkillByID(r.Context(), id)
if !ok {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "skill not found"})
return uuid.Nil, store.SkillInfo{}, false
}
return id, info, true
}
func (h *SkillsHandler) handleGetEvolution(w http.ResponseWriter, r *http.Request) {
if !h.evolutionConfigured(w) {
return
}
skillID, _, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
settings, err := h.evolutionStore.GetSettings(r.Context(), skillID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, settings)
}
func (h *SkillsHandler) handlePatchEvolution(w http.ResponseWriter, r *http.Request) {
if !h.evolutionConfigured(w) {
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 {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": 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 {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid evolution mode"})
return
}
updated, err := h.evolutionStore.UpsertSettings(r.Context(), store.SkillEvolutionSettings{
SkillID: skillID,
Enabled: enabled,
Mode: mode,
})
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": 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) {
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 {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": 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 {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "activity store not configured"})
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 {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": 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) {
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 {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": 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) {
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) == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "suggestion_type is required"})
return
}
target := strings.TrimSpace(req.TargetFile)
if target != "" {
clean, err := skills.ValidateSkillTargetPath(target, true)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": 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 {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": 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) {
return
}
skillID, _, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
suggestionID, err := uuid.Parse(r.PathValue("suggestionID"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid suggestion id"})
return
}
updated, err := h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, status, "user", store.ActorIDFromContext(r.Context()))
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if updated.SkillID != skillID {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "suggestion not found"})
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) {
return
}
skillID, info, ok := h.skillIDFromRequest(w, r)
if !ok {
return
}
if info.IsSystem {
writeJSON(w, http.StatusForbidden, map[string]string{"error": "system skill mutation is blocked"})
return
}
suggestionID, err := uuid.Parse(r.PathValue("suggestionID"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid suggestion id"})
return
}
var req skillSuggestionApplyRequest
_ = json.NewDecoder(r.Body).Decode(&req)
sg, err := h.evolutionStore.GetSuggestion(r.Context(), suggestionID)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if sg == nil || sg.SkillID != skillID {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "suggestion not found"})
return
}
if sg.Status == store.SkillSuggestionStatusApplied && sg.AppliedVersion != nil {
writeJSON(w, http.StatusOK, sg)
return
}
if sg.Status != store.SkillSuggestionStatusApproved {
if !req.Approve {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "suggestion must be approved before apply"})
return
}
sg, err = h.evolutionStore.UpdateSuggestionStatus(r.Context(), suggestionID, store.SkillSuggestionStatusApproved, "user", store.ActorIDFromContext(r.Context()))
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
}
applied, err := h.applySkillSuggestionPatch(r, skillID, sg)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
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, fmt.Errorf("skill not found")
}
if isSystem {
return nil, fmt.Errorf("system skill mutation is blocked")
}
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("invalid draft_patch: %w", err)
}
}
if patch.Content == nil && patch.Find == "" {
return nil, fmt.Errorf("draft_patch requires content or find/replace")
}
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)
}
cleanup := true
defer func() {
if cleanup {
_ = os.RemoveAll(tmpDir)
_ = 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, fmt.Errorf("find text not found in target file")
}
nextContent = replaced
}
if cleanTarget == "SKILL.md" {
violations, safe := skills.GuardSkillContent(nextContent)
if !safe {
return nil, errors.New(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)
}
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,
})
cleanup = false
return applied, nil
}
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
}
+131
View File
@@ -0,0 +1,131 @@
package http
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
func TestApplySkillSuggestionPatchCreatesNewReferenceFile(t *testing.T) {
handler, skillStore, ctx, root := newTestUploadHandler(t)
evolution := &skillEvolutionStoreStub{}
handler.SetEvolutionStore(evolution, nil)
currentDir := filepath.Join(root, "skills-store", "reference-skill", "1")
if err := os.MkdirAll(currentDir, 0755); err != nil {
t.Fatalf("mkdir current skill: %v", err)
}
if err := os.WriteFile(filepath.Join(currentDir, "SKILL.md"), []byte(skillMarkdown("Reference Skill", "reference-skill")), 0644); err != nil {
t.Fatalf("write skill: %v", err)
}
skillID := skillStore.seedCustomSkill("reference-skill", currentDir, "active", nil)
content := "Use the documented query syntax.\n"
patch, err := json.Marshal(skillDraftPatch{Content: &content})
if err != nil {
t.Fatalf("marshal draft patch: %v", err)
}
suggestion := &store.SkillImprovementSuggestion{
ID: uuid.New(),
SkillID: skillID,
TargetFile: "references/troubleshooting.md",
DraftPatch: patch,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SkillSlug: "reference-skill",
Status: store.SkillSuggestionStatusApproved,
SuggestionType: "skill_reference_add",
}
req := httptest.NewRequest(http.MethodPost, "/v1/skills/"+skillID.String()+"/evolution/suggestions/"+suggestion.ID.String()+"/apply", nil).WithContext(ctx)
applied, err := handler.applySkillSuggestionPatch(req, skillID, suggestion)
if err != nil {
t.Fatalf("apply suggestion: %v", err)
}
if applied.Status != store.SkillSuggestionStatusApplied {
t.Fatalf("status = %q, want applied", applied.Status)
}
newReference := filepath.Join(root, "skills-store", "reference-skill", "2", "references", "troubleshooting.md")
got, err := os.ReadFile(newReference)
if err != nil {
t.Fatalf("read created reference: %v", err)
}
if string(got) != content {
t.Fatalf("created reference = %q, want %q", got, content)
}
if len(evolution.versions) != 1 || evolution.versions[0].Version != 2 {
t.Fatalf("versions = %+v, want one version 2", evolution.versions)
}
}
type skillEvolutionStoreStub struct {
versions []store.SkillVersion
}
func (s *skillEvolutionStoreStub) GetSettings(context.Context, uuid.UUID) (*store.SkillEvolutionSettings, error) {
return nil, nil
}
func (s *skillEvolutionStoreStub) UpsertSettings(context.Context, store.SkillEvolutionSettings) (*store.SkillEvolutionSettings, error) {
return nil, nil
}
func (s *skillEvolutionStoreStub) RecordUsage(context.Context, store.SkillUsageMetric) error {
return nil
}
func (s *skillEvolutionStoreStub) AggregateUsage(context.Context, uuid.UUID, *time.Time) (*store.SkillUsageStats, error) {
return nil, nil
}
func (s *skillEvolutionStoreStub) ListUsage(context.Context, uuid.UUID, int) ([]store.SkillUsageMetric, error) {
return nil, nil
}
func (s *skillEvolutionStoreStub) CreateSuggestion(context.Context, store.SkillImprovementSuggestion) (*store.SkillImprovementSuggestion, error) {
return nil, nil
}
func (s *skillEvolutionStoreStub) ListSuggestions(context.Context, uuid.UUID, string, int) ([]store.SkillImprovementSuggestion, error) {
return nil, nil
}
func (s *skillEvolutionStoreStub) GetSuggestion(context.Context, uuid.UUID) (*store.SkillImprovementSuggestion, error) {
return nil, nil
}
func (s *skillEvolutionStoreStub) UpdateSuggestionStatus(context.Context, uuid.UUID, string, string, string) (*store.SkillImprovementSuggestion, error) {
return nil, nil
}
func (s *skillEvolutionStoreStub) MarkSuggestionApplied(_ context.Context, id uuid.UUID, version int, actorType, actorID string) (*store.SkillImprovementSuggestion, error) {
return &store.SkillImprovementSuggestion{
ID: id,
Status: store.SkillSuggestionStatusApplied,
ReviewedByActorType: actorType,
ReviewedByActorID: actorID,
AppliedVersion: &version,
}, nil
}
func (s *skillEvolutionStoreStub) CreateSkillVersion(_ context.Context, version store.SkillVersion) (*store.SkillVersion, error) {
s.versions = append(s.versions, version)
return &version, nil
}
func (s *skillEvolutionStoreStub) ListSkillVersions(context.Context, uuid.UUID, int) ([]store.SkillVersion, error) {
return s.versions, nil
}
func (s *skillEvolutionStoreStub) GetSkillVersion(context.Context, uuid.UUID, int) (*store.SkillVersion, error) {
return nil, nil
}
+19 -2
View File
@@ -939,6 +939,9 @@ func (s *skillManageStoreStub) seedCustomSkill(slug, dir, status string, missing
func (s *skillManageStoreStub) seedCustomSkillForTenant(tenantID uuid.UUID, slug, dir, status string, missing []string) uuid.UUID {
id := uuid.New()
if s.nextBySlug[slug] < 1 {
s.nextBySlug[slug] = 1
}
s.skills[id] = store.SkillInfo{
ID: id.String(),
TenantID: tenantID.String(),
@@ -1046,6 +1049,16 @@ func (s *skillManageStoreStub) UpdateSkill(ctx context.Context, id uuid.UUID, up
if visibility, ok := updates["visibility"].(string); ok {
skill.Visibility = visibility
}
if version, ok := updates["version"].(int); ok {
skill.Version = version
if version > s.nextBySlug[skill.Slug] {
s.nextBySlug[skill.Slug] = version
}
}
if filePath, ok := updates["file_path"].(string); ok {
skill.BaseDir = filePath
skill.Path = filepath.Join(filePath, "SKILL.md")
}
s.lastUpdates[id] = maps.Clone(updates)
s.skills[id] = skill
return nil
@@ -1156,8 +1169,12 @@ func (s *skillManageStoreStub) ListAgentGrantsForSkill(context.Context, uuid.UUI
func (s *skillManageStoreStub) AgentCanManageSkill(context.Context, uuid.UUID, uuid.UUID) (bool, error) {
return false, nil
}
func (s *skillManageStoreStub) GetSkillFilePath(context.Context, uuid.UUID) (string, string, int, bool, bool) {
return "", "", 0, false, false
func (s *skillManageStoreStub) GetSkillFilePath(ctx context.Context, id uuid.UUID) (string, string, int, bool, bool) {
skill, ok := s.skills[id]
if !ok || !s.canAccessSkill(ctx, skill) {
return "", "", 0, false, false
}
return skill.BaseDir, skill.Slug, skill.Version, skill.IsSystem, true
}
// ---------------------------------------------------------------------------
+50
View File
@@ -0,0 +1,50 @@
package skills
import (
"fmt"
"path"
"strings"
)
// ValidateSkillTargetPath validates a relative path inside a managed skill root.
// Set allowSkillMD when the caller is explicitly editing the primary SKILL.md.
func ValidateSkillTargetPath(rawPath string, allowSkillMD bool) (string, error) {
if rawPath == "" {
return "", fmt.Errorf("invalid file path %q: empty path", rawPath)
}
if strings.ContainsRune(rawPath, 0x00) {
return "", fmt.Errorf("invalid file path %q: null byte", rawPath)
}
if len(rawPath) >= 2 && rawPath[1] == ':' {
return "", fmt.Errorf("invalid file path %q: windows drive paths are not allowed", rawPath)
}
normalized := strings.ReplaceAll(rawPath, "\\", "/")
if strings.HasPrefix(normalized, "/") {
return "", fmt.Errorf("invalid file path %q: absolute paths are not allowed", rawPath)
}
for part := range strings.SplitSeq(normalized, "/") {
switch part {
case "..":
return "", fmt.Errorf("invalid file path %q: parent traversal is not allowed", rawPath)
case ".git":
return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath)
}
if strings.HasPrefix(part, ".") {
return "", fmt.Errorf("invalid file path %q: hidden files are not allowed", rawPath)
}
}
cleanPath := path.Clean(normalized)
if cleanPath == "." || strings.HasPrefix(cleanPath, "../") || cleanPath == ".." || strings.HasPrefix(cleanPath, "/") {
return "", fmt.Errorf("invalid file path %q: path escapes skill root", rawPath)
}
if strings.EqualFold(cleanPath, "SKILL.md") {
if allowSkillMD {
return "SKILL.md", nil
}
return "", fmt.Errorf("invalid file path %q: SKILL.md must be provided via content or find/replace", rawPath)
}
if IsSystemArtifact(cleanPath) {
return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath)
}
return cleanPath, nil
}
@@ -0,0 +1,40 @@
package skills
import "testing"
func TestValidateSkillTargetPath(t *testing.T) {
tests := []struct {
name string
raw string
allowSkillMD bool
want string
wantErr bool
}{
{name: "reference file", raw: `references\troubleshooting.md`, want: "references/troubleshooting.md"},
{name: "skill markdown allowed", raw: "SKILL.md", allowSkillMD: true, want: "SKILL.md"},
{name: "skill markdown blocked", raw: "SKILL.md", wantErr: true},
{name: "absolute path", raw: "/tmp/SKILL.md", wantErr: true},
{name: "parent traversal", raw: "../outside.md", wantErr: true},
{name: "hidden file", raw: "references/.env", wantErr: true},
{name: "system artifact", raw: ".git/config", wantErr: true},
{name: "windows drive", raw: "C:/Users/secret.md", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := ValidateSkillTargetPath(tt.raw, tt.allowSkillMD)
if tt.wantErr {
if err == nil {
t.Fatalf("expected error, got path %q", got)
}
return
}
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Fatalf("got %q, want %q", got, tt.want)
}
})
}
}
+1
View File
@@ -55,6 +55,7 @@ func NewPGStores(cfg store.StoreConfig) (*store.Stores, error) {
Tenants: NewPGTenantStore(db),
BuiltinToolTenantCfgs: NewPGBuiltinToolTenantConfigStore(db),
SkillTenantCfgs: NewPGSkillTenantConfigStore(db),
SkillEvolution: NewPGSkillEvolutionStore(db),
SystemConfigs: NewPGSystemConfigStore(db),
SubagentTasks: NewPGSubagentTaskStore(db),
Vault: NewPGVaultStore(db),
+440
View File
@@ -0,0 +1,440 @@
package pg
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
type PGSkillEvolutionStore struct {
db *sql.DB
}
func NewPGSkillEvolutionStore(db *sql.DB) *PGSkillEvolutionStore {
return &PGSkillEvolutionStore{db: db}
}
func (s *PGSkillEvolutionStore) resolveSkill(ctx context.Context, skillID uuid.UUID) (string, int, error) {
tenantID := tenantIDForInsert(ctx)
var slug string
var version int
var skillTenant uuid.UUID
var isSystem bool
err := s.db.QueryRowContext(ctx,
`SELECT slug, version, tenant_id, is_system
FROM skills
WHERE id = $1 AND status != 'deleted'`,
skillID,
).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 {
return "", 0, fmt.Errorf("skill not found")
}
return slug, version, nil
}
func (s *PGSkillEvolutionStore) 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
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 = $1 AND skill_id = $2`,
tenantID, skillID,
).Scan(&out.TenantID, &out.SkillID, &out.Enabled, &out.Mode, &out.LastAnalyzedAt, &out.CreatedAt, &out.UpdatedAt)
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
}
return &out, nil
}
func (s *PGSkillEvolutionStore) 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
}
var out store.SkillEvolutionSettings
err := s.db.QueryRowContext(ctx,
`INSERT INTO skill_evolution_settings (tenant_id, skill_id, enabled, mode, last_analyzed_at)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (tenant_id, skill_id) DO UPDATE SET
enabled = EXCLUDED.enabled,
mode = EXCLUDED.mode,
last_analyzed_at = EXCLUDED.last_analyzed_at,
updated_at = NOW()
RETURNING tenant_id, skill_id, enabled, mode, last_analyzed_at, created_at, updated_at`,
tenantID, settings.SkillID, settings.Enabled, mode, settings.LastAnalyzedAt,
).Scan(&out.TenantID, &out.SkillID, &out.Enabled, &out.Mode, &out.LastAnalyzedAt, &out.CreatedAt, &out.UpdatedAt)
if err != nil {
return nil, err
}
return &out, nil
}
func (s *PGSkillEvolutionStore) 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
}
_, 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 ($1, $2, $3, $4, $5, NULLIF($6, $16), $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
metric.ID, tenantID, metric.SkillID, metric.SkillSlug, metric.SkillVersion,
metric.AgentID, metric.UserID, metric.SessionKey, metric.TraceID, metric.InvocationID,
metric.InvocationSource, metric.Status, metric.FailureReason, metric.ToolCallsCount, metric.DurationMs, uuid.Nil,
)
return err
}
func (s *PGSkillEvolutionStore) 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 = $1 AND skill_id = $2"
args := []any{tenantID, skillID}
if since != nil {
where += " AND created_at >= $3"
args = append(args, *since)
}
query := fmt.Sprintf(
`SELECT COUNT(*),
COUNT(*) FILTER (WHERE status = 'started'),
COUNT(*) FILTER (WHERE status = 'succeeded'),
COUNT(*) FILTER (WHERE status = 'failed'),
COUNT(*) FILTER (WHERE status = 'abandoned'),
MAX(created_at)
FROM skill_usage_metrics WHERE %s`,
where,
)
var out store.SkillUsageStats
out.SkillID = skillID
var last sql.NullTime
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 {
out.LastUsedAt = &last.Time
}
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
if err := rows.Scan(&r.Reason, &r.Count, &r.LastSeen); err != nil {
return nil, err
}
out.TopFailureReasons = append(out.TopFailureReasons, r)
}
return &out, rows.Err()
}
func (s *PGSkillEvolutionStore) 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, '00000000-0000-0000-0000-000000000000'::uuid),
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 = $1 AND skill_id = $2
ORDER BY created_at DESC LIMIT $3`,
tenantID, skillID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var out []store.SkillUsageMetric
for rows.Next() {
var m store.SkillUsageMetric
if err := rows.Scan(&m.ID, &m.TenantID, &m.SkillID, &m.SkillSlug, &m.SkillVersion, &m.AgentID,
&m.UserID, &m.SessionKey, &m.TraceID, &m.InvocationID, &m.InvocationSource, &m.Status,
&m.FailureReason, &m.ToolCallsCount, &m.DurationMs, &m.CreatedAt); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
func (s *PGSkillEvolutionStore) 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)
var out store.SkillImprovementSuggestion
err = s.db.QueryRowContext(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)
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE($8, '{}'::jsonb), COALESCE($9, '{}'::jsonb), $10, $11, $12)
RETURNING 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`,
sg.ID, tenantID, sg.SkillID, sg.SkillSlug, sg.SuggestionType, sg.Status, sg.Reason,
jsonOrEmpty(sg.Evidence), jsonOrEmpty(sg.DraftPatch), sg.TargetFile, sg.CreatedByActorType, sg.CreatedByActorID,
).Scan(&out.ID, &out.TenantID, &out.SkillID, &out.SkillSlug, &out.SuggestionType, &out.Status, &out.Reason,
&out.Evidence, &out.DraftPatch, &out.TargetFile, &out.CreatedByActorType, &out.CreatedByActorID,
&out.ReviewedByActorType, &out.ReviewedByActorID, &out.ReviewedAt, &out.AppliedVersion,
&out.CreatedAt, &out.UpdatedAt)
if err != nil {
return nil, err
}
return &out, nil
}
func (s *PGSkillEvolutionStore) 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 = $1 AND skill_id = $2`
args := []any{tenantID, skillID}
if status != "" {
args = append(args, status)
q += fmt.Sprintf(" AND status = $%d", len(args))
}
args = append(args, limit)
q += fmt.Sprintf(" ORDER BY created_at DESC LIMIT $%d", len(args))
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPGSuggestions(rows)
}
func (s *PGSkillEvolutionStore) 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 = $1 AND id = $2`,
tenantID, id)
if err != nil {
return nil, err
}
defer rows.Close()
items, err := scanPGSuggestions(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 *PGSkillEvolutionStore) UpdateSuggestionStatus(ctx context.Context, id uuid.UUID, status, actorType, actorID string) (*store.SkillImprovementSuggestion, error) {
tenantID := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE skill_improvement_suggestions
SET status = $1, reviewed_by_actor_type = $2, reviewed_by_actor_id = $3, reviewed_at = NOW(), updated_at = NOW()
WHERE tenant_id = $4 AND id = $5`,
status, actorType, actorID, tenantID, id)
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 *PGSkillEvolutionStore) MarkSuggestionApplied(ctx context.Context, id uuid.UUID, version int, actorType, actorID string) (*store.SkillImprovementSuggestion, error) {
tenantID := tenantIDForInsert(ctx)
res, err := s.db.ExecContext(ctx,
`UPDATE skill_improvement_suggestions
SET status = 'applied', applied_version = $1, reviewed_by_actor_type = $2,
reviewed_by_actor_id = $3, reviewed_at = COALESCE(reviewed_at, NOW()), updated_at = NOW()
WHERE tenant_id = $4 AND id = $5`,
version, actorType, actorID, tenantID, id)
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 *PGSkillEvolutionStore) 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)
var out store.SkillVersion
err := s.db.QueryRowContext(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)
VALUES ($1, $2, $3, $4, $5, COALESCE($6, '[]'::jsonb), $7, $8, $9)
RETURNING 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`,
v.ID, tenantID, v.SkillID, v.Version, v.ContentHash, jsonOrEmptyArray(v.ChangedFiles),
v.CreatedByActorType, v.CreatedByActorID, v.CreatedFromSuggestionID,
).Scan(&out.ID, &out.TenantID, &out.SkillID, &out.Version, &out.ContentHash, &out.ChangedFiles,
&out.CreatedByActorType, &out.CreatedByActorID, &out.CreatedFromSuggestionID, &out.CreatedAt)
if err != nil {
return nil, err
}
return &out, nil
}
func (s *PGSkillEvolutionStore) 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 = $1 AND skill_id = $2
ORDER BY version DESC LIMIT $3`,
tenantID, skillID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanPGSkillVersions(rows)
}
func (s *PGSkillEvolutionStore) 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 = $1 AND skill_id = $2 AND version = $3`,
tenantID, skillID, version)
if err != nil {
return nil, err
}
defer rows.Close()
items, err := scanPGSkillVersions(rows)
if err != nil || len(items) == 0 {
return nil, err
}
return &items[0], nil
}
func scanPGSuggestions(rows *sql.Rows) ([]store.SkillImprovementSuggestion, error) {
var out []store.SkillImprovementSuggestion
for rows.Next() {
var sg store.SkillImprovementSuggestion
if err := rows.Scan(&sg.ID, &sg.TenantID, &sg.SkillID, &sg.SkillSlug, &sg.SuggestionType, &sg.Status,
&sg.Reason, &sg.Evidence, &sg.DraftPatch, &sg.TargetFile, &sg.CreatedByActorType, &sg.CreatedByActorID,
&sg.ReviewedByActorType, &sg.ReviewedByActorID, &sg.ReviewedAt, &sg.AppliedVersion,
&sg.CreatedAt, &sg.UpdatedAt); err != nil {
return nil, err
}
out = append(out, sg)
}
return out, rows.Err()
}
func scanPGSkillVersions(rows *sql.Rows) ([]store.SkillVersion, error) {
var out []store.SkillVersion
for rows.Next() {
var v store.SkillVersion
if err := rows.Scan(&v.ID, &v.TenantID, &v.SkillID, &v.Version, &v.ContentHash, &v.ChangedFiles,
&v.CreatedByActorType, &v.CreatedByActorID, &v.CreatedFromSuggestionID, &v.CreatedAt); err != nil {
return nil, err
}
out = append(out, v)
}
return out, rows.Err()
}
var _ store.SkillEvolutionStore = (*PGSkillEvolutionStore)(nil)
+124
View File
@@ -0,0 +1,124 @@
package store
import (
"context"
"encoding/json"
"time"
"github.com/google/uuid"
)
const (
SkillEvolutionModeSuggestOnly = "suggest_only"
SkillEvolutionModeAutoAnalyze = "auto_analyze"
SkillUsageStatusStarted = "started"
SkillUsageStatusSucceeded = "succeeded"
SkillUsageStatusFailed = "failed"
SkillUsageStatusAbandoned = "abandoned"
SkillSuggestionStatusPending = "pending"
SkillSuggestionStatusApproved = "approved"
SkillSuggestionStatusRejected = "rejected"
SkillSuggestionStatusApplied = "applied"
)
// SkillEvolutionSettings stores tenant-scoped self-evolution controls for one skill.
type SkillEvolutionSettings struct {
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
SkillID uuid.UUID `json:"skill_id" db:"skill_id"`
Enabled bool `json:"enabled" db:"enabled"`
Mode string `json:"mode" db:"mode"`
LastAnalyzedAt *time.Time `json:"last_analyzed_at,omitempty" db:"last_analyzed_at"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
// SkillUsageMetric is a durable usage event emitted by trusted runtime paths.
type SkillUsageMetric struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
SkillID uuid.UUID `json:"skill_id" db:"skill_id"`
SkillSlug string `json:"skill_slug" db:"skill_slug"`
SkillVersion int `json:"skill_version" db:"skill_version"`
AgentID uuid.UUID `json:"agent_id,omitempty" db:"agent_id"`
UserID string `json:"user_id,omitempty" db:"user_id"`
SessionKey string `json:"session_key,omitempty" db:"session_key"`
TraceID string `json:"trace_id,omitempty" db:"trace_id"`
InvocationID string `json:"invocation_id,omitempty" db:"invocation_id"`
InvocationSource string `json:"invocation_source" db:"invocation_source"`
Status string `json:"status" db:"status"`
FailureReason string `json:"failure_reason,omitempty" db:"failure_reason"`
ToolCallsCount int `json:"tool_calls_count" db:"tool_calls_count"`
DurationMs int64 `json:"duration_ms" db:"duration_ms"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type SkillFailureReason struct {
Reason string `json:"reason"`
Count int `json:"count"`
LastSeen time.Time `json:"last_seen"`
}
type SkillUsageStats struct {
SkillID uuid.UUID `json:"skill_id"`
TotalCalls int `json:"total_calls"`
Started int `json:"started"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Abandoned int `json:"abandoned"`
SuccessRate float64 `json:"success_rate"`
FailureRate float64 `json:"failure_rate"`
LastUsedAt *time.Time `json:"last_used_at,omitempty"`
TopFailureReasons []SkillFailureReason `json:"top_failure_reasons,omitempty"`
}
type SkillImprovementSuggestion struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
SkillID uuid.UUID `json:"skill_id" db:"skill_id"`
SkillSlug string `json:"skill_slug" db:"skill_slug"`
SuggestionType string `json:"suggestion_type" db:"suggestion_type"`
Status string `json:"status" db:"status"`
Reason string `json:"reason" db:"reason"`
Evidence json.RawMessage `json:"evidence,omitempty" db:"evidence"`
DraftPatch json.RawMessage `json:"draft_patch,omitempty" db:"draft_patch"`
TargetFile string `json:"target_file,omitempty" db:"target_file"`
CreatedByActorType string `json:"created_by_actor_type,omitempty" db:"created_by_actor_type"`
CreatedByActorID string `json:"created_by_actor_id,omitempty" db:"created_by_actor_id"`
ReviewedByActorType string `json:"reviewed_by_actor_type,omitempty" db:"reviewed_by_actor_type"`
ReviewedByActorID string `json:"reviewed_by_actor_id,omitempty" db:"reviewed_by_actor_id"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty" db:"reviewed_at"`
AppliedVersion *int `json:"applied_version,omitempty" db:"applied_version"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
}
type SkillVersion struct {
ID uuid.UUID `json:"id" db:"id"`
TenantID uuid.UUID `json:"tenant_id" db:"tenant_id"`
SkillID uuid.UUID `json:"skill_id" db:"skill_id"`
Version int `json:"version" db:"version"`
ContentHash string `json:"content_hash" db:"content_hash"`
ChangedFiles json.RawMessage `json:"changed_files,omitempty" db:"changed_files"`
CreatedByActorType string `json:"created_by_actor_type,omitempty" db:"created_by_actor_type"`
CreatedByActorID string `json:"created_by_actor_id,omitempty" db:"created_by_actor_id"`
CreatedFromSuggestionID *uuid.UUID `json:"created_from_suggestion_id,omitempty" db:"created_from_suggestion_id"`
CreatedAt time.Time `json:"created_at" db:"created_at"`
}
type SkillEvolutionStore interface {
GetSettings(ctx context.Context, skillID uuid.UUID) (*SkillEvolutionSettings, error)
UpsertSettings(ctx context.Context, settings SkillEvolutionSettings) (*SkillEvolutionSettings, error)
RecordUsage(ctx context.Context, metric SkillUsageMetric) error
AggregateUsage(ctx context.Context, skillID uuid.UUID, since *time.Time) (*SkillUsageStats, error)
ListUsage(ctx context.Context, skillID uuid.UUID, limit int) ([]SkillUsageMetric, error)
CreateSuggestion(ctx context.Context, suggestion SkillImprovementSuggestion) (*SkillImprovementSuggestion, error)
ListSuggestions(ctx context.Context, skillID uuid.UUID, status string, limit int) ([]SkillImprovementSuggestion, error)
GetSuggestion(ctx context.Context, id uuid.UUID) (*SkillImprovementSuggestion, error)
UpdateSuggestionStatus(ctx context.Context, id uuid.UUID, status, actorType, actorID string) (*SkillImprovementSuggestion, error)
MarkSuggestionApplied(ctx context.Context, id uuid.UUID, version int, actorType, actorID string) (*SkillImprovementSuggestion, error)
CreateSkillVersion(ctx context.Context, version SkillVersion) (*SkillVersion, error)
ListSkillVersions(ctx context.Context, skillID uuid.UUID, limit int) ([]SkillVersion, error)
GetSkillVersion(ctx context.Context, skillID uuid.UUID, version int) (*SkillVersion, error)
}
+1
View File
@@ -48,6 +48,7 @@ func NewSQLiteStores(cfg store.StoreConfig) (*store.Stores, error) {
Tenants: NewSQLiteTenantStore(db),
BuiltinToolTenantCfgs: NewSQLiteBuiltinToolTenantConfigStore(db),
SkillTenantCfgs: NewSQLiteSkillTenantConfigStore(db),
SkillEvolution: NewSQLiteSkillEvolutionStore(db),
SystemConfigs: NewSQLiteSystemConfigStore(db),
Snapshots: NewSQLiteSnapshotStore(db),
Cron: NewSQLiteCronStore(db),
+91 -1
View File
@@ -16,7 +16,7 @@ var schemaSQL string
// SchemaVersion is the current SQLite schema version.
// Bump this when adding new migration steps below.
const SchemaVersion = 46
const SchemaVersion = 47
// migrations maps version → SQL to apply when upgrading FROM that version.
// schema.sql always represents the LATEST full schema (for fresh DBs).
@@ -831,8 +831,98 @@ CREATE TABLE IF NOT EXISTS secure_cli_agent_credentials (
CREATE INDEX IF NOT EXISTS idx_scac_tenant ON secure_cli_agent_credentials(tenant_id);
CREATE INDEX IF NOT EXISTS idx_scac_binary ON secure_cli_agent_credentials(binary_id);
CREATE INDEX IF NOT EXISTS idx_scac_agent ON secure_cli_agent_credentials(agent_id);`,
// Version 46 → 47: skill self-evolution settings, metrics, suggestions, and immutable version records.
46: addSkillSelfEvolutionTables,
}
const addSkillSelfEvolutionTables = `
CREATE TABLE IF NOT EXISTS skill_evolution_settings (
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
enabled INTEGER NOT NULL DEFAULT 0,
mode VARCHAR(32) NOT NULL DEFAULT 'suggest_only',
last_analyzed_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (tenant_id, skill_id),
CHECK (mode IN ('suggest_only', 'auto_analyze'))
);
CREATE INDEX IF NOT EXISTS idx_skill_evolution_settings_skill ON skill_evolution_settings(skill_id);
CREATE TABLE IF NOT EXISTS skill_usage_metrics (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
skill_slug VARCHAR(255) NOT NULL,
skill_version INTEGER NOT NULL DEFAULT 1,
agent_id TEXT,
user_id VARCHAR(255),
session_key TEXT,
trace_id TEXT,
invocation_id TEXT,
invocation_source VARCHAR(32) NOT NULL DEFAULT 'runtime',
status VARCHAR(32) NOT NULL DEFAULT 'started',
failure_reason TEXT,
tool_calls_count INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (status IN ('started', 'succeeded', 'failed', 'abandoned'))
);
CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_skill_created ON skill_usage_metrics(skill_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_tenant_created ON skill_usage_metrics(tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_status ON skill_usage_metrics(skill_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_invocation ON skill_usage_metrics(invocation_id);
CREATE TABLE IF NOT EXISTS skill_improvement_suggestions (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
skill_slug VARCHAR(255) NOT NULL,
suggestion_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
reason TEXT NOT NULL DEFAULT '',
evidence TEXT NOT NULL DEFAULT '{}',
draft_patch TEXT NOT NULL DEFAULT '{}',
target_file TEXT NOT NULL DEFAULT '',
created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '',
created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '',
reviewed_by_actor_type VARCHAR(32) NOT NULL DEFAULT '',
reviewed_by_actor_id VARCHAR(255) NOT NULL DEFAULT '',
reviewed_at TEXT,
applied_version INTEGER,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (status IN ('pending', 'approved', 'rejected', 'applied'))
);
CREATE INDEX IF NOT EXISTS idx_skill_suggestions_skill_status_created ON skill_improvement_suggestions(skill_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_skill_suggestions_tenant_created ON skill_improvement_suggestions(tenant_id, created_at DESC);
CREATE TABLE IF NOT EXISTS skill_versions (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
content_hash VARCHAR(64) NOT NULL DEFAULT '',
changed_files TEXT NOT NULL DEFAULT '[]',
created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '',
created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '',
created_from_suggestion_id TEXT REFERENCES skill_improvement_suggestions(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(skill_id, version)
);
CREATE INDEX IF NOT EXISTS idx_skill_versions_tenant_skill ON skill_versions(tenant_id, skill_id, version DESC);
INSERT OR IGNORE INTO skill_versions (
id, tenant_id, skill_id, version, content_hash, changed_files,
created_by_actor_type, created_by_actor_id, created_at
)
SELECT lower(hex(randomblob(4))) || '-' || lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(2))) || '-' ||
lower(hex(randomblob(2))) || '-' || lower(hex(randomblob(6))),
tenant_id, id, version, COALESCE(file_hash, ''), '[]',
'system', 'migration', COALESCE(created_at, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
FROM skills
WHERE status != 'deleted';`
const addChannelMemoryExtractionTables = `
CREATE TABLE IF NOT EXISTS channel_memory_extraction_runs (
id TEXT NOT NULL PRIMARY KEY,
+80
View File
@@ -2102,3 +2102,83 @@ CREATE INDEX IF NOT EXISTS idx_browser_cookies_scope_domain
ON browser_cookies (tenant_id, user_id, agent_id, domain);
CREATE INDEX IF NOT EXISTS idx_browser_cookies_expires_at
ON browser_cookies (expires_at);
-- ============================================================
-- Skill self-evolution (migration 000078 / SQLite schema 47)
-- ============================================================
CREATE TABLE IF NOT EXISTS skill_evolution_settings (
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
enabled INTEGER NOT NULL DEFAULT 0,
mode VARCHAR(32) NOT NULL DEFAULT 'suggest_only',
last_analyzed_at TEXT,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
PRIMARY KEY (tenant_id, skill_id),
CHECK (mode IN ('suggest_only', 'auto_analyze'))
);
CREATE INDEX IF NOT EXISTS idx_skill_evolution_settings_skill ON skill_evolution_settings(skill_id);
CREATE TABLE IF NOT EXISTS skill_usage_metrics (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
skill_slug VARCHAR(255) NOT NULL,
skill_version INTEGER NOT NULL DEFAULT 1,
agent_id TEXT,
user_id VARCHAR(255),
session_key TEXT,
trace_id TEXT,
invocation_id TEXT,
invocation_source VARCHAR(32) NOT NULL DEFAULT 'runtime',
status VARCHAR(32) NOT NULL DEFAULT 'started',
failure_reason TEXT,
tool_calls_count INTEGER NOT NULL DEFAULT 0,
duration_ms INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (status IN ('started', 'succeeded', 'failed', 'abandoned'))
);
CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_skill_created ON skill_usage_metrics(skill_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_tenant_created ON skill_usage_metrics(tenant_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_status ON skill_usage_metrics(skill_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_skill_usage_metrics_invocation ON skill_usage_metrics(invocation_id);
CREATE TABLE IF NOT EXISTS skill_improvement_suggestions (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
skill_slug VARCHAR(255) NOT NULL,
suggestion_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
reason TEXT NOT NULL DEFAULT '',
evidence TEXT NOT NULL DEFAULT '{}',
draft_patch TEXT NOT NULL DEFAULT '{}',
target_file TEXT NOT NULL DEFAULT '',
created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '',
created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '',
reviewed_by_actor_type VARCHAR(32) NOT NULL DEFAULT '',
reviewed_by_actor_id VARCHAR(255) NOT NULL DEFAULT '',
reviewed_at TEXT,
applied_version INTEGER,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
CHECK (status IN ('pending', 'approved', 'rejected', 'applied'))
);
CREATE INDEX IF NOT EXISTS idx_skill_suggestions_skill_status_created ON skill_improvement_suggestions(skill_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_skill_suggestions_tenant_created ON skill_improvement_suggestions(tenant_id, created_at DESC);
CREATE TABLE IF NOT EXISTS skill_versions (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
skill_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
version INTEGER NOT NULL,
content_hash VARCHAR(64) NOT NULL DEFAULT '',
changed_files TEXT NOT NULL DEFAULT '[]',
created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '',
created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '',
created_from_suggestion_id TEXT REFERENCES skill_improvement_suggestions(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
UNIQUE(skill_id, version)
);
CREATE INDEX IF NOT EXISTS idx_skill_versions_tenant_skill ON skill_versions(tenant_id, skill_id, version DESC);
@@ -0,0 +1,505 @@
//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)
+1
View File
@@ -35,6 +35,7 @@ type Stores struct {
Tenants TenantStore
BuiltinToolTenantCfgs BuiltinToolTenantConfigStore
SkillTenantCfgs SkillTenantConfigStore
SkillEvolution SkillEvolutionStore
SystemConfigs SystemConfigStore
SubagentTasks SubagentTaskStore
Vault VaultStore
+1 -35
View File
@@ -558,41 +558,7 @@ func parseManagedSkillFiles(raw any) ([]managedSkillFile, error) {
}
func validateManagedSkillFilePath(rawPath string) (string, error) {
if rawPath == "" {
return "", fmt.Errorf("invalid file path %q: empty path", rawPath)
}
if strings.ContainsRune(rawPath, 0x00) {
return "", fmt.Errorf("invalid file path %q: null byte", rawPath)
}
if len(rawPath) >= 2 && rawPath[1] == ':' {
return "", fmt.Errorf("invalid file path %q: windows drive paths are not allowed", rawPath)
}
normalized := strings.ReplaceAll(rawPath, "\\", "/")
if strings.HasPrefix(normalized, "/") {
return "", fmt.Errorf("invalid file path %q: absolute paths are not allowed", rawPath)
}
for part := range strings.SplitSeq(normalized, "/") {
switch part {
case "..":
return "", fmt.Errorf("invalid file path %q: parent traversal is not allowed", rawPath)
case ".git":
return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath)
}
if strings.HasPrefix(part, ".") {
return "", fmt.Errorf("invalid file path %q: hidden files are not allowed", rawPath)
}
}
cleanPath := path.Clean(normalized)
if cleanPath == "." || cleanPath == "SKILL.md" || strings.EqualFold(cleanPath, "SKILL.md") {
return "", fmt.Errorf("invalid file path %q: SKILL.md must be provided via content or find/replace", rawPath)
}
if strings.HasPrefix(cleanPath, "../") || cleanPath == ".." || strings.HasPrefix(cleanPath, "/") {
return "", fmt.Errorf("invalid file path %q: path escapes skill root", rawPath)
}
if skills.IsSystemArtifact(cleanPath) {
return "", fmt.Errorf("invalid file path %q: system artifact paths are not allowed", rawPath)
}
return cleanPath, nil
return skills.ValidateSkillTargetPath(rawPath, false)
}
func collectExistingManagedSkillCompanionFiles(srcDir string) ([]managedSkillFile, error) {
+1 -1
View File
@@ -2,4 +2,4 @@ package upgrade
// RequiredSchemaVersion is the schema migration version this binary requires.
// Bump this whenever adding a new SQL migration file.
const RequiredSchemaVersion uint = 77
const RequiredSchemaVersion uint = 78
@@ -0,0 +1,4 @@
DROP TABLE IF EXISTS skill_versions;
DROP TABLE IF EXISTS skill_improvement_suggestions;
DROP TABLE IF EXISTS skill_usage_metrics;
DROP TABLE IF EXISTS skill_evolution_settings;
@@ -0,0 +1,89 @@
CREATE TABLE skill_evolution_settings (
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
enabled BOOLEAN NOT NULL DEFAULT false,
mode VARCHAR(32) NOT NULL DEFAULT 'suggest_only',
last_analyzed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (tenant_id, skill_id),
CONSTRAINT chk_skill_evolution_mode CHECK (mode IN ('suggest_only', 'auto_analyze'))
);
CREATE INDEX idx_skill_evolution_settings_skill ON skill_evolution_settings(skill_id);
CREATE TABLE skill_usage_metrics (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
skill_slug VARCHAR(255) NOT NULL,
skill_version INT NOT NULL DEFAULT 1,
agent_id UUID REFERENCES agents(id) ON DELETE SET NULL,
user_id VARCHAR(255),
session_key TEXT,
trace_id TEXT,
invocation_id TEXT,
invocation_source VARCHAR(32) NOT NULL DEFAULT 'runtime',
status VARCHAR(32) NOT NULL DEFAULT 'started',
failure_reason TEXT,
tool_calls_count INT NOT NULL DEFAULT 0,
duration_ms BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_skill_usage_status CHECK (status IN ('started', 'succeeded', 'failed', 'abandoned'))
);
CREATE INDEX idx_skill_usage_metrics_skill_created ON skill_usage_metrics(skill_id, created_at DESC);
CREATE INDEX idx_skill_usage_metrics_tenant_created ON skill_usage_metrics(tenant_id, created_at DESC);
CREATE INDEX idx_skill_usage_metrics_status ON skill_usage_metrics(skill_id, status, created_at DESC);
CREATE INDEX idx_skill_usage_metrics_invocation ON skill_usage_metrics(invocation_id) WHERE invocation_id IS NOT NULL;
CREATE TABLE skill_improvement_suggestions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
skill_slug VARCHAR(255) NOT NULL,
suggestion_type VARCHAR(64) NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
reason TEXT NOT NULL DEFAULT '',
evidence JSONB NOT NULL DEFAULT '{}',
draft_patch JSONB NOT NULL DEFAULT '{}',
target_file TEXT NOT NULL DEFAULT '',
created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '',
created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '',
reviewed_by_actor_type VARCHAR(32) NOT NULL DEFAULT '',
reviewed_by_actor_id VARCHAR(255) NOT NULL DEFAULT '',
reviewed_at TIMESTAMPTZ,
applied_version INT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT chk_skill_suggestion_status CHECK (status IN ('pending', 'approved', 'rejected', 'applied'))
);
CREATE INDEX idx_skill_suggestions_skill_status_created ON skill_improvement_suggestions(skill_id, status, created_at DESC);
CREATE INDEX idx_skill_suggestions_tenant_created ON skill_improvement_suggestions(tenant_id, created_at DESC);
CREATE TABLE skill_versions (
id UUID PRIMARY KEY DEFAULT uuid_generate_v7(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
skill_id UUID NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
version INT NOT NULL,
content_hash VARCHAR(64) NOT NULL DEFAULT '',
changed_files JSONB NOT NULL DEFAULT '[]',
created_by_actor_type VARCHAR(32) NOT NULL DEFAULT '',
created_by_actor_id VARCHAR(255) NOT NULL DEFAULT '',
created_from_suggestion_id UUID REFERENCES skill_improvement_suggestions(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(skill_id, version)
);
CREATE INDEX idx_skill_versions_tenant_skill ON skill_versions(tenant_id, skill_id, version DESC);
INSERT INTO skill_versions (
tenant_id, skill_id, version, content_hash, changed_files,
created_by_actor_type, created_by_actor_id, created_at
)
SELECT tenant_id, id, version, COALESCE(file_hash, ''), '[]'::jsonb,
'system', 'migration', COALESCE(created_at, NOW())
FROM skills
WHERE status != 'deleted'
ON CONFLICT (skill_id, version) DO NOTHING;
+25
View File
@@ -227,6 +227,31 @@
"copySuccess": "Deeplink copied",
"copyFailed": "Failed to copy deeplink"
},
"evolution": {
"tab": "Evolution",
"title": "Self-evolution",
"enabled": "Enabled",
"disabled": "Disabled",
"status": "Status: {{status}}",
"unavailable": "Evolution data is unavailable for this skill.",
"modes": {
"suggest_only": "Suggest only",
"auto_analyze": "Auto analyze"
},
"metrics": "Metrics",
"total": "Total",
"succeeded": "Succeeded",
"failed": "Failed",
"successRate": "Success rate",
"suggestions": "Suggestions",
"noSuggestions": "No suggestions.",
"noReason": "No reason recorded.",
"approve": "Approve",
"reject": "Reject",
"apply": "Apply",
"activity": "Activity",
"noActivity": "No recent activity."
},
"toast": {
"updated": "Skill updated",
"updateFailed": "Failed to update skill",
+25
View File
@@ -177,6 +177,31 @@
"copySuccess": "Đã copy deeplink",
"copyFailed": "Không thể copy deeplink"
},
"evolution": {
"tab": "Tự tiến hóa",
"title": "Tự tiến hóa",
"enabled": "Bật",
"disabled": "Tắt",
"status": "Trạng thái: {{status}}",
"unavailable": "Không có dữ liệu tiến hóa cho skill này.",
"modes": {
"suggest_only": "Chỉ đề xuất",
"auto_analyze": "Tự phân tích"
},
"metrics": "Chỉ số",
"total": "Tổng",
"succeeded": "Thành công",
"failed": "Thất bại",
"successRate": "Tỉ lệ thành công",
"suggestions": "Đề xuất",
"noSuggestions": "Chưa có đề xuất.",
"noReason": "Chưa ghi lý do.",
"approve": "Duyệt",
"reject": "Từ chối",
"apply": "Áp dụng",
"activity": "Hoạt động",
"noActivity": "Chưa có hoạt động gần đây."
},
"toast": {
"updated": "Đã cập nhật skill",
"updateFailed": "Không thể cập nhật skill",
+25
View File
@@ -177,6 +177,31 @@
"copySuccess": "已复制 Deeplink",
"copyFailed": "复制 Deeplink 失败"
},
"evolution": {
"tab": "进化",
"title": "自进化",
"enabled": "启用",
"disabled": "禁用",
"status": "状态:{{status}}",
"unavailable": "此 Skill 没有可用的进化数据。",
"modes": {
"suggest_only": "仅建议",
"auto_analyze": "自动分析"
},
"metrics": "指标",
"total": "总计",
"succeeded": "成功",
"failed": "失败",
"successRate": "成功率",
"suggestions": "建议",
"noSuggestions": "暂无建议。",
"noReason": "未记录原因。",
"approve": "批准",
"reject": "拒绝",
"apply": "应用",
"activity": "活动",
"noActivity": "暂无最近活动。"
},
"toast": {
"updated": "技能已更新",
"updateFailed": "更新技能失败",
+4
View File
@@ -57,6 +57,10 @@ export const queryKeys = {
skills: {
all: ["skills"] as const,
agentGrants: (agentId: string) => ["skills", "agent", agentId] as const,
evolution: (skillId: string) => ["skills", skillId, "evolution"] as const,
metrics: (skillId: string) => ["skills", skillId, "metrics"] as const,
suggestions: (skillId: string) => ["skills", skillId, "suggestions"] as const,
activity: (skillId: string) => ["skills", skillId, "activity"] as const,
runtimes: ["skills", "runtimes"] as const,
},
cron: {
@@ -0,0 +1,91 @@
import { useCallback } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useHttp } from "@/hooks/use-ws";
import { queryKeys } from "@/lib/query-keys";
import type {
SkillActivityLog,
SkillEvolutionSettings,
SkillImprovementSuggestion,
SkillUsageStats,
} from "@/types/skill";
export function useSkillEvolution(skillId: string | undefined, enabled: boolean) {
const http = useHttp();
const queryClient = useQueryClient();
const settings = useQuery({
queryKey: skillId ? queryKeys.skills.evolution(skillId) : ["skills", "missing", "evolution"],
queryFn: () => http.get<SkillEvolutionSettings>(`/v1/skills/${skillId}/evolution`),
enabled: enabled && !!skillId,
});
const metrics = useQuery({
queryKey: skillId ? queryKeys.skills.metrics(skillId) : ["skills", "missing", "metrics"],
queryFn: () => http.get<SkillUsageStats>(`/v1/skills/${skillId}/metrics`),
enabled: enabled && !!skillId,
});
const suggestions = useQuery({
queryKey: skillId ? queryKeys.skills.suggestions(skillId) : ["skills", "missing", "suggestions"],
queryFn: async () => {
const res = await http.get<{ suggestions: SkillImprovementSuggestion[] }>(`/v1/skills/${skillId}/evolution/suggestions`);
return res.suggestions ?? [];
},
enabled: enabled && !!skillId,
});
const activity = useQuery({
queryKey: skillId ? queryKeys.skills.activity(skillId) : ["skills", "missing", "activity"],
queryFn: async () => {
const res = await http.get<{ activity: SkillActivityLog[] }>(`/v1/skills/${skillId}/activity`);
return res.activity ?? [];
},
enabled: enabled && !!skillId,
retry: false,
});
const invalidate = useCallback(async () => {
if (!skillId) return;
await Promise.all([
queryClient.invalidateQueries({ queryKey: queryKeys.skills.evolution(skillId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.skills.metrics(skillId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.skills.suggestions(skillId) }),
queryClient.invalidateQueries({ queryKey: queryKeys.skills.activity(skillId) }),
]);
}, [queryClient, skillId]);
const updateSettings = useCallback(async (updates: Partial<Pick<SkillEvolutionSettings, "enabled" | "mode">>) => {
if (!skillId) return;
await http.patch<SkillEvolutionSettings>(`/v1/skills/${skillId}/evolution`, updates);
await invalidate();
}, [http, invalidate, skillId]);
const approveSuggestion = useCallback(async (id: string) => {
if (!skillId) return;
await http.post(`/v1/skills/${skillId}/evolution/suggestions/${id}/approve`, {});
await invalidate();
}, [http, invalidate, skillId]);
const rejectSuggestion = useCallback(async (id: string) => {
if (!skillId) return;
await http.post(`/v1/skills/${skillId}/evolution/suggestions/${id}/reject`, {});
await invalidate();
}, [http, invalidate, skillId]);
const applySuggestion = useCallback(async (id: string) => {
if (!skillId) return;
await http.post(`/v1/skills/${skillId}/evolution/suggestions/${id}/apply`, {});
await invalidate();
}, [http, invalidate, skillId]);
return {
settings,
metrics,
suggestions,
activity,
updateSettings,
approveSuggestion,
rejectSuggestion,
applySuggestion,
};
}
@@ -1,29 +1,20 @@
import { describe, expect, it } from "vitest";
import {
parseSkillDetailVersionParam,
shouldLoadSkillDetailFile,
} from "./skill-detail-deeplink";
import { normalizeSkillDetailTab, parseSkillDetailVersionParam } from "./skill-detail-deeplink";
describe("skill detail deeplink helpers", () => {
it("parses valid version params", () => {
expect(parseSkillDetailVersionParam("1")).toBe(1);
expect(parseSkillDetailVersionParam("42")).toBe(42);
describe("skill detail deeplink", () => {
it("keeps evolution tab when the skill has an id", () => {
expect(normalizeSkillDetailTab("evolution", true, true)).toBe("evolution");
});
it("rejects malformed version params", () => {
expect(parseSkillDetailVersionParam(null)).toBeNull();
expect(parseSkillDetailVersionParam("")).toBeNull();
expect(parseSkillDetailVersionParam("abc")).toBeNull();
expect(parseSkillDetailVersionParam("1.5")).toBeNull();
it("falls back from unavailable tabs to content", () => {
expect(normalizeSkillDetailTab("files", false, true)).toBe("content");
expect(normalizeSkillDetailTab("evolution", true, false)).toBe("content");
expect(normalizeSkillDetailTab("unknown", true, true)).toBe("content");
});
it("parses positive integer versions only", () => {
expect(parseSkillDetailVersionParam("4")).toBe(4);
expect(parseSkillDetailVersionParam("0")).toBeNull();
expect(parseSkillDetailVersionParam("-1")).toBeNull();
});
it("loads a deeplinked file only from the files tab when a file list exists", () => {
expect(shouldLoadSkillDetailFile("files", "scripts/run.py", 3, null)).toBe(true);
expect(shouldLoadSkillDetailFile("content", "scripts/run.py", 3, null)).toBe(false);
expect(shouldLoadSkillDetailFile("files", null, 3, null)).toBe(false);
expect(shouldLoadSkillDetailFile("files", "scripts/run.py", 0, null)).toBe(false);
expect(shouldLoadSkillDetailFile("files", "scripts/run.py", 3, "scripts/run.py")).toBe(false);
expect(parseSkillDetailVersionParam("bad")).toBeNull();
});
});
@@ -5,6 +5,12 @@ export function parseSkillDetailVersionParam(value: string | null): number | nul
return parsed;
}
export function normalizeSkillDetailTab(value: string, hasFiles: boolean, hasEvolution: boolean): "content" | "files" | "evolution" {
if (value === "files" && hasFiles) return "files";
if (value === "evolution" && hasEvolution) return "evolution";
return "content";
}
export function shouldLoadSkillDetailFile(
detailTab: string,
selectedFilePath: string | null,
@@ -18,14 +18,14 @@ describe("skills page state", () => {
});
it("serializes non-default filters while preserving modal params", () => {
const params = new URLSearchParams("skill=abc&detailTab=files&file=SKILL.md");
const params = new URLSearchParams("skill=abc&detailTab=evolution&file=SKILL.md");
const next = serializeSkillsPageState(params, { tab: "custom", q: "pdf", filter: "missing-deps", sort: "deps" });
expect(next.get("tab")).toBe("custom");
expect(next.get("q")).toBe("pdf");
expect(next.get("filter")).toBe("missing-deps");
expect(next.get("sort")).toBe("deps");
expect(next.get("skill")).toBe("abc");
expect(next.get("detailTab")).toBe("files");
expect(next.get("detailTab")).toBe("evolution");
expect(next.get("file")).toBe("SKILL.md");
});
+20 -10
View File
@@ -23,9 +23,10 @@ import { toast } from "@/stores/use-toast-store";
import type { SkillInfo, SkillFile, SkillVersions } from "@/types/skill";
import { buildTree } from "./skill-file-helpers";
import { FileBrowser } from "./skill-file-browser";
import { parseSkillDetailVersionParam, shouldLoadSkillDetailFile } from "./lib/skill-detail-deeplink";
import { normalizeSkillDetailTab, parseSkillDetailVersionParam, shouldLoadSkillDetailFile } from "./lib/skill-detail-deeplink";
import { getSkillAccessModeKey } from "./lib/skill-access-mode";
import type { SkillExportFormat } from "./lib/skill-export-download";
import { SkillEvolutionPanel } from "./skill-evolution-panel";
interface SkillDetailDialogProps {
skill: SkillInfo & { content: string };
@@ -60,6 +61,8 @@ export function SkillDetailDialog({
}: SkillDetailDialogProps) {
const { t } = useTranslation("skills");
const hasFiles = !!skill.id;
const hasEvolution = !!skill.id;
const activeDetailTab = normalizeSkillDetailTab(detailTab, hasFiles, hasEvolution);
const accessModeKey = getSkillAccessModeKey(skill.visibility);
const accessModeLabel = accessModeKey === "unknown"
? t("accessMode.unknown", { value: skill.visibility || t("unknownOwner") })
@@ -137,7 +140,7 @@ export function SkillDetailDialog({
}, [selectedVersion, loadFiles]);
useEffect(() => {
if (detailTab !== "files" || !hasFiles) return;
if (activeDetailTab !== "files" || !hasFiles) return;
loadVersions();
const versionParam = parseSkillDetailVersionParam(selectedVersionParam);
if (versionParam !== null && versionParam !== selectedVersion) {
@@ -147,15 +150,15 @@ export function SkillDetailDialog({
if (selectedVersion == null && skill.version) {
setSelectedVersion(skill.version);
}
}, [detailTab, hasFiles, loadVersions, selectedVersion, selectedVersionParam, skill.version]);
}, [activeDetailTab, hasFiles, loadVersions, selectedVersion, selectedVersionParam, skill.version]);
useEffect(() => {
if (!shouldLoadSkillDetailFile(detailTab, selectedFilePath, files.length, activePath)) return;
if (!shouldLoadSkillDetailFile(activeDetailTab, selectedFilePath, files.length, activePath)) return;
loadFileContent(selectedFilePath);
}, [activePath, detailTab, files.length, loadFileContent, selectedFilePath]);
}, [activePath, activeDetailTab, files.length, loadFileContent, selectedFilePath]);
const handleTabChange = (tab: string) => {
onStateChange({ detailTab: tab });
onStateChange({ detailTab: tab, version: tab === "files" ? selectedVersionParam : null, file: tab === "files" ? activePath : null });
if (tab === "files" && hasFiles) {
loadVersions();
if (files.length === 0 && !filesLoading) {
@@ -184,10 +187,10 @@ export function SkillDetailDialog({
url.pathname = "/skills";
const next = new URLSearchParams(url.search);
next.set("skill", skill.id || skill.slug || skill.name);
next.set("detailTab", detailTab === "files" ? "files" : "content");
if (detailTab === "files" && selectedVersion != null) next.set("version", String(selectedVersion));
next.set("detailTab", activeDetailTab);
if (activeDetailTab === "files" && selectedVersion != null) next.set("version", String(selectedVersion));
else next.delete("version");
if (detailTab === "files" && activePath) next.set("file", activePath);
if (activeDetailTab === "files" && activePath) next.set("file", activePath);
else next.delete("file");
url.search = next.toString();
@@ -290,10 +293,11 @@ export function SkillDetailDialog({
)}
</DialogHeader>
<Tabs value={detailTab === "files" && hasFiles ? "files" : "content"} className="flex-1 overflow-hidden flex flex-col" onValueChange={handleTabChange}>
<Tabs value={activeDetailTab} className="flex-1 overflow-hidden flex flex-col" onValueChange={handleTabChange}>
<TabsList>
<TabsTrigger value="content">{t("detail.content")}</TabsTrigger>
{hasFiles && <TabsTrigger value="files">{t("detail.files")}</TabsTrigger>}
{hasEvolution && <TabsTrigger value="evolution">{t("evolution.tab")}</TabsTrigger>}
</TabsList>
<TabsContent value="content" className="flex-1 overflow-y-auto mt-2 -mx-4 px-4 sm:-mx-6 sm:px-6">
@@ -331,6 +335,12 @@ export function SkillDetailDialog({
/>
</TabsContent>
)}
{hasEvolution && (
<TabsContent value="evolution" className="flex-1 overflow-y-auto mt-2 -mx-4 px-4 sm:-mx-6 sm:px-6">
<SkillEvolutionPanel skill={skill} active={activeDetailTab === "evolution"} />
</TabsContent>
)}
</Tabs>
</DialogContent>
</Dialog>
@@ -0,0 +1,170 @@
import { useTranslation } from "react-i18next";
import { Check, X, Play, Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { useSkillEvolution } from "./hooks/use-skill-evolution";
import type { SkillInfo } from "@/types/skill";
interface SkillEvolutionPanelProps {
skill: SkillInfo;
active: boolean;
}
export function SkillEvolutionPanel({ skill, active }: SkillEvolutionPanelProps) {
const { t } = useTranslation("skills");
const {
settings,
metrics,
suggestions,
activity,
updateSettings,
approveSuggestion,
rejectSuggestion,
applySuggestion,
} = useSkillEvolution(skill.id, active);
if (!skill.id) {
return (
<div className="rounded-md border border-dashed bg-muted/20 p-6 text-sm text-muted-foreground">
{t("evolution.unavailable")}
</div>
);
}
const data = settings.data;
const stats = metrics.data;
const pending = suggestions.data?.filter((item) => item.status === "pending") ?? [];
const isBusy = settings.isFetching || metrics.isFetching || suggestions.isFetching;
return (
<div className="space-y-4">
<section className="rounded-md border p-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="space-y-1">
<div className="flex items-center gap-2">
<h3 className="text-sm font-medium">{t("evolution.title")}</h3>
{isBusy && <Loader2 className="h-3.5 w-3.5 animate-spin text-muted-foreground" />}
</div>
<p className="text-sm text-muted-foreground">{t("evolution.status", { status: data?.enabled ? t("evolution.enabled") : t("evolution.disabled") })}</p>
</div>
<div className="flex flex-wrap items-center gap-3">
<div className="flex items-center gap-2">
<Label htmlFor="skill-evolution-enabled" className="text-sm">{t("evolution.enabled")}</Label>
<Switch
id="skill-evolution-enabled"
checked={!!data?.enabled}
disabled={settings.isLoading}
onCheckedChange={(enabled) => updateSettings({ enabled })}
/>
</div>
<Select
value={data?.mode ?? "suggest_only"}
onValueChange={(mode) => updateSettings({ mode: mode as "suggest_only" | "auto_analyze" })}
>
<SelectTrigger className="h-9 w-[180px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="suggest_only">{t("evolution.modes.suggest_only")}</SelectItem>
<SelectItem value="auto_analyze">{t("evolution.modes.auto_analyze")}</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</section>
<section className="rounded-md border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium">{t("evolution.metrics")}</h3>
{stats?.last_used_at && <span className="text-xs text-muted-foreground">{new Date(stats.last_used_at).toLocaleString()}</span>}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<Metric label={t("evolution.total")} value={stats?.total_calls ?? 0} />
<Metric label={t("evolution.succeeded")} value={stats?.succeeded ?? 0} />
<Metric label={t("evolution.failed")} value={stats?.failed ?? 0} />
<Metric label={t("evolution.successRate")} value={`${Math.round((stats?.success_rate ?? 0) * 100)}%`} />
</div>
</section>
<section className="rounded-md border p-4">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-sm font-medium">{t("evolution.suggestions")}</h3>
<Badge variant={pending.length > 0 ? "default" : "outline"}>{pending.length}</Badge>
</div>
<div className="space-y-2">
{(suggestions.data ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">{t("evolution.noSuggestions")}</p>
) : (
suggestions.data?.map((item) => (
<div key={item.id} className="rounded-md border bg-muted/20 p-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline">{item.status}</Badge>
<span className="text-sm font-medium">{item.suggestion_type}</span>
{item.target_file && <span className="text-xs text-muted-foreground">{item.target_file}</span>}
</div>
<p className="text-sm text-muted-foreground">{item.reason || t("evolution.noReason")}</p>
</div>
<div className="flex shrink-0 gap-1">
{item.status === "pending" && (
<>
<Button type="button" variant="outline" size="icon" className="h-8 w-8" onClick={() => approveSuggestion(item.id)} aria-label={t("evolution.approve")}>
<Check className="h-3.5 w-3.5" />
</Button>
<Button type="button" variant="outline" size="icon" className="h-8 w-8" onClick={() => rejectSuggestion(item.id)} aria-label={t("evolution.reject")}>
<X className="h-3.5 w-3.5" />
</Button>
</>
)}
{item.status === "approved" && !skill.is_system && (
<Button type="button" variant="outline" size="icon" className="h-8 w-8" onClick={() => applySuggestion(item.id)} aria-label={t("evolution.apply")}>
<Play className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</div>
))
)}
</div>
</section>
{!activity.isError && (
<section className="rounded-md border p-4">
<h3 className="mb-3 text-sm font-medium">{t("evolution.activity")}</h3>
<div className="space-y-2">
{(activity.data ?? []).length === 0 ? (
<p className="text-sm text-muted-foreground">{t("evolution.noActivity")}</p>
) : (
activity.data?.slice(0, 8).map((item) => (
<div key={item.id} className="flex flex-col gap-1 rounded-md bg-muted/30 px-3 py-2 sm:flex-row sm:items-center sm:justify-between">
<span className="text-sm">{item.action}</span>
<span className="text-xs text-muted-foreground">{new Date(item.created_at).toLocaleString()}</span>
</div>
))
)}
</div>
</section>
)}
</div>
);
}
function Metric({ label, value }: { label: string; value: number | string }) {
return (
<div className="rounded-md bg-muted/30 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="text-lg font-semibold">{value}</div>
</div>
);
}
+51
View File
@@ -56,3 +56,54 @@ export interface SkillAgentGrant {
granted_by: string;
can_manage: boolean;
}
export interface SkillEvolutionSettings {
tenant_id: string;
skill_id: string;
enabled: boolean;
mode: "suggest_only" | "auto_analyze";
last_analyzed_at?: string;
}
export interface SkillFailureReason {
reason: string;
count: number;
last_seen: string;
}
export interface SkillUsageStats {
skill_id: string;
total_calls: number;
started: number;
succeeded: number;
failed: number;
abandoned: number;
success_rate: number;
failure_rate: number;
last_used_at?: string;
top_failure_reasons?: SkillFailureReason[];
}
export interface SkillImprovementSuggestion {
id: string;
skill_id: string;
skill_slug: string;
suggestion_type: string;
status: "pending" | "approved" | "rejected" | "applied";
reason: string;
target_file?: string;
applied_version?: number;
created_at: string;
updated_at: string;
}
export interface SkillActivityLog {
id: string;
actor_type: string;
actor_id: string;
action: string;
entity_type?: string;
entity_id?: string;
details?: unknown;
created_at: string;
}