Files
multica/server/internal/handler/skill_create.go

217 lines
7.5 KiB
Go

package handler
import (
"context"
"encoding/json"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
skillpkg "github.com/multica-ai/multica/server/internal/skill"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
type skillCreateInput struct {
WorkspaceID pgtype.UUID
CreatorID pgtype.UUID
Name string
Description string
Content string
Config any
Files []CreateSkillFileRequest
}
// createSkillWithFilesInTx writes a skill plus its supporting files using the
// provided sqlc Queries handle, which must already be bound to an open
// transaction. Callers compose skill creation with other writes (e.g. agent
// template materialization) inside one outer transaction. For standalone
// skill creation, prefer createSkillWithFiles, which manages its own tx.
func createSkillWithFilesInTx(ctx context.Context, qtx *db.Queries, input skillCreateInput) (SkillWithFilesResponse, error) {
config, err := json.Marshal(input.Config)
if err != nil {
return SkillWithFilesResponse{}, err
}
if input.Config == nil {
config = []byte("{}")
}
skill, err := qtx.CreateSkill(ctx, db.CreateSkillParams{
WorkspaceID: input.WorkspaceID,
Name: sanitizeNullBytes(input.Name),
Description: sanitizeNullBytes(input.Description),
Content: sanitizeNullBytes(input.Content),
Config: config,
CreatedBy: input.CreatorID,
})
if err != nil {
return SkillWithFilesResponse{}, err
}
fileResps := make([]SkillFileResponse, 0, len(input.Files))
for _, f := range input.Files {
// SKILL.md is reserved for the primary skill content (skill.Content).
// Supporting files must carry additional assets, not duplicate the main file.
if skillpkg.IsReservedContentPath(f.Path) {
continue
}
sf, err := qtx.UpsertSkillFile(ctx, db.UpsertSkillFileParams{
SkillID: skill.ID,
Path: sanitizeNullBytes(f.Path),
Content: sanitizeNullBytes(f.Content),
})
if err != nil {
return SkillWithFilesResponse{}, err
}
fileResps = append(fileResps, skillFileToResponse(sf))
}
return SkillWithFilesResponse{
SkillResponse: skillToResponse(skill),
Files: fileResps,
}, nil
}
func (h *Handler) createSkillWithFiles(ctx context.Context, input skillCreateInput) (SkillWithFilesResponse, error) {
tx, err := h.TxStarter.Begin(ctx)
if err != nil {
return SkillWithFilesResponse{}, err
}
defer tx.Rollback(ctx)
qtx := h.Queries.WithTx(tx)
result, err := createSkillWithFilesInTx(ctx, qtx, input)
if err != nil {
return SkillWithFilesResponse{}, err
}
if err := tx.Commit(ctx); err != nil {
return SkillWithFilesResponse{}, err
}
return result, nil
}
// errSkillOverwriteNotFound / errSkillOverwriteForbidden are the terminal
// boundary cases of overwriteSkillWithFiles: the target was deleted (or moved
// out of the workspace) or the caller lost overwrite permission between the
// user's confirm and this write. Callers map them to a failed import and must
// NOT fall back to creating a new skill.
var (
errSkillOverwriteNotFound = errors.New("target skill not found")
errSkillOverwriteForbidden = errors.New("not permitted to overwrite target skill")
errSkillOverwriteNameMismatch = errors.New("target skill name does not match the imported skill")
)
type skillOverwriteInput struct {
WorkspaceID pgtype.UUID
TargetSkillID pgtype.UUID
UserID string // re-checked against the skill creator inside the tx
// ExpectedName, when non-empty, must equal the target's current name. Guards
// against a client sending the wrong target_skill_id and overwriting a
// different skill than the one the conflict dialog showed the user. The
// caller passes the sanitized effective import name.
ExpectedName string
Description string
Content string
Config any
Files []CreateSkillFileRequest
}
// overwriteSkillWithFiles re-imports a bundle onto an existing skill in a single
// transaction. It re-verifies, inside that tx, that the target still exists in
// the workspace and that UserID may overwrite it (creator-only — see
// canOverwriteSkillByLocalImport). A target deleted or a creator change between
// the user's confirm and this write fails cleanly via errSkillOverwriteNotFound
// / errSkillOverwriteForbidden rather than falling back to create.
//
// Preserved: id, created_by, created_at, name, and agent_skill bindings (the
// row identity and the binding table are never touched). Replaced: description,
// content, config (origin), and the full file set — files absent from the new
// bundle are pruned via DeleteSkillFilesBySkill. On any error the tx rolls back,
// leaving the original skill unchanged.
func (h *Handler) overwriteSkillWithFiles(ctx context.Context, input skillOverwriteInput) (SkillWithFilesResponse, error) {
config, err := json.Marshal(input.Config)
if err != nil {
return SkillWithFilesResponse{}, err
}
if input.Config == nil {
config = []byte("{}")
}
tx, err := h.TxStarter.Begin(ctx)
if err != nil {
return SkillWithFilesResponse{}, err
}
defer tx.Rollback(ctx)
qtx := h.Queries.WithTx(tx)
existing, err := qtx.GetSkillInWorkspace(ctx, db.GetSkillInWorkspaceParams{
ID: input.TargetSkillID,
WorkspaceID: input.WorkspaceID,
})
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return SkillWithFilesResponse{}, errSkillOverwriteNotFound
}
return SkillWithFilesResponse{}, err
}
if !canOverwriteSkillByLocalImport(input.UserID, existing) {
return SkillWithFilesResponse{}, errSkillOverwriteForbidden
}
// The overwrite is keyed on target_skill_id, but the conflict the user
// confirmed was a same-name collision; reject if the target's name no longer
// matches the imported skill so a stale/wrong target_skill_id can't write
// one skill's content onto another.
if input.ExpectedName != "" && existing.Name != input.ExpectedName {
return SkillWithFilesResponse{}, errSkillOverwriteNameMismatch
}
// Name is intentionally left unset (COALESCE keeps the existing name): the
// overwrite targets the same-name skill, so preserving it avoids any
// unique-name churn.
skill, err := qtx.UpdateSkill(ctx, db.UpdateSkillParams{
ID: existing.ID,
Description: pgtype.Text{String: sanitizeNullBytes(input.Description), Valid: true},
Content: pgtype.Text{String: sanitizeNullBytes(input.Content), Valid: true},
Config: config,
})
if err != nil {
// A committed concurrent DELETE can land between the read above and this
// UPDATE (READ COMMITTED), so UpdateSkill matches 0 rows. Classify it as
// the same "target gone" terminal case rather than a generic failure.
if errors.Is(err, pgx.ErrNoRows) {
return SkillWithFilesResponse{}, errSkillOverwriteNotFound
}
return SkillWithFilesResponse{}, err
}
// Full replace: drop every existing file, then re-insert the new set so
// files no longer present in the local source are removed.
if err := qtx.DeleteSkillFilesBySkill(ctx, skill.ID); err != nil {
return SkillWithFilesResponse{}, err
}
fileResps := make([]SkillFileResponse, 0, len(input.Files))
for _, f := range input.Files {
sf, err := qtx.UpsertSkillFile(ctx, db.UpsertSkillFileParams{
SkillID: skill.ID,
Path: sanitizeNullBytes(f.Path),
Content: sanitizeNullBytes(f.Content),
})
if err != nil {
return SkillWithFilesResponse{}, err
}
fileResps = append(fileResps, skillFileToResponse(sf))
}
if err := tx.Commit(ctx); err != nil {
return SkillWithFilesResponse{}, err
}
return SkillWithFilesResponse{
SkillResponse: skillToResponse(skill),
Files: fileResps,
}, nil
}