mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-14 00:16:38 +00:00
feat(skills): add per-tenant skill config HTTP endpoints (#428)
Add PUT/DELETE /v1/skills/{id}/tenant-config routes for managing
per-tenant skill visibility overrides. Enrich GET /v1/skills list
with tenant_enabled field when tenant-scoped. Add ListAll method
to SkillTenantConfigStore interface + PG implementation.
This commit is contained in:
1 parent
db0f62215a
commit
c5164255ef
4 files changed
+129
-10
No files matched your search
@@ -33,7 +33,7 @@ func wireHTTP(stores *store.Stores, defaultWorkspace, dataDir, bundledSkillsDir
|
||||
if pgSkills, ok := stores.Skills.(*pg.PGSkillStore); ok {
|
||||
dirs := pgSkills.Dirs()
|
||||
if len(dirs) > 0 {
|
||||
skillsH = httpapi.NewSkillsHandler(pgSkills, dirs[0], dataDir, bundledSkillsDir, msgBus)
|
||||
skillsH = httpapi.NewSkillsHandler(pgSkills, dirs[0], dataDir, bundledSkillsDir, msgBus, stores.SkillTenantCfgs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+105
-9
@@ -24,16 +24,17 @@ const maxSkillUploadSize = 20 << 20 // 20 MB
|
||||
|
||||
// SkillsHandler handles skill management HTTP endpoints.
|
||||
type SkillsHandler struct {
|
||||
skills *pg.PGSkillStore
|
||||
baseDir string // filesystem base for skill content (skills-store/) — master tenant
|
||||
dataDir string // parent data dir for tenant-scoped skill paths
|
||||
bundledDir string // original bundled skills dir (fallback for broken managed copies)
|
||||
msgBus *bus.MessageBus
|
||||
skills *pg.PGSkillStore
|
||||
baseDir string // filesystem base for skill content (skills-store/) — master tenant
|
||||
dataDir string // parent data dir for tenant-scoped skill paths
|
||||
bundledDir string // original bundled skills dir (fallback for broken managed copies)
|
||||
msgBus *bus.MessageBus
|
||||
tenantCfgStore store.SkillTenantConfigStore
|
||||
}
|
||||
|
||||
// NewSkillsHandler creates a handler for skill management endpoints.
|
||||
func NewSkillsHandler(skills *pg.PGSkillStore, baseDir, dataDir, bundledDir string, msgBus *bus.MessageBus) *SkillsHandler {
|
||||
return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, msgBus: msgBus}
|
||||
func NewSkillsHandler(skills *pg.PGSkillStore, baseDir, dataDir, bundledDir string, msgBus *bus.MessageBus, tenantCfgStore store.SkillTenantConfigStore) *SkillsHandler {
|
||||
return &SkillsHandler{skills: skills, baseDir: baseDir, dataDir: dataDir, bundledDir: bundledDir, msgBus: msgBus, tenantCfgStore: tenantCfgStore}
|
||||
}
|
||||
|
||||
// tenantSkillsDir returns the skills-store directory scoped to the requesting tenant.
|
||||
@@ -77,6 +78,8 @@ func (h *SkillsHandler) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /v1/skills/install-dep", h.adminMiddleware(h.handleInstallDep))
|
||||
mux.HandleFunc("GET /v1/skills/runtimes", h.adminMiddleware(h.handleRuntimes))
|
||||
mux.HandleFunc("POST /v1/skills/{id}/toggle", h.adminMiddleware(h.handleToggle))
|
||||
mux.HandleFunc("PUT /v1/skills/{id}/tenant-config", h.adminMiddleware(h.handleSetTenantConfig))
|
||||
mux.HandleFunc("DELETE /v1/skills/{id}/tenant-config", h.adminMiddleware(h.handleDeleteTenantConfig))
|
||||
}
|
||||
|
||||
func (h *SkillsHandler) authMiddleware(next http.HandlerFunc) http.HandlerFunc {
|
||||
@@ -109,8 +112,35 @@ func (h *SkillsHandler) requireMasterTenant(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
|
||||
func (h *SkillsHandler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||
skills := h.skills.ListSkills(r.Context())
|
||||
writeJSON(w, http.StatusOK, map[string]any{"skills": skills})
|
||||
skillList := h.skills.ListSkills(r.Context())
|
||||
|
||||
// Merge per-tenant overrides into response when tenant-scoped
|
||||
tid := store.TenantIDFromContext(r.Context())
|
||||
if tid != uuid.Nil && h.tenantCfgStore != nil {
|
||||
overrides, err := h.tenantCfgStore.ListAll(r.Context(), tid)
|
||||
if err != nil {
|
||||
slog.Warn("skill tenant config list failed", "tenant", tid, "error", err)
|
||||
}
|
||||
if err == nil && len(overrides) > 0 {
|
||||
type skillWithTenant struct {
|
||||
store.SkillInfo
|
||||
TenantEnabled *bool `json:"tenant_enabled"`
|
||||
}
|
||||
enriched := make([]skillWithTenant, len(skillList))
|
||||
for i, sk := range skillList {
|
||||
enriched[i] = skillWithTenant{SkillInfo: sk}
|
||||
if skID, err := uuid.Parse(sk.ID); err == nil {
|
||||
if enabled, ok := overrides[skID]; ok {
|
||||
enriched[i].TenantEnabled = &enabled
|
||||
}
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"skills": enriched})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]any{"skills": skillList})
|
||||
}
|
||||
|
||||
func (h *SkillsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -488,3 +518,69 @@ func (h *SkillsHandler) handleToggle(w http.ResponseWriter, r *http.Request) {
|
||||
emitAudit(h.msgBus, r, "skill.toggled", "skill", idStr)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "enabled": body.Enabled, "status": newStatus})
|
||||
}
|
||||
|
||||
// handleSetTenantConfig sets a per-tenant override for a skill.
|
||||
func (h *SkillsHandler) handleSetTenantConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if h.tenantCfgStore == nil {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "tenant config not available"})
|
||||
return
|
||||
}
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
tid := store.TenantIDFromContext(r.Context())
|
||||
if tid == uuid.Nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tenant context required"})
|
||||
return
|
||||
}
|
||||
|
||||
idStr := r.PathValue("id")
|
||||
skillID, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidJSON)})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tenantCfgStore.Set(r.Context(), tid, skillID, body.Enabled); err != nil {
|
||||
slog.Warn("set tenant skill config failed", "skill", idStr, "tenant", tid, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "skill tenant config")})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "updated"})
|
||||
}
|
||||
|
||||
// handleDeleteTenantConfig removes a per-tenant override for a skill (reverts to default).
|
||||
func (h *SkillsHandler) handleDeleteTenantConfig(w http.ResponseWriter, r *http.Request) {
|
||||
if h.tenantCfgStore == nil {
|
||||
writeJSON(w, http.StatusNotImplemented, map[string]string{"error": "tenant config not available"})
|
||||
return
|
||||
}
|
||||
locale := store.LocaleFromContext(r.Context())
|
||||
tid := store.TenantIDFromContext(r.Context())
|
||||
if tid == uuid.Nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "tenant context required"})
|
||||
return
|
||||
}
|
||||
|
||||
idStr := r.PathValue("id")
|
||||
skillID, err := uuid.Parse(idStr)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": i18n.T(locale, i18n.MsgInvalidID, "skill")})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.tenantCfgStore.Delete(r.Context(), tid, skillID); err != nil {
|
||||
slog.Warn("delete tenant skill config failed", "skill", idStr, "tenant", tid, "error", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": i18n.T(locale, i18n.MsgInternalError, "skill tenant config")})
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||
}
|
||||
@@ -107,6 +107,27 @@ func (s *PGSkillTenantConfigStore) ListDisabledSkillIDs(ctx context.Context, ten
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PGSkillTenantConfigStore) ListAll(ctx context.Context, tenantID uuid.UUID) (map[uuid.UUID]bool, error) {
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT skill_id, enabled FROM skill_tenant_configs WHERE tenant_id = $1`,
|
||||
tenantID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make(map[uuid.UUID]bool)
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var enabled bool
|
||||
if err := rows.Scan(&id, &enabled); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[id] = enabled
|
||||
}
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
func (s *PGSkillTenantConfigStore) Set(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID, enabled bool) error {
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`INSERT INTO skill_tenant_configs (skill_id, tenant_id, enabled, updated_at)
|
||||
|
||||
@@ -36,6 +36,8 @@ type SkillTenantConfig struct {
|
||||
type SkillTenantConfigStore interface {
|
||||
// ListDisabledSkillIDs returns skill IDs disabled for a tenant.
|
||||
ListDisabledSkillIDs(ctx context.Context, tenantID uuid.UUID) ([]uuid.UUID, error)
|
||||
// ListAll returns all tenant overrides (skillID → enabled) for a tenant.
|
||||
ListAll(ctx context.Context, tenantID uuid.UUID) (map[uuid.UUID]bool, error)
|
||||
// Set creates or updates a tenant skill config.
|
||||
Set(ctx context.Context, tenantID uuid.UUID, skillID uuid.UUID, enabled bool) error
|
||||
// Delete removes a tenant skill config (reverts to default).
|
||||
|
||||
Reference in new issue
Block a user