mirror of
https://github.com/coder/coder.git
synced 2026-10-16 14:48:56 +08:00
Skills gain two independent settings. action marks a project skill as a one-click action above project composers; a check constraint keeps it on project skills, and personal and organization routes reject the field. model_invocable controls whether agents see and load a skill on their own: skills with it off are left out of <available-skills> and read_skill, but structured skill invocations still load them.
464 lines
15 KiB
Go
464 lines
15 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/x/skills"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
const (
|
|
// skillJSONEscapeExpansion is the maximum expansion for one byte in a JSON string.
|
|
skillJSONEscapeExpansion = 6
|
|
// skillRequestEnvelopeBytes leaves room for the surrounding JSON object.
|
|
skillRequestEnvelopeBytes = 1024
|
|
// maxSkillRequestBytes allows worst-case JSON string escaping for
|
|
// otherwise valid raw skill content.
|
|
maxSkillRequestBytes = skills.MaxPersonalSkillSizeBytes*skillJSONEscapeExpansion + skillRequestEnvelopeBytes
|
|
|
|
// These names are raised by trigger functions with USING CONSTRAINT.
|
|
// They are not table CHECK constraints, so dbgen does not emit them in
|
|
// check_constraint.go.
|
|
skillsPerUserLimitConstraint database.CheckConstraint = "skills_per_user_limit"
|
|
userSkillUserDeletedConstraint database.CheckConstraint = "user_skill_user_deleted"
|
|
)
|
|
|
|
// @Summary Create a user skill
|
|
// @ID create-a-user-skill
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, username, or me"
|
|
// @Param request body codersdk.CreateSkillRequest true "Create user skill request"
|
|
// @Success 201 {object} codersdk.Skill
|
|
// @Router /api/experimental/users/{user}/skills [post]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) postUserSkill(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
auditor = api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.Skill](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionCreate,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
|
|
skill, ok := createSkill(ctx, rw, r, userSkillCreateErrors, skillOwnerPersonal, func(parsed skills.ParsedSkill, create skillCreate) (database.Skill, error) {
|
|
return api.Database.InsertUserSkill(ctx, database.InsertUserSkillParams{
|
|
ID: uuid.New(),
|
|
UserID: user.ID,
|
|
Name: parsed.Name,
|
|
Description: parsed.Description,
|
|
Content: create.Content,
|
|
ModelInvocable: create.ModelInvocable,
|
|
})
|
|
})
|
|
if !ok {
|
|
return
|
|
}
|
|
aReq.New = skill
|
|
}
|
|
|
|
// @Summary List user skills
|
|
// @ID list-user-skills
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, username, or me"
|
|
// @Success 200 {array} codersdk.SkillMetadata
|
|
// @Router /api/experimental/users/{user}/skills [get]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) getUserSkills(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route.
|
|
ctx := r.Context()
|
|
user := httpmw.UserParam(r)
|
|
|
|
rows, err := api.Database.ListUserSkillMetadataByUserID(ctx, user.ID)
|
|
if err != nil {
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.UserSkillMetadataList(rows))
|
|
}
|
|
|
|
// @Summary Get a user skill by name
|
|
// @ID get-a-user-skill-by-name
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, username, or me"
|
|
// @Param skillName path string true "Skill name"
|
|
// @Success 200 {object} codersdk.Skill
|
|
// @Router /api/experimental/users/{user}/skills/{skillName} [get]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) getUserSkill(rw http.ResponseWriter, r *http.Request) { //nolint:revive // Method name matches route.
|
|
ctx := r.Context()
|
|
user := httpmw.UserParam(r)
|
|
name := chi.URLParam(r, "skillName")
|
|
|
|
skill, err := api.Database.GetUserSkillByUserIDAndName(ctx, database.GetUserSkillByUserIDAndNameParams{
|
|
UserID: user.ID,
|
|
Name: name,
|
|
})
|
|
if err != nil {
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Skill(skill))
|
|
}
|
|
|
|
// @Summary Update a user skill
|
|
// @ID update-a-user-skill
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, username, or me"
|
|
// @Param skillName path string true "Skill name"
|
|
// @Param request body codersdk.UpdateSkillRequest true "Update user skill request"
|
|
// @Success 200 {object} codersdk.Skill
|
|
// @Router /api/experimental/users/{user}/skills/{skillName} [patch]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) patchUserSkill(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
name = chi.URLParam(r, "skillName")
|
|
auditor = api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.Skill](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionWrite,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
|
|
oldSkill, skill, ok := api.updateSkill(ctx, rw, r, name, userSkillUpdateErrors, skillOwnerPersonal, func(tx database.Store, update skillUpdate) (database.Skill, database.Skill, error) {
|
|
fetched, err := tx.GetUserSkillByUserIDAndName(ctx, database.GetUserSkillByUserIDAndNameParams{
|
|
UserID: user.ID,
|
|
Name: name,
|
|
})
|
|
if err != nil {
|
|
return database.Skill{}, database.Skill{}, xerrors.Errorf("fetch user skill: %w", err)
|
|
}
|
|
updated, err := tx.UpdateUserSkillByUserIDAndName(ctx, database.UpdateUserSkillByUserIDAndNameParams{
|
|
UserID: user.ID,
|
|
Name: name,
|
|
Description: update.Description,
|
|
Content: update.Content,
|
|
Enabled: update.Enabled,
|
|
ModelInvocable: update.ModelInvocable,
|
|
})
|
|
if err != nil {
|
|
return database.Skill{}, database.Skill{}, xerrors.Errorf("update user skill: %w", err)
|
|
}
|
|
return fetched, updated, nil
|
|
})
|
|
if !ok {
|
|
return
|
|
}
|
|
aReq.Old = oldSkill
|
|
aReq.New = skill
|
|
}
|
|
|
|
// @Summary Delete a user skill
|
|
// @ID delete-a-user-skill
|
|
// @Security CoderSessionToken
|
|
// @Tags Users
|
|
// @Param user path string true "User ID, username, or me"
|
|
// @Param skillName path string true "Skill name"
|
|
// @Success 204
|
|
// @Router /api/experimental/users/{user}/skills/{skillName} [delete]
|
|
// @x-apidocgen {"skip": true}
|
|
func (api *API) deleteUserSkill(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
user = httpmw.UserParam(r)
|
|
name = chi.URLParam(r, "skillName")
|
|
auditor = api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.Skill](rw, &audit.RequestParams{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionDelete,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
|
|
deleted, err := api.Database.DeleteUserSkillByUserIDAndName(ctx, database.DeleteUserSkillByUserIDAndNameParams{
|
|
UserID: user.ID,
|
|
Name: name,
|
|
})
|
|
if err != nil {
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
aReq.Old = deleted
|
|
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
var (
|
|
userSkillCreateErrors = skillWriteErrors{
|
|
ownerDeleted: userDeletedSkillResponse("Cannot create skills for deleted users."),
|
|
limit: skillsPerUserLimitConstraint,
|
|
limitReached: codersdk.Response{
|
|
Message: "Personal skill limit reached.",
|
|
Detail: fmt.Sprintf("Each user can have at most %d personal skills.", skills.MaxPersonalSkillsPerUser),
|
|
},
|
|
nameIndex: database.UniqueSkillsUserIDNameIndex,
|
|
}
|
|
userSkillUpdateErrors = skillWriteErrors{
|
|
ownerDeleted: userDeletedSkillResponse("Cannot modify skills for deleted users."),
|
|
}
|
|
)
|
|
|
|
func userDeletedSkillResponse(message string) codersdk.Response {
|
|
return codersdk.Response{Message: message, Detail: "This user has been deleted and cannot be modified."}
|
|
}
|
|
|
|
// skillWriteErrors holds the owner-specific responses for errors from a
|
|
// skill insert or update. Empty responses leave their error to the generic
|
|
// mapping, and an empty forbidden response uses httpapi.Forbidden.
|
|
type skillWriteErrors struct {
|
|
forbidden codersdk.Response
|
|
ownerDeleted codersdk.Response
|
|
limit database.CheckConstraint
|
|
limitReached codersdk.Response
|
|
nameIndex database.UniqueConstraint
|
|
}
|
|
|
|
func (e skillWriteErrors) write(ctx context.Context, rw http.ResponseWriter, err error) {
|
|
switch {
|
|
case httpapi.IsUnauthorizedError(err) && e.forbidden.Message == "":
|
|
httpapi.Forbidden(rw)
|
|
case httpapi.IsUnauthorizedError(err):
|
|
httpapi.Write(ctx, rw, http.StatusForbidden, e.forbidden)
|
|
case e.ownerDeleted.Message != "" && database.IsCheckViolation(err, userSkillUserDeletedConstraint):
|
|
httpapi.Write(ctx, rw, http.StatusConflict, e.ownerDeleted)
|
|
case httpapi.Is404Error(err):
|
|
httpapi.ResourceNotFound(rw)
|
|
case e.limitReached.Message != "" && database.IsCheckViolation(err, e.limit):
|
|
httpapi.Write(ctx, rw, http.StatusConflict, e.limitReached)
|
|
case e.nameIndex != "" && database.IsUniqueViolation(err, e.nameIndex):
|
|
writeSkillNameConflict(ctx, rw)
|
|
case database.IsCheckViolation(err, database.CheckSkillsActionProjectOnly):
|
|
writeSkillActionNotAllowed(ctx, rw)
|
|
default:
|
|
httpapi.InternalServerError(rw, err)
|
|
}
|
|
}
|
|
|
|
// skillOwnerKind is the kind of owner a skill route manages.
|
|
type skillOwnerKind int
|
|
|
|
const (
|
|
skillOwnerPersonal skillOwnerKind = iota
|
|
skillOwnerOrganization
|
|
skillOwnerProject
|
|
)
|
|
|
|
// allowsAction reports whether skills of this owner kind can be actions.
|
|
func (k skillOwnerKind) allowsAction() bool {
|
|
return k == skillOwnerProject
|
|
}
|
|
|
|
// skillCreate holds the columns a skill create request writes. Unset
|
|
// optional columns keep their database defaults.
|
|
type skillCreate struct {
|
|
Content string
|
|
Action sql.NullBool
|
|
ModelInvocable sql.NullBool
|
|
}
|
|
|
|
// createSkill parses a skill create request, stores the skill with insert,
|
|
// and writes the response. It returns false when it wrote an error.
|
|
func createSkill(
|
|
ctx context.Context,
|
|
rw http.ResponseWriter,
|
|
r *http.Request,
|
|
errs skillWriteErrors,
|
|
kind skillOwnerKind,
|
|
insert func(parsed skills.ParsedSkill, create skillCreate) (database.Skill, error),
|
|
) (database.Skill, bool) {
|
|
var req codersdk.CreateSkillRequest
|
|
if !httpapi.ReadLimit(ctx, rw, r, maxSkillRequestBytes, &req) {
|
|
return database.Skill{}, false
|
|
}
|
|
if req.Action != nil && !kind.allowsAction() {
|
|
writeSkillActionNotAllowed(ctx, rw)
|
|
return database.Skill{}, false
|
|
}
|
|
parsed, err := skills.ParsePersonalSkillMarkdown([]byte(req.Content))
|
|
if err != nil {
|
|
writeInvalidSkillContent(ctx, rw, err)
|
|
return database.Skill{}, false
|
|
}
|
|
skill, err := insert(parsed, skillCreate{
|
|
Content: req.Content,
|
|
Action: nullBool(req.Action),
|
|
ModelInvocable: nullBool(req.ModelInvocable),
|
|
})
|
|
if err != nil {
|
|
errs.write(ctx, rw, err)
|
|
return database.Skill{}, false
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusCreated, db2sdk.Skill(skill))
|
|
return skill, true
|
|
}
|
|
|
|
// updateSkill applies a skill PATCH request for the skill named name and
|
|
// writes the response. apply runs in a transaction and returns the rows
|
|
// before and after the update. Callers assign audit state only after it
|
|
// returns true, so the audit log never claims a rolled-back update.
|
|
func (api *API) updateSkill(
|
|
ctx context.Context,
|
|
rw http.ResponseWriter,
|
|
r *http.Request,
|
|
name string,
|
|
errs skillWriteErrors,
|
|
kind skillOwnerKind,
|
|
apply func(tx database.Store, update skillUpdate) (old database.Skill, updated database.Skill, err error),
|
|
) (old database.Skill, updated database.Skill, ok bool) {
|
|
update, ok := readSkillUpdate(ctx, rw, r, name, kind)
|
|
if !ok {
|
|
return database.Skill{}, database.Skill{}, false
|
|
}
|
|
err := api.Database.InTx(func(tx database.Store) error {
|
|
var err error
|
|
old, updated, err = apply(tx, update)
|
|
return err
|
|
}, nil)
|
|
if err != nil {
|
|
errs.write(ctx, rw, err)
|
|
return database.Skill{}, database.Skill{}, false
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusOK, db2sdk.Skill(updated))
|
|
return old, updated, true
|
|
}
|
|
|
|
// skillUpdate holds the optional columns a skill PATCH writes.
|
|
type skillUpdate struct {
|
|
Description sql.NullString
|
|
Content sql.NullString
|
|
Enabled sql.NullBool
|
|
Action sql.NullBool
|
|
ModelInvocable sql.NullBool
|
|
}
|
|
|
|
// readSkillUpdate reads and validates a PATCH request for the skill named
|
|
// name for an owner of the given kind. It writes the error response and
|
|
// returns false when the request is invalid.
|
|
func readSkillUpdate(ctx context.Context, rw http.ResponseWriter, r *http.Request, name string, kind skillOwnerKind) (skillUpdate, bool) {
|
|
var req codersdk.UpdateSkillRequest
|
|
if !httpapi.ReadLimit(ctx, rw, r, maxSkillRequestBytes, &req) {
|
|
return skillUpdate{}, false
|
|
}
|
|
if req.Action != nil && !kind.allowsAction() {
|
|
writeSkillActionNotAllowed(ctx, rw)
|
|
return skillUpdate{}, false
|
|
}
|
|
if req.Content == nil && req.Enabled == nil && req.Action == nil && req.ModelInvocable == nil {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "No skill fields to update.",
|
|
Detail: "Set at least one of content, enabled, action, or model_invocable.",
|
|
})
|
|
return skillUpdate{}, false
|
|
}
|
|
|
|
update := skillUpdate{
|
|
Enabled: nullBool(req.Enabled),
|
|
Action: nullBool(req.Action),
|
|
ModelInvocable: nullBool(req.ModelInvocable),
|
|
}
|
|
if req.Content == nil {
|
|
return update, true
|
|
}
|
|
parsed, err := skills.ParsePersonalSkillMarkdown([]byte(*req.Content))
|
|
if err != nil {
|
|
writeInvalidSkillContent(ctx, rw, err)
|
|
return skillUpdate{}, false
|
|
}
|
|
if parsed.Name != name {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Skill name in path does not match frontmatter name.",
|
|
Detail: fmt.Sprintf("path has %q, frontmatter has %q", name, parsed.Name),
|
|
})
|
|
return skillUpdate{}, false
|
|
}
|
|
update.Description = sql.NullString{String: parsed.Description, Valid: true}
|
|
update.Content = sql.NullString{String: *req.Content, Valid: true}
|
|
return update, true
|
|
}
|
|
|
|
func nullBool(value *bool) sql.NullBool {
|
|
if value == nil {
|
|
return sql.NullBool{}
|
|
}
|
|
return sql.NullBool{Bool: *value, Valid: true}
|
|
}
|
|
|
|
func writeSkillActionNotAllowed(ctx context.Context, rw http.ResponseWriter) {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Only project skills can be actions.",
|
|
Detail: "Remove the action field; personal and organization skills cannot be shown as actions.",
|
|
})
|
|
}
|
|
|
|
func writeSkillNameConflict(ctx context.Context, rw http.ResponseWriter) {
|
|
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
|
Message: "A skill with that name already exists.",
|
|
Detail: "Choose a different name, or edit the existing skill.",
|
|
})
|
|
}
|
|
|
|
func writeInvalidSkillContent(ctx context.Context, rw http.ResponseWriter, err error) {
|
|
message := "Invalid skill content."
|
|
switch {
|
|
case errors.Is(err, skills.ErrInvalidSkillName):
|
|
message = "Invalid skill name."
|
|
case errors.Is(err, skills.ErrSkillBodyRequired):
|
|
message = "Skill body is required."
|
|
case errors.Is(err, skills.ErrSkillTooLarge):
|
|
message = "Skill content is too large."
|
|
case errors.Is(err, skills.ErrSkillDescriptionTooLarge):
|
|
message = "Skill description is too large."
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: message,
|
|
Detail: err.Error(),
|
|
})
|
|
}
|