390 lines
12 KiB
Go
390 lines
12 KiB
Go
package registry
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"doormile/models"
|
|
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/clause"
|
|
)
|
|
|
|
// Seed upserts the code-defined registry. Idempotent, and safe to run on every
|
|
// boot: code-defined columns are brought in line with the code, operator-owned
|
|
// columns (skill enabled/thresholds, agent autonomous/model) are only written
|
|
// when the row is first created. Custom skills are never touched.
|
|
func Seed(db *gorm.DB) error {
|
|
return db.Transaction(func(tx *gorm.DB) error {
|
|
for i := range SeedAgents {
|
|
a := SeedAgents[i]
|
|
if err := tx.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "agentid"}},
|
|
DoUpdates: clause.AssignmentColumns([]string{"name", "runtime", "classref", "purpose", "wakeon", "status", "llmdecision", "hasautonomygate", "sortorder"}),
|
|
}).Create(&a).Error; err != nil {
|
|
return fmt.Errorf("seed agent %s: %w", a.Agentid, err)
|
|
}
|
|
}
|
|
|
|
for i := range SeedTools {
|
|
t := SeedTools[i]
|
|
if err := tx.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "toolname"}},
|
|
DoUpdates: clause.AssignmentColumns([]string{"description", "kind", "target", "implementedat", "inputschema", "requiresconfirmation"}),
|
|
}).Create(&t).Error; err != nil {
|
|
return fmt.Errorf("seed tool %s: %w", t.Toolname, err)
|
|
}
|
|
}
|
|
|
|
seededIDs := make([]string, 0, len(SeedSkills))
|
|
for _, s := range SeedSkills {
|
|
row := s.Skill
|
|
row.Thresholdsschema = mustJSON(schemaOrEmpty(s.Schema))
|
|
row.Thresholds = mustJSON(DefaultThresholds(s.Schema))
|
|
row.Version = 1
|
|
// enabled, thresholds and version are written on first insert only;
|
|
// they are operator-owned from then on (see DoUpdates).
|
|
if err := tx.Clauses(clause.OnConflict{
|
|
Columns: []clause.Column{{Name: "skillid"}},
|
|
DoUpdates: clause.AssignmentColumns([]string{"agentid", "title", "category", "description", "sampleprompt", "source", "thresholdsschema"}),
|
|
}).Create(&row).Error; err != nil {
|
|
return fmt.Errorf("seed skill %s: %w", row.Skillid, err)
|
|
}
|
|
seededIDs = append(seededIDs, row.Skillid)
|
|
}
|
|
|
|
// Tool links of seeded skills are code-defined: replace them wholesale.
|
|
if err := tx.Where("skillid IN ?", seededIDs).Delete(&models.AISkillTool{}).Error; err != nil {
|
|
return fmt.Errorf("seed skill tools: %w", err)
|
|
}
|
|
var links []models.AISkillTool
|
|
for _, s := range SeedSkills {
|
|
for _, t := range s.Tools {
|
|
links = append(links, models.AISkillTool{Skillid: s.Skill.Skillid, Toolname: t})
|
|
}
|
|
}
|
|
if len(links) > 0 {
|
|
if err := tx.Create(&links).Error; err != nil {
|
|
return fmt.Errorf("seed skill tools: %w", err)
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func schemaOrEmpty(s []ThresholdSpec) []ThresholdSpec {
|
|
if s == nil {
|
|
return []ThresholdSpec{}
|
|
}
|
|
return s
|
|
}
|
|
|
|
// AgentView is an agent as the API returns it.
|
|
type AgentView struct {
|
|
models.AIAgent
|
|
Skillcount int `json:"skillcount"`
|
|
Toolcount int `json:"toolcount"`
|
|
}
|
|
|
|
// ToolView is a tool with its input schema as real JSON.
|
|
type ToolView struct {
|
|
models.AITool
|
|
Inputschema json.RawMessage `json:"inputschema"`
|
|
}
|
|
|
|
// SkillView is a skill with its tools and its EFFECTIVE thresholds: stored
|
|
// values where still valid, defaults otherwise.
|
|
type SkillView struct {
|
|
models.AISkill
|
|
Tools []string `json:"tools"`
|
|
Thresholds map[string]float64 `json:"thresholds"`
|
|
Thresholdsschema []ThresholdSpec `json:"thresholdsschema"`
|
|
}
|
|
|
|
// Snapshot is the whole registry. Small by construction (tens of rows), so it
|
|
// is always read whole — a fixed four queries — and filtered in memory.
|
|
type Snapshot struct {
|
|
Agents []AgentView `json:"agents"`
|
|
Skills []SkillView `json:"skills"`
|
|
Tools []ToolView `json:"tools"`
|
|
}
|
|
|
|
// Load reads the registry.
|
|
func Load(db *gorm.DB) (*Snapshot, error) {
|
|
var agents []models.AIAgent
|
|
var tools []models.AITool
|
|
var skills []models.AISkill
|
|
var links []models.AISkillTool
|
|
|
|
if err := db.Order("sortorder, agentid").Find(&agents).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.Order("toolname").Find(&tools).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.Order("agentid, skillid").Find(&skills).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
if err := db.Order("skillid, toolname").Find(&links).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return Build(agents, tools, skills, links), nil
|
|
}
|
|
|
|
// Build assembles a Snapshot from rows. Pure; Load is its only database step.
|
|
func Build(agents []models.AIAgent, tools []models.AITool, skills []models.AISkill, links []models.AISkillTool) *Snapshot {
|
|
toolsBySkill := map[string][]string{}
|
|
for _, l := range links {
|
|
toolsBySkill[l.Skillid] = append(toolsBySkill[l.Skillid], l.Toolname)
|
|
}
|
|
|
|
snap := &Snapshot{Agents: []AgentView{}, Skills: []SkillView{}, Tools: []ToolView{}}
|
|
|
|
skillCount := map[string]int{}
|
|
agentTools := map[string]map[string]bool{}
|
|
for _, s := range skills {
|
|
schema := ParseSchema(s.Thresholdsschema)
|
|
ts := toolsBySkill[s.Skillid]
|
|
if ts == nil {
|
|
ts = []string{}
|
|
}
|
|
snap.Skills = append(snap.Skills, SkillView{
|
|
AISkill: s,
|
|
Tools: ts,
|
|
Thresholds: EffectiveThresholds(schema, ParseThresholds(s.Thresholds)),
|
|
Thresholdsschema: schema,
|
|
})
|
|
skillCount[s.Agentid]++
|
|
if agentTools[s.Agentid] == nil {
|
|
agentTools[s.Agentid] = map[string]bool{}
|
|
}
|
|
for _, t := range ts {
|
|
agentTools[s.Agentid][t] = true
|
|
}
|
|
}
|
|
|
|
for _, a := range agents {
|
|
snap.Agents = append(snap.Agents, AgentView{AIAgent: a, Skillcount: skillCount[a.Agentid], Toolcount: len(agentTools[a.Agentid])})
|
|
}
|
|
for _, t := range tools {
|
|
raw := json.RawMessage(t.Inputschema)
|
|
if !json.Valid(raw) {
|
|
raw = json.RawMessage(`{"type":"object"}`)
|
|
}
|
|
snap.Tools = append(snap.Tools, ToolView{AITool: t, Inputschema: raw})
|
|
}
|
|
return snap
|
|
}
|
|
|
|
// ETag fingerprints a snapshot, so the engine can poll with If-None-Match and
|
|
// get a 304 until an operator actually changes something.
|
|
func ETag(s *Snapshot) string {
|
|
b, _ := json.Marshal(s)
|
|
sum := sha256.Sum256(b)
|
|
return `W/"` + hex.EncodeToString(sum[:8]) + `"`
|
|
}
|
|
|
|
func audit(tx *gorm.DB, entity, id, field string, oldV, newV any, actor Actor) error {
|
|
return tx.Create(&models.AIRegistryAudit{
|
|
Entity: entity, Entityid: id, Field: field,
|
|
Oldvalue: mustJSON(oldV), Newvalue: mustJSON(newV), Changedby: actor.UserID, Changedbyemail: actor.Email,
|
|
}).Error
|
|
}
|
|
|
|
// UpdateSkill applies an operator's patch. Row-locked, audited in the same
|
|
// transaction, and a no-op (no version bump, no audit) when nothing changes.
|
|
func UpdateSkill(db *gorm.DB, skillID string, p SkillPatch, actor Actor) error {
|
|
if p.Enabled == nil && p.Thresholds == nil {
|
|
return invalid("nothing to change: send enabled and/or thresholds")
|
|
}
|
|
return db.Transaction(func(tx *gorm.DB) error {
|
|
var s models.AISkill
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("skillid = ?", skillID).First(&s).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
|
|
updates := map[string]any{}
|
|
if p.Enabled != nil && *p.Enabled != s.Enabled {
|
|
if err := audit(tx, "skill", s.Skillid, "enabled", s.Enabled, *p.Enabled, actor); err != nil {
|
|
return err
|
|
}
|
|
updates["enabled"] = *p.Enabled
|
|
}
|
|
if p.Thresholds != nil {
|
|
schema := ParseSchema(s.Thresholdsschema)
|
|
current := EffectiveThresholds(schema, ParseThresholds(s.Thresholds))
|
|
next, err := ApplyThresholdPatch(schema, current, p.Thresholds)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if mustJSON(next) != mustJSON(current) {
|
|
if err := audit(tx, "skill", s.Skillid, "thresholds", current, next, actor); err != nil {
|
|
return err
|
|
}
|
|
updates["thresholds"] = mustJSON(next)
|
|
}
|
|
}
|
|
if len(updates) == 0 {
|
|
return nil
|
|
}
|
|
updates["version"] = gorm.Expr("version + 1")
|
|
updates["updatedby"] = actor.UserID
|
|
updates["updatedat"] = time.Now()
|
|
return tx.Model(&models.AISkill{}).Where("skillid = ?", s.Skillid).Updates(updates).Error
|
|
})
|
|
}
|
|
|
|
// UpdateAgent applies an operator's patch to an agent's autonomy or model.
|
|
func UpdateAgent(db *gorm.DB, agentID string, p AgentPatch, actor Actor) error {
|
|
return db.Transaction(func(tx *gorm.DB) error {
|
|
var a models.AIAgent
|
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("agentid = ?", agentID).First(&a).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return ErrNotFound
|
|
}
|
|
return err
|
|
}
|
|
if err := CheckAgentPatch(a, p); err != nil {
|
|
return err
|
|
}
|
|
|
|
updates := map[string]any{}
|
|
if p.Autonomous != nil && *p.Autonomous != a.Autonomous {
|
|
if err := audit(tx, "agent", a.Agentid, "autonomous", a.Autonomous, *p.Autonomous, actor); err != nil {
|
|
return err
|
|
}
|
|
updates["autonomous"] = *p.Autonomous
|
|
}
|
|
if p.Model != nil && *p.Model != a.Model {
|
|
if err := audit(tx, "agent", a.Agentid, "model", a.Model, *p.Model, actor); err != nil {
|
|
return err
|
|
}
|
|
updates["model"] = *p.Model
|
|
}
|
|
if len(updates) == 0 {
|
|
return nil
|
|
}
|
|
updates["updatedby"] = actor.UserID
|
|
updates["updatedat"] = time.Now()
|
|
return tx.Model(&models.AIAgent{}).Where("agentid = ?", a.Agentid).Updates(updates).Error
|
|
})
|
|
}
|
|
|
|
// CreateSkill registers an operator-made skill on a console agent, built only
|
|
// from tools that exist. Returns the new skill id.
|
|
//
|
|
// Engine agents are refused: AI_engine runs only the skills written in its
|
|
// code, so a custom skill attached to one would do nothing while looking live.
|
|
func CreateSkill(db *gorm.DB, n NewSkill, actor Actor) (string, error) {
|
|
if err := CheckNewSkill(n); err != nil {
|
|
return "", err
|
|
}
|
|
var id string
|
|
err := db.Transaction(func(tx *gorm.DB) error {
|
|
var a models.AIAgent
|
|
if err := tx.Where("agentid = ?", n.Agentid).First(&a).Error; err != nil {
|
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return invalid("agent %s does not exist", n.Agentid)
|
|
}
|
|
return err
|
|
}
|
|
if a.Runtime != RuntimeConsole {
|
|
return invalid("%s runs in AI_engine, which only runs the skills in its code; custom skills can be added to console agents only", a.Agentid)
|
|
}
|
|
|
|
var found []string
|
|
if err := tx.Model(&models.AITool{}).Where("toolname IN ?", n.Tools).Pluck("toolname", &found).Error; err != nil {
|
|
return err
|
|
}
|
|
if len(found) != len(n.Tools) {
|
|
have := map[string]bool{}
|
|
for _, f := range found {
|
|
have[f] = true
|
|
}
|
|
var missing []string
|
|
for _, t := range n.Tools {
|
|
if !have[t] {
|
|
missing = append(missing, t)
|
|
}
|
|
}
|
|
return invalid("unknown tools: %s", strings.Join(missing, ", "))
|
|
}
|
|
|
|
base := customSkillID(n.Title)
|
|
id = base
|
|
for i := 2; ; i++ {
|
|
var count int64
|
|
if err := tx.Model(&models.AISkill{}).Where("skillid = ?", id).Count(&count).Error; err != nil {
|
|
return err
|
|
}
|
|
if count == 0 {
|
|
break
|
|
}
|
|
if i > 50 {
|
|
return invalid("too many skills are already called %q", strings.TrimSpace(n.Title))
|
|
}
|
|
id = fmt.Sprintf("%s_%d", base, i)
|
|
}
|
|
|
|
uid := actor.UserID
|
|
row := models.AISkill{
|
|
Skillid: id, Agentid: a.Agentid, Title: strings.TrimSpace(n.Title), Category: strings.TrimSpace(n.Category),
|
|
Description: strings.TrimSpace(n.Description), Sampleprompt: strings.TrimSpace(n.Sampleprompt),
|
|
Source: SourceCustom, Enabled: true, Thresholds: "{}", Thresholdsschema: "[]", Version: 1, Updatedby: &uid,
|
|
}
|
|
if err := tx.Create(&row).Error; err != nil {
|
|
return err
|
|
}
|
|
links := make([]models.AISkillTool, 0, len(n.Tools))
|
|
for _, t := range n.Tools {
|
|
links = append(links, models.AISkillTool{Skillid: id, Toolname: t})
|
|
}
|
|
if err := tx.Create(&links).Error; err != nil {
|
|
return err
|
|
}
|
|
return audit(tx, "skill", id, "created", nil, map[string]any{"agentid": a.Agentid, "title": row.Title, "tools": n.Tools}, actor)
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// AuditView is an audit row with its values as real JSON.
|
|
type AuditView struct {
|
|
models.AIRegistryAudit
|
|
Oldvalue json.RawMessage `json:"oldvalue"`
|
|
Newvalue json.RawMessage `json:"newvalue"`
|
|
}
|
|
|
|
// ListAudit returns the most recent registry changes, newest first.
|
|
func ListAudit(db *gorm.DB, limit int) ([]AuditView, error) {
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 100
|
|
}
|
|
var rows []models.AIRegistryAudit
|
|
if err := db.Order("changedat DESC, auditid DESC").Limit(limit).Find(&rows).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]AuditView, 0, len(rows))
|
|
for _, r := range rows {
|
|
out = append(out, AuditView{AIRegistryAudit: r, Oldvalue: rawOrNull(r.Oldvalue), Newvalue: rawOrNull(r.Newvalue)})
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func rawOrNull(s string) json.RawMessage {
|
|
if s == "" || !json.Valid([]byte(s)) {
|
|
return json.RawMessage("null")
|
|
}
|
|
return json.RawMessage(s)
|
|
}
|