Merge origin/main which added the skills system (structured skills with meta skill runtime injection). Resolve 4 conflicts: - workspace/store.ts: keep both skills state + issue/inbox fetch - types/index.ts: keep Skill types + our event exports - handler/agent.go: merge visibility filtering + skills batch loading - pnpm-lock.yaml: accept main's lockfile with skills deps Also fix skill.go: migrate h.broadcast → h.publish (event bus) to match our architecture where all WS events go through the bus. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
540 lines
14 KiB
Go
540 lines
14 KiB
Go
package handler
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5/pgtype"
|
|
db "github.com/multica-ai/multica/server/pkg/db/generated"
|
|
)
|
|
|
|
// --- Response structs ---
|
|
|
|
type SkillResponse struct {
|
|
ID string `json:"id"`
|
|
WorkspaceID string `json:"workspace_id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Content string `json:"content"`
|
|
Config any `json:"config"`
|
|
CreatedBy *string `json:"created_by"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type SkillFileResponse struct {
|
|
ID string `json:"id"`
|
|
SkillID string `json:"skill_id"`
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
type SkillWithFilesResponse struct {
|
|
SkillResponse
|
|
Files []SkillFileResponse `json:"files"`
|
|
}
|
|
|
|
func skillToResponse(s db.Skill) SkillResponse {
|
|
var config any
|
|
if s.Config != nil {
|
|
json.Unmarshal(s.Config, &config)
|
|
}
|
|
if config == nil {
|
|
config = map[string]any{}
|
|
}
|
|
|
|
return SkillResponse{
|
|
ID: uuidToString(s.ID),
|
|
WorkspaceID: uuidToString(s.WorkspaceID),
|
|
Name: s.Name,
|
|
Description: s.Description,
|
|
Content: s.Content,
|
|
Config: config,
|
|
CreatedBy: uuidToPtr(s.CreatedBy),
|
|
CreatedAt: timestampToString(s.CreatedAt),
|
|
UpdatedAt: timestampToString(s.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
func skillFileToResponse(f db.SkillFile) SkillFileResponse {
|
|
return SkillFileResponse{
|
|
ID: uuidToString(f.ID),
|
|
SkillID: uuidToString(f.SkillID),
|
|
Path: f.Path,
|
|
Content: f.Content,
|
|
CreatedAt: timestampToString(f.CreatedAt),
|
|
UpdatedAt: timestampToString(f.UpdatedAt),
|
|
}
|
|
}
|
|
|
|
// --- Request structs ---
|
|
|
|
type CreateSkillRequest struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
Content string `json:"content"`
|
|
Config any `json:"config"`
|
|
Files []CreateSkillFileRequest `json:"files,omitempty"`
|
|
}
|
|
|
|
type CreateSkillFileRequest struct {
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
}
|
|
|
|
type UpdateSkillRequest struct {
|
|
Name *string `json:"name"`
|
|
Description *string `json:"description"`
|
|
Content *string `json:"content"`
|
|
Config any `json:"config"`
|
|
Files []CreateSkillFileRequest `json:"files,omitempty"`
|
|
}
|
|
|
|
type SetAgentSkillsRequest struct {
|
|
SkillIDs []string `json:"skill_ids"`
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
// validateFilePath checks that a file path is safe (no traversal, no absolute paths).
|
|
func validateFilePath(p string) bool {
|
|
if p == "" {
|
|
return false
|
|
}
|
|
if filepath.IsAbs(p) {
|
|
return false
|
|
}
|
|
cleaned := filepath.Clean(p)
|
|
if strings.HasPrefix(cleaned, "..") {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (h *Handler) loadSkillForUser(w http.ResponseWriter, r *http.Request, id string) (db.Skill, bool) {
|
|
skill, err := h.Queries.GetSkill(r.Context(), parseUUID(id))
|
|
if err != nil {
|
|
if isNotFound(err) {
|
|
writeError(w, http.StatusNotFound, "skill not found")
|
|
} else {
|
|
writeError(w, http.StatusInternalServerError, "failed to load skill")
|
|
}
|
|
return skill, false
|
|
}
|
|
if _, ok := h.requireWorkspaceMember(w, r, uuidToString(skill.WorkspaceID), "skill not found"); !ok {
|
|
return skill, false
|
|
}
|
|
return skill, true
|
|
}
|
|
|
|
// --- Skill CRUD ---
|
|
|
|
func (h *Handler) ListSkills(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := resolveWorkspaceID(r)
|
|
if _, ok := h.requireWorkspaceMember(w, r, workspaceID, "workspace not found"); !ok {
|
|
return
|
|
}
|
|
|
|
skills, err := h.Queries.ListSkillsByWorkspace(r.Context(), parseUUID(workspaceID))
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list skills")
|
|
return
|
|
}
|
|
|
|
resp := make([]SkillResponse, len(skills))
|
|
for i, s := range skills {
|
|
resp[i] = skillToResponse(s)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) GetSkill(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
skill, ok := h.loadSkillForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
files, err := h.Queries.ListSkillFiles(r.Context(), skill.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list skill files")
|
|
return
|
|
}
|
|
|
|
fileResps := make([]SkillFileResponse, len(files))
|
|
for i, f := range files {
|
|
fileResps[i] = skillFileToResponse(f)
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, SkillWithFilesResponse{
|
|
SkillResponse: skillToResponse(skill),
|
|
Files: fileResps,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) CreateSkill(w http.ResponseWriter, r *http.Request) {
|
|
workspaceID := resolveWorkspaceID(r)
|
|
if _, ok := h.requireWorkspaceRole(w, r, workspaceID, "workspace not found", "owner", "admin"); !ok {
|
|
return
|
|
}
|
|
|
|
creatorID, ok := requireUserID(w, r)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateSkillRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if req.Name == "" {
|
|
writeError(w, http.StatusBadRequest, "name is required")
|
|
return
|
|
}
|
|
|
|
for _, f := range req.Files {
|
|
if !validateFilePath(f.Path) {
|
|
writeError(w, http.StatusBadRequest, "invalid file path: "+f.Path)
|
|
return
|
|
}
|
|
}
|
|
|
|
config, _ := json.Marshal(req.Config)
|
|
if req.Config == nil {
|
|
config = []byte("{}")
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to start transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
skill, err := qtx.CreateSkill(r.Context(), db.CreateSkillParams{
|
|
WorkspaceID: parseUUID(workspaceID),
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
Content: req.Content,
|
|
Config: config,
|
|
CreatedBy: parseUUID(creatorID),
|
|
})
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
writeError(w, http.StatusConflict, "a skill with this name already exists")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to create skill: "+err.Error())
|
|
return
|
|
}
|
|
|
|
fileResps := make([]SkillFileResponse, 0, len(req.Files))
|
|
for _, f := range req.Files {
|
|
sf, err := qtx.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
|
|
SkillID: skill.ID,
|
|
Path: f.Path,
|
|
Content: f.Content,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to create skill file: "+err.Error())
|
|
return
|
|
}
|
|
fileResps = append(fileResps, skillFileToResponse(sf))
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to commit")
|
|
return
|
|
}
|
|
|
|
resp := SkillWithFilesResponse{
|
|
SkillResponse: skillToResponse(skill),
|
|
Files: fileResps,
|
|
}
|
|
h.publish("skill:created", workspaceID, "member", creatorID, map[string]any{"skill": resp})
|
|
writeJSON(w, http.StatusCreated, resp)
|
|
}
|
|
|
|
func (h *Handler) UpdateSkill(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
skill, ok := h.loadSkillForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok {
|
|
return
|
|
}
|
|
|
|
var req UpdateSkillRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
for _, f := range req.Files {
|
|
if !validateFilePath(f.Path) {
|
|
writeError(w, http.StatusBadRequest, "invalid file path: "+f.Path)
|
|
return
|
|
}
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to start transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
params := db.UpdateSkillParams{
|
|
ID: parseUUID(id),
|
|
}
|
|
if req.Name != nil {
|
|
params.Name = pgtype.Text{String: *req.Name, Valid: true}
|
|
}
|
|
if req.Description != nil {
|
|
params.Description = pgtype.Text{String: *req.Description, Valid: true}
|
|
}
|
|
if req.Content != nil {
|
|
params.Content = pgtype.Text{String: *req.Content, Valid: true}
|
|
}
|
|
if req.Config != nil {
|
|
config, _ := json.Marshal(req.Config)
|
|
params.Config = config
|
|
}
|
|
|
|
skill, err = qtx.UpdateSkill(r.Context(), params)
|
|
if err != nil {
|
|
if isUniqueViolation(err) {
|
|
writeError(w, http.StatusConflict, "a skill with this name already exists")
|
|
return
|
|
}
|
|
writeError(w, http.StatusInternalServerError, "failed to update skill: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// If files are provided, replace all files.
|
|
var fileResps []SkillFileResponse
|
|
if req.Files != nil {
|
|
if err := qtx.DeleteSkillFilesBySkill(r.Context(), skill.ID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete old skill files")
|
|
return
|
|
}
|
|
fileResps = make([]SkillFileResponse, 0, len(req.Files))
|
|
for _, f := range req.Files {
|
|
sf, err := qtx.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
|
|
SkillID: skill.ID,
|
|
Path: f.Path,
|
|
Content: f.Content,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to upsert skill file: "+err.Error())
|
|
return
|
|
}
|
|
fileResps = append(fileResps, skillFileToResponse(sf))
|
|
}
|
|
} else {
|
|
files, _ := qtx.ListSkillFiles(r.Context(), skill.ID)
|
|
fileResps = make([]SkillFileResponse, len(files))
|
|
for i, f := range files {
|
|
fileResps[i] = skillFileToResponse(f)
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to commit")
|
|
return
|
|
}
|
|
|
|
resp := SkillWithFilesResponse{
|
|
SkillResponse: skillToResponse(skill),
|
|
Files: fileResps,
|
|
}
|
|
h.publish("skill:updated", resolveWorkspaceID(r), "member", requestUserID(r), map[string]any{"skill": resp})
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) DeleteSkill(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
skill, ok := h.loadSkillForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok {
|
|
return
|
|
}
|
|
|
|
if err := h.Queries.DeleteSkill(r.Context(), parseUUID(id)); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete skill")
|
|
return
|
|
}
|
|
h.publish("skill:deleted", uuidToString(skill.WorkspaceID), "member", requestUserID(r), map[string]any{"skill_id": id})
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- Skill File endpoints ---
|
|
|
|
func (h *Handler) ListSkillFiles(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
skill, ok := h.loadSkillForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
files, err := h.Queries.ListSkillFiles(r.Context(), skill.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list skill files")
|
|
return
|
|
}
|
|
|
|
resp := make([]SkillFileResponse, len(files))
|
|
for i, f := range files {
|
|
resp[i] = skillFileToResponse(f)
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) UpsertSkillFile(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
skill, ok := h.loadSkillForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok {
|
|
return
|
|
}
|
|
|
|
var req CreateSkillFileRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
if !validateFilePath(req.Path) {
|
|
writeError(w, http.StatusBadRequest, "invalid file path")
|
|
return
|
|
}
|
|
|
|
sf, err := h.Queries.UpsertSkillFile(r.Context(), db.UpsertSkillFileParams{
|
|
SkillID: skill.ID,
|
|
Path: req.Path,
|
|
Content: req.Content,
|
|
})
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to upsert skill file: "+err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, skillFileToResponse(sf))
|
|
}
|
|
|
|
func (h *Handler) DeleteSkillFile(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
skill, ok := h.loadSkillForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(skill.WorkspaceID), "skill not found", "owner", "admin"); !ok {
|
|
return
|
|
}
|
|
|
|
fileID := chi.URLParam(r, "fileId")
|
|
if err := h.Queries.DeleteSkillFile(r.Context(), parseUUID(fileID)); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to delete skill file")
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- Agent-Skill junction ---
|
|
|
|
func (h *Handler) ListAgentSkills(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
agent, ok := h.loadAgentForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
skills, err := h.Queries.ListAgentSkills(r.Context(), agent.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list agent skills")
|
|
return
|
|
}
|
|
|
|
resp := make([]SkillResponse, len(skills))
|
|
for i, s := range skills {
|
|
resp[i] = skillToResponse(s)
|
|
}
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|
|
|
|
func (h *Handler) SetAgentSkills(w http.ResponseWriter, r *http.Request) {
|
|
id := chi.URLParam(r, "id")
|
|
agent, ok := h.loadAgentForUser(w, r, id)
|
|
if !ok {
|
|
return
|
|
}
|
|
if _, ok := h.requireWorkspaceRole(w, r, uuidToString(agent.WorkspaceID), "agent not found", "owner", "admin"); !ok {
|
|
return
|
|
}
|
|
|
|
var req SetAgentSkillsRequest
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid request body")
|
|
return
|
|
}
|
|
|
|
tx, err := h.TxStarter.Begin(r.Context())
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to start transaction")
|
|
return
|
|
}
|
|
defer tx.Rollback(r.Context())
|
|
|
|
qtx := h.Queries.WithTx(tx)
|
|
|
|
if err := qtx.RemoveAllAgentSkills(r.Context(), agent.ID); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to clear agent skills")
|
|
return
|
|
}
|
|
|
|
for _, skillID := range req.SkillIDs {
|
|
if err := qtx.AddAgentSkill(r.Context(), db.AddAgentSkillParams{
|
|
AgentID: agent.ID,
|
|
SkillID: parseUUID(skillID),
|
|
}); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to add agent skill: "+err.Error())
|
|
return
|
|
}
|
|
}
|
|
|
|
if err := tx.Commit(r.Context()); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to commit")
|
|
return
|
|
}
|
|
|
|
// Return the updated skills list.
|
|
skills, err := h.Queries.ListAgentSkills(r.Context(), agent.ID)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "failed to list agent skills")
|
|
return
|
|
}
|
|
|
|
resp := make([]SkillResponse, len(skills))
|
|
for i, s := range skills {
|
|
resp[i] = skillToResponse(s)
|
|
}
|
|
h.publish("agent:status", uuidToString(agent.WorkspaceID), "member", requestUserID(r), map[string]any{"agent_id": uuidToString(agent.ID), "skills": resp})
|
|
writeJSON(w, http.StatusOK, resp)
|
|
}
|