mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-11 12:18:59 +00:00
feat(skills): add skill self-evolution metrics
This commit is contained in:
1 parent
95fd1f8f2d
commit
f45bfa860c
41 files changed
+3008
-79
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
@@ -35,6 +35,7 @@ type Stores struct {
|
||||
Tenants TenantStore
|
||||
BuiltinToolTenantCfgs BuiltinToolTenantConfigStore
|
||||
SkillTenantCfgs SkillTenantConfigStore
|
||||
SkillEvolution SkillEvolutionStore
|
||||
SystemConfigs SystemConfigStore
|
||||
SubagentTasks SubagentTaskStore
|
||||
Vault VaultStore
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "更新技能失败",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user