mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 22:17:36 +00:00
1 parent
328cbf3d61
commit
1858cebe9c
28 files changed
+1604
-200
No files matched your search
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -71,6 +72,12 @@ func (m *SkillsMethods) handleList(ctx context.Context, client *gateway.Client,
|
||||
if s.Author != "" {
|
||||
entry["author"] = s.Author
|
||||
}
|
||||
if s.CreatorAgent != nil {
|
||||
entry["creator_agent"] = s.CreatorAgent
|
||||
}
|
||||
if len(s.ManagerAgents) > 0 {
|
||||
entry["manager_agents"] = s.ManagerAgents
|
||||
}
|
||||
if len(s.MissingDeps) > 0 {
|
||||
entry["missing_deps"] = s.MissingDeps
|
||||
}
|
||||
@@ -130,7 +137,12 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r
|
||||
return
|
||||
}
|
||||
|
||||
content, _ := m.store.LoadSkill(ctx, params.Name)
|
||||
content, ok := m.store.LoadSkill(ctx, info.Slug)
|
||||
if !ok && info.Path != "" {
|
||||
if b, err := os.ReadFile(info.Path); err == nil {
|
||||
content = string(b)
|
||||
}
|
||||
}
|
||||
|
||||
resp := map[string]any{
|
||||
"name": info.Name,
|
||||
@@ -139,6 +151,7 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r
|
||||
"source": info.Source,
|
||||
"content": content,
|
||||
"version": info.Version,
|
||||
"enabled": info.Enabled,
|
||||
}
|
||||
if info.ID != "" {
|
||||
resp["id"] = info.ID
|
||||
@@ -149,6 +162,21 @@ func (m *SkillsMethods) handleGet(ctx context.Context, client *gateway.Client, r
|
||||
if len(info.Tags) > 0 {
|
||||
resp["tags"] = info.Tags
|
||||
}
|
||||
if info.Status != "" {
|
||||
resp["status"] = info.Status
|
||||
}
|
||||
if info.Author != "" {
|
||||
resp["author"] = info.Author
|
||||
}
|
||||
if info.CreatorAgent != nil {
|
||||
resp["creator_agent"] = info.CreatorAgent
|
||||
}
|
||||
if len(info.ManagerAgents) > 0 {
|
||||
resp["manager_agents"] = info.ManagerAgents
|
||||
}
|
||||
if len(info.MissingDeps) > 0 {
|
||||
resp["missing_deps"] = info.MissingDeps
|
||||
}
|
||||
client.SendResponse(protocol.NewOKResponse(req.ID, resp))
|
||||
}
|
||||
|
||||
|
||||
+89
-66
@@ -26,6 +26,7 @@ const maxSkillUploadSize = 20 << 20 // 20 MB
|
||||
var (
|
||||
aggregateInstallDeps = skills.AggregateMissingDeps
|
||||
installManagedDeps = skills.InstallDeps
|
||||
installSingleDep = skills.InstallSingleDep
|
||||
)
|
||||
|
||||
// SkillsHandler handles skill management HTTP endpoints.
|
||||
@@ -260,7 +261,7 @@ func (h *SkillsHandler) handleDelete(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "true"})
|
||||
}
|
||||
|
||||
// handleInstallDeps installs missing dependencies for all system skills, then re-checks status.
|
||||
// handleInstallDeps installs missing dependencies for all enabled skills, then re-checks status.
|
||||
func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request) {
|
||||
if !h.requireMasterTenant(w, r) {
|
||||
return
|
||||
@@ -269,15 +270,23 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request
|
||||
// consistent with rescanAndUpdate() pattern.
|
||||
masterCtx := store.WithTenantID(r.Context(), store.MasterTenantID)
|
||||
|
||||
dirs := h.skills.ListSystemSkillDirs(masterCtx)
|
||||
dirs := h.installableSkillDirs(masterCtx)
|
||||
if len(dirs) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "no system skills"})
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "no skills"})
|
||||
return
|
||||
}
|
||||
|
||||
manifest, missing := aggregateInstallDeps(dirs)
|
||||
if len(missing) == 0 {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "all deps satisfied"})
|
||||
updated, results := h.rescanAndUpdate(masterCtx)
|
||||
if updated > 0 {
|
||||
h.emitCacheInvalidate(bus.CacheKindSkills, "", uuid.Nil)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"message": "all deps satisfied",
|
||||
"updated": updated,
|
||||
"results": results,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -294,58 +303,22 @@ func (h *SkillsHandler) handleInstallDeps(w http.ResponseWriter, r *http.Request
|
||||
return
|
||||
}
|
||||
|
||||
// Re-check all system skills, persist missing deps, and update status.
|
||||
allSkills := h.skills.ListAllSkills(masterCtx)
|
||||
statusChanged := false
|
||||
for _, sk := range allSkills {
|
||||
if !sk.IsSystem {
|
||||
continue
|
||||
}
|
||||
if _, exists := dirs[sk.Slug]; !exists {
|
||||
continue
|
||||
}
|
||||
m := h.scanWithFallback(sk)
|
||||
if m == nil || m.IsEmpty() {
|
||||
continue
|
||||
}
|
||||
ok, miss := skills.CheckSkillDeps(m)
|
||||
id, err := uuid.Parse(sk.ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Persist actual missing deps to DB so reload reflects reality.
|
||||
_ = h.skills.StoreMissingDeps(masterCtx, id, miss)
|
||||
|
||||
// Update status in both directions.
|
||||
switch {
|
||||
case ok && sk.Status == "archived":
|
||||
_ = h.skills.UpdateSkill(masterCtx, id, map[string]any{"status": "active"})
|
||||
statusChanged = true
|
||||
case !ok && sk.Status != "archived":
|
||||
_ = h.skills.UpdateSkill(masterCtx, id, map[string]any{"status": "archived"})
|
||||
statusChanged = true
|
||||
}
|
||||
|
||||
status := "active"
|
||||
if !ok {
|
||||
status = "archived"
|
||||
}
|
||||
updated, results := h.rescanAndUpdate(masterCtx)
|
||||
if updated > 0 {
|
||||
h.emitCacheInvalidate(bus.CacheKindSkills, "", uuid.Nil)
|
||||
}
|
||||
for _, depResult := range results {
|
||||
if h.msgBus != nil {
|
||||
h.msgBus.Broadcast(bus.Event{
|
||||
Name: protocol.EventSkillDepsChecked,
|
||||
Payload: map[string]any{
|
||||
"slug": sk.Slug,
|
||||
"status": status,
|
||||
"missing": miss,
|
||||
"slug": depResult.Slug,
|
||||
"status": depResult.Status,
|
||||
"missing": depResult.Missing,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
if statusChanged {
|
||||
h.skills.BumpVersion()
|
||||
h.emitCacheInvalidate(bus.CacheKindSkills, "", uuid.Nil)
|
||||
}
|
||||
|
||||
if h.msgBus != nil {
|
||||
h.msgBus.Broadcast(bus.Event{
|
||||
@@ -382,7 +355,7 @@ func (h *SkillsHandler) handleInstallDep(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
ok, errMsg := skills.InstallSingleDep(r.Context(), body.Dep)
|
||||
ok, errMsg := installSingleDep(r.Context(), body.Dep)
|
||||
|
||||
if h.msgBus != nil {
|
||||
payload := map[string]any{"dep": body.Dep, "ok": ok}
|
||||
@@ -395,8 +368,9 @@ func (h *SkillsHandler) handleInstallDep(w http.ResponseWriter, r *http.Request)
|
||||
})
|
||||
}
|
||||
|
||||
if ok {
|
||||
h.rescanAndUpdate()
|
||||
updated, _ := h.rescanAndUpdate(store.WithTenantID(r.Context(), store.MasterTenantID))
|
||||
if updated > 0 {
|
||||
h.emitCacheInvalidate(bus.CacheKindSkills, "", uuid.Nil)
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": ok, "error": errMsg})
|
||||
@@ -408,14 +382,28 @@ type depResult struct {
|
||||
Missing []string `json:"missing,omitempty"`
|
||||
}
|
||||
|
||||
// rescanAndUpdate re-checks system skills and updates their status + missing deps in DB.
|
||||
// Only system skills have filesystem dependencies that need rescanning.
|
||||
func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) {
|
||||
masterCtx := store.WithTenantID(context.Background(), store.MasterTenantID)
|
||||
allSkills := h.skills.ListAllSystemSkills(context.Background())
|
||||
func (h *SkillsHandler) installableSkillDirs(ctx context.Context) map[string]string {
|
||||
dirs := make(map[string]string)
|
||||
for _, sk := range h.skills.ListAllSkills(store.WithCrossTenant(ctx)) {
|
||||
if !sk.Enabled || sk.BaseDir == "" {
|
||||
continue
|
||||
}
|
||||
key := sk.ID
|
||||
if key == "" {
|
||||
key = sk.Slug
|
||||
}
|
||||
dirs[key] = sk.BaseDir
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// rescanAndUpdate re-checks enabled skills and updates their status + missing deps in DB.
|
||||
func (h *SkillsHandler) rescanAndUpdate(ctx context.Context) (updated int, results []depResult) {
|
||||
allSkills := h.skills.ListAllSkills(store.WithCrossTenant(ctx))
|
||||
|
||||
for _, sk := range allSkills {
|
||||
manifest := h.scanWithFallback(sk)
|
||||
updateCtx := skillTenantContext(ctx, sk)
|
||||
|
||||
id, err := uuid.Parse(sk.ID)
|
||||
if err != nil {
|
||||
@@ -423,36 +411,50 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) {
|
||||
}
|
||||
|
||||
if manifest == nil || manifest.IsEmpty() {
|
||||
// No deps needed — if archived, recover to active and clear stale deps.
|
||||
changed := false
|
||||
// No deps needed — recover archived skills and clear stale persisted deps.
|
||||
if len(sk.MissingDeps) > 0 {
|
||||
_ = h.skills.StoreMissingDeps(updateCtx, id, nil)
|
||||
changed = true
|
||||
}
|
||||
if sk.Status == "archived" {
|
||||
_ = h.skills.StoreMissingDeps(masterCtx, id, nil)
|
||||
_ = h.skills.UpdateSkill(masterCtx, id, map[string]any{"status": "active"})
|
||||
_ = h.skills.UpdateSkill(updateCtx, id, map[string]any{"status": "active"})
|
||||
results = append(results, depResult{Slug: sk.Slug, Status: "active"})
|
||||
updated++
|
||||
changed = true
|
||||
slog.Debug("rescan: recovered archived skill (no deps)", "slug", sk.Slug)
|
||||
} else {
|
||||
results = append(results, depResult{Slug: sk.Slug, Status: "ok"})
|
||||
}
|
||||
if changed {
|
||||
updated++
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
ok, missing := skills.CheckSkillDeps(manifest)
|
||||
_ = h.skills.StoreMissingDeps(masterCtx, id, missing)
|
||||
changed := false
|
||||
if !stringSlicesEqual(sk.MissingDeps, missing) {
|
||||
_ = h.skills.StoreMissingDeps(updateCtx, id, missing)
|
||||
changed = true
|
||||
}
|
||||
|
||||
switch {
|
||||
case ok && sk.Status == "archived":
|
||||
_ = h.skills.UpdateSkill(masterCtx, id, map[string]any{"status": "active"})
|
||||
_ = h.skills.UpdateSkill(updateCtx, id, map[string]any{"status": "active"})
|
||||
results = append(results, depResult{Slug: sk.Slug, Status: "active"})
|
||||
updated++
|
||||
changed = true
|
||||
case !ok && sk.Status == "active":
|
||||
_ = h.skills.UpdateSkill(masterCtx, id, map[string]any{"status": "archived"})
|
||||
_ = h.skills.UpdateSkill(updateCtx, id, map[string]any{"status": "archived"})
|
||||
results = append(results, depResult{Slug: sk.Slug, Status: "archived", Missing: missing})
|
||||
updated++
|
||||
changed = true
|
||||
case !ok:
|
||||
results = append(results, depResult{Slug: sk.Slug, Status: sk.Status, Missing: missing})
|
||||
default:
|
||||
results = append(results, depResult{Slug: sk.Slug, Status: "ok"})
|
||||
}
|
||||
if changed {
|
||||
updated++
|
||||
}
|
||||
|
||||
slog.Debug("rescan: checked skill", "slug", sk.Slug, "ok", ok, "missing", len(missing))
|
||||
}
|
||||
@@ -463,6 +465,27 @@ func (h *SkillsHandler) rescanAndUpdate() (updated int, results []depResult) {
|
||||
return updated, results
|
||||
}
|
||||
|
||||
func skillTenantContext(ctx context.Context, sk store.SkillInfo) context.Context {
|
||||
if sk.TenantID != "" {
|
||||
if tid, err := uuid.Parse(sk.TenantID); err == nil && tid != uuid.Nil {
|
||||
return store.WithTenantID(ctx, tid)
|
||||
}
|
||||
}
|
||||
return store.WithTenantID(ctx, store.MasterTenantID)
|
||||
}
|
||||
|
||||
func stringSlicesEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// scanWithFallback scans skill deps from the managed dir, falling back to the
|
||||
// bundled dir if the managed copy's scripts/ directory is missing or empty.
|
||||
// If a fallback scan succeeds, re-copies the bundled scripts to the managed dir.
|
||||
@@ -506,7 +529,7 @@ func (h *SkillsHandler) handleRescanDeps(w http.ResponseWriter, r *http.Request)
|
||||
if !h.requireMasterTenant(w, r) {
|
||||
return
|
||||
}
|
||||
updated, results := h.rescanAndUpdate()
|
||||
updated, results := h.rescanAndUpdate(store.WithTenantID(r.Context(), store.MasterTenantID))
|
||||
if updated > 0 {
|
||||
// rescanAndUpdate bumped the skills version already; emit a global
|
||||
// invalidate so cached agent Loops pick up the new status set.
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
@@ -23,7 +24,10 @@ import (
|
||||
"github.com/nextlevelbuilder/goclaw/pkg/protocol"
|
||||
)
|
||||
|
||||
const uploadDepsInstallTimeout = 5 * time.Minute
|
||||
const (
|
||||
uploadDepsInstallTimeout = 5 * time.Minute
|
||||
maxUploadManagerAgentIDs = 100
|
||||
)
|
||||
|
||||
var (
|
||||
installUploadedSkillDeps = skills.InstallDeps
|
||||
@@ -48,6 +52,12 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
managerAgentIDs, err := parseUploadManagerAgentIDs(r)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidRequest, err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
// Save to temp file for zip processing
|
||||
tmp, err := os.CreateTemp("", "skill-upload-*.zip")
|
||||
if err != nil {
|
||||
@@ -164,12 +174,23 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
// could both pass the hash check before either creates a new version.
|
||||
existingHash, existingVer, skillExists := h.skills.GetSkillHashBySlug(r.Context(), slug)
|
||||
if skillExists && existingHash != "" && existingHash == skillHash {
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
response := map[string]any{
|
||||
"slug": slug,
|
||||
"version": existingVer,
|
||||
"name": name,
|
||||
"status": "unchanged",
|
||||
})
|
||||
}
|
||||
if len(managerAgentIDs) > 0 {
|
||||
if existing, ok := h.skills.GetSkill(r.Context(), slug); ok && existing.ID != "" {
|
||||
if existingID, err := uuid.Parse(existing.ID); err == nil {
|
||||
grantErrors := h.grantUploadedSkillManagers(r.Context(), existingID, managerAgentIDs, existingVer, userID)
|
||||
if len(grantErrors) > 0 {
|
||||
response["grant_errors"] = grantErrors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, response)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -269,6 +290,12 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
response["id"] = id
|
||||
if len(managerAgentIDs) > 0 {
|
||||
grantErrors := h.grantUploadedSkillManagers(depsCtx, id, managerAgentIDs, version, userID)
|
||||
if len(grantErrors) > 0 {
|
||||
response["grant_errors"] = grantErrors
|
||||
}
|
||||
}
|
||||
|
||||
h.skills.BumpVersion()
|
||||
h.emitCacheInvalidate(bus.CacheKindSkills, id.String(), uuid.Nil)
|
||||
@@ -279,6 +306,47 @@ func (h *SkillsHandler) handleUpload(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusCreated, response)
|
||||
}
|
||||
|
||||
func parseUploadManagerAgentIDs(r *http.Request) ([]uuid.UUID, error) {
|
||||
raw := strings.TrimSpace(r.FormValue("manager_agent_ids"))
|
||||
if raw == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var values []string
|
||||
if err := json.Unmarshal([]byte(raw), &values); err != nil {
|
||||
return nil, fmt.Errorf("manager_agent_ids must be a JSON array")
|
||||
}
|
||||
if len(values) > maxUploadManagerAgentIDs {
|
||||
return nil, fmt.Errorf("manager_agent_ids exceeds limit of %d", maxUploadManagerAgentIDs)
|
||||
}
|
||||
out := make([]uuid.UUID, 0, len(values))
|
||||
seen := make(map[uuid.UUID]bool, len(values))
|
||||
for _, value := range values {
|
||||
id, err := uuid.Parse(strings.TrimSpace(value))
|
||||
if err != nil || id == uuid.Nil {
|
||||
return nil, fmt.Errorf("invalid manager_agent_ids value")
|
||||
}
|
||||
if seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (h *SkillsHandler) grantUploadedSkillManagers(ctx context.Context, skillID uuid.UUID, agentIDs []uuid.UUID, version int, userID string) []string {
|
||||
if version <= 0 {
|
||||
version = 1
|
||||
}
|
||||
var errs []string
|
||||
for _, agentID := range agentIDs {
|
||||
if err := h.skills.GrantToAgent(ctx, skillID, agentID, version, userID, true); err != nil {
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", agentID, err))
|
||||
}
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
func canAutoInstallUploadedSkillDeps(ctx context.Context) bool {
|
||||
return store.IsOwnerRole(ctx) || store.TenantIDFromContext(ctx) == store.MasterTenantID
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"maps"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -278,8 +279,8 @@ func TestHandleInstallDeps_ExistingEndpointStillReturnsInstallResult(t *testing.
|
||||
prevAggregate := aggregateInstallDeps
|
||||
prevInstall := installManagedDeps
|
||||
aggregateInstallDeps = func(dirs map[string]string) (*skills.SkillManifest, []string) {
|
||||
if got := dirs["system-skill"]; got != systemDir {
|
||||
t.Fatalf("system dir = %q, want %q", got, systemDir)
|
||||
if !mapContainsValue(dirs, systemDir) {
|
||||
t.Fatalf("install dirs missing system dir: %v", dirs)
|
||||
}
|
||||
return &skills.SkillManifest{RequiresPython: []string{"requests"}}, []string{"pip:requests"}
|
||||
}
|
||||
@@ -308,6 +309,367 @@ func TestHandleInstallDeps_ExistingEndpointStillReturnsInstallResult(t *testing.
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInstallDeps_IncludesCustomSkillDirsAndRefreshesStaleDeps(t *testing.T) {
|
||||
handler, skillStore, ctx, root := newTestUploadHandler(t)
|
||||
systemDir := filepath.Join(root, "skills-store", "system-skill", "1")
|
||||
customDir := filepath.Join(root, "skills-store", "custom-skill", "1")
|
||||
skillStore.seedSystemSkill("system-skill", systemDir)
|
||||
customID := skillStore.seedCustomSkill("custom-skill", customDir, "archived", []string{"pip:requests"})
|
||||
|
||||
prevAggregate := aggregateInstallDeps
|
||||
prevInstall := installManagedDeps
|
||||
aggregateInstallDeps = func(dirs map[string]string) (*skills.SkillManifest, []string) {
|
||||
if !mapContainsValue(dirs, systemDir) {
|
||||
t.Fatalf("install dirs missing system dir: %v", dirs)
|
||||
}
|
||||
if !mapContainsValue(dirs, customDir) {
|
||||
t.Fatalf("install dirs missing custom dir: %v", dirs)
|
||||
}
|
||||
return &skills.SkillManifest{RequiresPython: []string{"requests"}}, []string{"pip:requests"}
|
||||
}
|
||||
installManagedDeps = func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
|
||||
return &skills.InstallResult{Pip: []string{"requests"}}, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
aggregateInstallDeps = prevAggregate
|
||||
installManagedDeps = prevInstall
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/skills/install-deps", http.NoBody).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.handleInstallDeps(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
info, ok := skillStore.GetSkillByID(ctx, customID)
|
||||
if !ok {
|
||||
t.Fatal("custom skill missing")
|
||||
}
|
||||
if info.Status != "active" {
|
||||
t.Fatalf("custom status = %q, want active", info.Status)
|
||||
}
|
||||
if len(info.MissingDeps) != 0 {
|
||||
t.Fatalf("custom missing_deps = %v, want none", info.MissingDeps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInstallDeps_NoMissingPackagesStillRefreshesStaleDeps(t *testing.T) {
|
||||
handler, skillStore, ctx, root := newTestUploadHandler(t)
|
||||
customDir := filepath.Join(root, "skills-store", "custom-skill", "1")
|
||||
customID := skillStore.seedCustomSkill("custom-skill", customDir, "archived", []string{"pip:requests"})
|
||||
|
||||
prevAggregate := aggregateInstallDeps
|
||||
prevInstall := installManagedDeps
|
||||
aggregateInstallDeps = func(dirs map[string]string) (*skills.SkillManifest, []string) {
|
||||
if !mapContainsValue(dirs, customDir) {
|
||||
t.Fatalf("install dirs missing custom dir: %v", dirs)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
installManagedDeps = func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
|
||||
t.Fatal("install should not run when aggregate reports no missing packages")
|
||||
return nil, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
aggregateInstallDeps = prevAggregate
|
||||
installManagedDeps = prevInstall
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/skills/install-deps", http.NoBody).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.handleInstallDeps(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
info, ok := skillStore.GetSkillByID(ctx, customID)
|
||||
if !ok {
|
||||
t.Fatal("custom skill missing")
|
||||
}
|
||||
if info.Status != "active" {
|
||||
t.Fatalf("custom status = %q, want active", info.Status)
|
||||
}
|
||||
if len(info.MissingDeps) != 0 {
|
||||
t.Fatalf("custom missing_deps = %v, want none", info.MissingDeps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInstallDeps_RefreshesNonMasterTenantCustomSkills(t *testing.T) {
|
||||
handler, skillStore, ctx, root := newTestUploadHandler(t)
|
||||
tenantID := uuid.New()
|
||||
customDir := filepath.Join(root, "tenants", tenantID.String(), "skills-store", "tenant-skill", "1")
|
||||
customID := skillStore.seedCustomSkillForTenant(tenantID, "tenant-skill", customDir, "archived", []string{"pip:requests"})
|
||||
|
||||
prevAggregate := aggregateInstallDeps
|
||||
prevInstall := installManagedDeps
|
||||
aggregateInstallDeps = func(dirs map[string]string) (*skills.SkillManifest, []string) {
|
||||
if !mapContainsValue(dirs, customDir) {
|
||||
t.Fatalf("install dirs missing non-master custom dir: %v", dirs)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
installManagedDeps = func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
|
||||
t.Fatal("install should not run when aggregate reports no missing packages")
|
||||
return nil, nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
aggregateInstallDeps = prevAggregate
|
||||
installManagedDeps = prevInstall
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/skills/install-deps", http.NoBody).WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.handleInstallDeps(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
info, ok := skillStore.GetSkillByID(store.WithTenantID(context.Background(), tenantID), customID)
|
||||
if !ok {
|
||||
t.Fatal("custom skill missing")
|
||||
}
|
||||
if info.Status != "active" {
|
||||
t.Fatalf("custom status = %q, want active", info.Status)
|
||||
}
|
||||
if len(info.MissingDeps) != 0 {
|
||||
t.Fatalf("custom missing_deps = %v, want none", info.MissingDeps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInstallDep_RescansCustomSkillsAfterSuccessfulInstall(t *testing.T) {
|
||||
handler, skillStore, ctx, root := newTestUploadHandler(t)
|
||||
customDir := filepath.Join(root, "skills-store", "custom-skill", "1")
|
||||
customID := skillStore.seedCustomSkill("custom-skill", customDir, "archived", []string{"pip:requests"})
|
||||
|
||||
prevInstallSingle := installSingleDep
|
||||
installSingleDep = func(context.Context, string) (bool, string) { return true, "" }
|
||||
t.Cleanup(func() { installSingleDep = prevInstallSingle })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/skills/install-dep", bytes.NewBufferString(`{"dep":"pip:requests"}`)).WithContext(ctx)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.handleInstallDep(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
info, ok := skillStore.GetSkillByID(ctx, customID)
|
||||
if !ok {
|
||||
t.Fatal("custom skill missing")
|
||||
}
|
||||
if info.Status != "active" {
|
||||
t.Fatalf("custom status = %q, want active", info.Status)
|
||||
}
|
||||
if len(info.MissingDeps) != 0 {
|
||||
t.Fatalf("custom missing_deps = %v, want none", info.MissingDeps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleInstallDep_RescansCustomSkillsAfterFailedInstall(t *testing.T) {
|
||||
handler, skillStore, ctx, root := newTestUploadHandler(t)
|
||||
customDir := filepath.Join(root, "skills-store", "custom-skill", "1")
|
||||
customID := skillStore.seedCustomSkill("custom-skill", customDir, "archived", []string{"pip:requests"})
|
||||
|
||||
prevInstallSingle := installSingleDep
|
||||
installSingleDep = func(context.Context, string) (bool, string) { return false, "install failed" }
|
||||
t.Cleanup(func() { installSingleDep = prevInstallSingle })
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/skills/install-dep", bytes.NewBufferString(`{"dep":"pip:requests"}`)).WithContext(ctx)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
handler.handleInstallDep(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
info, ok := skillStore.GetSkillByID(ctx, customID)
|
||||
if !ok {
|
||||
t.Fatal("custom skill missing")
|
||||
}
|
||||
if info.Status != "active" {
|
||||
t.Fatalf("custom status = %q, want active", info.Status)
|
||||
}
|
||||
if len(info.MissingDeps) != 0 {
|
||||
t.Fatalf("custom missing_deps = %v, want none", info.MissingDeps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUploadManagerAgentIDs_RejectsTooManyValues(t *testing.T) {
|
||||
values := make([]string, maxUploadManagerAgentIDs+1)
|
||||
for i := range values {
|
||||
values[i] = uuid.NewString()
|
||||
}
|
||||
raw, err := json.Marshal(values)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal manager ids: %v", err)
|
||||
}
|
||||
|
||||
req := newUploadManagerIDsFormRequest(t, string(raw))
|
||||
if _, err := parseUploadManagerAgentIDs(req); err == nil {
|
||||
t.Fatal("expected too many manager_agent_ids to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUploadManagerAgentIDs_RejectsNilUUID(t *testing.T) {
|
||||
raw, err := json.Marshal([]string{uuid.Nil.String()})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal manager ids: %v", err)
|
||||
}
|
||||
|
||||
req := newUploadManagerIDsFormRequest(t, string(raw))
|
||||
if _, err := parseUploadManagerAgentIDs(req); err == nil {
|
||||
t.Fatal("expected nil UUID to be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUploadManagerAgentIDs_DeduplicatesValues(t *testing.T) {
|
||||
id := uuid.New()
|
||||
raw, err := json.Marshal([]string{id.String(), id.String()})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal manager ids: %v", err)
|
||||
}
|
||||
|
||||
req := newUploadManagerIDsFormRequest(t, string(raw))
|
||||
got, err := parseUploadManagerAgentIDs(req)
|
||||
if err != nil {
|
||||
t.Fatalf("parse manager ids: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0] != id {
|
||||
t.Fatalf("manager ids = %v, want [%s]", got, id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpload_GrantsSelectedAgentsCanManageOnCreatedSkill(t *testing.T) {
|
||||
handler, skillStore, ctx, _ := newTestUploadHandler(t)
|
||||
stubUploadDepFns(t,
|
||||
func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
|
||||
return nil, nil
|
||||
},
|
||||
func(*skills.SkillManifest) (bool, []string) { return true, nil },
|
||||
)
|
||||
|
||||
agentA := uuid.New()
|
||||
agentB := uuid.New()
|
||||
req := newZipUploadRequestWithManagers(t, ctx, map[string]string{
|
||||
"SKILL.md": skillMarkdown("Managed Skill", "managed-skill"),
|
||||
}, []string{agentA.String(), agentB.String()})
|
||||
w := httptest.NewRecorder()
|
||||
handler.handleUpload(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
if len(skillStore.grantCalls) != 2 {
|
||||
t.Fatalf("grant calls = %d, want 2", len(skillStore.grantCalls))
|
||||
}
|
||||
for i, call := range skillStore.grantCalls {
|
||||
if call.SkillID == uuid.Nil {
|
||||
t.Fatalf("grant call %d has nil skill id", i)
|
||||
}
|
||||
if call.Version != 1 {
|
||||
t.Fatalf("grant call %d version = %d, want 1", i, call.Version)
|
||||
}
|
||||
if call.GrantedBy != "user-1" {
|
||||
t.Fatalf("grant call %d granted by = %q, want user-1", i, call.GrantedBy)
|
||||
}
|
||||
if !call.CanManage {
|
||||
t.Fatalf("grant call %d canManage = false, want true", i)
|
||||
}
|
||||
}
|
||||
if skillStore.grantCalls[0].AgentID != agentA || skillStore.grantCalls[1].AgentID != agentB {
|
||||
t.Fatalf("grant agent ids = %s, %s; want %s, %s",
|
||||
skillStore.grantCalls[0].AgentID,
|
||||
skillStore.grantCalls[1].AgentID,
|
||||
agentA,
|
||||
agentB,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpload_GrantsSelectedAgentsOnUnchangedSkill(t *testing.T) {
|
||||
handler, skillStore, ctx, _ := newTestUploadHandler(t)
|
||||
stubUploadDepFns(t,
|
||||
func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
|
||||
return nil, nil
|
||||
},
|
||||
func(*skills.SkillManifest) (bool, []string) { return true, nil },
|
||||
)
|
||||
files := map[string]string{
|
||||
"SKILL.md": skillMarkdown("Unchanged Managed Skill", "unchanged-managed-skill"),
|
||||
}
|
||||
|
||||
w1 := httptest.NewRecorder()
|
||||
handler.handleUpload(w1, newZipUploadRequest(t, ctx, files))
|
||||
if w1.Code != http.StatusCreated {
|
||||
t.Fatalf("first upload status = %d, body = %s", w1.Code, w1.Body.String())
|
||||
}
|
||||
|
||||
agentID := uuid.New()
|
||||
w2 := httptest.NewRecorder()
|
||||
handler.handleUpload(w2, newZipUploadRequestWithManagers(t, ctx, files, []string{agentID.String()}))
|
||||
if w2.Code != http.StatusOK {
|
||||
t.Fatalf("second upload status = %d, body = %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.NewDecoder(w2.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if resp.Status != "unchanged" {
|
||||
t.Fatalf("status = %q, want unchanged", resp.Status)
|
||||
}
|
||||
if len(skillStore.grantCalls) != 1 {
|
||||
t.Fatalf("grant calls = %d, want 1", len(skillStore.grantCalls))
|
||||
}
|
||||
call := skillStore.grantCalls[0]
|
||||
if call.AgentID != agentID {
|
||||
t.Fatalf("grant agent id = %s, want %s", call.AgentID, agentID)
|
||||
}
|
||||
if call.Version != 1 {
|
||||
t.Fatalf("grant version = %d, want 1", call.Version)
|
||||
}
|
||||
if !call.CanManage {
|
||||
t.Fatal("grant canManage = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleUpload_ReturnsGrantErrors(t *testing.T) {
|
||||
handler, skillStore, ctx, _ := newTestUploadHandler(t)
|
||||
stubUploadDepFns(t,
|
||||
func(context.Context, *skills.SkillManifest, []string) (*skills.InstallResult, error) {
|
||||
return nil, nil
|
||||
},
|
||||
func(*skills.SkillManifest) (bool, []string) { return true, nil },
|
||||
)
|
||||
|
||||
agentID := uuid.New()
|
||||
skillStore.grantErrors[agentID] = errors.New("agent tenant mismatch")
|
||||
req := newZipUploadRequestWithManagers(t, ctx, map[string]string{
|
||||
"SKILL.md": skillMarkdown("Grant Error Skill", "grant-error-skill"),
|
||||
}, []string{agentID.String()})
|
||||
w := httptest.NewRecorder()
|
||||
handler.handleUpload(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("status = %d, body = %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
GrantErrors []string `json:"grant_errors"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(resp.GrantErrors) != 1 {
|
||||
t.Fatalf("grant_errors = %v, want one error", resp.GrantErrors)
|
||||
}
|
||||
if want := agentID.String() + ": agent tenant mismatch"; resp.GrantErrors[0] != want {
|
||||
t.Fatalf("grant error = %q, want %q", resp.GrantErrors[0], want)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestUploadHandler(t *testing.T) (*SkillsHandler, *skillManageStoreStub, context.Context, string) {
|
||||
t.Helper()
|
||||
|
||||
@@ -328,6 +690,12 @@ func newTestUploadHandler(t *testing.T) (*SkillsHandler, *skillManageStoreStub,
|
||||
func newZipUploadRequest(t *testing.T, ctx context.Context, files map[string]string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
return newZipUploadRequestWithManagers(t, ctx, files, nil)
|
||||
}
|
||||
|
||||
func newZipUploadRequestWithManagers(t *testing.T, ctx context.Context, files map[string]string, managerAgentIDs []string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
var zipBuf bytes.Buffer
|
||||
zw := zip.NewWriter(&zipBuf)
|
||||
for name, content := range files {
|
||||
@@ -352,6 +720,15 @@ func newZipUploadRequest(t *testing.T, ctx context.Context, files map[string]str
|
||||
if _, err := part.Write(zipBuf.Bytes()); err != nil {
|
||||
t.Fatalf("multipart write: %v", err)
|
||||
}
|
||||
if managerAgentIDs != nil {
|
||||
raw, err := json.Marshal(managerAgentIDs)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal manager_agent_ids: %v", err)
|
||||
}
|
||||
if err := mw.WriteField("manager_agent_ids", string(raw)); err != nil {
|
||||
t.Fatalf("multipart manager_agent_ids: %v", err)
|
||||
}
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatalf("multipart close: %v", err)
|
||||
}
|
||||
@@ -361,26 +738,53 @@ func newZipUploadRequest(t *testing.T, ctx context.Context, files map[string]str
|
||||
return req.WithContext(ctx)
|
||||
}
|
||||
|
||||
func newUploadManagerIDsFormRequest(t *testing.T, raw string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
if err := mw.WriteField("manager_agent_ids", raw); err != nil {
|
||||
t.Fatalf("multipart manager_agent_ids: %v", err)
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
t.Fatalf("multipart close: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/v1/skills/upload", &body)
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func skillMarkdown(name, slug string) string {
|
||||
return "---\nname: " + name + "\nslug: " + slug + "\n---\nSkill body\n"
|
||||
}
|
||||
|
||||
type skillManageStoreStub struct {
|
||||
baseDir string
|
||||
version int64
|
||||
nextBySlug map[string]int
|
||||
skills map[uuid.UUID]store.SkillInfo
|
||||
systemDirs map[string]string
|
||||
hashBySlug map[string]string // slug -> SKILL.md content hash (most recent)
|
||||
baseDir string
|
||||
version int64
|
||||
nextBySlug map[string]int
|
||||
skills map[uuid.UUID]store.SkillInfo
|
||||
systemDirs map[string]string
|
||||
hashBySlug map[string]string // slug -> SKILL.md content hash (most recent)
|
||||
grantCalls []skillGrantCall
|
||||
grantErrors map[uuid.UUID]error
|
||||
}
|
||||
|
||||
type skillGrantCall struct {
|
||||
SkillID uuid.UUID
|
||||
AgentID uuid.UUID
|
||||
Version int
|
||||
GrantedBy string
|
||||
CanManage bool
|
||||
}
|
||||
|
||||
func newSkillManageStoreStub(baseDir string) *skillManageStoreStub {
|
||||
return &skillManageStoreStub{
|
||||
baseDir: baseDir,
|
||||
nextBySlug: map[string]int{},
|
||||
skills: map[uuid.UUID]store.SkillInfo{},
|
||||
systemDirs: map[string]string{},
|
||||
hashBySlug: map[string]string{},
|
||||
baseDir: baseDir,
|
||||
nextBySlug: map[string]int{},
|
||||
skills: map[uuid.UUID]store.SkillInfo{},
|
||||
systemDirs: map[string]string{},
|
||||
hashBySlug: map[string]string{},
|
||||
grantErrors: map[uuid.UUID]error{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -388,6 +792,7 @@ func (s *skillManageStoreStub) seedSystemSkill(slug, dir string) {
|
||||
id := uuid.New()
|
||||
s.skills[id] = store.SkillInfo{
|
||||
ID: id.String(),
|
||||
TenantID: store.MasterTenantID.String(),
|
||||
Name: "System Skill",
|
||||
Slug: slug,
|
||||
Path: filepath.Join(dir, "SKILL.md"),
|
||||
@@ -400,6 +805,36 @@ func (s *skillManageStoreStub) seedSystemSkill(slug, dir string) {
|
||||
s.systemDirs[slug] = dir
|
||||
}
|
||||
|
||||
func (s *skillManageStoreStub) seedCustomSkill(slug, dir, status string, missing []string) uuid.UUID {
|
||||
return s.seedCustomSkillForTenant(store.MasterTenantID, slug, dir, status, missing)
|
||||
}
|
||||
|
||||
func (s *skillManageStoreStub) seedCustomSkillForTenant(tenantID uuid.UUID, slug, dir, status string, missing []string) uuid.UUID {
|
||||
id := uuid.New()
|
||||
s.skills[id] = store.SkillInfo{
|
||||
ID: id.String(),
|
||||
TenantID: tenantID.String(),
|
||||
Name: "Custom Skill",
|
||||
Slug: slug,
|
||||
Path: filepath.Join(dir, "SKILL.md"),
|
||||
BaseDir: dir,
|
||||
Version: 1,
|
||||
Status: status,
|
||||
Enabled: true,
|
||||
MissingDeps: append([]string(nil), missing...),
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func mapContainsValue(values map[string]string, want string) bool {
|
||||
for _, got := range values {
|
||||
if got == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *skillManageStoreStub) ListSkills(context.Context) []store.SkillInfo {
|
||||
return s.ListAllSkills(context.Background())
|
||||
}
|
||||
@@ -423,8 +858,12 @@ func (s *skillManageStoreStub) Version() int64 { return s.version }
|
||||
func (s *skillManageStoreStub) BumpVersion() { s.version++ }
|
||||
func (s *skillManageStoreStub) Dirs() []string { return []string{s.baseDir} }
|
||||
|
||||
func (s *skillManageStoreStub) CreateSkillManaged(_ context.Context, p store.SkillCreateParams) (uuid.UUID, error) {
|
||||
func (s *skillManageStoreStub) CreateSkillManaged(ctx context.Context, p store.SkillCreateParams) (uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
tenantID := store.TenantIDFromContext(ctx)
|
||||
if tenantID == uuid.Nil {
|
||||
tenantID = store.MasterTenantID
|
||||
}
|
||||
status := p.Status
|
||||
if status == "" {
|
||||
status = "active"
|
||||
@@ -438,6 +877,7 @@ func (s *skillManageStoreStub) CreateSkillManaged(_ context.Context, p store.Ski
|
||||
}
|
||||
s.skills[id] = store.SkillInfo{
|
||||
ID: id.String(),
|
||||
TenantID: tenantID.String(),
|
||||
Name: p.Name,
|
||||
Slug: p.Slug,
|
||||
Path: filepath.Join(p.FilePath, "SKILL.md"),
|
||||
@@ -464,11 +904,14 @@ func (s *skillManageStoreStub) GetSkillHashBySlug(_ context.Context, slug string
|
||||
return hash, version, true
|
||||
}
|
||||
|
||||
func (s *skillManageStoreStub) UpdateSkill(_ context.Context, id uuid.UUID, updates map[string]any) error {
|
||||
func (s *skillManageStoreStub) UpdateSkill(ctx context.Context, id uuid.UUID, updates map[string]any) error {
|
||||
skill, ok := s.skills[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !s.canAccessSkill(ctx, skill) {
|
||||
return nil
|
||||
}
|
||||
if status, ok := updates["status"].(string); ok {
|
||||
skill.Status = status
|
||||
}
|
||||
@@ -498,9 +941,16 @@ func (s *skillManageStoreStub) IsSystemSkill(slug string) bool {
|
||||
_, ok := s.systemDirs[slug]
|
||||
return ok
|
||||
}
|
||||
func (s *skillManageStoreStub) ListAllSkills(context.Context) []store.SkillInfo {
|
||||
func (s *skillManageStoreStub) ListAllSkills(ctx context.Context) []store.SkillInfo {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
tid = store.MasterTenantID
|
||||
}
|
||||
out := make([]store.SkillInfo, 0, len(s.skills))
|
||||
for _, skill := range s.skills {
|
||||
if !store.IsCrossTenant(ctx) && !skill.IsSystem && skill.TenantID != tid.String() {
|
||||
continue
|
||||
}
|
||||
out = append(out, skill)
|
||||
}
|
||||
return out
|
||||
@@ -519,16 +969,43 @@ func (s *skillManageStoreStub) ListSystemSkillDirs(context.Context) map[string]s
|
||||
maps.Copy(out, s.systemDirs)
|
||||
return out
|
||||
}
|
||||
func (s *skillManageStoreStub) StoreMissingDeps(_ context.Context, id uuid.UUID, missing []string) error {
|
||||
func (s *skillManageStoreStub) StoreMissingDeps(ctx context.Context, id uuid.UUID, missing []string) error {
|
||||
skill, ok := s.skills[id]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if !s.canAccessSkill(ctx, skill) {
|
||||
return nil
|
||||
}
|
||||
skill.MissingDeps = append([]string(nil), missing...)
|
||||
s.skills[id] = skill
|
||||
return nil
|
||||
}
|
||||
func (s *skillManageStoreStub) GrantToAgent(context.Context, uuid.UUID, uuid.UUID, int, string, ...bool) error {
|
||||
|
||||
func (s *skillManageStoreStub) canAccessSkill(ctx context.Context, skill store.SkillInfo) bool {
|
||||
if skill.IsSystem || store.IsCrossTenant(ctx) {
|
||||
return true
|
||||
}
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
tid = store.MasterTenantID
|
||||
}
|
||||
return skill.TenantID == "" || skill.TenantID == tid.String()
|
||||
}
|
||||
func (s *skillManageStoreStub) GrantToAgent(_ context.Context, skillID uuid.UUID, agentID uuid.UUID, version int, grantedBy string, canManage ...bool) error {
|
||||
if err := s.grantErrors[agentID]; err != nil {
|
||||
return err
|
||||
}
|
||||
call := skillGrantCall{
|
||||
SkillID: skillID,
|
||||
AgentID: agentID,
|
||||
Version: version,
|
||||
GrantedBy: grantedBy,
|
||||
}
|
||||
if len(canManage) > 0 {
|
||||
call.CanManage = canManage[0]
|
||||
}
|
||||
s.grantCalls = append(s.grantCalls, call)
|
||||
return nil
|
||||
}
|
||||
func (s *skillManageStoreStub) RevokeFromAgent(context.Context, uuid.UUID, uuid.UUID) error {
|
||||
|
||||
+22
-11
@@ -88,6 +88,7 @@ func (s *PGSkillStore) ListSkills(ctx context.Context) []store.SkillInfo {
|
||||
for i := range scanned {
|
||||
result = append(result, scanned[i].toSkillInfo(s.baseDir))
|
||||
}
|
||||
s.attachSkillAgentMetadata(ctx, result)
|
||||
|
||||
s.mu.Lock()
|
||||
s.listCache[tid] = &listCacheEntry{skills: result, ver: currentVer, time: time.Now()}
|
||||
@@ -96,19 +97,29 @@ func (s *PGSkillStore) ListSkills(ctx context.Context) []store.SkillInfo {
|
||||
return result
|
||||
}
|
||||
|
||||
// ListAllSkills returns system skills + custom skills for the given tenant (for admin operations like rescan-deps).
|
||||
// ListAllSkills returns system skills + custom skills for the given tenant.
|
||||
// Cross-tenant callers get every enabled non-deleted skill for global admin operations like rescan-deps.
|
||||
// Disabled skills are excluded — no point scanning or updating them.
|
||||
func (s *PGSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
tid = store.MasterTenantID
|
||||
}
|
||||
var scanned []skillInfoRow
|
||||
if err := pkgSqlxDB.SelectContext(ctx, &scanned,
|
||||
`SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
|
||||
FROM skills WHERE enabled = true AND status != 'deleted' AND (is_system = true OR tenant_id = $1)
|
||||
ORDER BY name`, tid); err != nil {
|
||||
return nil
|
||||
if store.IsCrossTenant(ctx) {
|
||||
if err := pkgSqlxDB.SelectContext(ctx, &scanned,
|
||||
`SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
|
||||
FROM skills WHERE enabled = true AND status != 'deleted'
|
||||
ORDER BY name`); err != nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
tid = store.MasterTenantID
|
||||
}
|
||||
if err := pkgSqlxDB.SelectContext(ctx, &scanned,
|
||||
`SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
|
||||
FROM skills WHERE enabled = true AND status != 'deleted' AND (is_system = true OR tenant_id = $1)
|
||||
ORDER BY name`, tid); err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return skillInfoRowsToSlice(scanned, s.baseDir)
|
||||
}
|
||||
@@ -118,7 +129,7 @@ func (s *PGSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo {
|
||||
func (s *PGSkillStore) ListAllSystemSkills(ctx context.Context) []store.SkillInfo {
|
||||
var scanned []skillInfoRow
|
||||
if err := pkgSqlxDB.SelectContext(ctx, &scanned,
|
||||
`SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
|
||||
`SELECT id, tenant_id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, file_path
|
||||
FROM skills WHERE is_system = true AND enabled = true AND status != 'deleted'
|
||||
ORDER BY name`); err != nil {
|
||||
return nil
|
||||
|
||||
@@ -89,33 +89,54 @@ func (s *PGSkillStore) BuildSummary(ctx context.Context, allowList []string) str
|
||||
}
|
||||
|
||||
func (s *PGSkillStore) GetSkill(ctx context.Context, name string) (*store.SkillInfo, bool) {
|
||||
var id uuid.UUID
|
||||
var skillName, slug, visibility, ownerID string
|
||||
var desc *string
|
||||
var tags []string
|
||||
var version int
|
||||
var isSystem bool
|
||||
var filePath *string
|
||||
q := "SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, file_path FROM skills WHERE slug = $1 AND status = 'active'"
|
||||
args := []any{name}
|
||||
baseSelect := `SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, frontmatter, file_path
|
||||
FROM skills WHERE `
|
||||
scope := ""
|
||||
args := []any{}
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
tid = store.MasterTenantID
|
||||
}
|
||||
q += " AND (is_system = true OR tenant_id = $2)"
|
||||
args = append(args, tid)
|
||||
scope = fmt.Sprintf(" AND (is_system = true OR tenant_id = $%d)", len(args))
|
||||
}
|
||||
err := s.db.QueryRowContext(ctx, q, args...).Scan(&id, &skillName, &slug, &desc, &visibility, &ownerID, pq.Array(&tags), &version, &isSystem, &filePath)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
|
||||
scan := func(q string, qArgs ...any) (*store.SkillInfo, bool) {
|
||||
var id uuid.UUID
|
||||
var skillName, slug, visibility, ownerID, status string
|
||||
var desc *string
|
||||
var tags []string
|
||||
var version int
|
||||
var isSystem, enabled bool
|
||||
var depsRaw, fmRaw []byte
|
||||
var filePath *string
|
||||
err := s.db.QueryRowContext(ctx, q, qArgs...).Scan(&id, &skillName, &slug, &desc, &visibility, &ownerID, pq.Array(&tags), &version, &isSystem, &status, &enabled, &depsRaw, &fmRaw, &filePath)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir, filePath)
|
||||
info.Visibility = visibility
|
||||
info.OwnerID = ownerID
|
||||
info.Tags = tags
|
||||
info.IsSystem = isSystem
|
||||
info.Status = status
|
||||
info.Enabled = enabled
|
||||
info.Author = parseFrontmatterAuthor(fmRaw)
|
||||
info.CreatorAgent = parseFrontmatterCreatorAgent(fmRaw)
|
||||
info.MissingDeps = parseDepsColumn(depsRaw)
|
||||
enriched := []store.SkillInfo{info}
|
||||
s.attachSkillAgentMetadata(ctx, enriched)
|
||||
return &enriched[0], true
|
||||
}
|
||||
info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir, filePath)
|
||||
info.Visibility = visibility
|
||||
info.OwnerID = ownerID
|
||||
info.Tags = tags
|
||||
info.IsSystem = isSystem
|
||||
return &info, true
|
||||
|
||||
if id, err := uuid.Parse(name); err == nil {
|
||||
return scan(baseSelect+"id = $"+fmt.Sprint(len(args)+1)+" AND status IN ('active', 'archived')"+scope, append(args, id)...)
|
||||
}
|
||||
if info, ok := scan(baseSelect+"slug = $"+fmt.Sprint(len(args)+1)+" AND status = 'active'"+scope, append(args, name)...); ok {
|
||||
return info, true
|
||||
}
|
||||
return scan(baseSelect+"name = $"+fmt.Sprint(len(args)+1)+" AND status = 'active'"+scope+" ORDER BY id LIMIT 1", append(args, name)...)
|
||||
}
|
||||
|
||||
func (s *PGSkillStore) FilterSkills(ctx context.Context, allowList []string) []store.SkillInfo {
|
||||
|
||||
@@ -309,6 +309,33 @@ func parseFrontmatterAuthor(raw []byte) string {
|
||||
return fm["author"]
|
||||
}
|
||||
|
||||
func parseFrontmatterCreatorAgent(raw []byte) *store.SkillAgentRef {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var fm map[string]string
|
||||
if err := json.Unmarshal(raw, &fm); err != nil {
|
||||
return nil
|
||||
}
|
||||
ref := store.SkillAgentRef{
|
||||
ID: fm["created_by_agent_id"],
|
||||
AgentKey: firstNonEmpty(fm["created_by_agent_key"], fm["creator_agent_key"]),
|
||||
}
|
||||
if ref.ID == "" && ref.AgentKey == "" {
|
||||
return nil
|
||||
}
|
||||
return &ref
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func marshalFrontmatter(fm map[string]string) []byte {
|
||||
if len(fm) == 0 {
|
||||
return []byte("{}")
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lib/pq"
|
||||
|
||||
"github.com/nextlevelbuilder/goclaw/internal/store"
|
||||
)
|
||||
@@ -152,13 +153,18 @@ func (s *PGSkillStore) ListAgentGrantsForSkill(ctx context.Context, skillID uuid
|
||||
if err := s.verifySkillInGrantScope(ctx, skillID, tenantIDForInsert(ctx)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tClause, tArgs, _, err := scopeClause(ctx, 2)
|
||||
tClause, tArgs, _, err := scopeClauseAlias(ctx, 2, "sag")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var result []store.SkillAgentGrantInfo
|
||||
err = pkgSqlxDB.SelectContext(ctx, &result,
|
||||
"SELECT agent_id, pinned_version, granted_by, can_manage FROM skill_agent_grants WHERE skill_id = $1"+tClause+" ORDER BY created_at DESC",
|
||||
`SELECT sag.agent_id, COALESCE(a.agent_key, '') AS agent_key, COALESCE(a.display_name, '') AS display_name,
|
||||
sag.pinned_version, sag.granted_by, sag.can_manage
|
||||
FROM skill_agent_grants sag
|
||||
LEFT JOIN agents a ON a.id = sag.agent_id
|
||||
WHERE sag.skill_id = $1`+tClause+`
|
||||
ORDER BY sag.created_at DESC`,
|
||||
append([]any{skillID}, tArgs...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -166,6 +172,124 @@ func (s *PGSkillStore) ListAgentGrantsForSkill(ctx context.Context, skillID uuid
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *PGSkillStore) attachSkillAgentMetadata(ctx context.Context, skills []store.SkillInfo) {
|
||||
if len(skills) == 0 {
|
||||
return
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(skills))
|
||||
byID := make(map[uuid.UUID]int, len(skills))
|
||||
creatorIDs := make([]uuid.UUID, 0)
|
||||
creatorByID := make(map[uuid.UUID][]int)
|
||||
creatorKeys := make([]string, 0)
|
||||
creatorByKey := make(map[string][]int)
|
||||
for i := range skills {
|
||||
id, err := uuid.Parse(skills[i].ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
byID[id] = i
|
||||
if ref := skills[i].CreatorAgent; ref != nil {
|
||||
skills[i].CreatorAgent = nil
|
||||
if ref.ID != "" {
|
||||
if agentID, err := uuid.Parse(ref.ID); err == nil {
|
||||
if _, exists := creatorByID[agentID]; !exists {
|
||||
creatorIDs = append(creatorIDs, agentID)
|
||||
}
|
||||
creatorByID[agentID] = append(creatorByID[agentID], i)
|
||||
}
|
||||
}
|
||||
if ref.AgentKey != "" {
|
||||
if _, exists := creatorByKey[ref.AgentKey]; !exists {
|
||||
creatorKeys = append(creatorKeys, ref.AgentKey)
|
||||
}
|
||||
creatorByKey[ref.AgentKey] = append(creatorByKey[ref.AgentKey], i)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.attachVerifiedCreatorAgents(ctx, skills, creatorIDs, creatorByID, creatorKeys, creatorByKey)
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
tClause, tArgs, _, err := scopeClauseAlias(ctx, 2, "sag")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT sag.skill_id, sag.agent_id, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '')
|
||||
FROM skill_agent_grants sag
|
||||
LEFT JOIN agents a ON a.id = sag.agent_id
|
||||
WHERE sag.skill_id = ANY($1) AND sag.can_manage = true`+tClause+`
|
||||
ORDER BY sag.created_at DESC`,
|
||||
append([]any{pq.Array(ids)}, tArgs...)...)
|
||||
if err != nil {
|
||||
slog.Warn("skill_grants: failed to attach manager agents", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var skillID, agentID uuid.UUID
|
||||
var agentKey, displayName string
|
||||
if err := rows.Scan(&skillID, &agentID, &agentKey, &displayName); err != nil {
|
||||
continue
|
||||
}
|
||||
i, ok := byID[skillID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
skills[i].ManagerAgents = append(skills[i].ManagerAgents, store.SkillAgentRef{
|
||||
ID: agentID.String(),
|
||||
AgentKey: agentKey,
|
||||
DisplayName: displayName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PGSkillStore) attachVerifiedCreatorAgents(
|
||||
ctx context.Context,
|
||||
skills []store.SkillInfo,
|
||||
creatorIDs []uuid.UUID,
|
||||
creatorByID map[uuid.UUID][]int,
|
||||
creatorKeys []string,
|
||||
creatorByKey map[string][]int,
|
||||
) {
|
||||
if len(creatorIDs) == 0 && len(creatorKeys) == 0 {
|
||||
return
|
||||
}
|
||||
tClause, tArgs, _, err := scopeClauseAlias(ctx, 3, "a")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT a.id, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '')
|
||||
FROM agents a
|
||||
WHERE a.deleted_at IS NULL AND (a.id = ANY($1) OR a.agent_key = ANY($2))`+tClause,
|
||||
append([]any{pq.Array(creatorIDs), pq.Array(creatorKeys)}, tArgs...)...)
|
||||
if err != nil {
|
||||
slog.Warn("skill_grants: failed to resolve creator agents", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var agentID uuid.UUID
|
||||
var agentKey, displayName string
|
||||
if err := rows.Scan(&agentID, &agentKey, &displayName); err != nil {
|
||||
continue
|
||||
}
|
||||
ref := store.SkillAgentRef{
|
||||
ID: agentID.String(),
|
||||
AgentKey: agentKey,
|
||||
DisplayName: displayName,
|
||||
}
|
||||
for _, i := range creatorByID[agentID] {
|
||||
skills[i].CreatorAgent = &ref
|
||||
}
|
||||
for _, i := range creatorByKey[agentKey] {
|
||||
skills[i].CreatorAgent = &ref
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AgentCanManageSkill reports whether an agent has explicit edit/delete rights for a skill.
|
||||
func (s *PGSkillStore) AgentCanManageSkill(ctx context.Context, skillID, agentID uuid.UUID) (bool, error) {
|
||||
if err := s.verifySkillInGrantScope(ctx, skillID, tenantIDForInsert(ctx)); err != nil {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
// Used by ListSkills (includes frontmatter) and scanSkillInfoList (no frontmatter).
|
||||
type skillInfoRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
TenantID uuid.UUID `db:"tenant_id"`
|
||||
Name string `db:"name"`
|
||||
Slug string `db:"slug"`
|
||||
Desc *string `db:"description"`
|
||||
@@ -37,6 +38,7 @@ type skillInfoRowWithFrontmatter struct {
|
||||
// toSkillInfo converts a skillInfoRow to store.SkillInfo, resolving computed fields from baseDir.
|
||||
func (r *skillInfoRow) toSkillInfo(baseDir string) store.SkillInfo {
|
||||
info := buildSkillInfo(r.ID.String(), r.Name, r.Slug, r.Desc, r.Version, baseDir, r.FilePath)
|
||||
info.TenantID = r.TenantID.String()
|
||||
info.Visibility = r.Visibility
|
||||
info.OwnerID = r.OwnerID
|
||||
info.Tags = []string(r.Tags)
|
||||
@@ -51,6 +53,7 @@ func (r *skillInfoRow) toSkillInfo(baseDir string) store.SkillInfo {
|
||||
func (r *skillInfoRowWithFrontmatter) toSkillInfo(baseDir string) store.SkillInfo {
|
||||
info := r.skillInfoRow.toSkillInfo(baseDir)
|
||||
info.Author = parseFrontmatterAuthor(r.FmRaw)
|
||||
info.CreatorAgent = parseFrontmatterCreatorAgent(r.FmRaw)
|
||||
return info
|
||||
}
|
||||
|
||||
|
||||
@@ -8,22 +8,32 @@ import (
|
||||
|
||||
// SkillInfo describes a discovered skill.
|
||||
type SkillInfo struct {
|
||||
ID string `json:"id,omitempty" db:"id"` // DB UUID
|
||||
Name string `json:"name" db:"name"`
|
||||
Slug string `json:"slug" db:"slug"`
|
||||
Path string `json:"path" db:"path"`
|
||||
BaseDir string `json:"baseDir" db:"-"`
|
||||
Source string `json:"source" db:"-"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Visibility string `json:"visibility,omitempty" db:"visibility"`
|
||||
OwnerID string `json:"-" db:"owner_id"`
|
||||
Tags []string `json:"tags,omitempty" db:"tags"`
|
||||
Version int `json:"version,omitempty" db:"version"`
|
||||
IsSystem bool `json:"is_system,omitempty" db:"is_system"`
|
||||
Status string `json:"status,omitempty" db:"status"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
Author string `json:"author,omitempty" db:"author"`
|
||||
MissingDeps []string `json:"missing_deps,omitempty" db:"missing_deps"`
|
||||
ID string `json:"id,omitempty" db:"id"` // DB UUID
|
||||
TenantID string `json:"-" db:"tenant_id"`
|
||||
Name string `json:"name" db:"name"`
|
||||
Slug string `json:"slug" db:"slug"`
|
||||
Path string `json:"path" db:"path"`
|
||||
BaseDir string `json:"baseDir" db:"-"`
|
||||
Source string `json:"source" db:"-"`
|
||||
Description string `json:"description" db:"description"`
|
||||
Visibility string `json:"visibility,omitempty" db:"visibility"`
|
||||
OwnerID string `json:"-" db:"owner_id"`
|
||||
Tags []string `json:"tags,omitempty" db:"tags"`
|
||||
Version int `json:"version,omitempty" db:"version"`
|
||||
IsSystem bool `json:"is_system,omitempty" db:"is_system"`
|
||||
Status string `json:"status,omitempty" db:"status"`
|
||||
Enabled bool `json:"enabled" db:"enabled"`
|
||||
Author string `json:"author,omitempty" db:"author"`
|
||||
CreatorAgent *SkillAgentRef `json:"creator_agent,omitempty" db:"-"`
|
||||
ManagerAgents []SkillAgentRef `json:"manager_agents,omitempty" db:"-"`
|
||||
MissingDeps []string `json:"missing_deps,omitempty" db:"missing_deps"`
|
||||
}
|
||||
|
||||
// SkillAgentRef is a small UI/API-safe agent reference for skill metadata.
|
||||
type SkillAgentRef struct {
|
||||
ID string `json:"id,omitempty" db:"id"`
|
||||
AgentKey string `json:"agent_key,omitempty" db:"agent_key"`
|
||||
DisplayName string `json:"display_name,omitempty" db:"display_name"`
|
||||
}
|
||||
|
||||
// SkillSearchResult is a scored skill returned from embedding search.
|
||||
@@ -97,6 +107,8 @@ type SkillWithGrantStatus struct {
|
||||
// SkillAgentGrantInfo is a grant row for one skill across agents.
|
||||
type SkillAgentGrantInfo struct {
|
||||
AgentID uuid.UUID `json:"agent_id" db:"agent_id"`
|
||||
AgentKey string `json:"agent_key,omitempty" db:"agent_key"`
|
||||
DisplayName string `json:"display_name,omitempty" db:"display_name"`
|
||||
PinnedVersion int `json:"pinned_version" db:"pinned_version"`
|
||||
GrantedBy string `json:"granted_by" db:"granted_by"`
|
||||
CanManage bool `json:"can_manage" db:"can_manage"`
|
||||
|
||||
@@ -102,12 +102,14 @@ func (s *SQLiteSkillStore) ListSkills(ctx context.Context) []store.SkillInfo {
|
||||
info.Enabled = enabled
|
||||
info.MissingDeps = parseDepsColumn(depsRaw)
|
||||
info.Author = parseFrontmatterAuthor(fmRaw)
|
||||
info.CreatorAgent = parseFrontmatterCreatorAgent(fmRaw)
|
||||
result = append(result, info)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
slog.Warn("ListSkills: rows iteration error", "error", err)
|
||||
return nil
|
||||
}
|
||||
s.attachSkillAgentMetadata(ctx, result)
|
||||
|
||||
s.mu.Lock()
|
||||
s.listCache[tid] = &skillListCacheEntry{skills: result, ver: currentVer, time: time.Now()}
|
||||
@@ -117,14 +119,23 @@ func (s *SQLiteSkillStore) ListSkills(ctx context.Context) []store.SkillInfo {
|
||||
}
|
||||
|
||||
func (s *SQLiteSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
tid = store.MasterTenantID
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if store.IsCrossTenant(ctx) {
|
||||
rows, err = s.db.QueryContext(ctx,
|
||||
`SELECT id, tenant_id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
|
||||
FROM skills WHERE enabled = 1 AND status != 'deleted'
|
||||
ORDER BY name`)
|
||||
} else {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
tid = store.MasterTenantID
|
||||
}
|
||||
rows, err = s.db.QueryContext(ctx,
|
||||
`SELECT id, tenant_id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
|
||||
FROM skills WHERE enabled = 1 AND status != 'deleted' AND (is_system = 1 OR tenant_id = ?)
|
||||
ORDER BY name`, tid)
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
|
||||
FROM skills WHERE enabled = 1 AND status != 'deleted' AND (is_system = 1 OR tenant_id = ?)
|
||||
ORDER BY name`, tid)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
@@ -134,7 +145,7 @@ func (s *SQLiteSkillStore) ListAllSkills(ctx context.Context) []store.SkillInfo
|
||||
|
||||
func (s *SQLiteSkillStore) ListAllSystemSkills(ctx context.Context) []store.SkillInfo {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
|
||||
`SELECT id, tenant_id, name, slug, description, visibility, tags, version, is_system, status, enabled, deps, file_path
|
||||
FROM skills WHERE is_system = 1 AND enabled = 1 AND status != 'deleted'
|
||||
ORDER BY name`)
|
||||
if err != nil {
|
||||
@@ -148,6 +159,7 @@ func (s *SQLiteSkillStore) scanSkillInfoList(rows *sql.Rows) []store.SkillInfo {
|
||||
var result []store.SkillInfo
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var tenantID uuid.UUID
|
||||
var name, slug, visibility, status string
|
||||
var desc *string
|
||||
var tagsJSON []byte
|
||||
@@ -155,11 +167,12 @@ func (s *SQLiteSkillStore) scanSkillInfoList(rows *sql.Rows) []store.SkillInfo {
|
||||
var isSystem, enabled bool
|
||||
var depsRaw []byte
|
||||
var filePath *string
|
||||
if err := rows.Scan(&id, &name, &slug, &desc, &visibility, &tagsJSON, &version,
|
||||
if err := rows.Scan(&id, &tenantID, &name, &slug, &desc, &visibility, &tagsJSON, &version,
|
||||
&isSystem, &status, &enabled, &depsRaw, &filePath); err != nil {
|
||||
continue
|
||||
}
|
||||
info := buildSkillInfo(id.String(), name, slug, desc, version, s.baseDir, filePath)
|
||||
info.TenantID = tenantID.String()
|
||||
info.Visibility = visibility
|
||||
scanJSONStringArray(tagsJSON, &info.Tags)
|
||||
info.IsSystem = isSystem
|
||||
@@ -257,6 +270,33 @@ func parseFrontmatterAuthor(raw []byte) string {
|
||||
return fm["author"]
|
||||
}
|
||||
|
||||
func parseFrontmatterCreatorAgent(raw []byte) *store.SkillAgentRef {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var fm map[string]string
|
||||
if err := json.Unmarshal(raw, &fm); err != nil {
|
||||
return nil
|
||||
}
|
||||
ref := store.SkillAgentRef{
|
||||
ID: fm["created_by_agent_id"],
|
||||
AgentKey: firstNonEmpty(fm["created_by_agent_key"], fm["creator_agent_key"]),
|
||||
}
|
||||
if ref.ID == "" && ref.AgentKey == "" {
|
||||
return nil
|
||||
}
|
||||
return &ref
|
||||
}
|
||||
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, value := range values {
|
||||
if value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func marshalFrontmatter(fm map[string]string) []byte {
|
||||
if len(fm) == 0 {
|
||||
return []byte("{}")
|
||||
|
||||
@@ -84,32 +84,53 @@ func (s *SQLiteSkillStore) BuildSummary(ctx context.Context, allowList []string)
|
||||
}
|
||||
|
||||
func (s *SQLiteSkillStore) GetSkill(ctx context.Context, name string) (*store.SkillInfo, bool) {
|
||||
var id uuid.UUID
|
||||
var skillName, slug, visibility, ownerID string
|
||||
var desc *string
|
||||
var tagsJSON []byte
|
||||
var version int
|
||||
var isSystem bool
|
||||
var filePath *string
|
||||
q := "SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, file_path FROM skills WHERE slug = ? AND status = 'active'"
|
||||
args := []any{name}
|
||||
baseSelect := `SELECT id, name, slug, description, visibility, owner_id, tags, version, is_system, status, enabled, deps, frontmatter, file_path
|
||||
FROM skills WHERE `
|
||||
scope := ""
|
||||
args := []any{}
|
||||
if !store.IsCrossTenant(ctx) {
|
||||
tid := store.TenantIDFromContext(ctx)
|
||||
if tid == uuid.Nil {
|
||||
tid = store.MasterTenantID
|
||||
}
|
||||
q += " AND (is_system = 1 OR tenant_id = ?)"
|
||||
args = append(args, tid)
|
||||
scope = " AND (is_system = 1 OR tenant_id = ?)"
|
||||
}
|
||||
if err := s.db.QueryRowContext(ctx, q, args...).Scan(&id, &skillName, &slug, &desc, &visibility, &ownerID, &tagsJSON, &version, &isSystem, &filePath); err != nil {
|
||||
return nil, false
|
||||
|
||||
scan := func(q string, qArgs ...any) (*store.SkillInfo, bool) {
|
||||
var id uuid.UUID
|
||||
var skillName, slug, visibility, ownerID, status string
|
||||
var desc *string
|
||||
var tagsJSON []byte
|
||||
var version int
|
||||
var isSystem, enabled bool
|
||||
var depsRaw, fmRaw []byte
|
||||
var filePath *string
|
||||
if err := s.db.QueryRowContext(ctx, q, qArgs...).Scan(&id, &skillName, &slug, &desc, &visibility, &ownerID, &tagsJSON, &version, &isSystem, &status, &enabled, &depsRaw, &fmRaw, &filePath); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir, filePath)
|
||||
info.Visibility = visibility
|
||||
info.OwnerID = ownerID
|
||||
scanJSONStringArray(tagsJSON, &info.Tags)
|
||||
info.IsSystem = isSystem
|
||||
info.Status = status
|
||||
info.Enabled = enabled
|
||||
info.Author = parseFrontmatterAuthor(fmRaw)
|
||||
info.CreatorAgent = parseFrontmatterCreatorAgent(fmRaw)
|
||||
info.MissingDeps = parseDepsColumn(depsRaw)
|
||||
enriched := []store.SkillInfo{info}
|
||||
s.attachSkillAgentMetadata(ctx, enriched)
|
||||
return &enriched[0], true
|
||||
}
|
||||
info := buildSkillInfo(id.String(), skillName, slug, desc, version, s.baseDir, filePath)
|
||||
info.Visibility = visibility
|
||||
info.OwnerID = ownerID
|
||||
scanJSONStringArray(tagsJSON, &info.Tags)
|
||||
info.IsSystem = isSystem
|
||||
return &info, true
|
||||
|
||||
if id, err := uuid.Parse(name); err == nil {
|
||||
return scan(baseSelect+"id = ? AND status IN ('active', 'archived')"+scope, append([]any{id}, args...)...)
|
||||
}
|
||||
if info, ok := scan(baseSelect+"slug = ? AND status = 'active'"+scope, append([]any{name}, args...)...); ok {
|
||||
return info, true
|
||||
}
|
||||
return scan(baseSelect+"name = ? AND status = 'active'"+scope+" ORDER BY id LIMIT 1", append([]any{name}, args...)...)
|
||||
}
|
||||
|
||||
func (s *SQLiteSkillStore) FilterSkills(ctx context.Context, allowList []string) []store.SkillInfo {
|
||||
@@ -290,4 +311,3 @@ func (s *SQLiteSkillStore) IsSystemSkill(slug string) bool {
|
||||
err := s.db.QueryRow("SELECT is_system FROM skills WHERE slug = ?", slug).Scan(&isSystem)
|
||||
return err == nil && isSystem
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -170,7 +171,12 @@ func (s *SQLiteSkillStore) ListAgentGrantsForSkill(ctx context.Context, skillID
|
||||
return nil, err
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
"SELECT agent_id, pinned_version, granted_by, can_manage FROM skill_agent_grants WHERE skill_id = ?"+tClause+" ORDER BY created_at DESC",
|
||||
`SELECT sag.agent_id, COALESCE(a.agent_key, ''), COALESCE(a.display_name, ''),
|
||||
sag.pinned_version, sag.granted_by, sag.can_manage
|
||||
FROM skill_agent_grants sag
|
||||
LEFT JOIN agents a ON a.id = sag.agent_id
|
||||
WHERE sag.skill_id = ?`+strings.ReplaceAll(tClause, "tenant_id", "sag.tenant_id")+`
|
||||
ORDER BY sag.created_at DESC`,
|
||||
append([]any{skillID}, tArgs...)...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -180,7 +186,7 @@ func (s *SQLiteSkillStore) ListAgentGrantsForSkill(ctx context.Context, skillID
|
||||
var result []store.SkillAgentGrantInfo
|
||||
for rows.Next() {
|
||||
var g store.SkillAgentGrantInfo
|
||||
if err := rows.Scan(&g.AgentID, &g.PinnedVersion, &g.GrantedBy, &g.CanManage); err != nil {
|
||||
if err := rows.Scan(&g.AgentID, &g.AgentKey, &g.DisplayName, &g.PinnedVersion, &g.GrantedBy, &g.CanManage); err != nil {
|
||||
slog.Warn("skill_grants: scan error in ListAgentGrantsForSkill", "error", err)
|
||||
continue
|
||||
}
|
||||
@@ -189,6 +195,150 @@ func (s *SQLiteSkillStore) ListAgentGrantsForSkill(ctx context.Context, skillID
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *SQLiteSkillStore) attachSkillAgentMetadata(ctx context.Context, skills []store.SkillInfo) {
|
||||
if len(skills) == 0 {
|
||||
return
|
||||
}
|
||||
ids := make([]uuid.UUID, 0, len(skills))
|
||||
byID := make(map[uuid.UUID]int, len(skills))
|
||||
creatorIDs := make([]uuid.UUID, 0)
|
||||
creatorByID := make(map[uuid.UUID][]int)
|
||||
creatorKeys := make([]string, 0)
|
||||
creatorByKey := make(map[string][]int)
|
||||
for i := range skills {
|
||||
id, err := uuid.Parse(skills[i].ID)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, id)
|
||||
byID[id] = i
|
||||
if ref := skills[i].CreatorAgent; ref != nil {
|
||||
skills[i].CreatorAgent = nil
|
||||
if ref.ID != "" {
|
||||
if agentID, err := uuid.Parse(ref.ID); err == nil {
|
||||
if _, exists := creatorByID[agentID]; !exists {
|
||||
creatorIDs = append(creatorIDs, agentID)
|
||||
}
|
||||
creatorByID[agentID] = append(creatorByID[agentID], i)
|
||||
}
|
||||
}
|
||||
if ref.AgentKey != "" {
|
||||
if _, exists := creatorByKey[ref.AgentKey]; !exists {
|
||||
creatorKeys = append(creatorKeys, ref.AgentKey)
|
||||
}
|
||||
creatorByKey[ref.AgentKey] = append(creatorByKey[ref.AgentKey], i)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.attachVerifiedCreatorAgents(ctx, skills, creatorIDs, creatorByID, creatorKeys, creatorByKey)
|
||||
if len(ids) == 0 {
|
||||
return
|
||||
}
|
||||
placeholders := make([]string, len(ids))
|
||||
args := make([]any, 0, len(ids)+1)
|
||||
for i, id := range ids {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
tClause, tArgs, err := scopeClause(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
args = append(args, tArgs...)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT sag.skill_id, sag.agent_id, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '')
|
||||
FROM skill_agent_grants sag
|
||||
LEFT JOIN agents a ON a.id = sag.agent_id
|
||||
WHERE sag.skill_id IN (`+strings.Join(placeholders, ",")+`) AND sag.can_manage = 1`+strings.ReplaceAll(tClause, "tenant_id", "sag.tenant_id")+`
|
||||
ORDER BY sag.created_at DESC`,
|
||||
args...)
|
||||
if err != nil {
|
||||
slog.Warn("skill_grants: failed to attach manager agents", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var skillID, agentID uuid.UUID
|
||||
var agentKey, displayName string
|
||||
if err := rows.Scan(&skillID, &agentID, &agentKey, &displayName); err != nil {
|
||||
continue
|
||||
}
|
||||
i, ok := byID[skillID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
skills[i].ManagerAgents = append(skills[i].ManagerAgents, store.SkillAgentRef{
|
||||
ID: agentID.String(),
|
||||
AgentKey: agentKey,
|
||||
DisplayName: displayName,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SQLiteSkillStore) attachVerifiedCreatorAgents(
|
||||
ctx context.Context,
|
||||
skills []store.SkillInfo,
|
||||
creatorIDs []uuid.UUID,
|
||||
creatorByID map[uuid.UUID][]int,
|
||||
creatorKeys []string,
|
||||
creatorByKey map[string][]int,
|
||||
) {
|
||||
if len(creatorIDs) == 0 && len(creatorKeys) == 0 {
|
||||
return
|
||||
}
|
||||
clauses := make([]string, 0, 2)
|
||||
args := make([]any, 0, len(creatorIDs)+len(creatorKeys)+1)
|
||||
if len(creatorIDs) > 0 {
|
||||
placeholders := make([]string, len(creatorIDs))
|
||||
for i, id := range creatorIDs {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, id)
|
||||
}
|
||||
clauses = append(clauses, "a.id IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
if len(creatorKeys) > 0 {
|
||||
placeholders := make([]string, len(creatorKeys))
|
||||
for i, key := range creatorKeys {
|
||||
placeholders[i] = "?"
|
||||
args = append(args, key)
|
||||
}
|
||||
clauses = append(clauses, "a.agent_key IN ("+strings.Join(placeholders, ",")+")")
|
||||
}
|
||||
tClause, tArgs, err := scopeClause(ctx)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
args = append(args, tArgs...)
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT a.id, COALESCE(a.agent_key, ''), COALESCE(a.display_name, '')
|
||||
FROM agents a
|
||||
WHERE a.deleted_at IS NULL AND (`+strings.Join(clauses, " OR ")+`)`+strings.ReplaceAll(tClause, "tenant_id", "a.tenant_id"),
|
||||
args...)
|
||||
if err != nil {
|
||||
slog.Warn("skill_grants: failed to resolve creator agents", "error", err)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var agentID uuid.UUID
|
||||
var agentKey, displayName string
|
||||
if err := rows.Scan(&agentID, &agentKey, &displayName); err != nil {
|
||||
continue
|
||||
}
|
||||
ref := store.SkillAgentRef{
|
||||
ID: agentID.String(),
|
||||
AgentKey: agentKey,
|
||||
DisplayName: displayName,
|
||||
}
|
||||
for _, i := range creatorByID[agentID] {
|
||||
skills[i].CreatorAgent = &ref
|
||||
}
|
||||
for _, i := range creatorByKey[agentKey] {
|
||||
skills[i].CreatorAgent = &ref
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AgentCanManageSkill reports whether an agent has explicit edit/delete rights for a skill.
|
||||
func (s *SQLiteSkillStore) AgentCanManageSkill(ctx context.Context, skillID, agentID uuid.UUID) (bool, error) {
|
||||
if err := s.verifySkillInGrantScope(ctx, skillID, tenantIDForInsert(ctx)); err != nil {
|
||||
|
||||
@@ -70,6 +70,82 @@ func TestSQLiteSkillStore_CreateSkillManaged_PersistsArchivedDependencyState(t *
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteSkillStore_GetSkill_UUIDCanReadArchivedSlugStaysActiveOnly(t *testing.T) {
|
||||
ctx, skillStore := newTestSQLiteSkillStore(t)
|
||||
skillID, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{
|
||||
Name: "Archived Detail",
|
||||
Slug: "archived-detail",
|
||||
OwnerID: "user-1",
|
||||
Visibility: "private",
|
||||
Status: "archived",
|
||||
FilePath: filepath.Join(t.TempDir(), "archived-detail", "1"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSkillManaged error: %v", err)
|
||||
}
|
||||
|
||||
if _, ok := skillStore.GetSkill(ctx, "archived-detail"); ok {
|
||||
t.Fatal("GetSkill by slug returned archived skill; want active-only slug lookup")
|
||||
}
|
||||
info, ok := skillStore.GetSkill(ctx, skillID.String())
|
||||
if !ok {
|
||||
t.Fatal("GetSkill by UUID returned !ok for archived skill")
|
||||
}
|
||||
if info.Status != "archived" {
|
||||
t.Fatalf("Status = %q, want archived", info.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteSkillStore_ListSkills_ResolvesCreatorAgentWithinTenant(t *testing.T) {
|
||||
_, skillStore, db := newTestSQLiteSkillStoreWithDB(t)
|
||||
tenantID, agentID := seedSQLiteTenantAgent(t, db)
|
||||
if _, err := db.Exec(`UPDATE agents SET display_name = ? WHERE id = ?`, "Creator Agent", agentID.String()); err != nil {
|
||||
t.Fatalf("update agent display_name: %v", err)
|
||||
}
|
||||
ctx := store.WithTenantID(context.Background(), tenantID)
|
||||
if _, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{
|
||||
Name: "Verified Creator",
|
||||
Slug: "verified-creator",
|
||||
OwnerID: "user-1",
|
||||
Visibility: "private",
|
||||
FilePath: filepath.Join(t.TempDir(), "verified-creator", "1"),
|
||||
Frontmatter: map[string]string{
|
||||
"created_by_agent_id": agentID.String(),
|
||||
"created_by_agent": "Spoofed Name",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateSkillManaged error: %v", err)
|
||||
}
|
||||
if _, err := skillStore.CreateSkillManaged(ctx, store.SkillCreateParams{
|
||||
Name: "Spoofed Creator",
|
||||
Slug: "spoofed-creator",
|
||||
OwnerID: "user-1",
|
||||
Visibility: "private",
|
||||
FilePath: filepath.Join(t.TempDir(), "spoofed-creator", "1"),
|
||||
Frontmatter: map[string]string{
|
||||
"created_by_agent": "Only A String",
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("CreateSkillManaged error: %v", err)
|
||||
}
|
||||
|
||||
list := skillStore.ListSkills(ctx)
|
||||
bySlug := map[string]store.SkillInfo{}
|
||||
for _, info := range list {
|
||||
bySlug[info.Slug] = info
|
||||
}
|
||||
verified := bySlug["verified-creator"].CreatorAgent
|
||||
if verified == nil {
|
||||
t.Fatal("CreatorAgent = nil, want verified creator")
|
||||
}
|
||||
if verified.ID != agentID.String() || verified.DisplayName != "Creator Agent" {
|
||||
t.Fatalf("CreatorAgent = %+v, want resolved DB agent", verified)
|
||||
}
|
||||
if got := bySlug["spoofed-creator"].CreatorAgent; got != nil {
|
||||
t.Fatalf("CreatorAgent = %+v, want nil for display-only spoof", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteSkillStore_GrantToAgentRejectsCrossTenantSkill(t *testing.T) {
|
||||
_, skillStore, db := newTestSQLiteSkillStoreWithDB(t)
|
||||
tenantA, agentA := seedSQLiteTenantAgent(t, db)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useMemo, useCallback } from "react";
|
||||
import { useState, useMemo, useCallback, useEffect } from "react";
|
||||
import { useUiStore } from "@/stores/use-ui-store";
|
||||
|
||||
export interface PaginationState {
|
||||
@@ -30,6 +30,12 @@ export function usePagination<T>(
|
||||
const [page, setPageRaw] = useState(1);
|
||||
const [pageSize, setPageSizeRaw] = useState(options.defaultPageSize ?? globalPageSize);
|
||||
|
||||
useEffect(() => {
|
||||
if (options.defaultPageSize === undefined) {
|
||||
setPageSizeRaw(globalPageSize);
|
||||
}
|
||||
}, [globalPageSize, options.defaultPageSize]);
|
||||
|
||||
const total = items.length;
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
const safePage = Math.min(page, totalPages);
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"description": "Description",
|
||||
"source": "Source",
|
||||
"author": "Author",
|
||||
"agents": "Agents",
|
||||
"owner": "Owner",
|
||||
"visibility": "Visibility",
|
||||
"status": "Status",
|
||||
@@ -72,7 +73,12 @@
|
||||
"skillUnchanged": "Content unchanged — skipped",
|
||||
"slugConflict": "Slug conflicts with a system skill",
|
||||
"unchanged": "Unchanged",
|
||||
"new": "New"
|
||||
"new": "New",
|
||||
"agentManagers": "Grant manager access to agents",
|
||||
"managerAgentsHelp": "Selected current agents can update this skill later.",
|
||||
"selectAllAgents": "Select all agents",
|
||||
"clearAgents": "Clear agents",
|
||||
"noAgents": "No agents available"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Edit Skill",
|
||||
@@ -112,6 +118,7 @@
|
||||
"rescanSuccess": "Updated {{count}} skills",
|
||||
"missing": "Missing: {{deps}}",
|
||||
"install": "Install Dependencies",
|
||||
"installAll": "Install all dependencies",
|
||||
"installing": "Installing...",
|
||||
"installSuccess": "Dependencies installed successfully",
|
||||
"installPartial": "Some dependencies failed to install",
|
||||
@@ -129,6 +136,10 @@
|
||||
"runtimeMissingAction": "Open Packages to verify or install runtimes in this container.",
|
||||
"runtimeRequired": "Install container runtimes first"
|
||||
},
|
||||
"agents": {
|
||||
"creator": "Creator",
|
||||
"managers": "Managers"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Skill Detail",
|
||||
"content": "Content",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"description": "Mô tả",
|
||||
"source": "Nguồn",
|
||||
"author": "Tác giả",
|
||||
"agents": "Agents",
|
||||
"visibility": "Hiển thị",
|
||||
"status": "Trạng thái",
|
||||
"actions": "Thao tác",
|
||||
@@ -53,7 +54,12 @@
|
||||
"skillUnchanged": "Nội dung không đổi — đã bỏ qua",
|
||||
"slugConflict": "Slug trùng với kỹ năng hệ thống",
|
||||
"unchanged": "Không đổi",
|
||||
"new": "Mới"
|
||||
"new": "Mới",
|
||||
"agentManagers": "Cấp quyền quản lý cho agents",
|
||||
"managerAgentsHelp": "Các agent hiện tại được chọn có thể cập nhật skill này sau.",
|
||||
"selectAllAgents": "Chọn tất cả agents",
|
||||
"clearAgents": "Bỏ chọn agents",
|
||||
"noAgents": "Chưa có agent"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Chỉnh sửa skill",
|
||||
@@ -93,6 +99,7 @@
|
||||
"rescanSuccess": "Updated {{count}} skills",
|
||||
"missing": "Missing: {{deps}}",
|
||||
"install": "Install Dependencies",
|
||||
"installAll": "Install all dependencies",
|
||||
"installing": "Installing...",
|
||||
"installSuccess": "Dependencies installed",
|
||||
"installPartial": "Some dependencies failed",
|
||||
@@ -110,6 +117,10 @@
|
||||
"runtimeMissingAction": "Mở trang Packages để kiểm tra hoặc cài runtime trong container này.",
|
||||
"runtimeRequired": "Hãy cài runtime trong container trước"
|
||||
},
|
||||
"agents": {
|
||||
"creator": "Agent tạo",
|
||||
"managers": "Agent quản lý"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Chi tiết skill",
|
||||
"content": "Nội dung",
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
"description": "描述",
|
||||
"source": "来源",
|
||||
"author": "作者",
|
||||
"agents": "Agent",
|
||||
"visibility": "可见性",
|
||||
"status": "状态",
|
||||
"actions": "操作",
|
||||
@@ -53,7 +54,12 @@
|
||||
"skillUnchanged": "内容未更改 — 已跳过",
|
||||
"slugConflict": "Slug 与系统技能冲突",
|
||||
"unchanged": "未更改",
|
||||
"new": "新建"
|
||||
"new": "新建",
|
||||
"agentManagers": "授予 Agent 管理权限",
|
||||
"managerAgentsHelp": "所选当前 Agent 以后可以更新此 Skill。",
|
||||
"selectAllAgents": "选择所有 Agent",
|
||||
"clearAgents": "清除 Agent",
|
||||
"noAgents": "暂无 Agent"
|
||||
},
|
||||
"edit": {
|
||||
"title": "编辑Skill",
|
||||
@@ -93,6 +99,7 @@
|
||||
"rescanSuccess": "Updated {{count}} skills",
|
||||
"missing": "Missing: {{deps}}",
|
||||
"install": "Install Dependencies",
|
||||
"installAll": "Install all dependencies",
|
||||
"installing": "Installing...",
|
||||
"installSuccess": "Dependencies installed",
|
||||
"installPartial": "Some dependencies failed",
|
||||
@@ -110,6 +117,10 @@
|
||||
"runtimeMissingAction": "打开 Packages 页面,在此容器中检查或安装运行时。",
|
||||
"runtimeRequired": "请先安装容器内运行时"
|
||||
},
|
||||
"agents": {
|
||||
"creator": "创建 Agent",
|
||||
"managers": "管理 Agent"
|
||||
},
|
||||
"detail": {
|
||||
"title": "Skill详情",
|
||||
"content": "内容",
|
||||
|
||||
@@ -21,7 +21,7 @@ export function useAgents() {
|
||||
const connected = useAuthStore((s) => s.connected);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: agents = [], isPending: loading, error: queryError } = useQuery({
|
||||
const { data: agents = [], isPending: loading, error: queryError, refetch } = useQuery({
|
||||
queryKey: queryKeys.agents.all,
|
||||
queryFn: async () => {
|
||||
// Try HTTP first (returns full agent data, filtered by user access)
|
||||
@@ -59,8 +59,11 @@ export function useAgents() {
|
||||
const error = queryError instanceof Error ? queryError.message : queryError ? "Failed to load agents" : null;
|
||||
|
||||
const invalidate = useCallback(
|
||||
() => queryClient.invalidateQueries({ queryKey: queryKeys.agents.all }),
|
||||
[queryClient],
|
||||
async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.all });
|
||||
await refetch();
|
||||
},
|
||||
[queryClient, refetch],
|
||||
);
|
||||
|
||||
const createAgent = useCallback(
|
||||
|
||||
@@ -24,6 +24,11 @@ export type SkillUploadResponse = {
|
||||
deps_errors?: string[];
|
||||
missing_deps?: string[];
|
||||
deps_installed?: boolean;
|
||||
grant_errors?: string[];
|
||||
};
|
||||
|
||||
export type SkillUploadOptions = {
|
||||
managerAgentIds?: string[];
|
||||
};
|
||||
|
||||
export function useSkills() {
|
||||
@@ -62,9 +67,12 @@ export function useSkills() {
|
||||
);
|
||||
|
||||
const uploadSkill = useCallback(
|
||||
async (file: File) => {
|
||||
async (file: File, options?: SkillUploadOptions) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
if (options?.managerAgentIds?.length) {
|
||||
formData.append("manager_agent_ids", JSON.stringify(options.managerAgentIds));
|
||||
}
|
||||
const res = await http.upload<SkillUploadResponse>(
|
||||
"/v1/skills/upload",
|
||||
formData,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseSkillDetailVersionParam,
|
||||
shouldLoadSkillDetailFile,
|
||||
} from "./skill-detail-deeplink";
|
||||
|
||||
describe("skill detail deeplink helpers", () => {
|
||||
it("parses valid version params", () => {
|
||||
expect(parseSkillDetailVersionParam("1")).toBe(1);
|
||||
expect(parseSkillDetailVersionParam("42")).toBe(42);
|
||||
});
|
||||
|
||||
it("rejects malformed version params", () => {
|
||||
expect(parseSkillDetailVersionParam(null)).toBeNull();
|
||||
expect(parseSkillDetailVersionParam("")).toBeNull();
|
||||
expect(parseSkillDetailVersionParam("abc")).toBeNull();
|
||||
expect(parseSkillDetailVersionParam("1.5")).toBeNull();
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
export function parseSkillDetailVersionParam(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const parsed = Number(value);
|
||||
if (!Number.isInteger(parsed) || parsed <= 0) return null;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function shouldLoadSkillDetailFile(
|
||||
detailTab: string,
|
||||
selectedFilePath: string | null,
|
||||
filesCount: number,
|
||||
activePath: string | null,
|
||||
): selectedFilePath is string {
|
||||
return detailTab === "files" && !!selectedFilePath && filesCount > 0 && activePath !== selectedFilePath;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ export function SkillAgentGrantsDialog({
|
||||
{grants.map((grant) => (
|
||||
<div key={grant.agent_id} className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium">{agentNames.get(grant.agent_id) || grant.agent_id}</p>
|
||||
<p className="truncate text-sm font-medium">{grant.display_name || grant.agent_key || agentNames.get(grant.agent_id) || grant.agent_id}</p>
|
||||
<div className="mt-1 flex items-center gap-1.5">
|
||||
<Badge variant="secondary" className="text-2xs">v{grant.pinned_version}</Badge>
|
||||
{grant.can_manage && (
|
||||
|
||||
@@ -19,9 +19,14 @@ import { MarkdownRenderer } from "@/components/shared/markdown-renderer";
|
||||
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";
|
||||
|
||||
interface SkillDetailDialogProps {
|
||||
skill: SkillInfo & { content: string };
|
||||
detailTab: string;
|
||||
selectedVersionParam: string | null;
|
||||
selectedFilePath: string | null;
|
||||
onStateChange: (updates: Record<string, string | null>) => void;
|
||||
onClose: () => void;
|
||||
getSkillVersions: (id: string) => Promise<SkillVersions>;
|
||||
getSkillFiles: (id: string, version?: number) => Promise<SkillFile[]>;
|
||||
@@ -30,6 +35,10 @@ interface SkillDetailDialogProps {
|
||||
|
||||
export function SkillDetailDialog({
|
||||
skill,
|
||||
detailTab,
|
||||
selectedVersionParam,
|
||||
selectedFilePath,
|
||||
onStateChange,
|
||||
onClose,
|
||||
getSkillVersions,
|
||||
getSkillFiles,
|
||||
@@ -40,7 +49,9 @@ export function SkillDetailDialog({
|
||||
|
||||
// Version state
|
||||
const [versions, setVersions] = useState<SkillVersions | null>(null);
|
||||
const [selectedVersion, setSelectedVersion] = useState<number | null>(null);
|
||||
const [selectedVersion, setSelectedVersion] = useState<number | null>(
|
||||
parseSkillDetailVersionParam(selectedVersionParam),
|
||||
);
|
||||
|
||||
// File tree state
|
||||
const [files, setFiles] = useState<SkillFile[]>([]);
|
||||
@@ -53,12 +64,22 @@ export function SkillDetailDialog({
|
||||
|
||||
const tree = useMemo(() => buildTree(files), [files]);
|
||||
|
||||
useEffect(() => {
|
||||
setVersions(null);
|
||||
setSelectedVersion(parseSkillDetailVersionParam(selectedVersionParam));
|
||||
setFiles([]);
|
||||
setActivePath(null);
|
||||
setFileContent(null);
|
||||
}, [skill.id, selectedVersionParam]);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
if (!skill.id || versions) return;
|
||||
const v = await getSkillVersions(skill.id);
|
||||
setVersions(v);
|
||||
setSelectedVersion(v.current);
|
||||
}, [skill.id, versions, getSkillVersions]);
|
||||
if (!selectedVersionParam) {
|
||||
setSelectedVersion(v.current);
|
||||
}
|
||||
}, [skill.id, versions, selectedVersionParam, getSkillVersions]);
|
||||
|
||||
const loadFiles = useCallback(async (version?: number) => {
|
||||
if (!skill.id) return;
|
||||
@@ -91,7 +112,26 @@ export function SkillDetailDialog({
|
||||
}
|
||||
}, [selectedVersion, loadFiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (detailTab !== "files" || !hasFiles) return;
|
||||
loadVersions();
|
||||
const versionParam = parseSkillDetailVersionParam(selectedVersionParam);
|
||||
if (versionParam !== null && versionParam !== selectedVersion) {
|
||||
setSelectedVersion(versionParam);
|
||||
return;
|
||||
}
|
||||
if (selectedVersion == null && skill.version) {
|
||||
setSelectedVersion(skill.version);
|
||||
}
|
||||
}, [detailTab, hasFiles, loadVersions, selectedVersion, selectedVersionParam, skill.version]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldLoadSkillDetailFile(detailTab, selectedFilePath, files.length, activePath)) return;
|
||||
loadFileContent(selectedFilePath);
|
||||
}, [activePath, detailTab, files.length, loadFileContent, selectedFilePath]);
|
||||
|
||||
const handleTabChange = (tab: string) => {
|
||||
onStateChange({ detailTab: tab });
|
||||
if (tab === "files" && hasFiles) {
|
||||
loadVersions();
|
||||
if (files.length === 0 && !filesLoading) {
|
||||
@@ -100,6 +140,21 @@ export function SkillDetailDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleVersionChange = (v: string) => {
|
||||
const next = Number(v);
|
||||
setSelectedVersion(next);
|
||||
onStateChange({ version: v, file: null });
|
||||
};
|
||||
|
||||
const handleFileSelect = (path: string) => {
|
||||
onStateChange({
|
||||
detailTab: "files",
|
||||
version: selectedVersion != null ? String(selectedVersion) : null,
|
||||
file: path,
|
||||
});
|
||||
loadFileContent(path);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={() => onClose()}>
|
||||
<DialogContent className="max-h-[85vh] md:min-h-[60vh] overflow-hidden flex flex-col sm:max-w-2xl md:max-w-4xl lg:max-w-5xl xl:max-w-6xl 2xl:max-w-7xl">
|
||||
@@ -117,6 +172,15 @@ export function SkillDetailDialog({
|
||||
{skill.description && (
|
||||
<p className="text-sm text-muted-foreground">{skill.description}</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-1 pt-1 text-xs text-muted-foreground">
|
||||
{skill.author && <span>{t("columns.author")}: {skill.author}</span>}
|
||||
{skill.creator_agent && (
|
||||
<span>{t("agents.creator")}: {skill.creator_agent.display_name || skill.creator_agent.agent_key || skill.creator_agent.id}</span>
|
||||
)}
|
||||
{skill.manager_agents && skill.manager_agents.length > 0 && (
|
||||
<span>{t("agents.managers")}: {skill.manager_agents.map((agent) => agent.display_name || agent.agent_key || agent.id).join(", ")}</span>
|
||||
)}
|
||||
</div>
|
||||
{skill.tags && skill.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1 pt-1">
|
||||
{skill.tags.map((tag) => (
|
||||
@@ -126,7 +190,7 @@ export function SkillDetailDialog({
|
||||
)}
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="content" className="flex-1 overflow-hidden flex flex-col" onValueChange={handleTabChange}>
|
||||
<Tabs value={detailTab === "files" && hasFiles ? "files" : "content"} 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>}
|
||||
@@ -149,7 +213,7 @@ export function SkillDetailDialog({
|
||||
<span className="text-sm text-muted-foreground">{t("detail.version")}</span>
|
||||
<Select
|
||||
value={String(selectedVersion ?? versions.current)}
|
||||
onValueChange={(v) => setSelectedVersion(Number(v))}
|
||||
onValueChange={handleVersionChange}
|
||||
>
|
||||
<SelectTrigger className="w-40 h-8">
|
||||
<SelectValue />
|
||||
@@ -169,7 +233,7 @@ export function SkillDetailDialog({
|
||||
tree={tree}
|
||||
filesLoading={filesLoading}
|
||||
activePath={activePath}
|
||||
onSelect={loadFileContent}
|
||||
onSelect={handleFileSelect}
|
||||
contentLoading={contentLoading}
|
||||
fileContent={fileContent}
|
||||
/>
|
||||
|
||||
@@ -18,7 +18,7 @@ interface SkillTableRowProps {
|
||||
tab: "core" | "custom";
|
||||
hasTenantScope: boolean;
|
||||
toggling: string | null;
|
||||
onView: (name: string) => void;
|
||||
onView: (skill: SkillInfo) => void;
|
||||
onEdit: (skill: SkillInfo) => void;
|
||||
onManageGrants: (skill: SkillInfo) => void;
|
||||
onDelete: (skill: SkillInfo) => void;
|
||||
@@ -47,7 +47,7 @@ export function SkillTableRow({
|
||||
<button
|
||||
type="button"
|
||||
className="font-medium text-left hover:underline cursor-pointer"
|
||||
onClick={() => onView(skill.name)}
|
||||
onClick={() => onView(skill)}
|
||||
>
|
||||
{skill.name}
|
||||
</button>
|
||||
@@ -63,7 +63,23 @@ export function SkillTableRow({
|
||||
{skill.description || t("noDescription")}
|
||||
</td>
|
||||
{tab === "custom" && (
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">{skill.author || "—"}</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
<div className="flex max-w-[220px] flex-col gap-1">
|
||||
{skill.author && <span className="truncate">{skill.author}</span>}
|
||||
{skill.creator_agent && (
|
||||
<span className="truncate text-2xs">
|
||||
{t("agents.creator")}: {skill.creator_agent.display_name || skill.creator_agent.agent_key || skill.creator_agent.id}
|
||||
</span>
|
||||
)}
|
||||
{skill.manager_agents && skill.manager_agents.length > 0 ? (
|
||||
<span className="truncate text-2xs">
|
||||
{t("agents.managers")}: {skill.manager_agents.map((agent) => agent.display_name || agent.agent_key || agent.id).join(", ")}
|
||||
</span>
|
||||
) : !skill.author && !skill.creator_agent ? (
|
||||
<span>—</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Upload } from "lucide-react";
|
||||
import {
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useAgents } from "@/pages/agents/hooks/use-agents";
|
||||
import { createSkillSubZip } from "./lib/create-skill-sub-zip";
|
||||
import { resolveUploadSkills } from "./lib/resolve-upload-skills";
|
||||
import { uniqueId } from "@/lib/utils";
|
||||
import type { SkillUploadResponse } from "./hooks/use-skills";
|
||||
import type { SkillUploadOptions, SkillUploadResponse } from "./hooks/use-skills";
|
||||
import type { FileEntry, SkillStatus } from "./lib/skill-upload-types";
|
||||
import { FileEntryBlock } from "./skill-upload-entry";
|
||||
import JSZip from "jszip";
|
||||
@@ -21,7 +24,7 @@ import JSZip from "jszip";
|
||||
interface SkillUploadDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onUpload: (file: File) => Promise<SkillUploadResponse>;
|
||||
onUpload: (file: File, options?: SkillUploadOptions) => Promise<SkillUploadResponse>;
|
||||
}
|
||||
|
||||
export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadDialogProps) {
|
||||
@@ -30,8 +33,15 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [done, setDone] = useState(false);
|
||||
const [grantManagers, setGrantManagers] = useState(true);
|
||||
const [managerAgentIds, setManagerAgentIds] = useState<string[]>([]);
|
||||
const { agents, refresh: refreshAgents } = useAgents();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) refreshAgents();
|
||||
}, [open, refreshAgents]);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// File handling
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -116,24 +126,44 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
uploadFile = fileEntry.file;
|
||||
}
|
||||
|
||||
const result = await onUpload(uploadFile);
|
||||
const result = await onUpload(uploadFile, {
|
||||
managerAgentIds: grantManagers ? managerAgentIds : [],
|
||||
});
|
||||
|
||||
if (result.status === "unchanged") {
|
||||
const grantDetail = result.grant_errors?.length
|
||||
? result.grant_errors.join("; ")
|
||||
: undefined;
|
||||
setEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
e.id === fileEntry.id
|
||||
? { ...e, skills: e.skills.map((s) => s.id === skill.id ? { ...s, status: "unchanged" as SkillStatus } : s) }
|
||||
? {
|
||||
...e,
|
||||
skills: e.skills.map((s) =>
|
||||
s.id === skill.id
|
||||
? {
|
||||
...s,
|
||||
status: grantDetail ? ("warning" as SkillStatus) : ("unchanged" as SkillStatus),
|
||||
error: grantDetail,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
}
|
||||
: e,
|
||||
),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const grantDetail = result.grant_errors?.length
|
||||
? result.grant_errors.join("; ")
|
||||
: undefined;
|
||||
const depDetail = result.deps_warning
|
||||
? result.deps_errors?.length
|
||||
? `${result.deps_warning}: ${result.deps_errors.join("; ")}`
|
||||
: result.deps_warning
|
||||
: undefined;
|
||||
const warningDetail = [depDetail, grantDetail].filter(Boolean).join("; ") || undefined;
|
||||
|
||||
setEntries((prev) =>
|
||||
prev.map((e) =>
|
||||
@@ -144,8 +174,8 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
s.id === skill.id
|
||||
? {
|
||||
...s,
|
||||
status: result.deps_warning ? ("warning" as SkillStatus) : ("success" as SkillStatus),
|
||||
error: depDetail,
|
||||
status: warningDetail ? ("warning" as SkillStatus) : ("success" as SkillStatus),
|
||||
error: warningDetail,
|
||||
}
|
||||
: s,
|
||||
),
|
||||
@@ -188,6 +218,8 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
setEntries([]);
|
||||
setDragging(false);
|
||||
setDone(false);
|
||||
setGrantManagers(true);
|
||||
setManagerAgentIds([]);
|
||||
onOpenChange(v);
|
||||
};
|
||||
|
||||
@@ -209,6 +241,17 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
const allSkills = entries.flatMap((e) => e.skills);
|
||||
const actionableCount = allSkills.filter((s) => s.status === "valid").length;
|
||||
const successCount = allSkills.filter((s) => s.status === "success" || s.status === "warning").length;
|
||||
const allCurrentAgentsSelected = agents.length > 0 && managerAgentIds.length === agents.length;
|
||||
|
||||
const toggleManagerAgent = (id: string) => {
|
||||
setManagerAgentIds((current) =>
|
||||
current.includes(id) ? current.filter((item) => item !== id) : [...current, id],
|
||||
);
|
||||
};
|
||||
|
||||
const toggleAllAgents = () => {
|
||||
setManagerAgentIds(allCurrentAgentsSelected ? [] : agents.map((agent) => agent.id));
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Render
|
||||
@@ -272,6 +315,40 @@ export function SkillUploadDialog({ open, onOpenChange, onUpload }: SkillUploadD
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entries.length > 0 && !uploading && !done && (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<label className="flex items-center justify-between gap-3">
|
||||
<span className="text-sm font-medium">{t("upload.agentManagers")}</span>
|
||||
<Switch checked={grantManagers} onCheckedChange={setGrantManagers} />
|
||||
</label>
|
||||
{grantManagers && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label className="text-xs text-muted-foreground">{t("upload.managerAgentsHelp")}</Label>
|
||||
<Button type="button" variant="ghost" size="sm" onClick={toggleAllAgents} disabled={agents.length === 0}>
|
||||
{allCurrentAgentsSelected ? t("upload.clearAgents") : t("upload.selectAllAgents")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="max-h-32 overflow-y-auto rounded-md border">
|
||||
{agents.length === 0 ? (
|
||||
<p className="px-3 py-2 text-sm text-muted-foreground">{t("upload.noAgents")}</p>
|
||||
) : agents.map((agent) => (
|
||||
<label key={agent.id} className="flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted/40">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={managerAgentIds.includes(agent.id)}
|
||||
onChange={() => toggleManagerAgent(agent.id)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<span className="min-w-0 truncate">{agent.display_name || agent.agent_key}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary line */}
|
||||
{entries.length > 0 && !done && !uploading && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect, lazy, Suspense } from "react";
|
||||
import { useSearchParams } from "react-router";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Zap, RefreshCw, Upload, ScanSearch } from "lucide-react";
|
||||
import { Zap, RefreshCw, Upload, ScanSearch, Download } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { PageHeader } from "@/components/shared/page-header";
|
||||
import { EmptyState } from "@/components/shared/empty-state";
|
||||
@@ -34,15 +35,17 @@ export function SkillsPage() {
|
||||
const {
|
||||
skills, loading, refresh, getSkill, uploadSkill, updateSkill, deleteSkill,
|
||||
listAgentGrants, grantSkillToAgent, revokeSkillFromAgent,
|
||||
getSkillVersions, getSkillFiles, getSkillFileContent, rescanDeps, installSingleDep, toggleSkill,
|
||||
getSkillVersions, getSkillFiles, getSkillFileContent, rescanDeps, installDeps, installSingleDep, toggleSkill,
|
||||
setTenantConfig, deleteTenantConfig,
|
||||
} = useSkills();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const { runtimes } = useRuntimes();
|
||||
const { currentTenantId } = useTenants();
|
||||
const hasTenantScope = !!currentTenantId && currentTenantId !== MASTER_TENANT_ID;
|
||||
const spinning = useMinLoading(loading);
|
||||
const showSkeleton = useDeferredLoading(loading && skills.length === 0);
|
||||
const [tab, setTab] = useState<Tab>("core");
|
||||
const urlTab = params.get("tab") === "custom" ? "custom" : "core";
|
||||
const tab: Tab = urlTab;
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedSkill, setSelectedSkill] = useState<(SkillInfo & { content: string }) | null>(null);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
@@ -51,6 +54,7 @@ export function SkillsPage() {
|
||||
const [deleteTarget, setDeleteTarget] = useState<SkillInfo | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
const [rescanning, setRescanning] = useState(false);
|
||||
const [installingDeps, setInstallingDeps] = useState(false);
|
||||
const [toggling, setToggling] = useState<string | null>(null);
|
||||
|
||||
const coreSkills = skills.filter((s: SkillInfo) => s.is_system);
|
||||
@@ -66,11 +70,58 @@ export function SkillsPage() {
|
||||
|
||||
useEffect(() => { resetPage(); }, [search, tab, resetPage]);
|
||||
|
||||
const handleViewSkill = async (name: string) => {
|
||||
const detail = await getSkill(name);
|
||||
if (detail) setSelectedSkill(detail);
|
||||
const setParamValues = (updates: Record<string, string | null>) => {
|
||||
const next = new URLSearchParams(params);
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value) next.set(key, value);
|
||||
else next.delete(key);
|
||||
}
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const setTab = (nextTab: Tab) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set("tab", nextTab);
|
||||
next.delete("skill");
|
||||
next.delete("detailTab");
|
||||
next.delete("version");
|
||||
next.delete("file");
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const closeDetail = () => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete("skill");
|
||||
next.delete("detailTab");
|
||||
next.delete("version");
|
||||
next.delete("file");
|
||||
setParams(next, { replace: true });
|
||||
setSelectedSkill(null);
|
||||
};
|
||||
|
||||
const handleViewSkill = async (skill: SkillInfo) => {
|
||||
const next = new URLSearchParams(params);
|
||||
next.set("tab", skill.is_system ? "core" : "custom");
|
||||
next.set("skill", skill.id || skill.slug || skill.name);
|
||||
next.set("detailTab", "content");
|
||||
next.delete("version");
|
||||
next.delete("file");
|
||||
setParams(next, { replace: true });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const skillRef = params.get("skill");
|
||||
if (!skillRef) {
|
||||
setSelectedSkill(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
getSkill(skillRef).then((detail) => {
|
||||
if (!cancelled) setSelectedSkill(detail);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [params, getSkill]);
|
||||
|
||||
const handleCycleVisibility = async (skill: SkillInfo) => {
|
||||
if (!skill.id) return;
|
||||
const order = ["private", "internal", "public"] as const;
|
||||
@@ -90,6 +141,11 @@ export function SkillsPage() {
|
||||
try { await rescanDeps(); } finally { setRescanning(false); }
|
||||
};
|
||||
|
||||
const handleInstallDeps = async () => {
|
||||
setInstallingDeps(true);
|
||||
try { await installDeps(); } finally { setInstallingDeps(false); }
|
||||
};
|
||||
|
||||
const handleToggle = async (skill: SkillInfo, enabled: boolean) => {
|
||||
if (!skill.id) return;
|
||||
setToggling(skill.id);
|
||||
@@ -121,6 +177,9 @@ export function SkillsPage() {
|
||||
<Button variant="outline" size="sm" onClick={handleRescanDeps} disabled={rescanning} className="gap-1">
|
||||
<ScanSearch className="h-3.5 w-3.5" /> {t("deps.rescan")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleInstallDeps} disabled={installingDeps || allMissing.length === 0} className="gap-1">
|
||||
<Download className="h-3.5 w-3.5" /> {installingDeps ? t("deps.installing") : t("deps.installAll")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={refresh} disabled={spinning} className="gap-1">
|
||||
<RefreshCw className={"h-3.5 w-3.5" + (spinning ? " animate-spin" : "")} /> {t("refresh", { ns: "common" })}
|
||||
</Button>
|
||||
@@ -167,7 +226,7 @@ export function SkillsPage() {
|
||||
<tr className="border-b bg-muted/50">
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.name")}</th>
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.description")}</th>
|
||||
{tab === "custom" && <th className="px-4 py-3 text-left font-medium">{t("columns.author")}</th>}
|
||||
{tab === "custom" && <th className="px-4 py-3 text-left font-medium">{t("columns.agents")}</th>}
|
||||
<th className="px-4 py-3 text-left font-medium">{t("columns.status")}</th>
|
||||
{tab === "custom" && <th className="px-4 py-3 text-left font-medium">{t("columns.visibility")}</th>}
|
||||
<th className="px-4 py-3 text-right font-medium">{t("columns.actions")}</th>
|
||||
@@ -208,7 +267,11 @@ export function SkillsPage() {
|
||||
{selectedSkill && (
|
||||
<SkillDetailDialog
|
||||
skill={selectedSkill}
|
||||
onClose={() => setSelectedSkill(null)}
|
||||
detailTab={params.get("detailTab") || "content"}
|
||||
selectedVersionParam={params.get("version")}
|
||||
selectedFilePath={params.get("file")}
|
||||
onStateChange={setParamValues}
|
||||
onClose={closeDetail}
|
||||
getSkillVersions={getSkillVersions}
|
||||
getSkillFiles={getSkillFiles}
|
||||
getSkillFileContent={getSkillFileContent}
|
||||
@@ -234,7 +297,7 @@ export function SkillsPage() {
|
||||
)}
|
||||
|
||||
<Suspense fallback={null}>
|
||||
<SkillUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onUpload={(f) => uploadSkill(f)} />
|
||||
<SkillUploadDialog open={uploadOpen} onOpenChange={setUploadOpen} onUpload={uploadSkill} />
|
||||
</Suspense>
|
||||
|
||||
<ConfirmDeleteDialog
|
||||
|
||||
@@ -12,9 +12,17 @@ export interface SkillInfo {
|
||||
enabled?: boolean;
|
||||
tenant_enabled?: boolean | null;
|
||||
author?: string;
|
||||
creator_agent?: SkillAgentRef;
|
||||
manager_agents?: SkillAgentRef[];
|
||||
missing_deps?: string[];
|
||||
}
|
||||
|
||||
export interface SkillAgentRef {
|
||||
id?: string;
|
||||
agent_key?: string;
|
||||
display_name?: string;
|
||||
}
|
||||
|
||||
export interface SkillFile {
|
||||
path: string;
|
||||
name: string;
|
||||
@@ -42,6 +50,8 @@ export interface SkillWithGrant {
|
||||
|
||||
export interface SkillAgentGrant {
|
||||
agent_id: string;
|
||||
agent_key?: string;
|
||||
display_name?: string;
|
||||
pinned_version: number;
|
||||
granted_by: string;
|
||||
can_manage: boolean;
|
||||
|
||||
Reference in new issue
Block a user