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) }