package controllers import ( "errors" "doormile/db" "doormile/internal/ai/registry" "doormile/utils" "github.com/gofiber/fiber/v2" ) // The AI agent registry: /admin/ai/* for the console's Agent Studio and // /internal/ai/registry for AI_engine. Every route here sits behind // DoormileStaffOnly (admin) or InternalKeyAuth (internal); writes additionally // require roleid 1. Logic lives in internal/ai/registry — these handlers only // translate HTTP. // registryError maps a registry error to a response. Validation messages are // written for operators and returned as-is; anything else is logged, not leaked. func registryError(c *fiber.Ctx, err error, what string) error { var v *registry.ValidationError switch { case errors.As(err, &v): return utils.BadRequest(c, v.Msg) case errors.Is(err, registry.ErrNotFound): return utils.NotFound(c, what+" not found") default: utils.Error("ai registry: "+what, "error", err.Error()) return utils.Internal(c, "failed to update the agent registry") } } // actorOf is the caller as the registry audit records it: the user id and the // email from the token (set by AuthMiddleware). func actorOf(c *fiber.Ctx) registry.Actor { userID, _ := c.Locals("userid").(int) email, _ := c.Locals("email").(string) return registry.Actor{UserID: userID, Email: email} } func loadRegistry(c *fiber.Ctx) (*registry.Snapshot, bool) { snap, err := registry.Load(db.DB) if err != nil { utils.Error("ai registry: load", "error", err.Error()) _ = utils.Internal(c, "failed to read the agent registry") return nil, false } return snap, true } // GetAIAgents — GET /admin/ai/agents func GetAIAgents(c *fiber.Ctx) error { snap, ok := loadRegistry(c) if !ok { return nil } return utils.List(c, snap.Agents, int64(len(snap.Agents))) } // GetAIAgent — GET /admin/ai/agents/:id, the agent with its skills and tools. func GetAIAgent(c *fiber.Ctx) error { snap, ok := loadRegistry(c) if !ok { return nil } id := c.Params("id") for _, a := range snap.Agents { if a.Agentid != id { continue } skills := []registry.SkillView{} used := map[string]bool{} for _, s := range snap.Skills { if s.Agentid == id { skills = append(skills, s) for _, t := range s.Tools { used[t] = true } } } tools := []registry.ToolView{} for _, t := range snap.Tools { if used[t.Toolname] { tools = append(tools, t) } } return utils.OK(c, fiber.Map{"agent": a, "skills": skills, "tools": tools}) } return utils.NotFound(c, "agent not found") } // GetAISkills — GET /admin/ai/skills[?agent=] func GetAISkills(c *fiber.Ctx) error { snap, ok := loadRegistry(c) if !ok { return nil } agent := c.Query("agent") out := []registry.SkillView{} for _, s := range snap.Skills { if agent == "" || s.Agentid == agent { out = append(out, s) } } return utils.List(c, out, int64(len(out))) } // GetAITools — GET /admin/ai/tools[?kind=] func GetAITools(c *fiber.Ctx) error { snap, ok := loadRegistry(c) if !ok { return nil } kind := c.Query("kind") out := []registry.ToolView{} for _, t := range snap.Tools { if kind == "" || t.Kind == kind { out = append(out, t) } } return utils.List(c, out, int64(len(out))) } // PatchAISkill — PATCH /admin/ai/skills/:id {enabled?, thresholds?} func PatchAISkill(c *fiber.Ctx) error { var p registry.SkillPatch if err := c.BodyParser(&p); err != nil { return utils.BadRequest(c, "invalid request body") } if err := registry.UpdateSkill(db.DB, c.Params("id"), p, actorOf(c)); err != nil { return registryError(c, err, "skill") } snap, ok := loadRegistry(c) if !ok { return nil } for _, s := range snap.Skills { if s.Skillid == c.Params("id") { return utils.OK(c, s) } } return utils.NotFound(c, "skill not found") } // CreateAISkill — POST /admin/ai/skills func CreateAISkill(c *fiber.Ctx) error { var n registry.NewSkill if err := c.BodyParser(&n); err != nil { return utils.BadRequest(c, "invalid request body") } id, err := registry.CreateSkill(db.DB, n, actorOf(c)) if err != nil { return registryError(c, err, "skill") } snap, ok := loadRegistry(c) if !ok { return nil } for _, s := range snap.Skills { if s.Skillid == id { return utils.Created(c, s) } } return utils.Internal(c, "skill was created but could not be read back") } // PatchAIAgent — PATCH /admin/ai/agents/:id {autonomous?, model?, confirm?} func PatchAIAgent(c *fiber.Ctx) error { var p registry.AgentPatch if err := c.BodyParser(&p); err != nil { return utils.BadRequest(c, "invalid request body") } if err := registry.UpdateAgent(db.DB, c.Params("id"), p, actorOf(c)); err != nil { return registryError(c, err, "agent") } snap, ok := loadRegistry(c) if !ok { return nil } for _, a := range snap.Agents { if a.Agentid == c.Params("id") { return utils.OK(c, a) } } return utils.NotFound(c, "agent not found") } // GetAIRegistryAudit — GET /admin/ai/audit[?limit=] func GetAIRegistryAudit(c *fiber.Ctx) error { rows, err := registry.ListAudit(db.DB, c.QueryInt("limit", 100)) if err != nil { utils.Error("ai registry: audit", "error", err.Error()) return utils.Internal(c, "failed to read the registry audit") } return utils.List(c, rows, int64(len(rows))) } // GetInternalAIRegistry — GET /internal/ai/registry, for AI_engine. // // Sends an ETag and honours If-None-Match, so the engine can poll every few // seconds and receive a 304 with no body until something actually changes. func GetInternalAIRegistry(c *fiber.Ctx) error { snap, ok := loadRegistry(c) if !ok { return nil } tag := registry.ETag(snap) c.Set(fiber.HeaderETag, tag) c.Set(fiber.HeaderCacheControl, "no-cache") if c.Get(fiber.HeaderIfNoneMatch) == tag { return c.SendStatus(fiber.StatusNotModified) } return utils.OK(c, snap) }