updates on the ai and agent and all thse things awith onboarding

This commit is contained in:
2026-09-30 14:47:58 +05:30
parent 44ba33eda2
commit 0ac5d3d54f
37 changed files with 7755 additions and 0 deletions

View File

@@ -2,6 +2,7 @@ package config
import (
"os"
"strings"
)
type Config struct {
@@ -49,6 +50,20 @@ type Config struct {
SMTPUser string
SMTPPassword string
SMTPFrom string
// ClientOnboardingOwners are the console logins allowed to onboard a new
// client (tenant + its console login). Comma-separated emails, compared
// case-insensitively. Deliberately a short allow-list rather than a role:
// every Doormile admin has roleid 1, and onboarding mints credentials.
ClientOnboardingOwners []string
// The Agent Studio Test playground's model: any OpenAI-compatible chat
// completions API (Groq by default; xAI works too). Empty API key leaves
// the playground off — the endpoint answers 503 PLAYGROUND_NOT_CONFIGURED.
// Set the key as a secret in the deployment, never in a tracked file.
PlaygroundLLMBaseURL string
PlaygroundLLMAPIKey string
PlaygroundLLMModel string
}
func Load() *Config {
@@ -79,12 +94,52 @@ func Load() *Config {
SMTPUser: getEnv("SMTP_USER", ""),
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
SMTPFrom: getEnv("SMTP_FROM", ""),
ClientOnboardingOwners: splitEmails(getEnv("CLIENT_ONBOARDING_OWNERS", "admin@doormile.com")),
PlaygroundLLMBaseURL: getEnv("PLAYGROUND_LLM_BASE_URL", "https://api.groq.com/openai/v1"),
PlaygroundLLMAPIKey: getEnv("PLAYGROUND_LLM_API_KEY", ""),
PlaygroundLLMModel: getEnv("PLAYGROUND_LLM_MODEL", "openai/gpt-oss-120b"),
}
}
// requiredInProduction are the secrets whose development fallback above is a
// literal committed to this repository. In production a missing one must stop
// the boot: falling back would sign every token with a JWT secret anyone with
// the source can read, and connect with a published password.
var requiredInProduction = []string{"JWT_SECRET_KEY", "DB_PASSWORD", "NATS_PASSWORD"}
// MissingProductionSecrets names each required secret that is unset when
// ENV=production. Always empty in any other environment, so local development
// keeps running on the fallbacks.
func (c *Config) MissingProductionSecrets() []string {
if !strings.EqualFold(c.Env, "production") {
return nil
}
var missing []string
for _, key := range requiredInProduction {
if os.Getenv(key) == "" {
missing = append(missing, key)
}
}
return missing
}
func getEnv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// splitEmails parses a comma-separated email list: trimmed, lower-cased,
// blanks dropped.
func splitEmails(v string) []string {
var out []string
for _, e := range strings.Split(v, ",") {
if e = strings.ToLower(strings.TrimSpace(e)); e != "" {
out = append(out, e)
}
}
return out
}

View File

@@ -82,6 +82,39 @@ func TestEnvironmentOverridesAreRead(t *testing.T) {
}
}
// Production must not boot on a secret whose fallback is committed to the repo.
func TestMissingProductionSecrets(t *testing.T) {
setEnv(t, "ENV", "production")
setEnv(t, "JWT_SECRET_KEY", "")
setEnv(t, "DB_PASSWORD", "set")
setEnv(t, "NATS_PASSWORD", "")
got := Load().MissingProductionSecrets()
if len(got) != 2 || got[0] != "JWT_SECRET_KEY" || got[1] != "NATS_PASSWORD" {
t.Fatalf("missing = %v, want [JWT_SECRET_KEY NATS_PASSWORD]", got)
}
setEnv(t, "JWT_SECRET_KEY", "set")
setEnv(t, "NATS_PASSWORD", "set")
if got := Load().MissingProductionSecrets(); len(got) != 0 {
t.Errorf("all secrets set, still reported missing: %v", got)
}
}
// Outside production the fallbacks are allowed, so local development runs
// with no .env at all.
func TestMissingProductionSecretsIgnoredOutsideProduction(t *testing.T) {
setEnv(t, "JWT_SECRET_KEY", "")
setEnv(t, "DB_PASSWORD", "")
setEnv(t, "NATS_PASSWORD", "")
for _, env := range []string{"", "development", "staging"} {
setEnv(t, "ENV", env)
if got := Load().MissingProductionSecrets(); len(got) != 0 {
t.Errorf("ENV=%q reported missing secrets %v; only production should", env, got)
}
}
}
// An empty env var must fall through to the default rather than blanking the
// setting — an empty DB host is a service that cannot start with no clue why.
func TestEmptyEnvFallsBackToTheDefault(t *testing.T) {

View File

@@ -0,0 +1,64 @@
package controllers
import (
"time"
"doormile/db"
"doormile/internal/ai/telemetry"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// GetAIInsights — GET /admin/ai/insights?days=7
//
// What AI_engine's agents did over the window: runs and failures per agent
// (aiagentruns, from telemetry.task), decisions by type and outcome
// (agent_decisions), and each agent's latest heartbeat (Redis). `receiving`
// says whether this backend is subscribed to the telemetry at all, so an
// empty page can tell "not connected" from "nothing happened".
func GetAIInsights(c *fiber.Ctx) error {
days := telemetry.ClampDays(c.QueryInt("days", 7))
// A real instant: aiagentruns.receivedat and agent_decisions.created_at are
// both timestamptz (see telemetry.NewRecorder on why not utils.DBNow).
since := time.Now().AddDate(0, 0, -days)
runs, err := telemetry.RunStats(db.DB, since)
if err != nil {
utils.Error("ai insights: runs", "error", err.Error())
return utils.Internal(c, "failed to read agent runs")
}
decisions, err := telemetry.DecisionCounts(db.DB, since)
if err != nil {
utils.Error("ai insights: decisions", "error", err.Error())
return utils.Internal(c, "failed to read agent decisions")
}
var engineAgents []string
if err := db.DB.Model(&models.AIAgent{}).Where("runtime = ?", "engine").Order("sortorder").Pluck("agentid", &engineAgents).Error; err != nil {
utils.Error("ai insights: agents", "error", err.Error())
}
return utils.OK(c, telemetry.Insights{
Days: days,
Since: since,
Receiving: telemetry.Receiving.Load(),
Runs: telemetry.SummariseRuns(runs),
Decisions: telemetry.SummariseDecisions(decisions),
Live: telemetry.LiveStates(db.Rdb, engineAgents),
})
}
// GetAIDecisions — GET /admin/ai/decisions?type=&before=&limit=
//
// Recent agent decisions, newest first, keyset-paged by id. Reasoning is
// trimmed and the context column is left out (it can hold rider data).
func GetAIDecisions(c *fiber.Ctx) error {
rows, err := telemetry.RecentDecisions(db.DB, c.Query("type"), uint64(c.QueryInt("before", 0)), c.QueryInt("limit", 25))
if err != nil {
utils.Error("ai insights: recent decisions", "error", err.Error())
return utils.Internal(c, "failed to read agent decisions")
}
return utils.List(c, rows, int64(len(rows)))
}

View File

@@ -0,0 +1,127 @@
package controllers
import (
"context"
"errors"
"strconv"
"strings"
"sync"
"time"
"unicode/utf8"
"doormile/db"
"doormile/internal/ai/playground"
"doormile/utils"
"github.com/gofiber/fiber/v2"
)
// POST /admin/ai/playground/run — Agent Studio's Test tab. Runs one prompt
// through the configured model with a registry skill's tools; see internal/ai/playground for
// what executes and what only becomes a proposal. Staff only, roleid 1 only,
// and rate-limited per user because every run is a paid API call.
// PlaygroundModel is the model client the playground uses (an OpenAI-compatible
// provider such as Groq, see main.go). Nil until PLAYGROUND_LLM_API_KEY is set; the endpoint then answers 503 and the console keeps the
// Test tab labelled as unavailable.
var PlaygroundModel playground.Model
const (
playgroundRunTimeout = 120 * time.Second
playgroundRunsPerWin = 10
playgroundWindow = 10 * time.Minute
)
type playgroundLimiter struct {
mu sync.Mutex
runs map[string][]time.Time
}
var playgroundRuns = &playgroundLimiter{runs: map[string][]time.Time{}}
// allow records a run for key and reports whether it is within the limit.
func (l *playgroundLimiter) allow(key string, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
kept := l.runs[key][:0]
for _, t := range l.runs[key] {
if now.Sub(t) < playgroundWindow {
kept = append(kept, t)
}
}
if len(kept) >= playgroundRunsPerWin {
l.runs[key] = kept
return false
}
l.runs[key] = append(kept, now)
return true
}
// RunAIPlayground — POST /admin/ai/playground/run {agentid, skillid?, prompt}
func RunAIPlayground(c *fiber.Ctx) error {
if PlaygroundModel == nil {
return utils.Fail(c, fiber.StatusServiceUnavailable, "PLAYGROUND_NOT_CONFIGURED",
"The Test playground has no model configured on this server.")
}
var body struct {
Agentid string `json:"agentid"`
Skillid string `json:"skillid"`
Prompt string `json:"prompt"`
}
if err := c.BodyParser(&body); err != nil {
return utils.BadRequest(c, "invalid request body")
}
body.Prompt = strings.TrimSpace(body.Prompt)
if body.Agentid == "" || body.Prompt == "" {
return utils.BadRequest(c, "agentid and prompt are required")
}
if utf8.RuneCountInString(body.Prompt) > playground.MaxPromptChars {
return utils.BadRequest(c, "prompt is too long (at most 2000 characters)")
}
actor := actorOf(c)
key := actor.Email
if key == "" {
key = "user:" + strconv.Itoa(actor.UserID)
}
if !playgroundRuns.allow(key, time.Now()) {
return utils.Fail(c, fiber.StatusTooManyRequests, "PLAYGROUND_RATE_LIMITED",
"Playground limit reached: 10 runs per 10 minutes. Try again shortly.")
}
snap, ok := loadRegistry(c)
if !ok {
return nil
}
plan, err := playground.Prepare(snap, body.Agentid, body.Skillid)
if errors.Is(err, playground.ErrNotFound) {
return utils.NotFound(c, err.Error())
}
if err != nil {
return utils.BadRequest(c, err.Error())
}
// An OpenAI-compatible provider serves its own configured model, not the
// agent's registry pin (a Claude id AI_engine uses); report the real one.
if named, ok := PlaygroundModel.(interface{ ModelName() string }); ok {
plan.Model = named.ModelName()
}
ctx, cancel := context.WithTimeout(context.Background(), playgroundRunTimeout)
defer cancel()
trace, err := playground.Run(ctx, PlaygroundModel, plan, body.Prompt, playground.Executors(db.DB, db.Rdb))
utils.Info("ai playground run", "email", actor.Email, "agent", plan.AgentID, "skill", plan.SkillID,
"model", plan.Model, "turns", trace.Turns, "ms", trace.Ms, "failed", err != nil)
if err != nil {
utils.Error("ai playground: model call failed", "error", err.Error())
var pe *playground.ProviderError
if errors.As(err, &pe) && pe.Status == fiber.StatusTooManyRequests {
return utils.Fail(c, fiber.StatusTooManyRequests, "PLAYGROUND_PROVIDER_RATE_LIMITED",
"The model provider's rate limit was reached (common on free plans). Wait a minute and try again.")
}
return utils.Fail(c, fiber.StatusBadGateway, "PLAYGROUND_MODEL_FAILED",
"The model request failed; nothing was changed. Try again.")
}
return utils.OK(c, trace)
}

View File

@@ -0,0 +1,215 @@
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)
}

View File

@@ -0,0 +1,566 @@
package controllers
import (
"errors"
"net/mail"
"regexp"
"strings"
"time"
"unicode/utf8"
"doormile/db"
"doormile/models"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"gorm.io/gorm"
)
// Client onboarding: one call creates everything a new client needs to sign in
// to the console, in one transaction —
//
// tenants the client company (the tenant every booking is scoped to)
// doormile_auth the console login LoginAdmin checks: email, bcrypt hash,
// role "manager", tenantid = the new tenant
// appusers the user row LoginAdmin reads the userid and name from,
// roleid 3, tenantid = the new tenant
//
// A client needs all three: an appusers row alone cannot log in (LoginAdmin
// authenticates against doormile_auth), and a doormile_auth row without a
// tenantid would be Doormile STAFF — unscoped, seeing every client's data.
//
// The client login gets role "manager" (roleid 3), not "admin": nothing a
// client does needs roleid 1, and roleid 1 is what gates the agent-registry
// writes. Their data scope comes from the tenantid in the token.
//
// Routes sit behind ClientOnboardingOwnerOnly (see routes.go).
const clientLoginRole = "manager"
const clientLoginRoleID = 3
var indianMobile = regexp.MustCompile(`^[6-9]\d{9}$`)
type onboardClientRequest struct {
Companyname string `json:"companyname"`
Contactname string `json:"contactname"`
Email string `json:"email"`
Phone string `json:"phone"`
Password string `json:"password"`
Applocationid int `json:"applocationid"`
Requiredeliveryotp bool `json:"requiredeliveryotp"`
}
// normalisePhone strips spaces, dashes and a +91/91/0 prefix.
func normalisePhone(p string) string {
p = strings.NewReplacer(" ", "", "-", "", "(", "", ")", "").Replace(strings.TrimSpace(p))
p = strings.TrimPrefix(p, "+91")
if len(p) == 12 && strings.HasPrefix(p, "91") {
p = p[2:]
}
if len(p) == 11 && strings.HasPrefix(p, "0") {
p = p[1:]
}
return p
}
// validate normalises the request in place and returns an operator-readable
// message for the first problem, or "".
func (r *onboardClientRequest) validate() string {
r.Companyname = strings.Join(strings.Fields(r.Companyname), " ")
r.Contactname = strings.Join(strings.Fields(r.Contactname), " ")
r.Email = strings.ToLower(strings.TrimSpace(r.Email))
r.Phone = normalisePhone(r.Phone)
switch n := utf8.RuneCountInString(r.Companyname); {
case n < 2:
return "company name is required"
case n > 120:
return "company name is too long (at most 120 characters)"
}
if utf8.RuneCountInString(r.Contactname) < 2 || utf8.RuneCountInString(r.Contactname) > 80 {
return "contact person's name is required (at most 80 characters)"
}
if addr, err := mail.ParseAddress(r.Email); err != nil || addr.Address != r.Email || !strings.Contains(r.Email[strings.LastIndex(r.Email, "@"):], ".") {
return "enter a valid email address"
}
if !indianMobile.MatchString(r.Phone) {
return "enter a valid 10-digit mobile number"
}
switch n := utf8.RuneCountInString(r.Password); {
case n < 8:
return "password must be at least 8 characters"
case n > 72: // bcrypt ignores everything past 72 bytes
return "password is too long (at most 72 characters)"
}
if strings.EqualFold(r.Password, r.Email) || strings.EqualFold(r.Password, r.Phone) {
return "password must not be the email or the phone number"
}
if r.Applocationid <= 0 {
return "choose the client's operating city"
}
return ""
}
// errOnboardingConflict carries a 409 message out of the transaction.
type errOnboardingConflict struct{ msg string }
func (e errOnboardingConflict) Error() string { return e.msg }
func isUniqueViolation(err error) bool {
s := err.Error()
return strings.Contains(s, "23505") || strings.Contains(strings.ToLower(s), "duplicate key")
}
// onboardingOwnerStillValid re-reads the caller's doormile_auth row: still an
// admin, still Doormile staff. The middleware checked the token; this checks
// the account behind it has not been removed or demoted since it was issued.
func onboardingOwnerStillValid(email string) bool {
var n int64
db.DB.Model(&models.DoormileAuth{}).
Where("LOWER(email) = ? AND role = ? AND tenantid IS NULL", strings.ToLower(email), "admin").
Count(&n)
return n == 1
}
// OnboardClient — POST /admin/clients/onboard
func OnboardClient(c *fiber.Ctx) error {
actor := actorOf(c)
if !onboardingOwnerStillValid(actor.Email) {
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
}
req := new(onboardClientRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if msg := req.validate(); msg != "" {
return utils.BadRequest(c, msg)
}
hash, err := utils.HashPassword(req.Password)
if err != nil {
return utils.Internal(c, "failed to process the password")
}
var tenant models.Tenant
var user models.AppUser
var auth models.DoormileAuth
err = db.DB.Transaction(func(tx *gorm.DB) error {
var city models.AppLocation
if err := tx.Where("applocationid = ?", req.Applocationid).First(&city).Error; err != nil {
return errOnboardingConflict{"that operating city does not exist"}
}
var n int64
tx.Model(&models.Tenant{}).Where("LOWER(tenantname) = LOWER(?)", req.Companyname).Count(&n)
if n > 0 {
return errOnboardingConflict{"a client with this company name already exists"}
}
tx.Model(&models.DoormileAuth{}).Where("LOWER(email) = ?", req.Email).Count(&n)
if n > 0 {
return errOnboardingConflict{"this email already has a console login"}
}
tx.Model(&models.AppUser{}).Where("LOWER(email) = ?", req.Email).Count(&n)
if n > 0 {
return errOnboardingConflict{"this email is already used by another user"}
}
tenant = models.Tenant{
Tenantname: req.Companyname,
Primaryemail: req.Email,
Primarycontact: req.Phone,
Status: "Active",
Requiredeliveryotp: req.Requiredeliveryotp,
}
if err := tx.Create(&tenant).Error; err != nil {
return err
}
tenantID := tenant.Tenantid
auth = models.DoormileAuth{Email: req.Email, PasswordHash: hash, Role: clientLoginRole, Tenantid: &tenantID}
if err := tx.Create(&auth).Error; err != nil {
return err
}
user = models.AppUser{
Authname: req.Contactname,
Email: req.Email,
Contactno: req.Phone,
Password: hash,
Roleid: clientLoginRoleID,
Tenantid: tenantID,
Applocationid: req.Applocationid,
Status: "Active",
}
return tx.Create(&user).Error
})
var conflict errOnboardingConflict
switch {
case errors.As(err, &conflict):
if conflict.msg == "that operating city does not exist" {
return utils.BadRequest(c, conflict.msg)
}
return utils.Conflict(c, conflict.msg)
case err != nil && isUniqueViolation(err):
// Lost a race with a concurrent onboarding of the same email.
return utils.Conflict(c, "this email already has a console login")
case err != nil:
utils.Error("client onboarding failed", "error", err.Error(), "by", actor.Email)
return utils.Internal(c, "failed to onboard the client; nothing was created")
}
utils.Info("client onboarded", "by", actor.Email, "tenantid", tenant.Tenantid, "login", auth.Email, "userid", user.Userid)
return utils.Created(c, fiber.Map{
"tenant": fiber.Map{
"tenantid": tenant.Tenantid,
"tenantname": tenant.Tenantname,
"primaryemail": tenant.Primaryemail,
"primarycontact": tenant.Primarycontact,
"status": tenant.Status,
"requiredeliveryotp": tenant.Requiredeliveryotp,
},
"login": fiber.Map{
"email": auth.Email,
"role": auth.Role,
"userid": user.Userid,
"name": user.Authname,
"tenantid": tenant.Tenantid,
},
})
}
type onboardedClient struct {
Authid uint64 `json:"authid"`
Tenantid int `json:"tenantid"`
Tenantname string `json:"tenantname"`
Primaryemail string `json:"primaryemail"`
Primarycontact string `json:"primarycontact"`
Status string `json:"status"`
Requiredeliveryotp bool `json:"requiredeliveryotp"`
Contactname string `json:"contactname"`
Loginemail string `json:"loginemail"`
Loginrole string `json:"loginrole"`
Logincreatedat *time.Time `json:"logincreatedat"`
}
// realTime drops the zero/placeholder timestamps some older logins carry (they
// render as "1 Jan 0001"), so the console shows "—" instead of a fake date.
func realTime(t *time.Time) *time.Time {
if t == nil || t.Year() < 2000 {
return nil
}
return t
}
// GetOnboardedClients — GET /admin/clients/onboarded: the clients that have a
// console login, newest first. One row per login. Never returns a password hash.
func GetOnboardedClients(c *fiber.Ctx) error {
if !onboardingOwnerStillValid(actorOf(c).Email) {
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
}
var rows []onboardedClientRow
err := db.DB.Table("doormile_auth AS a").
Select(`a.id AS authid, t.tenantid, t.tenantname, t.primaryemail, t.primarycontact, t.status,
t.requiredeliveryotp, COALESCE(u.authname, '') AS contactname,
a.email AS loginemail, a.role AS loginrole,
a.created_at AS authcreatedat, t.createdat AS tenantcreatedat`).
Joins("JOIN tenants t ON t.tenantid = a.tenantid").
Joins("LEFT JOIN appusers u ON LOWER(u.email) = LOWER(a.email) AND u.tenantid = a.tenantid").
Where("a.tenantid IS NOT NULL").
Order("a.id DESC").
Limit(200).
Scan(&rows).Error
if err != nil {
utils.Error("list onboarded clients", "error", err.Error())
return utils.Internal(c, "failed to list clients")
}
out := make([]onboardedClient, 0, len(rows))
for _, r := range rows {
out = append(out, r.toClient())
}
return utils.List(c, out, int64(len(out)))
}
// onboardedClientRow is what the list query scans into. It is deliberately
// FLAT with every column named: GORM silently skips an embedded struct of an
// unexported type, which once left every field but the dates empty (and every
// authid 0). TestOnboardedClientRowMapsEveryColumn guards this.
type onboardedClientRow struct {
Authid uint64 `gorm:"column:authid"`
Tenantid int `gorm:"column:tenantid"`
Tenantname string `gorm:"column:tenantname"`
Primaryemail string `gorm:"column:primaryemail"`
Primarycontact string `gorm:"column:primarycontact"`
Status string `gorm:"column:status"`
Requiredeliveryotp bool `gorm:"column:requiredeliveryotp"`
Contactname string `gorm:"column:contactname"`
Loginemail string `gorm:"column:loginemail"`
Loginrole string `gorm:"column:loginrole"`
Authcreatedat *time.Time `gorm:"column:authcreatedat"`
Tenantcreatedat *time.Time `gorm:"column:tenantcreatedat"`
}
func (r onboardedClientRow) toClient() onboardedClient {
created := realTime(r.Authcreatedat) // timestamptz: already the right instant
if created == nil {
// tenants.createdat is a legacy timestamp WITHOUT zone holding IST
// digits; read as UTC it shows 5h30m late. utils.IST puts it right.
if t := realTime(r.Tenantcreatedat); t != nil {
ist := utils.IST(*t)
created = &ist
}
}
return onboardedClient{
Authid: r.Authid, Tenantid: r.Tenantid, Tenantname: r.Tenantname,
Primaryemail: r.Primaryemail, Primarycontact: r.Primarycontact, Status: r.Status,
Requiredeliveryotp: r.Requiredeliveryotp, Contactname: r.Contactname,
Loginemail: r.Loginemail, Loginrole: r.Loginrole, Logincreatedat: created,
}
}
// loadClientLogin finds a CLIENT login by doormile_auth id. A Doormile staff
// login (tenantid NULL) is reported as not found: these routes never touch one.
func loadClientLogin(authID string) (*models.DoormileAuth, error) {
var auth models.DoormileAuth
if err := db.DB.Where("id = ? AND tenantid IS NOT NULL", authID).First(&auth).Error; err != nil {
return nil, err
}
return &auth, nil
}
type updateClientRequest struct {
Companyname *string `json:"companyname"`
Contactname *string `json:"contactname"`
Email *string `json:"email"`
Phone *string `json:"phone"`
Status *string `json:"status"`
Requiredeliveryotp *bool `json:"requiredeliveryotp"`
Password *string `json:"password"` // optional reset; empty = unchanged
}
var clientStatuses = map[string]string{"active": "Active", "pending": "Pending", "inactive": "Inactive"}
// UpdateOnboardedClient — PUT /admin/clients/:id (id = the login's authid).
// Edits the client company (tenants) and that login (doormile_auth + appusers)
// in one transaction. Only fields sent are changed.
func UpdateOnboardedClient(c *fiber.Ctx) error {
actor := actorOf(c)
if !onboardingOwnerStillValid(actor.Email) {
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
}
auth, err := loadClientLogin(c.Params("id"))
if err != nil {
return utils.NotFound(c, "client login not found")
}
req := new(updateClientRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
// Validate by reusing the onboarding rules on a filled-in copy.
var tenant models.Tenant
if err := db.DB.First(&tenant, *auth.Tenantid).Error; err != nil {
return utils.NotFound(c, "client not found")
}
check := onboardClientRequest{
Companyname: tenant.Tenantname, Contactname: "xx", Email: auth.Email,
Phone: tenant.Primarycontact, Password: "unchanged-ok", Applocationid: 1,
}
if req.Companyname != nil {
check.Companyname = *req.Companyname
}
if req.Contactname != nil {
check.Contactname = *req.Contactname
}
if req.Email != nil {
check.Email = *req.Email
} else {
check.Email = "unchanged@doormile.example" // as with the phone: only a changed email is validated
}
if req.Phone != nil {
check.Phone = *req.Phone
} else {
// An older client may carry a phone that fails today's rule; only a
// phone the caller is actually changing is validated.
check.Phone = "9000000000"
}
newPassword := ""
if req.Password != nil && *req.Password != "" {
newPassword = *req.Password
check.Password = newPassword
}
if msg := check.validate(); msg != "" {
return utils.BadRequest(c, msg)
}
status := tenant.Status
if req.Status != nil {
s, ok := clientStatuses[strings.ToLower(strings.TrimSpace(*req.Status))]
if !ok {
return utils.BadRequest(c, "status must be Active, Pending or Inactive")
}
status = s
}
var hash string
if newPassword != "" {
if hash, err = utils.HashPassword(newPassword); err != nil {
return utils.Internal(c, "failed to process the password")
}
}
oldEmail := auth.Email
err = db.DB.Transaction(func(tx *gorm.DB) error {
var n int64
if req.Companyname != nil && !strings.EqualFold(check.Companyname, tenant.Tenantname) {
tx.Model(&models.Tenant{}).Where("LOWER(tenantname) = LOWER(?) AND tenantid <> ?", check.Companyname, tenant.Tenantid).Count(&n)
if n > 0 {
return errOnboardingConflict{"a client with this company name already exists"}
}
}
emailChanged := req.Email != nil && check.Email != strings.ToLower(oldEmail)
if emailChanged {
tx.Model(&models.DoormileAuth{}).Where("LOWER(email) = ? AND id <> ?", check.Email, auth.ID).Count(&n)
if n > 0 {
return errOnboardingConflict{"this email already has a console login"}
}
tx.Model(&models.AppUser{}).Where("LOWER(email) = ? AND LOWER(email) <> LOWER(?)", check.Email, oldEmail).Count(&n)
if n > 0 {
return errOnboardingConflict{"this email is already used by another user"}
}
}
tenantUpdates := map[string]any{"status": status, "updatedat": gorm.Expr("CURRENT_TIMESTAMP")}
if req.Companyname != nil {
tenantUpdates["tenantname"] = check.Companyname
}
if req.Phone != nil {
tenantUpdates["primarycontact"] = check.Phone
}
if emailChanged && strings.EqualFold(tenant.Primaryemail, oldEmail) {
tenantUpdates["primaryemail"] = check.Email
}
if req.Requiredeliveryotp != nil {
tenantUpdates["requiredeliveryotp"] = *req.Requiredeliveryotp
}
if err := tx.Model(&models.Tenant{}).Where("tenantid = ?", tenant.Tenantid).Updates(tenantUpdates).Error; err != nil {
return err
}
authUpdates := map[string]any{"updated_at": time.Now()}
if emailChanged {
authUpdates["email"] = check.Email
}
if hash != "" {
authUpdates["password_hash"] = hash
}
if err := tx.Model(&models.DoormileAuth{}).Where("id = ?", auth.ID).Updates(authUpdates).Error; err != nil {
return err
}
userUpdates := map[string]any{}
if emailChanged {
userUpdates["email"] = check.Email
}
if req.Contactname != nil {
userUpdates["authname"] = check.Contactname
}
if req.Phone != nil {
userUpdates["contactno"] = check.Phone
}
if hash != "" {
userUpdates["password"] = hash
}
if len(userUpdates) > 0 {
userUpdates["updatedat"] = gorm.Expr("CURRENT_TIMESTAMP")
if err := tx.Model(&models.AppUser{}).
Where("LOWER(email) = LOWER(?) AND tenantid = ?", oldEmail, tenant.Tenantid).
Updates(userUpdates).Error; err != nil {
return err
}
}
return nil
})
var conflict errOnboardingConflict
switch {
case errors.As(err, &conflict):
return utils.Conflict(c, conflict.msg)
case err != nil && isUniqueViolation(err):
return utils.Conflict(c, "this email already has a console login")
case err != nil:
utils.Error("client update failed", "error", err.Error(), "by", actor.Email)
return utils.Internal(c, "failed to update the client; nothing was changed")
}
utils.Info("client updated", "by", actor.Email, "tenantid", tenant.Tenantid, "authid", auth.ID,
"password_reset", hash != "", "email_changed", req.Email != nil && check.Email != strings.ToLower(oldEmail))
return utils.OK(c, fiber.Map{"authid": auth.ID, "tenantid": tenant.Tenantid, "status": status, "password_reset": hash != ""})
}
// DeleteOnboardedClient — DELETE /admin/clients/:id (id = the login's authid).
//
// Removes the CONSOLE LOGIN, not the company's history: the doormile_auth row
// and the matching appusers row are deleted, so the client can no longer sign
// in, and the client is marked Inactive when this was its last login. The
// tenants row and every booking, consignment and price attached to it stay —
// deleting them would break past orders and reports.
//
// A token already issued keeps working until it expires (JWTs are stateless);
// the login cannot be used to sign in again.
func DeleteOnboardedClient(c *fiber.Ctx) error {
actor := actorOf(c)
if !onboardingOwnerStillValid(actor.Email) {
return utils.Forbidden(c, "client onboarding is restricted to the designated onboarding account")
}
auth, err := loadClientLogin(c.Params("id"))
if err != nil {
return utils.NotFound(c, "client login not found")
}
tenantID := *auth.Tenantid
deactivated := false
err = db.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("id = ?", auth.ID).Delete(&models.DoormileAuth{}).Error; err != nil {
return err
}
if err := tx.Where("LOWER(email) = LOWER(?) AND tenantid = ?", auth.Email, tenantID).
Delete(&models.AppUser{}).Error; err != nil {
return err
}
var remaining int64
tx.Model(&models.DoormileAuth{}).Where("tenantid = ?", tenantID).Count(&remaining)
if remaining == 0 {
deactivated = true
return tx.Model(&models.Tenant{}).Where("tenantid = ?", tenantID).
Updates(map[string]any{"status": "Inactive", "updatedat": gorm.Expr("CURRENT_TIMESTAMP")}).Error
}
return nil
})
if err != nil {
utils.Error("client login delete failed", "error", err.Error(), "by", actor.Email)
return utils.Internal(c, "failed to remove the client login; nothing was changed")
}
utils.Info("client login removed", "by", actor.Email, "tenantid", tenantID, "login", auth.Email, "client_deactivated", deactivated)
return utils.OK(c, fiber.Map{"authid": auth.ID, "tenantid": tenantID, "login_removed": true, "client_deactivated": deactivated})
}
// GetOnboardingCities — GET /admin/clients/cities: the operating cities a new
// client can be placed in, straight from applocations (the table OnboardClient
// validates against). The console's usual city picker derives cities from
// hubs, which would hide a city that has no hub yet.
func GetOnboardingCities(c *fiber.Ctx) error {
var cities []models.AppLocation
if err := db.DB.Where("status IS NULL OR status = '' OR LOWER(status) = 'active'").
Order("applocationid").Find(&cities).Error; err != nil {
utils.Error("list onboarding cities", "error", err.Error())
return utils.Internal(c, "failed to list cities")
}
if cities == nil {
cities = []models.AppLocation{}
}
return utils.List(c, cities, int64(len(cities)))
}

View File

@@ -0,0 +1,106 @@
package controllers
import (
"strings"
"sync"
"testing"
"time"
"gorm.io/gorm/schema"
)
func validOnboarding() onboardClientRequest {
return onboardClientRequest{
Companyname: " Acme Foods ",
Contactname: "Priya Raman",
Email: " Ops@Acme.Example ",
Phone: "+91 98765-43210",
Password: "s3cure-pass",
Applocationid: 1,
}
}
func TestOnboardingValidateNormalises(t *testing.T) {
r := validOnboarding()
if msg := r.validate(); msg != "" {
t.Fatalf("valid request refused: %s", msg)
}
if r.Companyname != "Acme Foods" || r.Contactname != "Priya Raman" || r.Email != "ops@acme.example" || r.Phone != "9876543210" {
t.Fatalf("not normalised: %+v", r)
}
}
func TestOnboardingValidateRefuses(t *testing.T) {
cases := map[string]func(*onboardClientRequest){
"company name is required": func(r *onboardClientRequest) { r.Companyname = " " },
"company name is too long": func(r *onboardClientRequest) { r.Companyname = strings.Repeat("a", 121) },
"contact person's name": func(r *onboardClientRequest) { r.Contactname = "" },
"valid email": func(r *onboardClientRequest) { r.Email = "not-an-email" },
"valid email ": func(r *onboardClientRequest) { r.Email = "Ops <ops@acme.example>" },
"valid email ": func(r *onboardClientRequest) { r.Email = "ops@localhost" },
"10-digit mobile": func(r *onboardClientRequest) { r.Phone = "12345" },
"10-digit mobile ": func(r *onboardClientRequest) { r.Phone = "5876543210" }, // must start 6-9
"at least 8 characters": func(r *onboardClientRequest) { r.Password = "short" },
"at most 72 characters": func(r *onboardClientRequest) { r.Password = strings.Repeat("x", 73) },
"must not be the email": func(r *onboardClientRequest) { r.Password = "OPS@acme.example" },
"must not be the email or the ": func(r *onboardClientRequest) { r.Password = "9876543210" },
"operating city": func(r *onboardClientRequest) { r.Applocationid = 0 },
}
for want, mutate := range cases {
r := validOnboarding()
mutate(&r)
if msg := r.validate(); !strings.Contains(msg, strings.TrimSpace(want)) {
t.Errorf("%q: got %q", want, msg)
}
}
}
func TestNormalisePhone(t *testing.T) {
for in, want := range map[string]string{
"9876543210": "9876543210",
"+919876543210": "9876543210",
"919876543210": "9876543210",
"09876543210": "9876543210",
" 98765 43210 ": "9876543210",
"(987) 654-3210": "9876543210",
} {
if got := normalisePhone(in); got != want {
t.Errorf("normalisePhone(%q) = %q, want %q", in, got, want)
}
}
}
// The list query selects these column aliases; every one must land in a field.
// GORM maps silently — a field it cannot see stays empty with no error — so
// this parses the scan struct exactly as GORM does and checks each alias.
func TestOnboardedClientRowMapsEveryColumn(t *testing.T) {
s, err := schema.Parse(&onboardedClientRow{}, &sync.Map{}, schema.NamingStrategy{})
if err != nil {
t.Fatal(err)
}
for _, col := range []string{
"authid", "tenantid", "tenantname", "primaryemail", "primarycontact", "status",
"requiredeliveryotp", "contactname", "loginemail", "loginrole", "authcreatedat", "tenantcreatedat",
} {
if s.LookUpField(col) == nil {
t.Errorf("column %q selected by the list query maps to no field", col)
}
}
}
func TestOnboardedClientRowToClient(t *testing.T) {
zero := time.Time{}
// As the driver hands back a timestamp-without-zone column: IST digits tagged UTC.
tenant := time.Date(2026, 6, 24, 16, 14, 0, 0, time.UTC)
c := onboardedClientRow{Authid: 7, Tenantname: "Acme", Loginemail: "a@b.co", Authcreatedat: &zero, Tenantcreatedat: &tenant}.toClient()
if c.Authid != 7 || c.Tenantname != "Acme" || c.Loginemail != "a@b.co" {
t.Fatalf("fields lost: %+v", c)
}
// 16:14 IST, i.e. 10:44 UTC — not 16:14 UTC (which would show as 21:44 in India).
if c.Logincreatedat == nil || !c.Logincreatedat.Equal(time.Date(2026, 6, 24, 10, 44, 0, 0, time.UTC)) {
t.Fatalf("a zero login date must fall back to the tenant's, read as IST: %v", c.Logincreatedat)
}
if (onboardedClientRow{}).toClient().Logincreatedat != nil {
t.Fatal("no real date must give null, not year 1")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,238 @@
package playground
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// OpenAICompat drives the playground through any OpenAI-compatible chat
// completions API — Groq by default (https://api.groq.com/openai/v1), or xAI,
// or another provider — with plain net/http, so no SDK dependency is needed.
//
// Configured from PLAYGROUND_LLM_BASE_URL / _API_KEY / _MODEL (see config).
// The model comes from that setting, not from the agent's registry pin: the
// registry holds Claude ids for AI_engine, which this provider cannot serve.
type OpenAICompat struct {
BaseURL string
APIKey string
Model string
HTTP *http.Client
}
// NewOpenAICompat returns a client with a bounded HTTP timeout.
func NewOpenAICompat(baseURL, apiKey, model string) *OpenAICompat {
return &OpenAICompat{
BaseURL: strings.TrimRight(baseURL, "/"),
APIKey: apiKey,
Model: model,
HTTP: &http.Client{Timeout: 90 * time.Second},
}
}
// ModelName is what the trace reports as the model that answered.
func (o *OpenAICompat) ModelName() string { return o.Model }
// ── Wire types (OpenAI chat completions) ────────────────────────────────────
type oaFunctionCall struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
}
type oaToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function oaFunctionCall `json:"function"`
}
type oaMessage struct {
Role string `json:"role"`
Content *string `json:"content"`
ToolCalls []oaToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}
type oaTool struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
} `json:"function"`
}
type oaRequest struct {
Model string `json:"model"`
Messages []oaMessage `json:"messages"`
Tools []oaTool `json:"tools,omitempty"`
MaxCompletionTokens int64 `json:"max_completion_tokens,omitempty"`
}
type oaResponse struct {
Choices []struct {
Message oaMessage `json:"message"`
FinishReason string `json:"finish_reason"`
} `json:"choices"`
Usage struct {
PromptTokens int64 `json:"prompt_tokens"`
CompletionTokens int64 `json:"completion_tokens"`
} `json:"usage"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
func strp(s string) *string { return &s }
// objectSchema makes sure a tool's parameters are an object schema with a
// properties map — OpenAI-style APIs reject `{"type":"object"}` alone on some
// models, and several registry tools have open schemas.
func objectSchema(raw json.RawMessage) json.RawMessage {
var m map[string]any
if len(raw) == 0 || json.Unmarshal(raw, &m) != nil || m == nil {
m = map[string]any{}
}
m["type"] = "object"
if _, ok := m["properties"]; !ok {
m["properties"] = map[string]any{}
}
b, _ := json.Marshal(m)
return b
}
// toWire converts the playground conversation into chat-completions messages.
func toWire(req Request) oaRequest {
out := oaRequest{MaxCompletionTokens: req.MaxTokens}
if req.System != "" {
out.Messages = append(out.Messages, oaMessage{Role: "system", Content: strp(req.System)})
}
for _, t := range req.Turns {
switch {
case t.Role == "assistant":
msg := oaMessage{Role: "assistant"}
var texts []string
for _, b := range t.Assistant {
switch {
case b.Type == "text" && b.Text != "":
texts = append(texts, b.Text)
case b.Type == "tool_use" && b.ToolUse != nil:
args := string(b.ToolUse.Input)
if args == "" {
args = "{}"
}
msg.ToolCalls = append(msg.ToolCalls, oaToolCall{
ID: b.ToolUse.ID, Type: "function",
Function: oaFunctionCall{Name: b.ToolUse.Name, Arguments: args},
})
}
}
if len(texts) > 0 {
msg.Content = strp(strings.Join(texts, "\n\n"))
}
out.Messages = append(out.Messages, msg)
case len(t.Results) > 0:
for _, r := range t.Results {
out.Messages = append(out.Messages, oaMessage{Role: "tool", ToolCallID: r.ToolUseID, Content: strp(r.Content)})
}
default:
out.Messages = append(out.Messages, oaMessage{Role: "user", Content: strp(t.Text)})
}
}
for _, td := range req.Tools {
var tool oaTool
tool.Type = "function"
tool.Function.Name = td.Name
tool.Function.Description = td.Description
tool.Function.Parameters = objectSchema(td.InputSchema)
out.Tools = append(out.Tools, tool)
}
return out
}
// fromWire converts one chat-completions response into a playground Reply.
func fromWire(resp oaResponse) (Reply, error) {
if len(resp.Choices) == 0 {
return Reply{}, errors.New("model returned no choices")
}
ch := resp.Choices[0]
r := Reply{InputTokens: resp.Usage.PromptTokens, OutputTokens: resp.Usage.CompletionTokens}
if ch.Message.Content != nil && strings.TrimSpace(*ch.Message.Content) != "" {
r.Blocks = append(r.Blocks, Block{Type: "text", Text: *ch.Message.Content})
}
for _, tc := range ch.Message.ToolCalls {
input := json.RawMessage(tc.Function.Arguments)
if !json.Valid(input) {
input = json.RawMessage(`{}`)
}
r.Blocks = append(r.Blocks, Block{Type: "tool_use", ToolUse: &ToolUse{ID: tc.ID, Name: tc.Function.Name, Input: input}})
}
switch ch.FinishReason {
case "tool_calls":
r.StopReason = "tool_use"
case "length":
r.StopReason = "max_tokens"
default:
r.StopReason = "end_turn"
}
// Some providers report "stop" while still returning tool calls; the calls win.
if len(ch.Message.ToolCalls) > 0 {
r.StopReason = "tool_use"
}
return r, nil
}
// Next performs one chat-completions call.
func (o *OpenAICompat) Next(ctx context.Context, req Request) (Reply, error) {
body := toWire(req)
body.Model = o.Model
payload, err := json.Marshal(body)
if err != nil {
return Reply{}, fmt.Errorf("encode request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, o.BaseURL+"/chat/completions", bytes.NewReader(payload))
if err != nil {
return Reply{}, fmt.Errorf("build request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+o.APIKey)
httpReq.Header.Set("Content-Type", "application/json")
res, err := o.HTTP.Do(httpReq)
if err != nil {
return Reply{}, fmt.Errorf("model request: %w", err)
}
defer res.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(res.Body, 4<<20))
var out oaResponse
_ = json.Unmarshal(raw, &out)
if res.StatusCode/100 != 2 {
msg := strings.TrimSpace(string(raw))
if out.Error != nil && out.Error.Message != "" {
msg = out.Error.Message
}
if len(msg) > 300 {
msg = msg[:300]
}
return Reply{}, &ProviderError{Status: res.StatusCode, Message: msg}
}
return fromWire(out)
}
// ProviderError is a non-2xx answer from the model provider. The status lets
// the controller tell "rate limited" (429, common on free tiers) from a
// genuine failure. The message never contains the API key.
type ProviderError struct {
Status int
Message string
}
func (e *ProviderError) Error() string {
return fmt.Sprintf("model provider answered %d: %s", e.Status, e.Message)
}

View File

@@ -0,0 +1,130 @@
package playground
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// A fake OpenAI-compatible server: first call asks for a tool, second answers.
func fakeProvider(t *testing.T, seen *[]map[string]any) *httptest.Server {
t.Helper()
calls := 0
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/openai/v1/chat/completions" {
t.Errorf("path = %s", r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer test-key" {
t.Errorf("auth header = %q", r.Header.Get("Authorization"))
}
b, _ := io.ReadAll(r.Body)
var body map[string]any
_ = json.Unmarshal(b, &body)
*seen = append(*seen, body)
calls++
w.Header().Set("Content-Type", "application/json")
if calls == 1 {
_, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":null,"tool_calls":[
{"id":"call_1","type":"function","function":{"name":"get_booking_cache","arguments":"{\"booking_id\":5}"}},
{"id":"call_2","type":"function","function":{"name":"reassign_booking","arguments":"not json"}}]},
"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":100,"completion_tokens":20}}`)
return
}
_, _ = io.WriteString(w, `{"choices":[{"message":{"role":"assistant","content":"Proposed a reassign."},"finish_reason":"stop"}],
"usage":{"prompt_tokens":150,"completion_tokens":10}}`)
}))
}
func TestOpenAICompatRunsTheToolLoop(t *testing.T) {
var seen []map[string]any
srv := fakeProvider(t, &seen)
defer srv.Close()
m := NewOpenAICompat(srv.URL+"/openai/v1/", "test-key", "openai/gpt-oss-120b")
plan := mustPlan(t, "EXCEPTION_AGENT", "stall_response")
plan.Model = m.ModelName()
execs := map[string]Executor{
"get_booking_cache": func(context.Context, json.RawMessage) (any, error) {
return map[string]any{"bookingid": 5, "customername": "Ravi"}, nil
},
}
tr, err := Run(context.Background(), m, plan, "booking 5 is stuck", execs)
if err != nil {
t.Fatal(err)
}
if tr.Final != "Proposed a reassign." || tr.Turns != 2 || tr.Inputtokens != 250 || tr.Outputtokens != 30 {
t.Fatalf("trace = %+v", tr)
}
if tr.Model != "openai/gpt-oss-120b" {
t.Fatalf("model = %s", tr.Model)
}
if tr.Steps[0].Outcome != OutcomeExecuted || tr.Steps[1].Outcome != OutcomeProposed || string(tr.Steps[1].Input) != "{}" {
t.Fatalf("steps = %+v", tr.Steps)
}
// First request: system + user, the model from config, tools as functions
// with object schemas.
first := seen[0]
if first["model"] != "openai/gpt-oss-120b" || first["max_completion_tokens"].(float64) != MaxTokens {
t.Fatalf("first request = %v", first)
}
msgs := first["messages"].([]any)
if msgs[0].(map[string]any)["role"] != "system" || msgs[1].(map[string]any)["role"] != "user" {
t.Fatalf("messages = %v", msgs)
}
tool := first["tools"].([]any)[0].(map[string]any)
params := tool["function"].(map[string]any)["parameters"].(map[string]any)
if tool["type"] != "function" || params["type"] != "object" || params["properties"] == nil {
t.Fatalf("tool = %v", tool)
}
// Second request: the assistant's tool_calls echoed, then one tool message
// per call, redacted.
msgs = seen[1]["messages"].([]any)
asst := msgs[2].(map[string]any)
if asst["role"] != "assistant" || len(asst["tool_calls"].([]any)) != 2 {
t.Fatalf("assistant echo = %v", asst)
}
res1, res2 := msgs[3].(map[string]any), msgs[4].(map[string]any)
if res1["role"] != "tool" || res1["tool_call_id"] != "call_1" || res2["tool_call_id"] != "call_2" {
t.Fatalf("tool results = %v %v", res1, res2)
}
if strings.Contains(res1["content"].(string), "Ravi") {
t.Fatal("personal data reached the provider")
}
if !strings.Contains(res2["content"].(string), `"executed":false`) {
t.Fatalf("write tool was not answered as a proposal: %v", res2["content"])
}
}
func TestOpenAICompatReportsProviderErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
_, _ = io.WriteString(w, `{"error":{"message":"Rate limit reached for model"}}`)
}))
defer srv.Close()
_, err := NewOpenAICompat(srv.URL, "secret-key-value", "m").Next(context.Background(), Request{Turns: []Turn{{Role: "user", Text: "hi"}}})
var pe *ProviderError
if !errors.As(err, &pe) || pe.Status != 429 || !strings.Contains(pe.Message, "Rate limit") {
t.Fatalf("err = %v", err)
}
if strings.Contains(err.Error(), "secret-key-value") {
t.Fatal("the API key leaked into the error")
}
}
func TestOpenAICompatStopWithToolCallsStillLoops(t *testing.T) {
r, err := fromWire(oaResponse{Choices: []struct {
Message oaMessage `json:"message"`
FinishReason string `json:"finish_reason"`
}{{Message: oaMessage{ToolCalls: []oaToolCall{{ID: "c", Function: oaFunctionCall{Name: "x", Arguments: "{}"}}}}, FinishReason: "stop"}}})
if err != nil || r.StopReason != "tool_use" {
t.Fatalf("reply = %+v, err = %v", r, err)
}
}

View File

@@ -0,0 +1,341 @@
// Package playground runs one operator prompt through Claude with a registry
// skill's tools, for Agent Studio's Test tab (Phase 6 of
// krow_talent_app/docs/agent-platform-plan.md).
//
// The rules that make it safe to point at production:
// - read tools the backend can serve run for real, and their results are
// redacted (names, phones, addresses, emails, free text) before Claude sees
// them — see redact.go;
// - write, notify and event tools NEVER run: the call is answered with a
// proposal and shown in the trace as "proposed";
// - a tool outside the selected skill is refused;
// - the loop is bounded (MaxTurns) and so is every tool call (ToolTimeout).
//
// Claude is reached through the Model interface, so the loop is tested with a
// fake and the server wires in a real client only when one is configured.
package playground
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"doormile/internal/ai/registry"
)
// DefaultModel is used when the agent has no model pinned in the registry.
const DefaultModel = "claude-opus-5-5"
const (
MaxTurns = 6
MaxTokens = 4096
MaxPromptChars = 2000
ToolTimeout = 5 * time.Second
maxResultBytes = 16 * 1024
)
// Tool kinds, as the registry stores them.
const (
kindRead = "read"
)
// Outcomes of a tool call, as the trace shows them.
const (
OutcomeExecuted = "executed"
OutcomeProposed = "proposed"
OutcomeUnavailable = "unavailable"
OutcomeError = "error"
OutcomeRejected = "rejected"
)
// ── The model boundary ──────────────────────────────────────────────────────
// ToolUse is a tool call Claude asked for.
type ToolUse struct {
ID string
Name string
Input json.RawMessage
}
// Block is one content block of a reply. Text and tool_use are read by the
// loop; anything else (thinking) is carried in Raw and sent back unchanged,
// which the API requires within a tool-use turn.
type Block struct {
Type string
Text string
ToolUse *ToolUse
Raw json.RawMessage
}
// Reply is one model response.
type Reply struct {
Blocks []Block
StopReason string
InputTokens int64
OutputTokens int64
}
// ToolResult answers one ToolUse.
type ToolResult struct {
ToolUseID string
Content string
IsError bool
}
// Turn is one message of the conversation: the user's prompt, an assistant
// reply, or the user turn carrying tool results.
type Turn struct {
Role string // "user" or "assistant"
Text string
Assistant []Block
Results []ToolResult
}
// ToolDef is a tool as offered to the model.
type ToolDef struct {
Name string
Description string
InputSchema json.RawMessage
}
// Request is one model call.
type Request struct {
Model string
System string
MaxTokens int64
Tools []ToolDef
Turns []Turn
}
// Model is the one call the loop needs from Claude.
type Model interface {
Next(ctx context.Context, req Request) (Reply, error)
}
// ── Plan: what a run may use ────────────────────────────────────────────────
// ErrNotFound is returned when the agent or skill does not exist.
var ErrNotFound = errors.New("not found")
// Plan is the resolved agent, skill and tools for one run.
type Plan struct {
AgentID string
AgentName string
SkillID string
Model string
System string
Tools []ToolDef
kinds map[string]string
}
// Prepare resolves a run from the registry. skillID may be empty: the run then
// gets every tool of the agent's enabled skills.
func Prepare(snap *registry.Snapshot, agentID, skillID string) (*Plan, error) {
var agent *registry.AgentView
for i := range snap.Agents {
if snap.Agents[i].Agentid == agentID {
agent = &snap.Agents[i]
}
}
if agent == nil {
return nil, fmt.Errorf("agent %q: %w", agentID, ErrNotFound)
}
toolNames := map[string]bool{}
var skillLines []string
found := skillID == ""
for _, s := range snap.Skills {
if s.Agentid != agentID {
continue
}
if skillID != "" && s.Skillid != skillID {
continue
}
if skillID == "" && !s.Enabled {
continue
}
found = true
skillLines = append(skillLines, fmt.Sprintf("- %s: %s", s.Title, s.Description))
for _, t := range s.Tools {
toolNames[t] = true
}
}
if !found {
return nil, fmt.Errorf("skill %q of agent %q: %w", skillID, agentID, ErrNotFound)
}
plan := &Plan{
AgentID: agent.Agentid, AgentName: agent.Name, SkillID: skillID,
Model: agent.Model, kinds: map[string]string{}, Tools: []ToolDef{},
}
if plan.Model == "" {
plan.Model = DefaultModel
}
for _, t := range snap.Tools {
if !toolNames[t.Toolname] {
continue
}
desc := t.Description
if t.Kind != kindRead {
desc += " [Playground: NOT executed — calling it records a proposal for a human.]"
}
plan.Tools = append(plan.Tools, ToolDef{Name: t.Toolname, Description: desc, InputSchema: t.Inputschema})
plan.kinds[t.Toolname] = t.Kind
}
plan.System = strings.Join([]string{
fmt.Sprintf("You are %s, an agent in Doormile's delivery operations, being tested by an operator in the Agent Studio playground.", agent.Name),
"Purpose: " + agent.Purpose,
"Skills in scope:\n" + strings.Join(skillLines, "\n"),
"Read tools return live data with personal details (names, phones, addresses, notes) removed; do not ask for them.",
"Write, notify and event tools are not executed here: calling one records a proposal for a human to review. Say plainly what you would do and why.",
"If a tool is unavailable, say so rather than guessing its result. Keep the final answer short and concrete.",
}, "\n\n")
return plan, nil
}
// ── The run ─────────────────────────────────────────────────────────────────
// Executor runs one read tool. Its result is redacted before the model sees it.
type Executor func(ctx context.Context, input json.RawMessage) (any, error)
// Step is one line of the trace the console shows.
type Step struct {
Kind string `json:"kind"` // "text" or "tool"
Text string `json:"text,omitempty"`
Tool string `json:"tool,omitempty"`
Toolkind string `json:"toolkind,omitempty"`
Input json.RawMessage `json:"input,omitempty"`
Outcome string `json:"outcome,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Ms int64 `json:"ms"`
}
// Trace is the whole run.
type Trace struct {
Agentid string `json:"agentid"`
Skillid string `json:"skillid"`
Model string `json:"model"`
Steps []Step `json:"steps"`
Final string `json:"final"`
Stopreason string `json:"stopreason"`
Turns int `json:"turns"`
Inputtokens int64 `json:"inputtokens"`
Outputtokens int64 `json:"outputtokens"`
Ms int64 `json:"ms"`
}
// Run executes the prompt. A model error ends the run with the error; the
// trace so far is still returned so the console can show how far it got.
func Run(ctx context.Context, m Model, plan *Plan, prompt string, execs map[string]Executor) (*Trace, error) {
started := time.Now()
tr := &Trace{Agentid: plan.AgentID, Skillid: plan.SkillID, Model: plan.Model, Steps: []Step{}}
turns := []Turn{{Role: "user", Text: prompt}}
defer func() { tr.Ms = time.Since(started).Milliseconds() }()
for tr.Turns < MaxTurns {
callStarted := time.Now()
reply, err := m.Next(ctx, Request{
Model: plan.Model, System: plan.System, MaxTokens: MaxTokens, Tools: plan.Tools, Turns: turns,
})
tr.Turns++
if err != nil {
tr.Stopreason = "error"
return tr, err
}
tr.Inputtokens += reply.InputTokens
tr.Outputtokens += reply.OutputTokens
tr.Stopreason = reply.StopReason
modelMs := time.Since(callStarted).Milliseconds()
turns = append(turns, Turn{Role: "assistant", Assistant: reply.Blocks})
var texts []string
var results []ToolResult
for _, b := range reply.Blocks {
switch {
case b.Type == "text" && strings.TrimSpace(b.Text) != "":
texts = append(texts, b.Text)
tr.Steps = append(tr.Steps, Step{Kind: "text", Text: b.Text, Ms: modelMs})
modelMs = 0
case b.Type == "tool_use" && b.ToolUse != nil:
step, res := callTool(ctx, plan, execs, *b.ToolUse)
tr.Steps = append(tr.Steps, step)
results = append(results, res)
}
}
if reply.StopReason != "tool_use" || len(results) == 0 {
tr.Final = strings.Join(texts, "\n\n")
return tr, nil
}
turns = append(turns, Turn{Role: "user", Results: results})
}
tr.Stopreason = "max_turns"
return tr, nil
}
func callTool(ctx context.Context, plan *Plan, execs map[string]Executor, use ToolUse) (Step, ToolResult) {
started := time.Now()
input := use.Input
if len(input) == 0 || !json.Valid(input) {
input = json.RawMessage(`{}`)
}
kind, inSkill := plan.kinds[use.Name]
step := Step{Kind: "tool", Tool: use.Name, Toolkind: kind, Input: input}
res := ToolResult{ToolUseID: use.ID}
finish := func(outcome string, payload any, isError bool) (Step, ToolResult) {
body := encode(payload)
step.Outcome, step.Result, step.Ms = outcome, body, time.Since(started).Milliseconds()
res.Content, res.IsError = string(body), isError
return step, res
}
switch {
case !inSkill:
return finish(OutcomeRejected, map[string]string{"error": "This tool is not part of the selected skill."}, true)
case kind != kindRead:
return finish(OutcomeProposed, map[string]any{
"executed": false,
"proposal": map[string]any{"tool": use.Name, "input": input},
"note": "Playground: recorded as a proposal for a human. Nothing was changed.",
}, false)
case execs[use.Name] == nil:
return finish(OutcomeUnavailable, map[string]string{
"error": "This read tool is not available in the playground (it runs inside AI_engine or calls an external service).",
}, true)
}
tctx, cancel := context.WithTimeout(ctx, ToolTimeout)
defer cancel()
out, err := execs[use.Name](tctx, input)
if err != nil {
return finish(OutcomeError, map[string]string{"error": err.Error()}, true)
}
return finish(OutcomeExecuted, Redact(out), false)
}
// encode marshals a tool result, capped so one large read cannot blow the
// context window or the console.
func encode(v any) json.RawMessage {
b, err := json.Marshal(v)
if err != nil {
b, _ = json.Marshal(map[string]string{"error": "result could not be encoded"})
}
if len(b) > maxResultBytes {
b, _ = json.Marshal(map[string]any{
"truncated": true,
"note": fmt.Sprintf("Result was %d bytes; showing the first %d.", len(b), maxResultBytes),
"partial": string(b[:maxResultBytes]),
})
}
return b
}

View File

@@ -0,0 +1,280 @@
package playground
import (
"context"
"encoding/json"
"errors"
"strings"
"testing"
"doormile/internal/ai/registry"
"doormile/models"
)
// fakeModel replays scripted replies and records every request.
type fakeModel struct {
replies []Reply
err error
reqs []Request
}
func (f *fakeModel) Next(_ context.Context, req Request) (Reply, error) {
f.reqs = append(f.reqs, req)
if f.err != nil {
return Reply{}, f.err
}
if len(f.replies) == 0 {
return Reply{StopReason: "end_turn", Blocks: []Block{{Type: "text", Text: "done"}}}, nil
}
r := f.replies[0]
f.replies = f.replies[1:]
return r, nil
}
func toolCall(id, name, input string) Block {
return Block{Type: "tool_use", ToolUse: &ToolUse{ID: id, Name: name, Input: json.RawMessage(input)}}
}
func testSnapshot() *registry.Snapshot {
agents := []models.AIAgent{
{Agentid: "EXCEPTION_AGENT", Name: "Exception", Purpose: "Handles stalled riders.", Model: "claude-sonnet-5-5"},
{Agentid: "CONSOLE_OPS_AGENT", Name: "Console Ops Agent", Purpose: "Watches the board."},
}
tools := []models.AITool{
{Toolname: "get_booking_cache", Kind: "read", Description: "Read a booking.", Inputschema: `{"type":"object"}`},
{Toolname: "nearby_milers", Kind: "read", Description: "Riders near a point.", Inputschema: `{"type":"object"}`},
{Toolname: "decide_stall_response", Kind: "read", Description: "Engine decision.", Inputschema: `{"type":"object"}`},
{Toolname: "reassign_booking", Kind: "write", Description: "Reassign.", Inputschema: `{"type":"object"}`},
{Toolname: "scan_bookings", Kind: "read", Description: "Scan.", Inputschema: `{"type":"object"}`},
}
skills := []models.AISkill{
{Skillid: "stall_response", Agentid: "EXCEPTION_AGENT", Title: "Stall", Description: "Respond to stalls.", Enabled: true},
{Skillid: "off_skill", Agentid: "EXCEPTION_AGENT", Title: "Off", Enabled: false},
{Skillid: "late_dispatch", Agentid: "CONSOLE_OPS_AGENT", Title: "Late", Enabled: true},
}
links := []models.AISkillTool{
{Skillid: "stall_response", Toolname: "get_booking_cache"},
{Skillid: "stall_response", Toolname: "nearby_milers"},
{Skillid: "stall_response", Toolname: "decide_stall_response"},
{Skillid: "stall_response", Toolname: "reassign_booking"},
{Skillid: "off_skill", Toolname: "scan_bookings"},
{Skillid: "late_dispatch", Toolname: "scan_bookings"},
}
return registry.Build(agents, tools, skills, links)
}
func mustPlan(t *testing.T, agent, skill string) *Plan {
t.Helper()
p, err := Prepare(testSnapshot(), agent, skill)
if err != nil {
t.Fatalf("Prepare: %v", err)
}
return p
}
func toolNames(p *Plan) []string {
var out []string
for _, t := range p.Tools {
out = append(out, t.Name)
}
return out
}
// ── Prepare ─────────────────────────────────────────────────────────────────
func TestPrepareUsesSkillToolsAndPinnedModel(t *testing.T) {
p := mustPlan(t, "EXCEPTION_AGENT", "stall_response")
if p.Model != "claude-sonnet-5-5" {
t.Fatalf("model = %q, want the registry pin", p.Model)
}
got := strings.Join(toolNames(p), ",")
// Registry order (Load sorts by toolname; this fixture is in its own order).
if got != "get_booking_cache,nearby_milers,decide_stall_response,reassign_booking" {
t.Fatalf("tools = %s", got)
}
for _, td := range p.Tools {
marked := strings.Contains(td.Description, "NOT executed")
if (td.Name == "reassign_booking") != marked {
t.Fatalf("%s: write-tool marking = %v", td.Name, marked)
}
}
}
func TestPrepareDefaultsModelAndSkipsDisabledSkillsWhenNoSkillGiven(t *testing.T) {
p := mustPlan(t, "CONSOLE_OPS_AGENT", "")
if p.Model != DefaultModel {
t.Fatalf("model = %q, want %q", p.Model, DefaultModel)
}
p = mustPlan(t, "EXCEPTION_AGENT", "")
for _, n := range toolNames(p) {
if n == "scan_bookings" {
t.Fatal("a disabled skill's tool was offered")
}
}
}
func TestPrepareNotFound(t *testing.T) {
for _, c := range [][2]string{{"NOPE", ""}, {"EXCEPTION_AGENT", "late_dispatch"}, {"EXCEPTION_AGENT", "missing"}} {
if _, err := Prepare(testSnapshot(), c[0], c[1]); !errors.Is(err, ErrNotFound) {
t.Fatalf("%v: err = %v, want ErrNotFound", c, err)
}
}
}
// ── Run ─────────────────────────────────────────────────────────────────────
func TestRunTextOnly(t *testing.T) {
m := &fakeModel{replies: []Reply{{StopReason: "end_turn", InputTokens: 10, OutputTokens: 5,
Blocks: []Block{{Type: "thinking", Raw: json.RawMessage(`{"type":"thinking"}`)}, {Type: "text", Text: "All clear."}}}}}
tr, err := Run(context.Background(), m, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "status?", nil)
if err != nil {
t.Fatal(err)
}
if tr.Final != "All clear." || tr.Turns != 1 || tr.Inputtokens != 10 || tr.Outputtokens != 5 || tr.Model != "claude-sonnet-5-5" {
t.Fatalf("trace = %+v", tr)
}
if len(tr.Steps) != 1 || tr.Steps[0].Kind != "text" {
t.Fatalf("steps = %+v", tr.Steps)
}
req := m.reqs[0]
if req.Turns[0].Text != "status?" || req.MaxTokens != MaxTokens || len(req.Tools) != 4 || req.System == "" {
t.Fatalf("request = %+v", req)
}
}
func TestRunToolOutcomes(t *testing.T) {
executed := 0
execs := map[string]Executor{
"get_booking_cache": func(_ context.Context, in json.RawMessage) (any, error) {
executed++
return map[string]any{"bookingid": 5, "customername": "Ravi", "notes": "call 9876543210"}, nil
},
// Present but NOT in the skill — must never run.
"scan_bookings": func(context.Context, json.RawMessage) (any, error) {
t.Fatal("a tool outside the skill was executed")
return nil, nil
},
"nearby_milers": func(context.Context, json.RawMessage) (any, error) { return nil, errors.New("positions unavailable") },
}
m := &fakeModel{replies: []Reply{
{StopReason: "tool_use", Blocks: []Block{
toolCall("t1", "get_booking_cache", `{"booking_id":5}`),
toolCall("t2", "reassign_booking", `{"booking_id":5}`),
toolCall("t3", "scan_bookings", `{}`),
toolCall("t4", "decide_stall_response", `{}`),
toolCall("t5", "nearby_milers", `{"lat":11,"lon":77}`),
}},
{StopReason: "end_turn", Blocks: []Block{{Type: "text", Text: "Proposed a reassign."}}},
}}
tr, err := Run(context.Background(), m, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "booking 5 is stuck", execs)
if err != nil {
t.Fatal(err)
}
want := map[string]string{
"get_booking_cache": OutcomeExecuted, "reassign_booking": OutcomeProposed, "scan_bookings": OutcomeRejected,
"decide_stall_response": OutcomeUnavailable, "nearby_milers": OutcomeError,
}
for _, s := range tr.Steps {
if s.Kind == "tool" && want[s.Tool] != s.Outcome {
t.Fatalf("%s outcome = %s, want %s", s.Tool, s.Outcome, want[s.Tool])
}
}
if executed != 1 {
t.Fatalf("read tool executed %d times", executed)
}
// The second request carries all five results, in order, and redacted.
results := m.reqs[1].Turns[2].Results
if len(results) != 5 || results[0].ToolUseID != "t1" {
t.Fatalf("results = %+v", results)
}
if strings.Contains(results[0].Content, "Ravi") || strings.Contains(results[0].Content, "9876543210") {
t.Fatalf("personal data reached the model: %s", results[0].Content)
}
if !strings.Contains(results[1].Content, `"executed":false`) || results[1].IsError {
t.Fatalf("write tool result = %+v", results[1])
}
for _, i := range []int{2, 3, 4} {
if !results[i].IsError {
t.Fatalf("result %d should be an error: %+v", i, results[i])
}
}
// The assistant turn (with its tool_use blocks) is echoed back before the results.
if m.reqs[1].Turns[1].Role != "assistant" || len(m.reqs[1].Turns[1].Assistant) != 5 {
t.Fatalf("assistant turn not echoed: %+v", m.reqs[1].Turns[1])
}
if tr.Final != "Proposed a reassign." || tr.Turns != 2 {
t.Fatalf("trace = %+v", tr)
}
}
func TestRunStopsAtMaxTurns(t *testing.T) {
var replies []Reply
for i := 0; i < MaxTurns+2; i++ {
replies = append(replies, Reply{StopReason: "tool_use", Blocks: []Block{toolCall("t", "reassign_booking", `{}`)}})
}
m := &fakeModel{replies: replies}
tr, err := Run(context.Background(), m, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "loop", nil)
if err != nil {
t.Fatal(err)
}
if tr.Turns != MaxTurns || tr.Stopreason != "max_turns" || len(m.reqs) != MaxTurns {
t.Fatalf("turns = %d, stop = %s, calls = %d", tr.Turns, tr.Stopreason, len(m.reqs))
}
}
func TestRunModelError(t *testing.T) {
tr, err := Run(context.Background(), &fakeModel{err: errors.New("overloaded")}, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "x", nil)
if err == nil || tr == nil || tr.Stopreason != "error" {
t.Fatalf("err = %v, trace = %+v", err, tr)
}
}
func TestInvalidToolInputBecomesEmptyObject(t *testing.T) {
m := &fakeModel{replies: []Reply{{StopReason: "tool_use", Blocks: []Block{toolCall("t", "reassign_booking", `{not json`)}}}}
tr, _ := Run(context.Background(), m, mustPlan(t, "EXCEPTION_AGENT", "stall_response"), "x", nil)
if string(tr.Steps[0].Input) != "{}" {
t.Fatalf("input = %s", tr.Steps[0].Input)
}
}
func TestEncodeCapsLargeResults(t *testing.T) {
b := encode(map[string]string{"blob": strings.Repeat("x", maxResultBytes*2)})
if len(b) > maxResultBytes+1024 || !strings.Contains(string(b), `"truncated":true`) {
t.Fatalf("len = %d", len(b))
}
}
// ── Redact ──────────────────────────────────────────────────────────────────
func TestRedact(t *testing.T) {
in := map[string]any{
"bookingid": 7,
"customerName": "Ravi",
"pickupaddress": "12 MG Road",
"status": "Created",
"createdat_ist": "2026-09-29 12:30",
"deliverycity": "Coimbatore",
"cancelreason": "customer asked",
"nested": []any{map[string]any{"phone": "9876543210", "hub": "call +91 98765 43210 or a@b.co"}},
"missingnote": nil,
}
out := Redact(in).(map[string]any)
for _, k := range []string{"customerName", "pickupaddress", "cancelreason"} {
if out[k] != Redacted {
t.Fatalf("%s = %v, want redacted", k, out[k])
}
}
for k, want := range map[string]any{"status": "Created", "createdat_ist": "2026-09-29 12:30", "deliverycity": "Coimbatore", "bookingid": float64(7)} {
if out[k] != want {
t.Fatalf("%s = %v, want %v (must not be redacted)", k, out[k], want)
}
}
nested := out["nested"].([]any)[0].(map[string]any)
if nested["phone"] != Redacted || strings.ContainsAny(nested["hub"].(string), "@") || strings.Contains(nested["hub"].(string), "98765") {
t.Fatalf("nested = %v", nested)
}
if out["missingnote"] != nil {
t.Fatal("a null personal field should stay null")
}
}

View File

@@ -0,0 +1,78 @@
package playground
import (
"encoding/json"
"regexp"
"strings"
)
// Redacted replaces every value Redact removes.
const Redacted = "[redacted]"
// piiKeys: a field whose name contains one of these is personal or free text
// (free text is where people type phone numbers and addresses).
var piiKeys = []string{
"name", "phone", "mobile", "email", "address", "landmark", "otp", "contact",
"note", "remark", "instruction", "description", "reason", "comment",
}
var (
emailLike = regexp.MustCompile(`[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}`)
// An Indian mobile (10 digits from 6-9, optional +91). Narrow on purpose:
// a looser digit-run pattern also masks dates like "2026-09-29 12".
phoneLike = regexp.MustCompile(`(?:\+?91[\s-]?)?\b[6-9]\d{4}[\s-]?\d{5}\b`)
)
// Redact returns a copy of v, as plain JSON values, with personal data
// removed: values under personal keys are replaced, and any remaining string
// that contains an email or a phone-like number has it masked.
//
// Executors already select only non-personal columns; this is the backstop
// that makes a later column addition safe by default.
func Redact(v any) any {
b, err := json.Marshal(v)
if err != nil {
return nil
}
var generic any
if err := json.Unmarshal(b, &generic); err != nil {
return nil
}
return redactValue(generic)
}
func redactValue(v any) any {
switch t := v.(type) {
case map[string]any:
out := make(map[string]any, len(t))
for k, val := range t {
if isPIIKey(k) && val != nil {
out[k] = Redacted
continue
}
out[k] = redactValue(val)
}
return out
case []any:
out := make([]any, len(t))
for i, val := range t {
out[i] = redactValue(val)
}
return out
case string:
s := emailLike.ReplaceAllString(t, Redacted)
return phoneLike.ReplaceAllString(s, Redacted)
default:
return v
}
}
func isPIIKey(k string) bool {
k = strings.ToLower(k)
for _, p := range piiKeys {
if strings.Contains(k, p) {
return true
}
}
return false
}

View File

@@ -0,0 +1,178 @@
package playground
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
"strings"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// The read tools the backend serves itself. Every other read tool in the
// registry runs inside AI_engine (decide_*), calls an external service
// (sequence_stops, simulate_pricing_quote) or an engine-only internal route
// (list_express_*), and answers "unavailable" in the playground.
//
// Each executor selects named, non-personal columns only — never addresses,
// names, phones or notes — and Redact runs over the result as a backstop.
// Coordinates are rounded to 2 decimals (about 1 km).
const (
scanDefaultLimit = 20
scanMaxLimit = 50
nearbyMaxKm = 10.0
nearbyMaxCount = 20
)
// bookingRow is the only projection of a booking the playground exposes.
type bookingRow struct {
Bookingid int `json:"bookingid"`
Bookingno string `json:"bookingno"`
Tenantid *int `json:"tenantid"`
Status string `json:"status"`
Pickuppincode string `json:"pickuppincode"`
Deliverypincode string `json:"deliverypincode"`
Deliverycity string `json:"deliverycity"`
Pickuplatitude float64 `json:"pickuplat"`
Pickuplongitude float64 `json:"pickuplon"`
Deliverylatitude float64 `json:"deliverylat"`
Deliverylongitude float64 `json:"deliverylon"`
Assignedmileruserid *int `json:"assignedmileruserid"`
Routekm *float64 `json:"routekm"`
Createdat *time.Time `json:"-"`
Createdatist string `json:"createdat_ist,omitempty" gorm:"-"`
}
const bookingColumns = "bookingid, bookingno, tenantid, status, pickuppincode, deliverypincode, deliverycity, " +
"pickuplatitude, pickuplongitude, deliverylatitude, deliverylongitude, assignedmileruserid, routekm, createdat"
func round2(f float64) float64 { return math.Round(f*100) / 100 }
func (r *bookingRow) tidy() {
r.Pickuplatitude, r.Pickuplongitude = round2(r.Pickuplatitude), round2(r.Pickuplongitude)
r.Deliverylatitude, r.Deliverylongitude = round2(r.Deliverylatitude), round2(r.Deliverylongitude)
if r.Createdat != nil {
// pickupbookings.createdat is timestamp WITHOUT zone holding IST digits.
r.Createdatist = r.Createdat.Format("2006-01-02 15:04")
}
}
// Executors returns the read tools this backend can serve. A nil db or rdb
// just leaves the tools that need it out (they then answer "unavailable").
func Executors(db *gorm.DB, rdb *redis.Client) map[string]Executor {
execs := map[string]Executor{}
if db != nil {
execs["get_booking_cache"] = getBooking(db)
execs["scan_bookings"] = scanBookings(db)
}
if rdb != nil {
execs["nearby_milers"] = nearbyMilers(rdb)
}
return execs
}
func decode(input json.RawMessage, into any) error {
if len(input) == 0 {
return nil
}
if err := json.Unmarshal(input, into); err != nil {
return fmt.Errorf("invalid input: %v", err)
}
return nil
}
func getBooking(db *gorm.DB) Executor {
return func(ctx context.Context, input json.RawMessage) (any, error) {
var in struct {
BookingID int `json:"booking_id"`
}
if err := decode(input, &in); err != nil {
return nil, err
}
if in.BookingID <= 0 {
return nil, errors.New("booking_id (a positive integer) is required")
}
var row bookingRow
res := db.WithContext(ctx).Table("pickupbookings").Select(bookingColumns).
Where("bookingid = ?", in.BookingID).Limit(1).Scan(&row)
if res.Error != nil {
return nil, errors.New("booking lookup failed")
}
if res.RowsAffected == 0 {
return map[string]any{"found": false, "booking_id": in.BookingID}, nil
}
row.tidy()
return map[string]any{"found": true, "booking": row, "source": "pickupbookings table"}, nil
}
}
func scanBookings(db *gorm.DB) Executor {
return func(ctx context.Context, input json.RawMessage) (any, error) {
var in struct {
Status string `json:"status"`
Limit int `json:"limit"`
}
if err := decode(input, &in); err != nil {
return nil, err
}
if in.Limit <= 0 {
in.Limit = scanDefaultLimit
}
if in.Limit > scanMaxLimit {
in.Limit = scanMaxLimit
}
q := db.WithContext(ctx).Table("pickupbookings").Select(bookingColumns)
if s := strings.TrimSpace(in.Status); s != "" {
q = q.Where("status = ?", s)
}
var rows []bookingRow
if err := q.Order("bookingid DESC").Limit(in.Limit).Scan(&rows).Error; err != nil {
return nil, errors.New("booking scan failed")
}
byStatus := map[string]int{}
for i := range rows {
rows[i].tidy()
byStatus[rows[i].Status]++
}
return map[string]any{"count": len(rows), "bystatus": byStatus, "bookings": rows, "order": "newest first"}, nil
}
}
func nearbyMilers(rdb *redis.Client) Executor {
return func(ctx context.Context, input json.RawMessage) (any, error) {
var in struct {
Lat *float64 `json:"lat"`
Lon *float64 `json:"lon"`
RadiusKm float64 `json:"radius_km"`
}
if err := decode(input, &in); err != nil {
return nil, err
}
if in.Lat == nil || in.Lon == nil || math.Abs(*in.Lat) > 90 || math.Abs(*in.Lon) > 180 {
return nil, errors.New("lat and lon (decimal degrees) are required")
}
if in.RadiusKm <= 0 || in.RadiusKm > nearbyMaxKm {
in.RadiusKm = 5
}
locs, err := rdb.GeoSearchLocation(ctx, "milers:locations", &redis.GeoSearchLocationQuery{
GeoSearchQuery: redis.GeoSearchQuery{
Longitude: *in.Lon, Latitude: *in.Lat,
Radius: in.RadiusKm, RadiusUnit: "km", Sort: "ASC", Count: nearbyMaxCount,
},
WithDist: true,
}).Result()
if err != nil {
return nil, errors.New("live rider positions are unavailable")
}
riders := make([]map[string]any, 0, len(locs))
for _, l := range locs {
riders = append(riders, map[string]any{"miler": l.Name, "distancekm": math.Round(l.Dist*100) / 100})
}
return map[string]any{"count": len(riders), "radiuskm": in.RadiusKm, "riders": riders}, nil
}
}

View File

@@ -0,0 +1,82 @@
package playground
import (
"context"
"encoding/json"
"os"
"strings"
"testing"
"doormile/internal/testpg"
)
// The booking executors against a real Postgres. Skipped unless
// REGISTRY_TEST_DSN points at a THROWAWAY database (see
// internal/ai/registry/store_integration_test.go for how to start one).
//
// The table is a minimal stand-in for pickupbookings WITH personal columns,
// so the test proves the executors never select them.
func TestBookingExecutorsSelectNoPersonalColumns(t *testing.T) {
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres integration test")
}
db := testpg.Open(t, dsn, "aiplayground_test")
for _, q := range []string{
`DROP TABLE IF EXISTS pickupbookings`,
`CREATE TABLE pickupbookings (
bookingid serial PRIMARY KEY, bookingno text NOT NULL, tenantid int, status text,
pickuppincode text, deliverypincode text, deliverycity text,
pickuplatitude double precision, pickuplongitude double precision,
deliverylatitude double precision, deliverylongitude double precision,
assignedmileruserid int, routekm double precision, createdat timestamp,
pickupaddress text, deliveryaddress text, notes text)`,
`INSERT INTO pickupbookings (bookingno, status, pickuppincode, deliverypincode, deliverycity,
pickuplatitude, pickuplongitude, deliverylatitude, deliverylongitude, createdat, pickupaddress, deliveryaddress, notes)
VALUES ('DM-1', 'Created', '641001', '641002', 'Coimbatore', 11.01684, 76.95583, 11.0, 76.9, '2026-09-29 12:30', '12 MG Road', '4 Park St', 'call 9876543210'),
('DM-2', 'Cancelled', '641001', '641003', 'Coimbatore', 11.0, 76.9, 11.0, 76.9, '2026-09-29 12:40', 'x', 'y', 'z')`,
} {
if err := db.Exec(q).Error; err != nil {
t.Fatalf("setup: %v", err)
}
}
execs := Executors(db, nil)
if _, ok := execs["nearby_milers"]; ok {
t.Fatal("nearby_milers offered without Redis")
}
run := func(tool, input string) string {
out, err := execs[tool](context.Background(), json.RawMessage(input))
if err != nil {
t.Fatalf("%s: %v", tool, err)
}
b, _ := json.Marshal(Redact(out))
return string(b)
}
got := run("get_booking_cache", `{"booking_id":1}`)
for _, leaked := range []string{"MG Road", "Park St", "9876543210"} {
if strings.Contains(got, leaked) {
t.Fatalf("personal data %q in %s", leaked, got)
}
}
for _, want := range []string{`"found":true`, `"pickuplat":11.02`, `"createdat_ist":"2026-09-29 12:30"`, `"status":"Created"`} {
if !strings.Contains(got, want) {
t.Fatalf("missing %s in %s", want, got)
}
}
if !strings.Contains(run("get_booking_cache", `{"booking_id":99}`), `"found":false`) {
t.Fatal("missing booking should be found:false")
}
if _, err := execs["get_booking_cache"](context.Background(), json.RawMessage(`{}`)); err == nil {
t.Fatal("booking_id should be required")
}
scan := run("scan_bookings", `{"status":"Cancelled"}`)
if !strings.Contains(scan, `"count":1`) || !strings.Contains(scan, `"Cancelled":1`) {
t.Fatalf("scan = %s", scan)
}
if all := run("scan_bookings", `{"limit":500}`); !strings.Contains(all, `"count":2`) {
t.Fatalf("scan all = %s", all)
}
}

View File

@@ -0,0 +1,480 @@
package registry
import (
"encoding/json"
"errors"
"strings"
"testing"
"doormile/models"
)
// No database here: these pin the seed's integrity and every rule a write must
// pass. Seed/Load/Update against Postgres are not exercised by this file.
func boolp(b bool) *bool { return &b }
func strp(s string) *string { return &s }
func isValidation(err error) bool { var v *ValidationError; return errors.As(err, &v) }
// ── Seed integrity ──────────────────────────────────────────────────────────
func TestSeedIDsAreUnique(t *testing.T) {
seen := map[string]bool{}
for _, a := range SeedAgents {
if seen["agent:"+a.Agentid] {
t.Errorf("agent %s seeded twice", a.Agentid)
}
seen["agent:"+a.Agentid] = true
}
for _, tl := range SeedTools {
if seen["tool:"+tl.Toolname] {
t.Errorf("tool %s seeded twice", tl.Toolname)
}
seen["tool:"+tl.Toolname] = true
}
for _, s := range SeedSkills {
if seen["skill:"+s.Skill.Skillid] {
t.Errorf("skill %s seeded twice", s.Skill.Skillid)
}
seen["skill:"+s.Skill.Skillid] = true
}
}
func TestSeedAgentsUseKnownValues(t *testing.T) {
for _, a := range SeedAgents {
if !validStatuses[a.Status] {
t.Errorf("%s: unknown status %q", a.Agentid, a.Status)
}
if !validRuntimes[a.Runtime] {
t.Errorf("%s: unknown runtime %q", a.Agentid, a.Runtime)
}
if a.Autonomous {
t.Errorf("%s is seeded autonomous; every agent must start with autonomy off", a.Agentid)
}
if a.Name == "" || a.Purpose == "" || a.Classref == "" {
t.Errorf("%s: name, purpose and classref are all required", a.Agentid)
}
}
}
// Autonomy can only be switched on the three agents whose writes AI_engine
// actually gates. A gate on any other agent would be a switch wired to nothing.
func TestOnlyTheThreeGatedAgentsHaveAutonomyGates(t *testing.T) {
want := map[string]bool{"DISPATCH_AGENT": true, "EXCEPTION_AGENT": true, "EXPRESS_DISPATCH_AGENT": true}
for _, a := range SeedAgents {
if a.Hasautonomygate != want[a.Agentid] {
t.Errorf("%s: hasautonomygate = %v, want %v", a.Agentid, a.Hasautonomygate, want[a.Agentid])
}
}
}
// The simulated agents must say so — the console renders this badge, and an
// agent that looks live and is not is the failure this registry exists to end.
func TestSimulatedAgentsAreSeededAsSimulation(t *testing.T) {
for _, id := range []string{"HUB_AGENT", "FLEET_AGENT", "ROUTE_OPTIMIZER"} {
for _, a := range SeedAgents {
if a.Agentid == id && a.Status != StatusSimulation {
t.Errorf("%s status = %q, want simulation", id, a.Status)
}
}
}
}
func TestSeedToolsAreWellFormed(t *testing.T) {
for _, tl := range SeedTools {
if !validKinds[tl.Kind] {
t.Errorf("%s: unknown kind %q", tl.Toolname, tl.Kind)
}
if (tl.Kind == KindWrite || tl.Kind == KindNotify) && !tl.Requiresconfirmation {
t.Errorf("%s is a %s tool but does not require confirmation", tl.Toolname, tl.Kind)
}
if tl.Kind == KindRead && tl.Requiresconfirmation {
t.Errorf("%s is read-only but requires confirmation", tl.Toolname)
}
var schema map[string]any
if err := json.Unmarshal([]byte(tl.Inputschema), &schema); err != nil || schema["type"] != "object" {
t.Errorf("%s: inputschema is not a JSON-schema object: %s", tl.Toolname, tl.Inputschema)
}
if tl.Description == "" || tl.Target == "" || tl.Implementedat == "" {
t.Errorf("%s: description, target and implementedat are all required", tl.Toolname)
}
}
}
func TestEverySkillPointsAtRealAgentsAndTools(t *testing.T) {
agents := map[string]bool{}
for _, a := range SeedAgents {
agents[a.Agentid] = true
}
tools := map[string]bool{}
for _, tl := range SeedTools {
tools[tl.Toolname] = true
}
for _, s := range SeedSkills {
if !agents[s.Skill.Agentid] {
t.Errorf("skill %s belongs to unknown agent %s", s.Skill.Skillid, s.Skill.Agentid)
}
if len(s.Tools) == 0 {
t.Errorf("skill %s has no tools", s.Skill.Skillid)
}
for _, tl := range s.Tools {
if !tools[tl] {
t.Errorf("skill %s uses unknown tool %s", s.Skill.Skillid, tl)
}
}
if s.Skill.Source != SourceEngine && s.Skill.Source != SourceConsole {
t.Errorf("seeded skill %s has source %q; custom is for operator-made skills only", s.Skill.Skillid, s.Skill.Source)
}
}
}
// Every seeded tool is used by some skill — the registry lists capabilities in
// use, not a catalogue of ideas.
func TestEveryToolIsUsedBySomeSkill(t *testing.T) {
used := map[string]bool{}
for _, s := range SeedSkills {
for _, tl := range s.Tools {
used[tl] = true
}
}
for _, tl := range SeedTools {
if !used[tl.Toolname] {
t.Errorf("tool %s is seeded but no skill uses it", tl.Toolname)
}
}
}
func TestSeedThresholdDefaultsAreValid(t *testing.T) {
for _, s := range SeedSkills {
keys := map[string]bool{}
for _, spec := range s.Schema {
if keys[spec.Key] {
t.Errorf("%s: threshold %s declared twice", s.Skill.Skillid, spec.Key)
}
keys[spec.Key] = true
if spec.Min >= spec.Max {
t.Errorf("%s.%s: min %v is not below max %v", s.Skill.Skillid, spec.Key, spec.Min, spec.Max)
}
if err := spec.check(spec.Default); err != nil {
t.Errorf("%s.%s: default is itself invalid: %v", s.Skill.Skillid, spec.Key, err)
}
}
}
}
// Rebalancing has no endpoint behind it. It must never ship switched on.
func TestRebalanceShipsDisabled(t *testing.T) {
for _, s := range SeedSkills {
if s.Skill.Skillid == "dispatch_rebalance" && s.Skill.Enabled {
t.Fatal("dispatch_rebalance is seeded enabled; nothing implements it")
}
}
}
// The ops-layer skills keep the branch's ids and threshold keys so the Phase 3
// port maps one to one. Pin them.
func TestConsoleOpsSkillsKeepBranchIDsAndKeys(t *testing.T) {
want := map[string][]string{
"skill_sla_guardian": {"slaRiskWindowMin", "unassignedAgingMin"},
"skill_doorstep_stall": {"arrivedStalledMin"},
"skill_fleet_balancer": {"riderActiveCap"},
"skill_high_value_cod": {"codRiskThresholdAmount"},
"skill_rider_battery_safety": {"criticalBatteryPercent"},
"skill_hub_congestion": {"hubDwellMinutes"},
"skill_late_dispatch": {"lateDispatchMinutes", "criticalDispatchMinutes"},
"skill_cash_exposure": {"maxCashPerRider", "warningCashPercent"},
}
for _, s := range SeedSkills {
keys, ok := want[s.Skill.Skillid]
if !ok {
continue
}
delete(want, s.Skill.Skillid)
var got []string
for _, spec := range s.Schema {
got = append(got, spec.Key)
}
if strings.Join(got, ",") != strings.Join(keys, ",") {
t.Errorf("%s threshold keys = %v, want %v", s.Skill.Skillid, got, keys)
}
}
for id := range want {
t.Errorf("branch skill %s is missing from the seed", id)
}
}
// ── Thresholds ──────────────────────────────────────────────────────────────
var testSchema = []ThresholdSpec{
{Key: "minutes", Default: 20, Min: 10, Max: 60, Step: 5},
{Key: "confidence", Default: 0.7, Min: 0.5, Max: 1, Step: 0.05},
}
func TestEffectiveThresholdsFillsDefaultsAndDropsStrays(t *testing.T) {
got := EffectiveThresholds(testSchema, map[string]float64{"minutes": 30, "removed": 9, "confidence": 5})
if got["minutes"] != 30 {
t.Errorf("a valid stored value was not kept: %v", got["minutes"])
}
if got["confidence"] != 0.7 {
t.Errorf("an out-of-range stored value was not replaced by the default: %v", got["confidence"])
}
if _, stray := got["removed"]; stray {
t.Error("a key the schema no longer has was kept")
}
}
func TestApplyThresholdPatchAcceptsValidValues(t *testing.T) {
got, err := ApplyThresholdPatch(testSchema, nil, map[string]any{"minutes": 45.0, "confidence": 0.85})
if err != nil {
t.Fatalf("valid patch refused: %v", err)
}
if got["minutes"] != 45 || got["confidence"] != 0.85 {
t.Errorf("patch not applied: %v", got)
}
}
func TestApplyThresholdPatchRefusesBadInput(t *testing.T) {
cases := map[string]map[string]any{
"unknown key": {"minuts": 30.0},
"not a number": {"minutes": "30"},
"a boolean": {"minutes": true},
"below min": {"minutes": 5.0},
"above max": {"minutes": 65.0},
"off the step": {"minutes": 33.0},
"off the fstep": {"confidence": 0.72},
}
for name, patch := range cases {
if _, err := ApplyThresholdPatch(testSchema, nil, patch); !isValidation(err) {
t.Errorf("%s: want a ValidationError, got %v", name, err)
}
}
}
// Every problem is reported at once, so an operator fixes the form in one pass.
func TestApplyThresholdPatchReportsEveryProblem(t *testing.T) {
_, err := ApplyThresholdPatch(testSchema, nil, map[string]any{"minutes": 5.0, "nope": 1.0})
if err == nil || !strings.Contains(err.Error(), "minutes") || !strings.Contains(err.Error(), "nope") {
t.Fatalf("want both problems named, got %v", err)
}
}
// A refused patch changes nothing — the whole set, not the valid half, is rejected.
func TestApplyThresholdPatchIsAllOrNothing(t *testing.T) {
got, err := ApplyThresholdPatch(testSchema, map[string]float64{"minutes": 20}, map[string]any{"minutes": 40.0, "confidence": 9.0})
if err == nil || got != nil {
t.Fatalf("partial patch was applied: %v, %v", got, err)
}
}
func TestParseThresholdsToleratesJunk(t *testing.T) {
for _, raw := range []string{"", "null", "not json", "[]"} {
if got := ParseThresholds(raw); got == nil || len(got) != 0 {
t.Errorf("ParseThresholds(%q) = %v, want an empty map", raw, got)
}
}
if got := ParseSchema("garbage"); got == nil || len(got) != 0 {
t.Errorf("ParseSchema(garbage) = %v, want empty", got)
}
}
// ── Agent patches ───────────────────────────────────────────────────────────
var gated = models.AIAgent{Agentid: "EXCEPTION_AGENT", Runtime: RuntimeEngine, Hasautonomygate: true}
func TestAutonomyOnNeedsTypedConfirmation(t *testing.T) {
if err := CheckAgentPatch(gated, AgentPatch{Autonomous: boolp(true)}); !isValidation(err) {
t.Errorf("autonomy switched on with no confirmation: %v", err)
}
if err := CheckAgentPatch(gated, AgentPatch{Autonomous: boolp(true), Confirm: "exception_agent"}); !isValidation(err) {
t.Errorf("a near-miss confirmation was accepted: %v", err)
}
if err := CheckAgentPatch(gated, AgentPatch{Autonomous: boolp(true), Confirm: "EXCEPTION_AGENT"}); err != nil {
t.Errorf("correct confirmation refused: %v", err)
}
}
// Switching autonomy OFF is always allowed without ceremony — the safe direction.
func TestAutonomyOffNeedsNoConfirmation(t *testing.T) {
on := gated
on.Autonomous = true
if err := CheckAgentPatch(on, AgentPatch{Autonomous: boolp(false)}); err != nil {
t.Errorf("switching autonomy off was refused: %v", err)
}
}
func TestAutonomyRefusedOnUngatedAgent(t *testing.T) {
hub := models.AIAgent{Agentid: "HUB_AGENT", Runtime: RuntimeEngine}
if err := CheckAgentPatch(hub, AgentPatch{Autonomous: boolp(true), Confirm: "HUB_AGENT"}); !isValidation(err) {
t.Errorf("autonomy set on an agent with no gate: %v", err)
}
}
func TestModelIDRules(t *testing.T) {
for _, ok := range []string{"", "claude-sonnet-5-5", "claude-haiku-4-5-20251001", "claude-opus-5-5"} {
if err := CheckAgentPatch(gated, AgentPatch{Model: strp(ok)}); err != nil {
t.Errorf("model %q refused: %v", ok, err)
}
}
for _, bad := range []string{"gpt-4o", "claude-", "Claude-Sonnet", "claude-x; drop table", strings.Repeat("claude-a", 20)} {
if err := CheckAgentPatch(gated, AgentPatch{Model: strp(bad)}); !isValidation(err) {
t.Errorf("model %q accepted", bad)
}
}
console := models.AIAgent{Agentid: "CONSOLE_ASSISTANT", Runtime: RuntimeConsole}
if err := CheckAgentPatch(console, AgentPatch{Model: strp("claude-sonnet-5-5")}); !isValidation(err) {
t.Error("a model was set on a console agent, which has no model setting")
}
}
func TestEmptyAgentPatchRefused(t *testing.T) {
if err := CheckAgentPatch(gated, AgentPatch{}); !isValidation(err) {
t.Error("an empty patch was accepted")
}
}
// ── New skills ──────────────────────────────────────────────────────────────
func TestCheckNewSkill(t *testing.T) {
good := NewSkill{Agentid: "CONSOLE_OPS_AGENT", Title: "Night shift watch", Tools: []string{"lookup_order"}}
if err := CheckNewSkill(good); err != nil {
t.Fatalf("valid skill refused: %v", err)
}
bad := map[string]NewSkill{
"no agent": {Title: "x", Tools: []string{"a"}},
"no title": {Agentid: "A", Title: " ", Tools: []string{"a"}},
"no tools": {Agentid: "A", Title: "x"},
"duplicate tool": {Agentid: "A", Title: "x", Tools: []string{"a", "a"}},
"title too long": {Agentid: "A", Title: strings.Repeat("x", 121), Tools: []string{"a"}},
}
for name, n := range bad {
if err := CheckNewSkill(n); !isValidation(err) {
t.Errorf("%s: accepted", name)
}
}
}
func TestCustomSkillID(t *testing.T) {
cases := map[string]string{
"Night Shift Watch": "custom_night_shift_watch",
" COD > ₹5,000 alerts!! ": "custom_cod_5_000_alerts",
"!!!": "custom_skill",
}
for in, want := range cases {
if got := customSkillID(in); got != want {
t.Errorf("customSkillID(%q) = %q, want %q", in, got, want)
}
}
if got := customSkillID(strings.Repeat("abc ", 40)); len(got) > 64 {
t.Errorf("id %q exceeds the 64-char column", got)
}
}
// ── Build & ETag ────────────────────────────────────────────────────────────
func seededRows() ([]models.AIAgent, []models.AITool, []models.AISkill, []models.AISkillTool) {
var skills []models.AISkill
var links []models.AISkillTool
for _, s := range SeedSkills {
row := s.Skill
row.Thresholdsschema = mustJSON(schemaOrEmpty(s.Schema))
row.Thresholds = mustJSON(DefaultThresholds(s.Schema))
skills = append(skills, row)
for _, tl := range s.Tools {
links = append(links, models.AISkillTool{Skillid: s.Skill.Skillid, Toolname: tl})
}
}
return SeedAgents, SeedTools, skills, links
}
func TestBuildCountsSkillsAndToolsPerAgent(t *testing.T) {
snap := Build(seededRows())
for _, a := range snap.Agents {
if a.Agentid == "EXPRESS_DISPATCH_AGENT" && (a.Skillcount != 1 || a.Toolcount != 4) {
t.Errorf("EXPRESS_DISPATCH_AGENT: %d skills, %d tools; want 1 and 4", a.Skillcount, a.Toolcount)
}
if a.Agentid == "HUB_AGENT" && (a.Skillcount != 0 || a.Toolcount != 0) {
t.Errorf("HUB_AGENT has no skills, got %d/%d", a.Skillcount, a.Toolcount)
}
}
}
// The API must emit thresholds and schemas as JSON values, not as strings of JSON.
func TestSnapshotSerialisesJSONColumnsAsJSON(t *testing.T) {
b, err := json.Marshal(Build(seededRows()))
if err != nil {
t.Fatal(err)
}
var out struct {
Skills []struct {
Skillid string `json:"skillid"`
Thresholds map[string]float64 `json:"thresholds"`
Thresholdsschema []ThresholdSpec `json:"thresholdsschema"`
Tools []string `json:"tools"`
} `json:"skills"`
Tools []struct {
Inputschema map[string]any `json:"inputschema"`
} `json:"tools"`
}
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("snapshot JSON does not decode as structured values: %v", err)
}
for _, s := range out.Skills {
if s.Skillid == "skill_cash_exposure" {
if s.Thresholds["maxCashPerRider"] != 10000 || len(s.Thresholdsschema) != 2 || len(s.Tools) != 2 {
t.Errorf("skill_cash_exposure serialised wrongly: %+v", s)
}
}
}
if len(out.Tools) == 0 || out.Tools[0].Inputschema["type"] != "object" {
t.Error("tool inputschema is not emitted as a JSON object")
}
}
func TestETagIsStableAndChangesWithContent(t *testing.T) {
a := ETag(Build(seededRows()))
if a != ETag(Build(seededRows())) {
t.Fatal("ETag differs for identical content")
}
agents, tools, skills, links := seededRows()
skills[0].Enabled = !skills[0].Enabled
if a == ETag(Build(agents, tools, skills, links)) {
t.Fatal("ETag did not change when a skill was toggled")
}
if !strings.HasPrefix(a, `W/"`) {
t.Errorf("ETag %s is not a weak validator", a)
}
}
// These three read fields /admin/bookings rows do not carry (payment amounts,
// battery). Enabled, they would read undefined on every row and report an
// all-clear board. They must ship off, in step with the console's defaults.
func TestSkillsWithNoDataSourceShipDisabled(t *testing.T) {
off := map[string]bool{"skill_high_value_cod": true, "skill_cash_exposure": true, "skill_rider_battery_safety": true}
for _, s := range SeedSkills {
if off[s.Skill.Skillid] {
delete(off, s.Skill.Skillid)
if s.Skill.Enabled {
t.Errorf("%s is seeded enabled but its rows carry no data for its rule", s.Skill.Skillid)
}
if !strings.Contains(s.Skill.Description, "OFF:") {
t.Errorf("%s does not say why it is off", s.Skill.Skillid)
}
}
}
for id := range off {
t.Errorf("%s missing from the seed", id)
}
}
// Only notify_riders has an executor in the console. Every other console write
// must say REVIEW ONLY, or Agent Studio would advertise an action that cannot run.
func TestConsoleWritesWithoutExecutorSayReviewOnly(t *testing.T) {
for _, tl := range SeedTools {
if !strings.HasPrefix(tl.Implementedat, consoleSrc+"lib/assistant/skills/") && !strings.Contains(tl.Implementedat, "(no executor)") {
continue
}
if tl.Kind != KindRead && !strings.HasPrefix(tl.Description, "REVIEW ONLY") {
t.Errorf("%s has no executor but its description does not start with REVIEW ONLY", tl.Toolname)
}
}
}

View File

@@ -0,0 +1,134 @@
package registry
import (
"errors"
"fmt"
"regexp"
"strings"
"unicode"
"doormile/models"
)
// ErrNotFound is returned when the agent or skill named by a request does not exist.
var ErrNotFound = errors.New("not found")
// ValidationError is a request the registry refuses. Its message is written for
// the operator and is safe to return as-is.
type ValidationError struct{ Msg string }
func (e *ValidationError) Error() string { return e.Msg }
func invalid(format string, args ...any) error {
return &ValidationError{Msg: fmt.Sprintf(format, args...)}
}
// Actor is who is making a change. The email is kept with the id because an
// admin login without an appusers row carries user id 0, and an audit that
// says "user 0" answers nothing.
type Actor struct {
UserID int
Email string
}
// SkillPatch is what an operator may change on a skill.
type SkillPatch struct {
Enabled *bool `json:"enabled"`
Thresholds map[string]any `json:"thresholds"`
}
// AgentPatch is what an operator may change on an agent.
//
// Confirm must repeat the agent id to switch autonomy ON. Autonomy lets an
// agent reassign riders or message customers with no human in the loop; a
// stray click or a replayed request must not be enough to turn that on.
type AgentPatch struct {
Autonomous *bool `json:"autonomous"`
Model *string `json:"model"`
Confirm string `json:"confirm"`
}
// NewSkill is an operator-created skill. It may only use tools that exist.
type NewSkill struct {
Agentid string `json:"agentid"`
Title string `json:"title"`
Category string `json:"category"`
Description string `json:"description"`
Sampleprompt string `json:"sampleprompt"`
Tools []string `json:"tools"`
}
var modelID = regexp.MustCompile(`^claude-[a-z0-9][a-z0-9.-]{0,56}$`)
// CheckAgentPatch validates a patch against the agent it targets. Pure, so the
// rules are tested without a database.
func CheckAgentPatch(agent models.AIAgent, p AgentPatch) error {
if p.Autonomous == nil && p.Model == nil {
return invalid("nothing to change: send autonomous and/or model")
}
if p.Autonomous != nil {
if !agent.Hasautonomygate {
return invalid("%s has no autonomy gate; only agents that can act on their own can be switched", agent.Agentid)
}
if *p.Autonomous && !agent.Autonomous && p.Confirm != agent.Agentid {
return invalid("switching %s to autonomous needs confirm set to %q", agent.Agentid, agent.Agentid)
}
}
if p.Model != nil && *p.Model != "" && !modelID.MatchString(*p.Model) {
return invalid("model must be a Claude model id such as claude-sonnet-5-5, or empty for the engine default")
}
if p.Model != nil && agent.Runtime != RuntimeEngine {
return invalid("%s runs in the console; it has no model setting", agent.Agentid)
}
return nil
}
// CheckNewSkill validates the shape of a new skill. Whether the agent and tools
// exist is checked against the database by CreateSkill.
func CheckNewSkill(n NewSkill) error {
title := strings.TrimSpace(n.Title)
switch {
case n.Agentid == "":
return invalid("agentid is required")
case title == "":
return invalid("title is required")
case len(title) > 120:
return invalid("title must be 120 characters or fewer")
case len(n.Tools) == 0:
return invalid("a skill needs at least one tool")
case len(n.Tools) > 20:
return invalid("a skill may use at most 20 tools")
}
seen := map[string]bool{}
for _, t := range n.Tools {
if seen[t] {
return invalid("tool %s is listed twice", t)
}
seen[t] = true
}
return nil
}
// customSkillID derives a stable id from a title: "custom_" plus a lowercase
// slug. CreateSkill appends a counter if it is taken.
func customSkillID(title string) string {
var b strings.Builder
lastUnderscore := false
for _, r := range strings.ToLower(strings.TrimSpace(title)) {
if unicode.IsLetter(r) && r < unicode.MaxASCII || unicode.IsDigit(r) {
b.WriteRune(r)
lastUnderscore = false
} else if !lastUnderscore && b.Len() > 0 {
b.WriteByte('_')
lastUnderscore = true
}
}
slug := strings.Trim(b.String(), "_")
if slug == "" {
slug = "skill"
}
if len(slug) > 48 {
slug = strings.Trim(slug[:48], "_")
}
return "custom_" + slug
}

View File

@@ -0,0 +1,304 @@
package registry
import "doormile/models"
// The registry as the code actually stands (verified 2026-09-29). Every entry
// says what the code DOES, not what a doc claims: an agent that only simulates
// is seeded as "simulation", and the console must show that badge. Source of
// this inventory: krow_talent_app/docs/agent-platform-plan.md §2.
//
// Upserted on every boot by Seed. Changing a code-defined field here changes
// it in the database on the next start; operator-owned fields (skill enabled
// and thresholds, agent autonomous and model) are only written on first insert.
//
// Deliberately NOT seeded:
// - AI_engine's ask_question, order_intake_skill and repeat_run_skill —
// registered in core/tool_registry.py but never loaded in production.
// - The four Krow workforce-training tools the console mock carried.
// - ORDER_AGENT's crmbooking calls — that route was renamed to
// expressbooking, so they reach nothing.
// - /internal/agent-decisions — an append-only log the Go side writes, not a
// capability any skill chooses to use.
// Agent statuses and runtimes. The console renders these verbatim.
const (
StatusLive = "live"
StatusPartial = "partial"
StatusSimulation = "simulation"
StatusBroken = "broken"
StatusUnmerged = "unmerged"
StatusRetired = "retired"
RuntimeEngine = "engine"
RuntimeConsole = "console"
KindRead = "read"
KindWrite = "write"
KindNotify = "notify"
// KindEvent is an internal bus event or record: no effect on an order, a
// rider or a customer by itself, so it is never behind confirmation.
KindEvent = "event"
SourceEngine = "engine"
SourceConsole = "console"
SourceCustom = "custom"
)
var (
validStatuses = map[string]bool{StatusLive: true, StatusPartial: true, StatusSimulation: true, StatusBroken: true, StatusUnmerged: true, StatusRetired: true}
validRuntimes = map[string]bool{RuntimeEngine: true, RuntimeConsole: true}
validKinds = map[string]bool{KindRead: true, KindWrite: true, KindNotify: true, KindEvent: true}
)
const consoleSrc = "krow_talent_app/src/"
// SeedAgents — AI_engine's nine, then the console's two.
var SeedAgents = []models.AIAgent{
{Agentid: "JARVIS", Name: "JARVIS", Runtime: RuntimeEngine, Classref: "AI_engine/core/agent.py:196 MasterAgent",
Purpose: "Orchestrator and escalation inbox. Receives EXCEPTION_DETECTED from other agents.",
Wakeon: "NATS logistics.direct.JARVIS", Status: StatusPartial, Sortorder: 10},
{Agentid: "DISPATCH_AGENT", Name: "Dispatch", Runtime: RuntimeEngine, Classref: "AI_engine/agents/dispatch_agent.py:72",
Purpose: "Watches assignment outcomes and flags coverage gaps. Alerts; notifies customers only when autonomous.",
Wakeon: "JetStream booking.assigned, booking.assignment_failed",
Status: StatusLive, Llmdecision: "decide_assignment_failure", Hasautonomygate: true, Sortorder: 20},
{Agentid: "EXCEPTION_AGENT", Name: "Exception", Runtime: RuntimeEngine, Classref: "AI_engine/agents/exception_agent.py:106",
Purpose: "Detects stalled riders and decides the response. Reassigns only when autonomous and confident.",
Wakeon: "TRACKING miler.location.updated, miler.stalled; 60 s database sweep",
Status: StatusLive, Llmdecision: "decide_stall_response", Hasautonomygate: true, Sortorder: 30},
{Agentid: "EXPRESS_DISPATCH_AGENT", Name: "Express Dispatch", Runtime: RuntimeEngine, Classref: "AI_engine/agents/express_dispatch_agent.py:80",
Purpose: "Tenant-scoped batch assignment for DoormileExpress, then road sequencing of each rider's stops.",
Wakeon: "JetStream express.dispatch_requested", Status: StatusLive, Hasautonomygate: true, Sortorder: 40},
{Agentid: "CUSTOMER_AGENT", Name: "Customer", Runtime: RuntimeEngine, Classref: "AI_engine/agents/customer_agent.py:50",
Purpose: "Customer notifications and tracking. Reached only from an autonomous Dispatch agent.",
Wakeon: "Direct task", Status: StatusPartial, Sortorder: 50},
{Agentid: "ORDER_AGENT", Name: "Order", Runtime: RuntimeEngine, Classref: "AI_engine/agents/order_agent.py:15",
Purpose: "Order intake and validation. Not connected: it has no working backend route (/admin/* needs a console JWT, crmbooking is gone), so its backend calls are refused and logged.",
Wakeon: "Direct task (no sender in production)", Status: StatusBroken, Sortorder: 60},
{Agentid: "HUB_AGENT", Name: "Hub", Runtime: RuntimeEngine, Classref: "AI_engine/agents/hub_agent.py:35",
Purpose: "Hub capacity over 8 hard-coded fictional hubs.", Wakeon: "Direct task", Status: StatusSimulation, Sortorder: 70},
{Agentid: "FLEET_AGENT", Name: "Fleet", Runtime: RuntimeEngine, Classref: "AI_engine/agents/fleet_agent.py:34",
Purpose: "An in-memory fleet of 19 fake vehicles.", Wakeon: "Direct task", Status: StatusSimulation, Sortorder: 80},
{Agentid: "ROUTE_OPTIMIZER", Name: "Route Optimizer", Runtime: RuntimeEngine, Classref: "AI_engine/agents/route_optimizer_agent.py:41",
Purpose: "Haversine routing with traffic multipliers. Real sequencing is done by Express Dispatch via routes.workolik.com.",
Wakeon: "Direct task", Status: StatusSimulation, Sortorder: 90},
{Agentid: "CONSOLE_ASSISTANT", Name: "Console Assistant", Runtime: RuntimeConsole, Classref: consoleSrc + "lib/assistant",
Purpose: "The Home chat: orders, bulk upload, assignment, repeat runs. Regex intent catalogue; every write is a proposal the operator confirms.",
Wakeon: "Operator prompt", Status: StatusLive, Sortorder: 100},
{Agentid: "CONSOLE_OPS_AGENT", Name: "Console Ops Agent", Runtime: RuntimeConsole, Classref: consoleSrc + "lib/assistant/agent",
Purpose: "The Exceptions early-warnings banner and the chat's 'what needs attention' briefing: eight rule-based monitoring skills over the booking scan. Nothing runs on its own; an action runs only when an operator clicks it.",
Wakeon: "Exceptions page load, 60 s poll, and the chat briefing", Status: StatusLive, Sortorder: 110},
}
// obj builds a JSON-schema object for a tool's input. Only parameters the code
// provably takes are listed; where the body is not pinned down, the schema is
// left open rather than invented.
func obj(props map[string]map[string]string, required ...string) string {
properties := map[string]any{}
for name, p := range props {
properties[name] = p
}
s := map[string]any{"type": "object", "properties": properties}
if len(required) > 0 {
s["required"] = required
}
return mustJSON(s)
}
var (
integer = func(desc string) map[string]string { return map[string]string{"type": "integer", "description": desc} }
number = func(desc string) map[string]string { return map[string]string{"type": "number", "description": desc} }
str = func(desc string) map[string]string { return map[string]string{"type": "string", "description": desc} }
open = obj(map[string]map[string]string{})
)
// SeedTools — every capability a seeded skill uses, and nothing else.
var SeedTools = []models.AITool{
// AI_engine
{Toolname: "reassign_booking", Kind: KindWrite, Requiresconfirmation: true,
Description: "Reassign a booking whose rider has stalled.", Target: "doormile_backend POST /internal/bookings/:id/reassign",
Implementedat: "AI_engine/agents/exception_agent.py:466", Inputschema: obj(map[string]map[string]string{"booking_id": integer("Booking to reassign")}, "booking_id")},
{Toolname: "notify_customer", Kind: KindNotify, Requiresconfirmation: true,
Description: "Send a customer a delivery update.", Target: "doormile_backend POST /internal/notify",
Implementedat: "AI_engine/agents/exception_agent.py:476, customer_agent.py:349", Inputschema: open},
{Toolname: "list_express_bookings", Kind: KindRead,
Description: "Read the bookings in an express dispatch batch.", Target: "doormile_backend GET /internal/express/bookings",
Implementedat: "AI_engine/agents/express_dispatch_agent.py:351", Inputschema: open},
{Toolname: "list_express_riders", Kind: KindRead,
Description: "Read a tenant's riders available for an express batch.", Target: "doormile_backend GET /internal/express/riders",
Implementedat: "AI_engine/agents/express_dispatch_agent.py:342", Inputschema: open},
{Toolname: "assign_express_batch", Kind: KindWrite, Requiresconfirmation: true,
Description: "Write back the rider assignments decided for an express batch.", Target: "doormile_backend POST /internal/express/assign",
Implementedat: "AI_engine/agents/express_dispatch_agent.py:222", Inputschema: open},
{Toolname: "sequence_stops", Kind: KindRead,
Description: "Order a rider's stops by road (computes, writes nothing).", Target: "routes.workolik.com POST /api/v1/optimization/doormile/sequence",
Implementedat: "AI_engine/agents/express_dispatch_agent.py:308", Inputschema: open},
{Toolname: "get_booking_cache", Kind: KindRead,
Description: "Read a booking from the backend's booking cache.", Target: "doormile_backend GET /bookings/cache/:id",
Implementedat: "AI_engine/agents/customer_agent.py:243", Inputschema: obj(map[string]map[string]string{"booking_id": integer("Booking to read")}, "booking_id")},
{Toolname: "nearby_milers", Kind: KindRead,
Description: "Find riders near a point from live positions.", Target: "Redis GEO milers:locations",
Implementedat: "AI_engine/agents/dispatch_agent.py:170-240",
Inputschema: obj(map[string]map[string]string{
"lat": number("Latitude of the point, decimal degrees"), "lon": number("Longitude of the point, decimal degrees"),
"radius_km": number("Search radius in km (default 5, at most 10)"),
}, "lat", "lon")},
{Toolname: "publish_miler_stalled", Kind: KindEvent,
Description: "Publish a stalled-rider event for other agents.", Target: "NATS miler.stalled",
Implementedat: "AI_engine/agents/exception_agent.py:377", Inputschema: open},
{Toolname: "decide_stall_response", Kind: KindRead,
Description: "Ask the model how to respond to a stalled rider (structured output).", Target: "Claude via AI_engine/core/llm.py",
Implementedat: "AI_engine/core/llm.py:157", Inputschema: open},
{Toolname: "decide_assignment_failure", Kind: KindRead,
Description: "Ask the model why an assignment failed and what to do (structured output).", Target: "Claude via AI_engine/core/llm.py",
Implementedat: "AI_engine/core/llm.py:227", Inputschema: open},
// Console ops layer (krow_talent_app, ported onto main in Phase 3). The
// tool names are the proposal VERBS the findings carry, because that is what
// an operator's click resolves (lib/assistant/agent/actions.js). Only
// notify_riders has an executor; every other write is review-only and the
// console renders it disabled — the descriptions say so.
{Toolname: "scan_bookings", Kind: KindRead,
Description: "Read open and recent bookings (drained page by page) for the rules to evaluate.", Target: "doormile_backend GET /admin/bookings",
Implementedat: consoleSrc + "lib/assistant/scan.js",
Inputschema: obj(map[string]map[string]string{
"status": str("Only bookings in this status, e.g. Created, Miler_Assigned, Picked_Up, Cancelled"),
"limit": integer("How many of the newest bookings (default 20, at most 50)"),
})},
{Toolname: "notify_riders", Kind: KindNotify, Requiresconfirmation: true,
Description: "Message the riders on a finding's orders. Runs only when an operator clicks it; partial success is reported as partial.", Target: "doormile_backend POST /admin/milers/:id/notify",
Implementedat: consoleSrc + "lib/assistant/agent/actions.js", Inputschema: open},
{Toolname: "assign_riders", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Would assign a finding's orders to riders, but POST /hub/bookings/batch-assign accepts hub-staff logins only, so the console cannot run it.", Target: "doormile_backend POST /hub/bookings/batch-assign (hub staff only)",
Implementedat: consoleSrc + "lib/assistant/agent/actions.js (no executor)", Inputschema: open},
{Toolname: "enforce_otp_verification", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Flag a high-value COD order as requiring the receiver's OTP at handover. No executor.", Target: "none yet",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/HighValueCodSkill.js", Inputschema: obj(map[string]map[string]string{"bookingId": integer("Booking to flag")}, "bookingId")},
{Toolname: "alert_low_battery_rider", Kind: KindNotify, Requiresconfirmation: true,
Description: "REVIEW ONLY. Tell a rider on a low battery to charge or report to the nearest hub. No executor.", Target: "doormile_backend POST /admin/milers/:id/notify",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/RiderBatterySafetySkill.js", Inputschema: obj(map[string]map[string]string{"milerId": integer("Rider to alert")}, "milerId")},
{Toolname: "dispatch_hub_idle_parcels", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Send an idle rider to collect parcels dwelling at a hub. No executor.", Target: "none yet",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/HubCongestionSkill.js",
Inputschema: obj(map[string]map[string]string{"hubId": str("Hub where parcels are waiting"), "milerId": integer("Idle rider")}, "hubId", "milerId")},
{Toolname: "trigger_auto_dispatch", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Auto-assign orders that have waited too long for dispatch. No executor.", Target: "none yet",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/LateDispatchSkill.js", Inputschema: open},
{Toolname: "enforce_cash_handoff", Kind: KindWrite, Requiresconfirmation: true,
Description: "REVIEW ONLY. Route a rider carrying too much COD via the nearest hub. No executor.", Target: "none yet",
Implementedat: consoleSrc + "lib/assistant/skills/definitions/CashExposureSkill.js", Inputschema: open},
// Console assistant
{Toolname: "simulate_pricing_quote", Kind: KindRead,
Description: "Quote a delivery from the tenant's pricing row and the routed distance, without booking it.", Target: "doormile_backend GET /admin/pricing + OSRM route",
Implementedat: consoleSrc + "lib/assistant/orderFlow.js", Inputschema: open},
{Toolname: "create_single_order", Kind: KindWrite, Requiresconfirmation: true,
Description: "Create one express booking. Returns a proposal; the operator confirms.", Target: "doormile_backend POST /admin/expressbooking",
Implementedat: consoleSrc + "lib/assistant/orderFlow.js", Inputschema: open},
{Toolname: "rebalance_riders", Kind: KindWrite, Requiresconfirmation: true,
Description: "Move idle riders between zones. NOT IMPLEMENTED: no endpoint does zone rebalancing yet.", Target: "none yet",
Implementedat: "none", Inputschema: open}}
// SeedSkill pairs a skill row with its tool links and threshold schema.
type SeedSkill struct {
Skill models.AISkill
Tools []string
Schema []ThresholdSpec
}
func minutes(key, label string, def, min, max, step float64) ThresholdSpec {
return ThresholdSpec{Key: key, Label: label, Unit: "min", Default: def, Min: min, Max: max, Step: step}
}
// SeedSkills. The console ops skills keep the ids and threshold keys of the
// feat/agentic-ops-layer branch, which were ported onto main one to one (Phase 3).
var SeedSkills = []SeedSkill{
// AI_engine. Thresholds mirror the env knobs the agents read today; the
// engine starts reading them from here in Phase 5.
{Skill: models.AISkill{Skillid: "stall_response", Agentid: "EXCEPTION_AGENT", Title: "Stalled-rider response", Category: "rider_operations", Source: SourceEngine, Enabled: true,
Description: "Detect a rider who has stopped moving, ask the model what to do, and alert or (when autonomous) reassign."},
Tools: []string{"nearby_milers", "decide_stall_response", "reassign_booking", "notify_customer", "publish_miler_stalled"},
Schema: []ThresholdSpec{
minutes("stallMinutes", "Stall threshold", 10, 5, 60, 5),
{Key: "reassignConfidence", Label: "Auto-reassign confidence floor", Default: 0.7, Min: 0.5, Max: 1, Step: 0.05},
}},
{Skill: models.AISkill{Skillid: "assignment_failure_triage", Agentid: "DISPATCH_AGENT", Title: "Assignment-failure triage", Category: "dispatch", Source: SourceEngine, Enabled: true,
Description: "When no rider could be assigned, work out why from nearby supply and raise one alert per gap."},
Tools: []string{"nearby_milers", "decide_assignment_failure", "notify_customer"},
Schema: []ThresholdSpec{
{Key: "realertEvery", Label: "Re-alert after N repeat failures", Unit: "failures", Default: 100, Min: 10, Max: 1000, Step: 10},
}},
{Skill: models.AISkill{Skillid: "express_batch_dispatch", Agentid: "EXPRESS_DISPATCH_AGENT", Title: "Express batch dispatch", Category: "dispatch", Source: SourceEngine, Enabled: true,
Description: "Assign a tenant's express batch to its riders greedily, then sequence each rider's stops by road."},
Tools: []string{"list_express_bookings", "list_express_riders", "sequence_stops", "assign_express_batch"},
Schema: []ThresholdSpec{
{Key: "maxRadiusKm", Label: "Max rider radius", Unit: "km", Default: 30, Min: 5, Max: 60, Step: 1},
{Key: "maxPerRider", Label: "Max stops per rider", Unit: "stops", Default: 5, Min: 1, Max: 10, Step: 1},
{Key: "loadPenaltyKm", Label: "Load penalty per held stop", Unit: "km", Default: 3, Min: 0, Max: 10, Step: 0.5},
}},
{Skill: models.AISkill{Skillid: "customer_notifications", Agentid: "CUSTOMER_AGENT", Title: "Customer notifications", Category: "customer", Source: SourceEngine, Enabled: true,
Description: "Tell a customer what is happening to their delivery."},
Tools: []string{"get_booking_cache", "notify_customer"}},
// Console ops layer (on main since Phase 3). Ids and threshold keys match
// lib/assistant/skills/definitions; defaults are copied from them. Every skill
// reads the booking scan; its other tools are the actions its findings propose.
{Skill: models.AISkill{Skillid: "skill_sla_guardian", Agentid: "CONSOLE_OPS_AGENT", Title: "SLA Breach Guardian", Category: "sla_management", Source: SourceConsole, Enabled: true,
Description: "Flags breached and imminent SLA violations against promised delivery ETAs."},
Tools: []string{"scan_bookings", "notify_riders", "assign_riders"},
Schema: []ThresholdSpec{
minutes("slaRiskWindowMin", "At-risk warning window", 45, 15, 90, 5),
minutes("unassignedAgingMin", "Unassigned aging threshold", 60, 15, 120, 5),
}},
{Skill: models.AISkill{Skillid: "skill_doorstep_stall", Agentid: "CONSOLE_OPS_AGENT", Title: "Doorstep Stall Rescuer", Category: "rider_operations", Source: SourceConsole, Enabled: true,
Description: "Flags riders who marked arrival at the doorstep but have made no progress since."},
Tools: []string{"scan_bookings", "notify_riders"},
Schema: []ThresholdSpec{minutes("arrivedStalledMin", "Doorstep stall timeout", 20, 10, 60, 5)}},
{Skill: models.AISkill{Skillid: "skill_fleet_balancer", Agentid: "CONSOLE_OPS_AGENT", Title: "Fleet Load Balancer", Category: "fleet_optimization", Source: SourceConsole, Enabled: true,
Description: "Flags riders at maximum active capacity while queued work waits."},
Tools: []string{"scan_bookings"}, // flags only; its findings propose no action
Schema: []ThresholdSpec{{Key: "riderActiveCap", Label: "Rider active capacity cap", Unit: "orders", Default: 3, Min: 1, Max: 6, Step: 1}}},
{Skill: models.AISkill{Skillid: "skill_high_value_cod", Agentid: "CONSOLE_OPS_AGENT", Title: "High-Value Cash Guardian", Category: "loss_prevention", Source: SourceConsole,
Enabled: false, // no data: /admin/bookings rows carry no payment amount or mode
Description: "Audits large cash-on-delivery consignments. OFF: the booking rows it reads carry no payment amount or mode (bookingpayments is not preloaded), so enabled it would always report all-clear."},
Tools: []string{"scan_bookings", "enforce_otp_verification"},
Schema: []ThresholdSpec{{Key: "codRiskThresholdAmount", Label: "High-value COD threshold", Unit: "₹", Default: 3000, Min: 1000, Max: 20000, Step: 500}}},
{Skill: models.AISkill{Skillid: "skill_rider_battery_safety", Agentid: "CONSOLE_OPS_AGENT", Title: "Rider Device & SOS Safety", Category: "rider_safety", Source: SourceConsole,
Enabled: false, // no data: battery lives on milerprofiles, not booking rows
Description: "Warns before a rider becomes unreachable on a flat battery. OFF: battery level is on the rider profile, not on the booking rows it reads, so enabled it would always report all-clear."},
Tools: []string{"scan_bookings", "alert_low_battery_rider"},
Schema: []ThresholdSpec{{Key: "criticalBatteryPercent", Label: "Critical battery level", Unit: "%", Default: 15, Min: 5, Max: 30, Step: 5}}},
{Skill: models.AISkill{Skillid: "skill_hub_congestion", Agentid: "CONSOLE_OPS_AGENT", Title: "Hub Congestion Agent", Category: "sla_management", Source: SourceConsole, Enabled: true,
Description: "Detects parcels dwelling at a hub without rider pickup and proposes the nearest idle rider."},
Tools: []string{"scan_bookings", "dispatch_hub_idle_parcels"},
Schema: []ThresholdSpec{minutes("hubDwellMinutes", "Hub dwell threshold", 45, 15, 120, 5)}},
{Skill: models.AISkill{Skillid: "skill_late_dispatch", Agentid: "CONSOLE_OPS_AGENT", Title: "Late Dispatch Agent", Category: "sla_management", Source: SourceConsole, Enabled: true,
Description: "Flags accepted orders still waiting for dispatch and proposes auto-assignment."},
Tools: []string{"scan_bookings", "trigger_auto_dispatch"},
Schema: []ThresholdSpec{
minutes("lateDispatchMinutes", "Dispatch deadline", 30, 10, 90, 5),
minutes("criticalDispatchMinutes", "Critical dispatch deadline", 60, 30, 180, 10),
}},
{Skill: models.AISkill{Skillid: "skill_cash_exposure", Agentid: "CONSOLE_OPS_AGENT", Title: "Cash Exposure Agent", Category: "loss_prevention", Source: SourceConsole,
Enabled: false, // no data: no COD amount per order in /admin/bookings rows
Description: "Tracks COD cash per rider and proposes a hub handoff. OFF: the booking rows it reads carry no COD amount, so enabled it would always report all-clear."},
Tools: []string{"scan_bookings", "enforce_cash_handoff"},
Schema: []ThresholdSpec{
{Key: "maxCashPerRider", Label: "Max safe cash per rider", Unit: "₹", Default: 10000, Min: 2000, Max: 50000, Step: 1000},
{Key: "warningCashPercent", Label: "Warning threshold", Unit: "%", Default: 75, Min: 50, Max: 95, Step: 5},
}},
{Skill: models.AISkill{Skillid: "ops_briefing", Agentid: "CONSOLE_OPS_AGENT", Title: "Ops briefing", Category: "operations", Source: SourceConsole, Enabled: true,
Description: "Answer \"what needs attention right now\" in the chat by running every enabled monitoring skill over the booking scan — the same engine as the Exceptions banner.", Sampleprompt: "What needs attention right now?"},
Tools: []string{"scan_bookings"}},
{Skill: models.AISkill{Skillid: "dispatch_rebalance", Agentid: "CONSOLE_OPS_AGENT", Title: "Dispatch Rebalance & Allocation", Category: "dispatch", Source: SourceConsole,
// Off: rebalance_riders has no implementation behind it.
Enabled: false,
Description: "Move idle riders into zones with a demand spike. Disabled until an endpoint implements it.",
Sampleprompt: "Rebalance available riders into Zone 1 to prevent SLA delays"},
Tools: []string{"rebalance_riders"}},
// Console assistant
{Skill: models.AISkill{Skillid: "order_intake_auto_schedule", Agentid: "CONSOLE_ASSISTANT", Title: "Order Intake & Auto-Schedule", Category: "logistics", Source: SourceConsole, Enabled: true,
Description: "Parse orders from text or a sheet, price them, and create them once the operator confirms.",
Sampleprompt: "Repeat yesterday's orders for Neptune"},
Tools: []string{"create_single_order", "simulate_pricing_quote"}},
}

View File

@@ -0,0 +1,389 @@
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)
}

View File

@@ -0,0 +1,280 @@
package registry
import (
"os"
"strings"
"testing"
"doormile/internal/testpg"
"doormile/models"
"gorm.io/gorm"
)
// Integration tests: the real SQL (upsert seed, row locks, jsonb, audit)
// against a real Postgres. Skipped unless REGISTRY_TEST_DSN is set.
//
// The DSN must point at a THROWAWAY database: these tests DROP and recreate
// the five registry tables. Never point it at a shared or production database.
// For example, with a disposable container:
//
// docker run --rm -d --name dm-registry-pg -e POSTGRES_PASSWORD=test -p 55432:5432 postgres:16-alpine
// REGISTRY_TEST_DSN="host=127.0.0.1 port=55432 user=postgres password=test dbname=postgres sslmode=disable" go test ./internal/ai/registry/
func testDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres integration test")
}
db := testpg.Open(t, dsn, "airegistry_store_test")
all := []any{&models.AIRegistryAudit{}, &models.AISkillTool{}, &models.AISkill{}, &models.AITool{}, &models.AIAgent{}}
if err := db.Migrator().DropTable(all...); err != nil {
t.Fatalf("drop: %v", err)
}
if err := db.AutoMigrate(all...); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := Seed(db); err != nil {
t.Fatalf("seed: %v", err)
}
return db
}
func skillRow(t *testing.T, db *gorm.DB, id string) models.AISkill {
t.Helper()
var s models.AISkill
if err := db.Where("skillid = ?", id).First(&s).Error; err != nil {
t.Fatalf("read skill %s: %v", id, err)
}
return s
}
func count(t *testing.T, db *gorm.DB, model any) int64 {
t.Helper()
var n int64
if err := db.Model(model).Count(&n).Error; err != nil {
t.Fatal(err)
}
return n
}
func TestPGSeedIsCompleteAndIdempotent(t *testing.T) {
db := testDB(t)
links := 0
for _, s := range SeedSkills {
links += len(s.Tools)
}
for i := 0; i < 2; i++ { // the second pass must change nothing
if i == 1 {
if err := Seed(db); err != nil {
t.Fatalf("second seed: %v", err)
}
}
if n := count(t, db, &models.AIAgent{}); n != int64(len(SeedAgents)) {
t.Errorf("pass %d: %d agents, want %d", i+1, n, len(SeedAgents))
}
if n := count(t, db, &models.AITool{}); n != int64(len(SeedTools)) {
t.Errorf("pass %d: %d tools, want %d", i+1, n, len(SeedTools))
}
if n := count(t, db, &models.AISkill{}); n != int64(len(SeedSkills)) {
t.Errorf("pass %d: %d skills, want %d", i+1, n, len(SeedSkills))
}
if n := count(t, db, &models.AISkillTool{}); n != int64(links) {
t.Errorf("pass %d: %d skill-tool links, want %d", i+1, n, links)
}
}
if n := count(t, db, &models.AIRegistryAudit{}); n != 0 {
t.Errorf("seeding wrote %d audit rows; only operator changes are audited", n)
}
}
// gorm drops a false bool that has a `default:true` tag from an INSERT. The
// seed selects columns explicitly so a skill seeded off really is off.
func TestPGSkillSeededOffStaysOff(t *testing.T) {
db := testDB(t)
if skillRow(t, db, "dispatch_rebalance").Enabled {
t.Fatal("dispatch_rebalance came up enabled in the database")
}
}
func TestPGLoadReturnsTheInventory(t *testing.T) {
db := testDB(t)
snap, err := Load(db)
if err != nil {
t.Fatal(err)
}
if len(snap.Agents) != len(SeedAgents) || len(snap.Skills) != len(SeedSkills) || len(snap.Tools) != len(SeedTools) {
t.Fatalf("snapshot sizes %d/%d/%d", len(snap.Agents), len(snap.Skills), len(snap.Tools))
}
if snap.Agents[0].Agentid != "JARVIS" {
t.Errorf("agents not in sort order: first is %s", snap.Agents[0].Agentid)
}
for _, s := range snap.Skills {
if s.Skillid == "skill_cash_exposure" && s.Thresholds["maxCashPerRider"] != 10000 {
t.Errorf("jsonb thresholds did not round-trip: %v", s.Thresholds)
}
}
}
func TestPGUpdateSkillIsAuditedAndVersioned(t *testing.T) {
db := testDB(t)
if err := UpdateSkill(db, "skill_sla_guardian", SkillPatch{Enabled: boolp(false)}, Actor{UserID: 42, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
s := skillRow(t, db, "skill_sla_guardian")
if s.Enabled || s.Version != 2 || s.Updatedby == nil || *s.Updatedby != 42 {
t.Fatalf("after disable: enabled=%v version=%d updatedby=%v", s.Enabled, s.Version, s.Updatedby)
}
// The same patch again is a no-op: no version bump, no second audit row.
if err := UpdateSkill(db, "skill_sla_guardian", SkillPatch{Enabled: boolp(false)}, Actor{UserID: 42, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
if v := skillRow(t, db, "skill_sla_guardian").Version; v != 2 {
t.Errorf("a no-op patch bumped the version to %d", v)
}
if err := UpdateSkill(db, "skill_sla_guardian", SkillPatch{Thresholds: map[string]any{"slaRiskWindowMin": 30.0}}, Actor{UserID: 42, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
got := ParseThresholds(skillRow(t, db, "skill_sla_guardian").Thresholds)
if got["slaRiskWindowMin"] != 30 || got["unassignedAgingMin"] != 60 {
t.Errorf("thresholds after patch = %v", got)
}
rows, err := ListAudit(db, 10)
if err != nil {
t.Fatal(err)
}
if len(rows) != 2 || rows[0].Field != "thresholds" || rows[1].Field != "enabled" {
t.Fatalf("audit = %+v, want [thresholds, enabled] newest first", rows)
}
if string(rows[1].Oldvalue) != "true" || string(rows[1].Newvalue) != "false" || rows[1].Changedby != 42 || rows[1].Changedbyemail != "tester@doormile.test" {
t.Errorf("enabled audit row = old %s new %s by %d", rows[1].Oldvalue, rows[1].Newvalue, rows[1].Changedby)
}
}
// A refused patch writes nothing — including the valid half of it.
func TestPGRefusedPatchChangesNothing(t *testing.T) {
db := testDB(t)
err := UpdateSkill(db, "skill_sla_guardian", SkillPatch{Enabled: boolp(false), Thresholds: map[string]any{"slaRiskWindowMin": 999.0}}, Actor{UserID: 42, Email: "tester@doormile.test"})
if !isValidation(err) {
t.Fatalf("want a ValidationError, got %v", err)
}
s := skillRow(t, db, "skill_sla_guardian")
if !s.Enabled || s.Version != 1 {
t.Errorf("refused patch leaked: enabled=%v version=%d", s.Enabled, s.Version)
}
if n := count(t, db, &models.AIRegistryAudit{}); n != 0 {
t.Errorf("refused patch wrote %d audit rows", n)
}
}
func TestPGUpdateUnknownSkillIsNotFound(t *testing.T) {
db := testDB(t)
if err := UpdateSkill(db, "no_such_skill", SkillPatch{Enabled: boolp(true)}, Actor{UserID: 1, Email: "tester@doormile.test"}); err != ErrNotFound {
t.Errorf("got %v, want ErrNotFound", err)
}
}
// Re-seeding (every boot) must never undo an operator's decision.
func TestPGReseedKeepsOperatorChanges(t *testing.T) {
db := testDB(t)
if err := UpdateSkill(db, "skill_fleet_balancer", SkillPatch{Enabled: boolp(false), Thresholds: map[string]any{"riderActiveCap": 5.0}}, Actor{UserID: 7, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
if err := UpdateAgent(db, "EXCEPTION_AGENT", AgentPatch{Model: strp("claude-haiku-4-5-20251001")}, Actor{UserID: 7, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
if err := Seed(db); err != nil {
t.Fatal(err)
}
s := skillRow(t, db, "skill_fleet_balancer")
if s.Enabled || ParseThresholds(s.Thresholds)["riderActiveCap"] != 5 || s.Version != 2 {
t.Errorf("re-seed reverted the operator's skill change: enabled=%v thresholds=%s version=%d", s.Enabled, s.Thresholds, s.Version)
}
var a models.AIAgent
db.Where("agentid = ?", "EXCEPTION_AGENT").First(&a)
if a.Model != "claude-haiku-4-5-20251001" {
t.Errorf("re-seed reverted the agent model to %q", a.Model)
}
}
func TestPGAutonomyNeedsConfirmationAndIsAudited(t *testing.T) {
db := testDB(t)
if err := UpdateAgent(db, "EXCEPTION_AGENT", AgentPatch{Autonomous: boolp(true)}, Actor{UserID: 1, Email: "tester@doormile.test"}); !isValidation(err) {
t.Fatalf("autonomy switched on without confirmation: %v", err)
}
if err := UpdateAgent(db, "EXCEPTION_AGENT", AgentPatch{Autonomous: boolp(true), Confirm: "EXCEPTION_AGENT"}, Actor{UserID: 1, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
var a models.AIAgent
db.Where("agentid = ?", "EXCEPTION_AGENT").First(&a)
if !a.Autonomous {
t.Fatal("autonomy was not saved")
}
rows, _ := ListAudit(db, 5)
if len(rows) != 1 || rows[0].Entity != "agent" || rows[0].Field != "autonomous" {
t.Errorf("autonomy change not audited: %+v", rows)
}
if err := UpdateAgent(db, "HUB_AGENT", AgentPatch{Autonomous: boolp(true), Confirm: "HUB_AGENT"}, Actor{UserID: 1, Email: "tester@doormile.test"}); !isValidation(err) {
t.Errorf("autonomy set on an ungated agent: %v", err)
}
}
func TestPGCreateSkill(t *testing.T) {
db := testDB(t)
n := NewSkill{Agentid: "CONSOLE_OPS_AGENT", Title: "Night shift watch", Tools: []string{"scan_bookings", "notify_riders"}}
id, err := CreateSkill(db, n, Actor{UserID: 9, Email: "tester@doormile.test"})
if err != nil || id != "custom_night_shift_watch" {
t.Fatalf("create = %q, %v", id, err)
}
id2, err := CreateSkill(db, n, Actor{UserID: 9, Email: "tester@doormile.test"})
if err != nil || id2 != "custom_night_shift_watch_2" {
t.Fatalf("second create with the same title = %q, %v", id2, err)
}
s := skillRow(t, db, id)
if s.Source != SourceCustom || !s.Enabled {
t.Errorf("custom skill row = %+v", s)
}
if _, err := CreateSkill(db, NewSkill{Agentid: "EXCEPTION_AGENT", Title: "x", Tools: []string{"scan_bookings"}}, Actor{UserID: 9, Email: "tester@doormile.test"}); !isValidation(err) || !strings.Contains(err.Error(), "console agents only") {
t.Errorf("custom skill on an engine agent: %v", err)
}
if _, err := CreateSkill(db, NewSkill{Agentid: "CONSOLE_OPS_AGENT", Title: "x", Tools: []string{"scan_bookings", "launch_rockets"}}, Actor{UserID: 9, Email: "tester@doormile.test"}); !isValidation(err) || !strings.Contains(err.Error(), "launch_rockets") {
t.Errorf("unknown tool not named: %v", err)
}
if _, err := CreateSkill(db, NewSkill{Agentid: "NOBODY", Title: "x", Tools: []string{"scan_bookings"}}, Actor{UserID: 9, Email: "tester@doormile.test"}); !isValidation(err) {
t.Errorf("unknown agent accepted: %v", err)
}
// Custom skills survive a re-seed untouched.
if err := Seed(db); err != nil {
t.Fatal(err)
}
var links int64
db.Model(&models.AISkillTool{}).Where("skillid = ?", id).Count(&links)
if links != 2 {
t.Errorf("re-seed touched a custom skill's tools: %d links", links)
}
}
func TestPGETagMovesOnlyWithRealChanges(t *testing.T) {
db := testDB(t)
before, _ := Load(db)
if err := Seed(db); err != nil {
t.Fatal(err)
}
same, _ := Load(db)
if ETag(before) != ETag(same) {
t.Error("a re-seed with no code change moved the ETag; the engine would refetch on every boot")
}
if err := UpdateSkill(db, "skill_doorstep_stall", SkillPatch{Thresholds: map[string]any{"arrivedStalledMin": 30.0}}, Actor{UserID: 1, Email: "tester@doormile.test"}); err != nil {
t.Fatal(err)
}
after, _ := Load(db)
if ETag(before) == ETag(after) {
t.Error("an operator change did not move the ETag")
}
}

View File

@@ -0,0 +1,152 @@
package registry
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
)
// ThresholdSpec is one tunable number on a skill: its default and the range an
// operator may move it within. The ranges are the product's safety rails — a
// stall timeout of 0 minutes or a cash cap of ₹10 lakh is a typo, not a policy.
type ThresholdSpec struct {
Key string `json:"key"`
Label string `json:"label"`
Unit string `json:"unit,omitempty"`
Default float64 `json:"default"`
Min float64 `json:"min"`
Max float64 `json:"max"`
Step float64 `json:"step"`
}
// onStep reports whether v sits on the spec's step grid, measured from Min.
// Tolerant of float noise: 0.7 must pass a 0.05 step starting at 0.5.
func (s ThresholdSpec) onStep(v float64) bool {
if s.Step <= 0 {
return true
}
n := (v - s.Min) / s.Step
return math.Abs(n-math.Round(n)) < 1e-6
}
func (s ThresholdSpec) check(v float64) error {
if math.IsNaN(v) || math.IsInf(v, 0) {
return fmt.Errorf("%s must be a number", s.Key)
}
if v < s.Min || v > s.Max {
return fmt.Errorf("%s must be between %s and %s", s.Key, fmtNum(s.Min), fmtNum(s.Max))
}
if !s.onStep(v) {
return fmt.Errorf("%s must move in steps of %s from %s", s.Key, fmtNum(s.Step), fmtNum(s.Min))
}
return nil
}
func fmtNum(v float64) string {
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.4f", v), "0"), ".")
}
// EffectiveThresholds is what a skill actually runs with: the stored value for
// every key the schema still defines and that is still in range, the default
// for everything else. Keys the schema no longer has are dropped. A schema
// change in code therefore never strands a skill on a value it can no longer
// validate, and never needs a data migration.
func EffectiveThresholds(schema []ThresholdSpec, stored map[string]float64) map[string]float64 {
out := make(map[string]float64, len(schema))
for _, s := range schema {
v, ok := stored[s.Key]
if !ok || s.check(v) != nil {
v = s.Default
}
out[s.Key] = v
}
return out
}
// ApplyThresholdPatch validates an operator's patch against the schema and
// returns the full resulting set. Unknown keys and non-numbers are refused, not
// ignored: a misspelt key silently doing nothing is exactly the failure an
// operator cannot see. All keys are checked before any error is returned, so
// the message lists every problem at once.
func ApplyThresholdPatch(schema []ThresholdSpec, current map[string]float64, patch map[string]any) (map[string]float64, error) {
byKey := make(map[string]ThresholdSpec, len(schema))
for _, s := range schema {
byKey[s.Key] = s
}
next := EffectiveThresholds(schema, current)
var problems []string
keys := make([]string, 0, len(patch))
for k := range patch {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
spec, known := byKey[k]
if !known {
problems = append(problems, fmt.Sprintf("%s is not a threshold of this skill", k))
continue
}
v, isNum := patch[k].(float64)
if !isNum {
problems = append(problems, fmt.Sprintf("%s must be a number", k))
continue
}
if err := spec.check(v); err != nil {
problems = append(problems, err.Error())
continue
}
next[k] = v
}
if len(problems) > 0 {
return nil, &ValidationError{Msg: strings.Join(problems, "; ")}
}
return next, nil
}
// ParseThresholds reads a stored jsonb thresholds document. Empty, null or
// malformed reads as "nothing stored", which EffectiveThresholds turns into
// the defaults — a bad row degrades to defaults rather than failing a read.
func ParseThresholds(raw string) map[string]float64 {
out := map[string]float64{}
if strings.TrimSpace(raw) == "" {
return out
}
_ = json.Unmarshal([]byte(raw), &out)
if out == nil {
out = map[string]float64{}
}
return out
}
// ParseSchema reads a stored thresholds schema. Malformed reads as no schema.
func ParseSchema(raw string) []ThresholdSpec {
var out []ThresholdSpec
if strings.TrimSpace(raw) == "" {
return []ThresholdSpec{}
}
if err := json.Unmarshal([]byte(raw), &out); err != nil || out == nil {
return []ThresholdSpec{}
}
return out
}
// DefaultThresholds is the value set a freshly seeded skill starts with.
func DefaultThresholds(schema []ThresholdSpec) map[string]float64 {
return EffectiveThresholds(schema, nil)
}
func mustJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
// Only ever called on values built in this package; a failure here is
// a programming error, and the seed tests exercise every one.
panic(err)
}
return string(b)
}

View File

@@ -0,0 +1,228 @@
package telemetry
import (
"context"
"encoding/json"
"sort"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// AgentRunStats is one agent's runs over the window.
type AgentRunStats struct {
Agentid string `json:"agentid"`
Runs int64 `json:"runs"`
Failed int64 `json:"failed"`
Avgdurationms float64 `json:"avgdurationms"`
Lastrunat *time.Time `json:"lastrunat"`
}
// DecisionCount is decisions of one type with one outcome over the window.
// Outcome is "pending" while none has been recorded.
type DecisionCount struct {
Decisiontype string `json:"decisiontype"`
Outcome string `json:"outcome"`
Count int64 `json:"count"`
}
// DecisionTypeStats rolls DecisionCount rows up per type.
type DecisionTypeStats struct {
Decisiontype string `json:"decisiontype"`
Total int64 `json:"total"`
Outcomes map[string]int64 `json:"outcomes"`
}
// Insights is everything the Insights tab shows.
type Insights struct {
Days int `json:"days"`
Since time.Time `json:"since"`
// Receiving is whether this backend is subscribed to AI_engine telemetry.
// False means an empty run list is "not connected", not "no activity".
Receiving bool `json:"receiving"`
Runs RunsSummary `json:"runs"`
Decisions DecisionSummary `json:"decisions"`
Live []AgentState `json:"live"`
}
type RunsSummary struct {
Total int64 `json:"total"`
Failed int64 `json:"failed"`
PerAgent []AgentRunStats `json:"peragent"`
}
type DecisionSummary struct {
Total int64 `json:"total"`
ByType []DecisionTypeStats `json:"bytype"`
}
// ClampDays keeps the window to what the table retains.
func ClampDays(days int) int {
switch {
case days < 1:
return 7
case days > RetentionDays:
return RetentionDays
default:
return days
}
}
// SummariseRuns totals per-agent rows, busiest agent first.
func SummariseRuns(rows []AgentRunStats) RunsSummary {
out := RunsSummary{PerAgent: append([]AgentRunStats{}, rows...)}
for _, r := range rows {
out.Total += r.Runs
out.Failed += r.Failed
}
sort.SliceStable(out.PerAgent, func(i, j int) bool {
if out.PerAgent[i].Runs != out.PerAgent[j].Runs {
return out.PerAgent[i].Runs > out.PerAgent[j].Runs
}
return out.PerAgent[i].Agentid < out.PerAgent[j].Agentid
})
return out
}
// SummariseDecisions rolls (type, outcome) counts up per type, largest first.
func SummariseDecisions(rows []DecisionCount) DecisionSummary {
byType := map[string]*DecisionTypeStats{}
var order []string
var total int64
for _, r := range rows {
st, ok := byType[r.Decisiontype]
if !ok {
st = &DecisionTypeStats{Decisiontype: r.Decisiontype, Outcomes: map[string]int64{}}
byType[r.Decisiontype] = st
order = append(order, r.Decisiontype)
}
st.Total += r.Count
st.Outcomes[r.Outcome] += r.Count
total += r.Count
}
out := DecisionSummary{Total: total, ByType: make([]DecisionTypeStats, 0, len(order))}
for _, k := range order {
out.ByType = append(out.ByType, *byType[k])
}
sort.SliceStable(out.ByType, func(i, j int) bool {
if out.ByType[i].Total != out.ByType[j].Total {
return out.ByType[i].Total > out.ByType[j].Total
}
return out.ByType[i].Decisiontype < out.ByType[j].Decisiontype
})
return out
}
// RunStats reads per-agent run statistics since the cutoff. A fixed single
// grouped query, whatever the volume.
func RunStats(db *gorm.DB, since time.Time) ([]AgentRunStats, error) {
var rows []AgentRunStats
err := db.Table("aiagentruns").
Select(`agentid,
COUNT(*) AS runs,
COUNT(*) FILTER (WHERE status <> 'completed') AS failed,
COALESCE(AVG(durationms), 0) AS avgdurationms,
MAX(receivedat) AS lastrunat`).
Where("receivedat >= ?", since).
Group("agentid").
Scan(&rows).Error
return rows, err
}
// DecisionCounts reads agent_decisions grouped by type and outcome. The table
// is written by the decision engine through POST /internal/agent-decisions.
func DecisionCounts(db *gorm.DB, since time.Time) ([]DecisionCount, error) {
var rows []DecisionCount
err := db.Table("agent_decisions").
Select("decision_type AS decisiontype, COALESCE(outcome, 'pending') AS outcome, COUNT(*) AS count").
Where("created_at >= ?", since).
Group("decision_type, COALESCE(outcome, 'pending')").
Scan(&rows).Error
return rows, err
}
// LiveStates reads the latest heartbeat of each agent that has one. Missing
// keys (an agent silent for over five minutes) are simply absent.
func LiveStates(rdb *redis.Client, agentIDs []string) []AgentState {
out := []AgentState{}
if rdb == nil || len(agentIDs) == 0 {
return out
}
keys := make([]string, len(agentIDs))
for i, id := range agentIDs {
keys[i] = StateKey(id)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
vals, err := rdb.MGet(ctx, keys...).Result()
if err != nil {
return out
}
for _, v := range vals {
s, ok := v.(string)
if !ok {
continue
}
var st AgentState
if json.Unmarshal([]byte(s), &st) == nil && st.AgentID != "" {
out = append(out, st)
}
}
return out
}
// DecisionRow is one recent decision, as the Insights list shows it.
type DecisionRow struct {
ID uint64 `json:"id"`
Decisiontype string `json:"decisiontype"`
Bookingid *uint64 `json:"bookingid"`
Decision json.RawMessage `json:"decision"`
Reasoning string `json:"reasoning"`
Outcome *string `json:"outcome"`
Createdat time.Time `json:"createdat"`
}
type decisionScan struct {
ID uint64
Decisiontype string
Bookingid *uint64
Decision string
Reasoning string
Outcome *string
Createdat time.Time
}
// RecentDecisions pages agent_decisions newest first. beforeID (0 = start)
// is a keyset cursor, so a page is stable while new rows arrive. The context
// column is deliberately not returned: it can be large and holds rider data.
func RecentDecisions(db *gorm.DB, decisionType string, beforeID uint64, limit int) ([]DecisionRow, error) {
if limit <= 0 || limit > 100 {
limit = 25
}
q := db.Table("agent_decisions").
Select("id, decision_type AS decisiontype, booking_id AS bookingid, COALESCE(decision::text, 'null') AS decision, reasoning, outcome, created_at AS createdat").
Order("id DESC").Limit(limit)
if decisionType != "" {
q = q.Where("decision_type = ?", decisionType)
}
if beforeID > 0 {
q = q.Where("id < ?", beforeID)
}
var rows []decisionScan
if err := q.Scan(&rows).Error; err != nil {
return nil, err
}
out := make([]DecisionRow, 0, len(rows))
for _, r := range rows {
raw := json.RawMessage(r.Decision)
if !json.Valid(raw) {
raw = json.RawMessage("null")
}
out = append(out, DecisionRow{
ID: r.ID, Decisiontype: r.Decisiontype, Bookingid: r.Bookingid, Decision: raw,
Reasoning: truncate(r.Reasoning, 1000), Outcome: r.Outcome, Createdat: r.Createdat,
})
}
return out, nil
}

View File

@@ -0,0 +1,146 @@
// Package telemetry records what AI_engine's agents actually do.
//
// AI_engine publishes two fire-and-forget subjects on plain NATS
// (core/message_bus.py publish_telemetry):
//
// telemetry.task — after every task: agent, task id/type, status, error, duration
// telemetry.agent — every ~5 s per agent: status, current task, counters
//
// Tasks become rows in aiagentruns (Postgres); agent state goes to Redis with a
// short TTL. Both are observability only: nothing here may slow a booking or
// fail a request, and a malformed event is dropped, never guessed at.
package telemetry
import (
"encoding/json"
"errors"
"strings"
"time"
"unicode/utf8"
"doormile/models"
)
const (
maxIDLen = 64
maxErrorLen = 2000
)
// ErrInvalid is returned for an event that cannot be recorded as-is.
var ErrInvalid = errors.New("invalid telemetry event")
type taskEvent struct {
TS string `json:"ts"`
AgentID string `json:"agent_id"`
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
Status string `json:"status"`
Error *string `json:"error"`
DurationMS float64 `json:"duration_ms"`
}
// engineTimeLayouts are what Python's datetime.now().isoformat() produces:
// a naive local time, with or without microseconds.
var engineTimeLayouts = []string{"2006-01-02T15:04:05.999999", "2006-01-02T15:04:05"}
func parseEngineTime(s string) *time.Time {
for _, layout := range engineTimeLayouts {
if t, err := time.Parse(layout, s); err == nil {
return &t
}
}
return nil
}
// truncate cuts s to at most n bytes without splitting a UTF-8 rune.
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
s = s[:n]
for !utf8.ValidString(s) {
s = s[:len(s)-1]
}
return s
}
// ParseTask turns a telemetry.task payload into a run row stamped with
// receivedAt. It refuses — rather than repairs — an event with no agent id or
// no status, or an id too long to be one: a run attributed to the wrong agent
// is worse than a missing one.
func ParseTask(body []byte, receivedAt time.Time) (models.AIAgentRun, error) {
var ev taskEvent
if err := json.Unmarshal(body, &ev); err != nil {
return models.AIAgentRun{}, ErrInvalid
}
agent := strings.TrimSpace(ev.AgentID)
status := strings.ToLower(strings.TrimSpace(ev.Status))
if agent == "" || len(agent) > maxIDLen || status == "" || len(status) > 20 {
return models.AIAgentRun{}, ErrInvalid
}
run := models.AIAgentRun{
Agentid: agent,
Tasktype: truncate(strings.TrimSpace(ev.TaskType), maxIDLen),
Status: status,
Durationms: int(ev.DurationMS),
Occurredat: parseEngineTime(ev.TS),
Receivedat: receivedAt,
}
if run.Durationms < 0 {
run.Durationms = 0
}
if id := strings.TrimSpace(ev.TaskID); id != "" {
id = truncate(id, maxIDLen)
run.Taskid = &id
}
if ev.Error != nil {
run.Error = truncate(*ev.Error, maxErrorLen)
}
return run, nil
}
// AgentState is an agent's latest telemetry.agent heartbeat, as kept in Redis.
type AgentState struct {
AgentID string `json:"agentid"`
Status string `json:"status"`
CurrentTask *string `json:"currenttask"`
TasksCompleted int64 `json:"taskscompleted"`
TasksFailed int64 `json:"tasksfailed"`
LastSeenAt time.Time `json:"lastseenat"`
}
type agentEvent struct {
AgentID string `json:"agent_id"`
Status string `json:"status"`
CurrentTask *string `json:"current_task"`
TasksCompleted int64 `json:"tasks_completed"`
TasksFailed int64 `json:"tasks_failed"`
}
// ParseAgent turns a telemetry.agent payload into the state kept for it.
func ParseAgent(body []byte, seenAt time.Time) (AgentState, error) {
var ev agentEvent
if err := json.Unmarshal(body, &ev); err != nil {
return AgentState{}, ErrInvalid
}
agent := strings.TrimSpace(ev.AgentID)
if agent == "" || len(agent) > maxIDLen {
return AgentState{}, ErrInvalid
}
st := AgentState{
AgentID: agent,
Status: truncate(strings.TrimSpace(ev.Status), 20),
TasksCompleted: ev.TasksCompleted,
TasksFailed: ev.TasksFailed,
LastSeenAt: seenAt,
}
if ev.CurrentTask != nil {
t := truncate(*ev.CurrentTask, maxIDLen)
st.CurrentTask = &t
}
return st, nil
}
// StateKey is the Redis key an agent's latest state lives under.
func StateKey(agentID string) string { return "ai:agent:state:" + agentID }

View File

@@ -0,0 +1,169 @@
package telemetry
import (
"context"
"encoding/json"
"sync"
"sync/atomic"
"time"
"doormile/models"
"doormile/utils"
"github.com/nats-io/nats.go"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
// QueueGroup makes each event land on exactly one backend replica, so a
// run is written once however many pods subscribe.
QueueGroup = "doormile-backend-telemetry"
bufferSize = 2000
flushEvery = 2 * time.Second
flushAt = 200
stateTTL = 5 * time.Minute
RetentionDays = 30
)
// Recorder buffers runs and writes them in batches. A NATS callback must never
// block on Postgres: a slow database would back up the subscription and, in
// nats.go, eventually mark it a slow consumer. So the callback only enqueues,
// and a full buffer drops (counted, logged) rather than waits.
type Recorder struct {
db *gorm.DB
rdb *redis.Client
runs chan models.AIAgentRun
now func() time.Time
dropped atomic.Int64
written atomic.Int64
once sync.Once
}
// NewRecorder builds a recorder. rdb may be nil: agent state is then not kept.
func NewRecorder(db *gorm.DB, rdb *redis.Client) *Recorder {
// time.Now, NOT utils.DBNow. DBNow returns IST digits labelled UTC, which
// is right only for the legacy timestamp-WITHOUT-time-zone columns. This
// table is created by AutoMigrate, so its columns are timestamptz, and a
// DBNow value lands 5h30m in the future — caught by the Phase 4 end-to-end
// run, where a run received at 20:57 IST read back as 02:27 next day.
return &Recorder{db: db, rdb: rdb, runs: make(chan models.AIAgentRun, bufferSize), now: time.Now}
}
// Enqueue offers a run to the writer without blocking. It reports whether the
// run was accepted.
func (r *Recorder) Enqueue(run models.AIAgentRun) bool {
select {
case r.runs <- run:
return true
default:
if n := r.dropped.Add(1); n == 1 || n%500 == 0 {
utils.Error("ai telemetry: run buffer full, dropping", "dropped_total", n)
}
return false
}
}
// HandleTask is the telemetry.task callback.
func (r *Recorder) HandleTask(body []byte) {
run, err := ParseTask(body, r.now())
if err != nil {
return
}
r.Enqueue(run)
}
// HandleAgent is the telemetry.agent callback. Best effort: Redis down means
// the Insights page shows no live state, never that a request fails.
func (r *Recorder) HandleAgent(body []byte) {
if r.rdb == nil {
return
}
st, err := ParseAgent(body, r.now())
if err != nil {
return
}
b, _ := json.Marshal(st)
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
_ = r.rdb.Set(ctx, StateKey(st.AgentID), b, stateTTL).Err()
}
// Flush writes whatever is buffered. Duplicates of an (agent, task id) pair —
// a redelivery — are ignored by the unique index.
func (r *Recorder) Flush(batch []models.AIAgentRun) {
if len(batch) == 0 {
return
}
if err := r.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(batch, 200).Error; err != nil {
utils.Error("ai telemetry: writing runs failed", "count", len(batch), "error", err.Error())
return
}
r.written.Add(int64(len(batch)))
}
func (r *Recorder) writeLoop() {
ticker := time.NewTicker(flushEvery)
defer ticker.Stop()
batch := make([]models.AIAgentRun, 0, flushAt)
for {
select {
case run := <-r.runs:
batch = append(batch, run)
if len(batch) >= flushAt {
r.Flush(batch)
batch = batch[:0]
}
case <-ticker.C:
r.Flush(batch)
batch = batch[:0]
}
}
}
// Prune deletes runs older than the retention window. Returns rows removed.
func (r *Recorder) Prune() (int64, error) {
cutoff := r.now().AddDate(0, 0, -RetentionDays)
res := r.db.Where("receivedat < ?", cutoff).Delete(&models.AIAgentRun{})
return res.RowsAffected, res.Error
}
func (r *Recorder) pruneLoop() {
for {
if n, err := r.Prune(); err != nil {
utils.Error("ai telemetry: pruning old runs failed", "error", err.Error())
} else if n > 0 {
utils.Info("ai telemetry: pruned old runs", "count", n)
}
time.Sleep(24 * time.Hour)
}
}
// Start subscribes to AI_engine's telemetry and starts the writer. A nil NATS
// connection (NATS down, or not configured) logs and returns: the API serves
// without it, and the Insights page says no telemetry is being received.
func (r *Recorder) Start(nc *nats.Conn) {
if nc == nil {
utils.Info("ai telemetry: NATS not connected, agent runs will not be recorded")
return
}
r.once.Do(func() {
go r.writeLoop()
go r.pruneLoop()
if _, err := nc.QueueSubscribe("telemetry.task", QueueGroup, func(m *nats.Msg) { r.HandleTask(m.Data) }); err != nil {
utils.Error("ai telemetry: subscribe telemetry.task failed", "error", err.Error())
}
if _, err := nc.QueueSubscribe("telemetry.agent", QueueGroup, func(m *nats.Msg) { r.HandleAgent(m.Data) }); err != nil {
utils.Error("ai telemetry: subscribe telemetry.agent failed", "error", err.Error())
}
Receiving.Store(true)
utils.Info("ai telemetry: recording agent runs", "queue_group", QueueGroup)
})
}
// Receiving reports whether this process subscribed to telemetry at boot. The
// Insights endpoint returns it, so an empty page can say "not connected"
// rather than "no runs".
var Receiving atomic.Bool

View File

@@ -0,0 +1,135 @@
package telemetry
import (
"encoding/json"
"os"
"strings"
"testing"
"time"
"doormile/internal/testpg"
"doormile/models"
"gorm.io/gorm"
)
// Real SQL against a THROWAWAY Postgres (REGISTRY_TEST_DSN); skipped otherwise.
// Drops and recreates aiagentruns and agent_decisions in its own schema.
func pgDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres integration test")
}
db := testpg.Open(t, dsn, "aitelemetry_test")
all := []any{&models.AIAgentRun{}, &models.AgentDecision{}}
if err := db.Migrator().DropTable(all...); err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(all...); err != nil {
t.Fatal(err)
}
return db
}
func strp(s string) *string { return &s }
func TestPGFlushStoresRunsOnceAndAggregates(t *testing.T) {
db := pgDB(t)
r := &Recorder{db: db, now: func() time.Time { return now }}
batch := []models.AIAgentRun{
{Agentid: "EXCEPTION_AGENT", Taskid: strp("t1"), Status: "completed", Durationms: 100, Receivedat: now},
{Agentid: "EXCEPTION_AGENT", Taskid: strp("t2"), Status: "failed", Error: "boom", Durationms: 300, Receivedat: now},
{Agentid: "DISPATCH_AGENT", Taskid: nil, Status: "completed", Durationms: 50, Receivedat: now},
{Agentid: "DISPATCH_AGENT", Taskid: nil, Status: "completed", Durationms: 70, Receivedat: now},
}
r.Flush(batch)
// A redelivered event (same agent + task id) is ignored, not double-counted.
r.Flush([]models.AIAgentRun{{Agentid: "EXCEPTION_AGENT", Taskid: strp("t1"), Status: "completed", Durationms: 999, Receivedat: now}})
var n int64
db.Model(&models.AIAgentRun{}).Count(&n)
if n != 4 {
t.Fatalf("stored %d runs, want 4 (redelivery ignored, null task ids both kept)", n)
}
stats, err := RunStats(db, now.Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
s := SummariseRuns(stats)
if s.Total != 4 || s.Failed != 1 || len(s.PerAgent) != 2 {
t.Fatalf("summary %+v", s)
}
for _, a := range s.PerAgent {
if a.Agentid == "EXCEPTION_AGENT" && (a.Runs != 2 || a.Failed != 1 || a.Avgdurationms != 200 || a.Lastrunat == nil) {
t.Errorf("EXCEPTION_AGENT stats %+v", a)
}
}
// Outside the window, nothing.
if stats, _ := RunStats(db, now.Add(time.Hour)); len(stats) != 0 {
t.Errorf("runs before the window were counted: %+v", stats)
}
}
func TestPGPruneRemovesOnlyExpiredRuns(t *testing.T) {
db := pgDB(t)
r := &Recorder{db: db, now: func() time.Time { return now }}
r.Flush([]models.AIAgentRun{
{Agentid: "A", Status: "completed", Receivedat: now.AddDate(0, 0, -(RetentionDays + 1))},
{Agentid: "A", Status: "completed", Receivedat: now.AddDate(0, 0, -1)},
})
removed, err := r.Prune()
if err != nil || removed != 1 {
t.Fatalf("pruned %d, %v; want 1", removed, err)
}
}
func TestPGDecisionsCountAndPage(t *testing.T) {
db := pgDB(t)
ok, pend := "success", (*string)(nil)
b1 := uint64(501)
for i, d := range []models.AgentDecision{
{DecisionType: "miler_assignment", BookingID: &b1, Context: `{"rider":"secret"}`, Decision: `{"miler_id":8}`, Reasoning: "nearest", Outcome: &ok},
{DecisionType: "miler_assignment", Context: `{}`, Decision: `{"miler_id":9}`, Reasoning: "load", Outcome: pend},
{DecisionType: "stall_response", Context: `{}`, Decision: `{"action":"alert"}`, Reasoning: "stalled 12m", Outcome: pend},
} {
d.CreatedAt = now.Add(time.Duration(i) * time.Minute)
if err := db.Create(&d).Error; err != nil {
t.Fatal(err)
}
}
counts, err := DecisionCounts(db, now.Add(-time.Hour))
if err != nil {
t.Fatal(err)
}
s := SummariseDecisions(counts)
if s.Total != 3 || s.ByType[0].Decisiontype != "miler_assignment" || s.ByType[0].Outcomes["success"] != 1 || s.ByType[0].Outcomes["pending"] != 1 {
t.Fatalf("decision summary %+v", s)
}
page, err := RecentDecisions(db, "", 0, 2)
if err != nil || len(page) != 2 || page[0].Decisiontype != "stall_response" {
t.Fatalf("first page %+v, %v", page, err)
}
var dec map[string]any
if json.Unmarshal(page[0].Decision, &dec) != nil || dec["action"] != "alert" {
t.Errorf("decision jsonb not returned as JSON: %s", page[0].Decision)
}
next, err := RecentDecisions(db, "", page[1].ID, 2)
if err != nil || len(next) != 1 || next[0].Bookingid == nil || *next[0].Bookingid != 501 {
t.Fatalf("second page %+v, %v", next, err)
}
only, _ := RecentDecisions(db, "stall_response", 0, 10)
if len(only) != 1 {
t.Errorf("type filter returned %d rows", len(only))
}
b, _ := json.Marshal(next)
if strings.Contains(string(b), "secret") {
t.Error("the context column (rider data) leaked into the decisions list")
}
}

View File

@@ -0,0 +1,177 @@
package telemetry
import (
"strings"
"testing"
"time"
"unicode/utf8"
"doormile/models"
)
var now = time.Date(2026, 9, 29, 18, 0, 0, 0, time.UTC)
// The exact shape core/agent.py publishes after a task.
const engineTask = `{"kind":"task","ts":"2026-09-29T17:59:58.123456","agent_id":"EXCEPTION_AGENT",
"task_id":"3f2c9a","task_type":"handle_stall","status":"completed","error":null,"duration_ms":412}`
func TestParseTaskReadsTheEngineShape(t *testing.T) {
run, err := ParseTask([]byte(engineTask), now)
if err != nil {
t.Fatal(err)
}
if run.Agentid != "EXCEPTION_AGENT" || run.Tasktype != "handle_stall" || run.Status != "completed" || run.Durationms != 412 {
t.Errorf("parsed %+v", run)
}
if run.Taskid == nil || *run.Taskid != "3f2c9a" {
t.Errorf("task id = %v", run.Taskid)
}
if !run.Receivedat.Equal(now) {
t.Errorf("receivedat = %v, want the backend's clock", run.Receivedat)
}
if run.Occurredat == nil || run.Occurredat.Format("15:04:05") != "17:59:58" {
t.Errorf("occurredat = %v", run.Occurredat)
}
}
func TestParseTaskKeepsAFailureAndItsError(t *testing.T) {
run, err := ParseTask([]byte(`{"agent_id":"DISPATCH_AGENT","task_id":"x","status":"FAILED","error":"boom","duration_ms":5}`), now)
if err != nil || run.Status != "failed" || run.Error != "boom" {
t.Fatalf("got %+v, %v", run, err)
}
}
// A run attributed to the wrong agent is worse than a missing one.
func TestParseTaskRefusesWhatItCannotAttribute(t *testing.T) {
cases := map[string]string{
"not json": `{nope`,
"no agent": `{"status":"completed"}`,
"blank agent": `{"agent_id":" ","status":"completed"}`,
"no status": `{"agent_id":"A"}`,
"agent id too long": `{"agent_id":"` + strings.Repeat("A", 65) + `","status":"completed"}`,
}
for name, body := range cases {
if _, err := ParseTask([]byte(body), now); err != ErrInvalid {
t.Errorf("%s: want ErrInvalid, got %v", name, err)
}
}
}
func TestParseTaskCleansUpEdgeValues(t *testing.T) {
long := strings.Repeat("é", 1500) // 3000 bytes of two-byte runes
run, err := ParseTask([]byte(`{"agent_id":"A","status":"completed","duration_ms":-40,"ts":"garbage","error":"`+long+`"}`), now)
if err != nil {
t.Fatal(err)
}
if run.Durationms != 0 {
t.Errorf("negative duration kept: %d", run.Durationms)
}
if run.Taskid != nil {
t.Error("an absent task id must be stored as null, not an empty string")
}
if run.Occurredat != nil {
t.Error("an unparseable engine time must be null, not guessed")
}
if len(run.Error) > maxErrorLen || !utf8.ValidString(run.Error) {
t.Errorf("error not truncated safely: %d bytes, valid=%v", len(run.Error), utf8.ValidString(run.Error))
}
}
func TestParseAgent(t *testing.T) {
st, err := ParseAgent([]byte(`{"kind":"agent","agent_id":"JARVIS","status":"idle","current_task":null,"tasks_completed":12,"tasks_failed":1}`), now)
if err != nil {
t.Fatal(err)
}
if st.AgentID != "JARVIS" || st.Status != "idle" || st.TasksCompleted != 12 || st.TasksFailed != 1 || !st.LastSeenAt.Equal(now) {
t.Errorf("parsed %+v", st)
}
if _, err := ParseAgent([]byte(`{"status":"idle"}`), now); err != ErrInvalid {
t.Error("an agent event with no id was accepted")
}
}
// The NATS callback must never block: a full buffer drops and counts.
func TestEnqueueDropsInsteadOfBlocking(t *testing.T) {
r := &Recorder{runs: make(chan models.AIAgentRun, 1), now: func() time.Time { return now }}
if !r.Enqueue(models.AIAgentRun{Agentid: "A"}) {
t.Fatal("first run refused")
}
done := make(chan bool)
go func() { done <- r.Enqueue(models.AIAgentRun{Agentid: "B"}) }()
select {
case ok := <-done:
if ok || r.dropped.Load() != 1 {
t.Errorf("full buffer: accepted=%v dropped=%d", ok, r.dropped.Load())
}
case <-time.After(time.Second):
t.Fatal("Enqueue blocked on a full buffer")
}
}
func TestHandlersIgnoreBadInputAndMissingRedis(t *testing.T) {
r := &Recorder{runs: make(chan models.AIAgentRun, 4), now: func() time.Time { return now }}
r.HandleTask([]byte(`{bad`))
r.HandleAgent([]byte(`{"agent_id":"A","status":"idle"}`)) // rdb nil: must not panic
if len(r.runs) != 0 {
t.Error("an invalid task event was enqueued")
}
r.HandleTask([]byte(engineTask))
if len(r.runs) != 1 {
t.Error("a valid task event was not enqueued")
}
}
func TestSummariseRuns(t *testing.T) {
s := SummariseRuns([]AgentRunStats{
{Agentid: "B", Runs: 3, Failed: 1},
{Agentid: "A", Runs: 10, Failed: 0},
{Agentid: "C", Runs: 3, Failed: 2},
})
if s.Total != 16 || s.Failed != 3 {
t.Errorf("totals %d/%d", s.Total, s.Failed)
}
var order []string
for _, a := range s.PerAgent {
order = append(order, a.Agentid)
}
if strings.Join(order, ",") != "A,B,C" {
t.Errorf("order %v, want busiest first then by id", order)
}
if empty := SummariseRuns(nil); empty.PerAgent == nil || empty.Total != 0 {
t.Error("no runs must summarise to an empty list, not null")
}
}
func TestSummariseDecisions(t *testing.T) {
s := SummariseDecisions([]DecisionCount{
{Decisiontype: "miler_assignment", Outcome: "success", Count: 7},
{Decisiontype: "stall_response", Outcome: "pending", Count: 2},
{Decisiontype: "miler_assignment", Outcome: "pending", Count: 3},
})
if s.Total != 12 || len(s.ByType) != 2 {
t.Fatalf("summary %+v", s)
}
first := s.ByType[0]
if first.Decisiontype != "miler_assignment" || first.Total != 10 || first.Outcomes["success"] != 7 || first.Outcomes["pending"] != 3 {
t.Errorf("miler_assignment rolled up wrong: %+v", first)
}
}
func TestClampDays(t *testing.T) {
for in, want := range map[int]int{0: 7, -3: 7, 1: 1, 7: 7, 30: 30, 90: RetentionDays} {
if got := ClampDays(in); got != want {
t.Errorf("ClampDays(%d) = %d, want %d", in, got, want)
}
}
}
// aiagentruns is created by AutoMigrate, so its columns are timestamptz. The
// recorder must stamp a true instant; utils.DBNow (IST digits labelled UTC) is
// 5h30m off as an instant and made the Phase 4 end-to-end run read a 20:57 IST
// run back as 02:27 the next day.
func TestRecorderStampsARealInstant(t *testing.T) {
r := NewRecorder(nil, nil)
if d := r.now().Sub(time.Now()); d > time.Minute || d < -time.Minute {
t.Fatalf("recorder clock is %v off real time; it must not use utils.DBNow", d)
}
}

48
internal/testpg/testpg.go Normal file
View File

@@ -0,0 +1,48 @@
// Package testpg opens throwaway Postgres schemas for integration tests.
// Imported only from _test.go files, so it never reaches the server binary.
package testpg
import (
"regexp"
"testing"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
var schemaName = regexp.MustCompile(`^[a-z_][a-z0-9_]{0,62}$`)
// Open connects to a THROWAWAY Postgres (the DSN from REGISTRY_TEST_DSN)
// inside a schema of its own, created if missing.
//
// Each test package passes a different schema: `go test ./...` runs packages
// in parallel, and two packages dropping and recreating the same tables in the
// same schema at once fail each other at random.
func Open(t testing.TB, dsn, schema string) *gorm.DB {
t.Helper()
if !schemaName.MatchString(schema) {
t.Fatalf("bad test schema name %q", schema)
}
cfg := &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)}
base, err := gorm.Open(postgres.Open(dsn), cfg)
if err != nil {
t.Fatalf("connect: %v", err)
}
if err := base.Exec("CREATE SCHEMA IF NOT EXISTS " + schema).Error; err != nil {
t.Fatalf("create schema %s: %v", schema, err)
}
if sqlDB, err := base.DB(); err == nil {
_ = sqlDB.Close()
}
db, err := gorm.Open(postgres.Open(dsn+" search_path="+schema), cfg)
if err != nil {
t.Fatalf("connect to schema %s: %v", schema, err)
}
t.Cleanup(func() {
if sqlDB, err := db.DB(); err == nil {
_ = sqlDB.Close()
}
})
return db
}

24
main.go
View File

@@ -12,6 +12,8 @@ import (
"doormile/config"
"doormile/controllers"
"doormile/db"
"doormile/internal/ai/playground"
"doormile/internal/ai/telemetry"
"doormile/internal/assignment"
"doormile/internal/notify"
"doormile/internal/routing"
@@ -85,6 +87,14 @@ func main() {
_ = godotenv.Load()
cfg := config.Load()
// Refuse to boot production on a committed fallback secret. Checked before
// anything connects, so a misconfigured deploy fails loudly at start rather
// than serving traffic with a JWT secret that is in the git history.
if missing := cfg.MissingProductionSecrets(); len(missing) > 0 {
utils.Error("Refusing to start: required secrets are not set in production", "missing", strings.Join(missing, ","))
os.Exit(1)
}
utils.Info("Starting Doormile Backend...")
// 2. Connect to Postgres, Redis & NATS
@@ -220,6 +230,20 @@ func main() {
// 9. Point the stop sequencer at the Route Optimization API.
routing.BaseURL = cfg.RouteOptimizerURL
// 10. Record AI_engine agent runs (telemetry.task) and live state
// (telemetry.agent). Observability only: a nil NATS connection logs and
// skips, and nothing here can block a request.
if db.DB != nil {
telemetry.NewRecorder(db.DB, db.Rdb).Start(db.Nc)
}
// Agent Studio Test playground: switched on only when a model API key is
// configured. Without one the endpoint answers 503 and the console says so.
if cfg.PlaygroundLLMAPIKey != "" {
controllers.PlaygroundModel = playground.NewOpenAICompat(cfg.PlaygroundLLMBaseURL, cfg.PlaygroundLLMAPIKey, cfg.PlaygroundLLMModel)
utils.Info("ai playground: enabled", "base_url", cfg.PlaygroundLLMBaseURL, "model", cfg.PlaygroundLLMModel)
}
// 7. Startup server in a background thread
go func() {
utils.Info("Server starting", "port", cfg.Port)

View File

@@ -0,0 +1,39 @@
package middlewares
import (
"strings"
"github.com/gofiber/fiber/v2"
)
// ClientOnboardingOwnerOnly admits only the console logins named in
// CLIENT_ONBOARDING_OWNERS (default admin@doormile.com): the token's email must
// be on that list, AND it must be Doormile staff (tenant 0) with roleid 1.
//
// Onboarding mints a client's console credentials, so it is narrower than
// "any admin". Everything else refuses with the same 403 — a partner login,
// another Doormile admin, a manager — and the refusal names no allowed email.
//
// Must run after AuthMiddleware. Missing locals fail closed. The handler also
// re-reads the owner's doormile_auth row, so a token that outlives a removed
// or demoted account stops working at once.
func ClientOnboardingOwnerOnly(owners []string) fiber.Handler {
allowed := make(map[string]bool, len(owners))
for _, e := range owners {
if e = strings.ToLower(strings.TrimSpace(e)); e != "" {
allowed[e] = true
}
}
return func(c *fiber.Ctx) error {
email, _ := c.Locals("email").(string)
tenantID, tenantOK := c.Locals("tenantid").(int)
roleID, roleOK := c.Locals("roleid").(int)
if !tenantOK || !roleOK || tenantID != 0 || roleID != 1 || !allowed[strings.ToLower(strings.TrimSpace(email))] {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"success": false,
"message": "client onboarding is restricted to the designated onboarding account",
})
}
return c.Next()
}
}

21
middlewares/staff_only.go Normal file
View File

@@ -0,0 +1,21 @@
package middlewares
import "github.com/gofiber/fiber/v2"
// DoormileStaffOnly admits only Doormile's own console staff: a login whose
// token carries tenant 0. A partner-tenant login is refused with 403, not
// handed an empty result — an empty list would read as "nothing configured"
// and hide that the caller is in the wrong place.
//
// Must run after AuthMiddleware, which sets the tenantid local. A missing
// local is treated as not staff: fail closed.
func DoormileStaffOnly(c *fiber.Ctx) error {
tenantID, ok := c.Locals("tenantid").(int)
if !ok || tenantID != 0 {
return c.Status(fiber.StatusForbidden).JSON(fiber.Map{
"success": false,
"message": "available to Doormile staff only",
})
}
return c.Next()
}

View File

@@ -1,6 +1,7 @@
package migrations
import (
"doormile/internal/ai/registry"
"doormile/models"
"doormile/utils"
"gorm.io/gorm"
@@ -60,6 +61,18 @@ func Migrate(db *gorm.DB) error {
&models.BookingStageEvent{},
&models.CustomerRefreshToken{},
&models.CustomerDevice{},
// AI agent registry (agent-platform-plan Phase 1). Five new tables,
// nothing existing touched.
&models.AIAgent{},
&models.AITool{},
&models.AISkill{},
&models.AISkillTool{},
&models.AIRegistryAudit{},
// Agent runs recorded from AI_engine telemetry (plan Phase 4). One new
// append-only table, pruned after 30 days.
&models.AIAgentRun{},
)
if err != nil {
@@ -69,6 +82,13 @@ func Migrate(db *gorm.DB) error {
utils.Info("✅ Database migration completed successfully!")
// Upsert the code-defined agent registry. A failure is logged, not fatal:
// the registry is read by Agent Studio and AI_engine, and neither is on a
// booking's path, so it must not stop the API from serving orders.
if err := registry.Seed(db); err != nil {
utils.Error("⚠️ AI registry seed failed", "error", err.Error())
}
if res := db.Exec(`ALTER TABLE agent_decisions ADD COLUMN IF NOT EXISTS context_embedding vector(1536)`); res.Error != nil {
utils.Error("❌ Failed to add context_embedding column", "error", res.Error)
} else {

123
models/ai_registry.go Normal file
View File

@@ -0,0 +1,123 @@
package models
import "time"
// The AI agent registry: the one source of truth for which agents exist, which
// skills they run and which tools those skills may call. The console's Agent
// Studio reads it (and edits the few operator-owned columns); the AI_engine
// will read it too. Plan: krow_talent_app/docs/agent-platform-plan.md.
//
// Agents, tools and seeded skills are DEFINED IN CODE (internal/ai/registry)
// and upserted on every boot. Only the columns marked "operator-owned" below
// change at runtime, only by roleid 1, and every change writes an
// AIRegistryAudit row in the same transaction. A tool can never be invented
// from the console: a tool with no implementation behind it is a lie on screen.
//
// JSON documents are stored as jsonb in string fields, the same way
// AgentDecision stores its context — the controllers re-emit them as raw JSON.
// AIAgent is one agent, in AI_engine or in the console.
type AIAgent struct {
Agentid string `json:"agentid" gorm:"primaryKey;column:agentid;size:64"`
Name string `json:"name" gorm:"column:name;not null"`
Runtime string `json:"runtime" gorm:"column:runtime;size:20;not null"` // engine, console
Classref string `json:"classref" gorm:"column:classref"` // where it is implemented, file:line
Purpose string `json:"purpose" gorm:"column:purpose"`
// Wakeon is what makes the agent run: a NATS subject, a direct task, an
// operator prompt. Free text for people, not parsed.
Wakeon string `json:"wakeon" gorm:"column:wakeon"`
// Status is what the code does today, not what the docs claim:
// live, partial, simulation, broken, unmerged, retired.
Status string `json:"status" gorm:"column:status;size:20;not null"`
// Llmdecision names the model call the agent makes, empty when it makes none.
Llmdecision string `json:"llmdecision" gorm:"column:llmdecision"`
// Hasautonomygate is true for the agents whose writes are behind an
// autonomy switch in AI_engine (Dispatch, Exception, Express). Autonomy can
// only be set on these.
Hasautonomygate bool `json:"hasautonomygate" gorm:"column:hasautonomygate;default:false"`
Sortorder int `json:"sortorder" gorm:"column:sortorder;default:0"`
// Operator-owned.
Autonomous bool `json:"autonomous" gorm:"column:autonomous;default:false"`
Model string `json:"model" gorm:"column:model;size:64"` // empty = the engine's own default
Updatedby *int `json:"updatedby" gorm:"column:updatedby"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (AIAgent) TableName() string { return "aiagents" }
// AITool is one capability a skill may call. Entirely code-defined.
type AITool struct {
Toolname string `json:"toolname" gorm:"primaryKey;column:toolname;size:64"`
Description string `json:"description" gorm:"column:description;not null"`
// Kind is read, write or notify. A write or notify tool always requires
// confirmation unless its agent is autonomous.
Kind string `json:"kind" gorm:"column:kind;size:10;not null"`
Target string `json:"target" gorm:"column:target"` // the system it touches
Implementedat string `json:"implementedat" gorm:"column:implementedat"` // file:line today
Inputschema string `json:"-" gorm:"column:inputschema;type:jsonb"`
Requiresconfirmation bool `json:"requiresconfirmation" gorm:"column:requiresconfirmation;default:false"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (AITool) TableName() string { return "aitools" }
// AISkill is a named behaviour of one agent, using one or more tools.
type AISkill struct {
Skillid string `json:"skillid" gorm:"primaryKey;column:skillid;size:64"`
Agentid string `json:"agentid" gorm:"column:agentid;size:64;not null;index"`
Title string `json:"title" gorm:"column:title;not null"`
Category string `json:"category" gorm:"column:category;size:40"`
Description string `json:"description" gorm:"column:description"`
Sampleprompt string `json:"sampleprompt" gorm:"column:sampleprompt"`
// Source is engine, console (the ops-layer skills), or custom (created in
// Agent Studio). Custom skills are never touched by the seed.
Source string `json:"source" gorm:"column:source;size:20;not null"`
Thresholdsschema string `json:"-" gorm:"column:thresholdsschema;type:jsonb"`
// Operator-owned.
// No gorm default on purpose: with `default:true` gorm omits a false value
// from the INSERT and the database default wins, so a skill seeded OFF came
// up ON (caught by TestPGSkillSeededOffStaysOff). Every insert sets it.
Enabled bool `json:"enabled" gorm:"column:enabled;not null"`
Thresholds string `json:"-" gorm:"column:thresholds;type:jsonb"`
// Version increases on every operator change, so a consumer holding a copy
// can tell it is stale.
Version int `json:"version" gorm:"column:version;default:1"`
Updatedby *int `json:"updatedby" gorm:"column:updatedby"`
Createdat time.Time `json:"createdat" gorm:"column:createdat;default:CURRENT_TIMESTAMP"`
Updatedat time.Time `json:"updatedat" gorm:"column:updatedat;default:CURRENT_TIMESTAMP"`
}
func (AISkill) TableName() string { return "aiskills" }
// AISkillTool links a skill to a tool it may call.
type AISkillTool struct {
Skillid string `json:"skillid" gorm:"primaryKey;column:skillid;size:64"`
Toolname string `json:"toolname" gorm:"primaryKey;column:toolname;size:64"`
}
func (AISkillTool) TableName() string { return "aiskilltools" }
// AIRegistryAudit is one operator change to the registry. Written in the same
// transaction as the change, so a change without its audit row cannot exist.
type AIRegistryAudit struct {
Auditid int64 `json:"auditid" gorm:"primaryKey;column:auditid;autoIncrement"`
Entity string `json:"entity" gorm:"column:entity;size:20;not null;index:idx_airegistryaudit_entity"` // agent, skill
Entityid string `json:"entityid" gorm:"column:entityid;size:64;not null;index:idx_airegistryaudit_entity"`
Field string `json:"field" gorm:"column:field;size:40;not null"`
Oldvalue string `json:"-" gorm:"column:oldvalue;type:jsonb"`
Newvalue string `json:"-" gorm:"column:newvalue;type:jsonb"`
Changedby int `json:"changedby" gorm:"column:changedby;not null"`
// Changedbyemail is the login's email from its token. Recorded alongside
// the id because an admin login with no appusers row carries user id 0 —
// found in the Phase 2 end-to-end run, where every audit row said "0".
Changedbyemail string `json:"changedbyemail" gorm:"column:changedbyemail;size:255"`
Changedat time.Time `json:"changedat" gorm:"column:changedat;default:CURRENT_TIMESTAMP;index"`
}
func (AIRegistryAudit) TableName() string { return "airegistryaudit" }

35
models/ai_runs.go Normal file
View File

@@ -0,0 +1,35 @@
package models
import "time"
// AIAgentRun is one task an AI_engine agent finished, recorded from its
// `telemetry.task` event (internal/ai/telemetry). Phase 4 of
// krow_talent_app/docs/agent-platform-plan.md: before this, a run existed only
// as a log line and an ephemeral NATS message, so nothing could say how often
// an agent ran or how often it failed.
//
// Append-only, and pruned after 30 days. Live agent STATE (the 5-second
// `telemetry.agent` heartbeat) is deliberately not stored here — it is
// ephemeral and lives in Redis.
type AIAgentRun struct {
Runid int64 `json:"runid" gorm:"primaryKey;column:runid;autoIncrement"`
Agentid string `json:"agentid" gorm:"column:agentid;size:64;not null;index:idx_aiagentruns_agent_received,priority:1;uniqueIndex:uq_aiagentruns_agent_task,priority:1"`
// Taskid is the engine's task id. Null when the event carried none; the
// unique index then does not apply (Postgres treats NULLs as distinct), so
// a redelivered event with an id is stored once and one without is not lost.
Taskid *string `json:"taskid" gorm:"column:taskid;size:64;uniqueIndex:uq_aiagentruns_agent_task,priority:2"`
Tasktype string `json:"tasktype" gorm:"column:tasktype;size:64"`
Status string `json:"status" gorm:"column:status;size:20;not null"` // completed, failed
Error string `json:"error" gorm:"column:error;type:text"`
// Durationms is how long the task ran, as the engine measured it.
Durationms int `json:"durationms" gorm:"column:durationms"`
// Occurredat is the engine's own timestamp, kept for display only: it is a
// naive local time from whatever clock and zone the engine host runs.
// Windows and ordering use Receivedat, which is this backend's clock as a
// true instant (time.Now) — the column is timestamptz, so utils.DBNow's
// IST-digits-labelled-UTC value would be 5h30m off here.
Occurredat *time.Time `json:"occurredat" gorm:"column:occurredat"`
Receivedat time.Time `json:"receivedat" gorm:"column:receivedat;not null;index:idx_aiagentruns_agent_received,priority:2;index:idx_aiagentruns_received"`
}
func (AIAgentRun) TableName() string { return "aiagentruns" }

View File

@@ -326,6 +326,18 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
adminAuth.Get("/tenants/:id", controllers.GetTenantDetails)
adminAuth.Put("/tenants/:id", controllers.UpdateTenant)
adminAuth.Delete("/tenants/:id", controllers.DeleteTenant)
// Client onboarding: a tenant + its console login in one transaction. Only
// the CLIENT_ONBOARDING_OWNERS logins (default admin@doormile.com), and
// only as Doormile staff with roleid 1 — onboarding mints credentials.
onboarding := adminAuth.Group("/clients", middlewares.ClientOnboardingOwnerOnly(cfg.ClientOnboardingOwners))
onboarding.Post("/onboard", controllers.OnboardClient)
onboarding.Get("/onboarded", controllers.GetOnboardedClients)
onboarding.Get("/cities", controllers.GetOnboardingCities)
// Edit and remove a client's console login (:id = doormile_auth id). Remove
// deletes the login only and marks the client Inactive; history is kept.
onboarding.Put("/:id", controllers.UpdateOnboardedClient)
onboarding.Delete("/:id", controllers.DeleteOnboardedClient)
adminAuth.Get("/tenants/:id/locations", controllers.GetTenantLocations)
adminAuth.Post("/tenants/:id/locations", controllers.CreateTenantLocation)
adminAuth.Put("/tenantlocations/:id", controllers.UpdateTenantLocation)
@@ -437,6 +449,27 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
adminAuth.Get("/exceptions/:id", controllers.GetExceptionDetails)
adminAuth.Put("/exceptions/:id/status", controllers.ResolveException)
// AI agent registry (krow_talent_app/docs/agent-platform-plan.md, Phase 1).
// Doormile staff only — a partner-tenant login gets 403. Reads are open to
// roles 1/3/4; every write is roleid 1 only and is audited.
aiRegistry := adminAuth.Group("/ai", middlewares.DoormileStaffOnly)
aiRegistry.Get("/agents", controllers.GetAIAgents)
aiRegistry.Get("/agents/:id", controllers.GetAIAgent)
aiRegistry.Patch("/agents/:id", middlewares.RoleCheckMiddleware(1), controllers.PatchAIAgent)
aiRegistry.Get("/skills", controllers.GetAISkills)
aiRegistry.Post("/skills", middlewares.RoleCheckMiddleware(1), controllers.CreateAISkill)
aiRegistry.Patch("/skills/:id", middlewares.RoleCheckMiddleware(1), controllers.PatchAISkill)
aiRegistry.Get("/tools", controllers.GetAITools)
aiRegistry.Get("/audit", controllers.GetAIRegistryAudit)
// What the agents did (Phase 4): runs from AI_engine telemetry, decisions
// from agent_decisions, live heartbeat from Redis. Read-only.
aiRegistry.Get("/insights", controllers.GetAIInsights)
aiRegistry.Get("/decisions", controllers.GetAIDecisions)
// Test tab (Phase 6): one prompt through Claude with a skill's tools. Reads
// run redacted; writes only become proposals. Admin only — every run is a
// paid API call — and rate-limited per user in the handler.
aiRegistry.Post("/playground/run", middlewares.RoleCheckMiddleware(1), controllers.RunAIPlayground)
// --------------------
// HUB CONSOLE APIS
// --------------------
@@ -532,6 +565,8 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
internal.Post("/agent-decisions", controllers.CreateAgentDecision)
internal.Get("/agent-decisions/similar", controllers.FindSimilarDecisions)
internal.Patch("/agent-decisions/:id/outcome", controllers.UpdateDecisionOutcome)
// The agent registry, for AI_engine to poll (ETag / If-None-Match → 304).
internal.Get("/ai/registry", controllers.GetInternalAIRegistry)
// Express-batch dispatch: the ExpressDispatchAgent reads a tenant's riders
// and the batch's bookings, then writes back the assignments it decided.

View File

@@ -0,0 +1,151 @@
package routes_test
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
"doormile/db"
"doormile/internal/ai/registry"
"doormile/internal/testpg"
"doormile/models"
)
// End to end through the real router and real handlers, against a real
// Postgres. Skipped unless REGISTRY_TEST_DSN is set; the DSN must be a
// THROWAWAY database — the five registry tables are dropped and recreated.
// See internal/ai/registry/store_integration_test.go for how to start one.
func registryApp(t *testing.T) func(method, path, bearer, body string, headers ...string) (int, http.Header, map[string]any) {
t.Helper()
dsn := os.Getenv("REGISTRY_TEST_DSN")
if dsn == "" {
t.Skip("REGISTRY_TEST_DSN not set; skipping Postgres end-to-end test")
}
gdb := testpg.Open(t, dsn, "airegistry_routes_test")
all := []any{&models.AIRegistryAudit{}, &models.AISkillTool{}, &models.AISkill{}, &models.AITool{}, &models.AIAgent{}}
if err := gdb.Migrator().DropTable(all...); err != nil {
t.Fatal(err)
}
if err := gdb.AutoMigrate(all...); err != nil {
t.Fatal(err)
}
if err := registry.Seed(gdb); err != nil {
t.Fatal(err)
}
prev := db.DB
db.DB = gdb
t.Cleanup(func() { db.DB = prev })
app := newApp()
return func(method, path, bearer, body string, headers ...string) (int, http.Header, map[string]any) {
var req *http.Request
if body != "" {
req = httptest.NewRequest(method, path, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
} else {
req = httptest.NewRequest(method, path, nil)
}
if bearer != "" {
req.Header.Set("Authorization", "Bearer "+bearer)
}
for i := 0; i+1 < len(headers); i += 2 {
req.Header.Set(headers[i], headers[i+1])
}
resp, err := app.Test(req, int(10*time.Second/time.Millisecond))
if err != nil {
t.Fatalf("%s %s: %v", method, path, err)
}
defer resp.Body.Close()
var out map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
return resp.StatusCode, resp.Header, out
}
}
func TestPGRegistryEndToEnd(t *testing.T) {
call := registryApp(t)
admin := token(t, 1, 1)
// Read the inventory.
code, _, body := call(http.MethodGet, "/api/v1/admin/ai/agents", admin, "")
if code != http.StatusOK {
t.Fatalf("GET agents = %d %v", code, body)
}
if total, _ := body["total"].(float64); int(total) != len(registry.SeedAgents) {
t.Errorf("GET agents total = %v, want %d (body %v)", body["total"], len(registry.SeedAgents), body)
}
code, _, body = call(http.MethodGet, "/api/v1/admin/ai/skills?agent=CONSOLE_OPS_AGENT", token(t, 1, 3), "")
if code != http.StatusOK {
t.Fatalf("manager GET skills = %d %v", code, body)
}
// Patch a skill as admin: 200, version 2, thresholds as a JSON object.
code, _, body = call(http.MethodPatch, "/api/v1/admin/ai/skills/skill_doorstep_stall", admin, `{"enabled":false,"thresholds":{"arrivedStalledMin":30}}`)
if code != http.StatusOK {
t.Fatalf("PATCH skill = %d %v", code, body)
}
data, _ := body["data"].(map[string]any)
th, _ := data["thresholds"].(map[string]any)
if data["enabled"] != false || data["version"] != float64(2) || th["arrivedStalledMin"] != float64(30) {
t.Errorf("PATCH response = %v", data)
}
// A bad value is a 400 naming the problem, and changes nothing.
code, _, body = call(http.MethodPatch, "/api/v1/admin/ai/skills/skill_doorstep_stall", admin, `{"thresholds":{"arrivedStalledMin":999}}`)
if code != http.StatusBadRequest {
t.Errorf("out-of-range PATCH = %d %v, want 400", code, body)
}
code, _, _ = call(http.MethodPatch, "/api/v1/admin/ai/skills/nope", admin, `{"enabled":true}`)
if code != http.StatusNotFound {
t.Errorf("PATCH unknown skill = %d, want 404", code)
}
// Autonomy: refused without the typed confirmation, accepted with it.
code, _, _ = call(http.MethodPatch, "/api/v1/admin/ai/agents/EXCEPTION_AGENT", admin, `{"autonomous":true}`)
if code != http.StatusBadRequest {
t.Errorf("autonomy without confirm = %d, want 400", code)
}
code, _, body = call(http.MethodPatch, "/api/v1/admin/ai/agents/EXCEPTION_AGENT", admin, `{"autonomous":false,"model":"claude-haiku-4-5-20251001"}`)
if code != http.StatusOK {
t.Errorf("model PATCH = %d %v", code, body)
}
// Create a custom skill.
code, _, body = call(http.MethodPost, "/api/v1/admin/ai/skills", admin, `{"agentid":"CONSOLE_OPS_AGENT","title":"Night shift watch","tools":["scan_bookings"]}`)
if code != http.StatusCreated {
t.Fatalf("POST skill = %d %v", code, body)
}
// The audit shows all three changes.
code, _, body = call(http.MethodGet, "/api/v1/admin/ai/audit", admin, "")
if total, _ := body["total"].(float64); code != http.StatusOK || int(total) != 4 {
t.Errorf("audit = %d, total %v, want 4 rows (enabled, thresholds, model, created)", code, body["total"])
}
// Every row names who made the change, even when the login has no appusers row.
for _, row := range body["data"].([]any) {
if email := row.(map[string]any)["changedbyemail"]; email != "test@doormile.com" {
t.Errorf("audit row changedbyemail = %v, want the token's email", email)
}
}
// The engine's endpoint: 200 with an ETag, then 304 for the same tag.
t.Setenv("INTERNAL_API_KEY", "engine-key")
code, hdr, body := call(http.MethodGet, "/api/v1/internal/ai/registry", "", "", "X-Internal-Key", "engine-key")
tag := hdr.Get("ETag")
if code != http.StatusOK || tag == "" {
t.Fatalf("internal registry = %d, etag %q", code, tag)
}
if snap, _ := body["data"].(map[string]any); len(snap["agents"].([]any)) != len(registry.SeedAgents) {
t.Errorf("internal registry agents = %v", len(snap["agents"].([]any)))
}
code, _, _ = call(http.MethodGet, "/api/v1/internal/ai/registry", "", "", "X-Internal-Key", "engine-key", "If-None-Match", tag)
if code != http.StatusNotModified {
t.Errorf("If-None-Match with the current tag = %d, want 304", code)
}
}

View File

@@ -0,0 +1,190 @@
package routes_test
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"doormile/controllers"
"doormile/internal/ai/playground"
"doormile/utils"
)
// The AI agent registry's gates, over real HTTP (see routes_logistics_test.go
// for what this style of test does and does not prove). Every refusal below
// happens in middleware, before a handler could touch the database.
func tenantToken(t *testing.T, userID, roleID, tenantID int) string {
t.Helper()
tok, err := utils.GenerateToken(userID, "client@partner.example", roleID, tenantID, 1001, jwtSecret)
if err != nil {
t.Fatalf("could not mint a tenant token: %v", err)
}
return tok
}
var aiReads = []string{
"/api/v1/admin/ai/agents",
"/api/v1/admin/ai/agents/EXCEPTION_AGENT",
"/api/v1/admin/ai/skills",
"/api/v1/admin/ai/tools",
"/api/v1/admin/ai/audit",
"/api/v1/admin/ai/insights",
"/api/v1/admin/ai/insights?days=30",
"/api/v1/admin/ai/decisions",
}
var aiWrites = []struct{ method, path, body string }{
{http.MethodPatch, "/api/v1/admin/ai/skills/skill_sla_guardian", `{"enabled":false}`},
{http.MethodPost, "/api/v1/admin/ai/skills", `{"agentid":"CONSOLE_OPS_AGENT","title":"x","tools":["scan_bookings"]}`},
{http.MethodPatch, "/api/v1/admin/ai/agents/EXCEPTION_AGENT", `{"autonomous":true,"confirm":"EXCEPTION_AGENT"}`},
{http.MethodPost, "/api/v1/admin/ai/playground/run", `{"agentid":"EXCEPTION_AGENT","prompt":"hi"}`},
}
func TestAIRegistryRequiresALogin(t *testing.T) {
app := newApp()
for _, p := range aiReads {
if code, _ := do(t, app, http.MethodGet, p, "", ""); code != http.StatusUnauthorized {
t.Errorf("GET %s with no token = %d, want 401", p, code)
}
}
}
func TestAIRegistryRefusesNonConsoleRoles(t *testing.T) {
app := newApp()
for _, role := range []int{5, 6, 9} { // miler, hub staff, customer
for _, p := range aiReads {
if code, _ := do(t, app, http.MethodGet, p, token(t, 1, role), ""); code != http.StatusForbidden {
t.Errorf("role %d GET %s = %d, want 403", role, p, code)
}
}
}
}
// A partner-tenant login is refused outright — even an admin-role one — rather
// than shown an empty registry.
func TestAIRegistryRefusesPartnerTenantLogins(t *testing.T) {
app := newApp()
tok := tenantToken(t, 50, 1, 7)
for _, p := range aiReads {
code, body := do(t, app, http.MethodGet, p, tok, "")
if code != http.StatusForbidden {
t.Errorf("tenant GET %s = %d, want 403", p, code)
}
if code == http.StatusForbidden && !strings.Contains(body, "Doormile staff only") {
t.Errorf("tenant GET %s refused with the wrong message: %s", p, body)
}
}
for _, w := range aiWrites {
if code, _ := do(t, app, w.method, w.path, tok, w.body); code != http.StatusForbidden {
t.Errorf("tenant %s %s = %d, want 403", w.method, w.path, code)
}
}
}
// Managers (3) and executives (4) may read the registry but not change it.
func TestAIRegistryWritesAreAdminOnly(t *testing.T) {
app := newApp()
for _, role := range []int{3, 4} {
for _, w := range aiWrites {
code, body := do(t, app, w.method, w.path, token(t, 1, role), w.body)
if code != http.StatusForbidden {
t.Errorf("role %d %s %s = %d, want 403", role, w.method, w.path, code)
}
if code == http.StatusForbidden && !strings.Contains(body, "insufficient permissions") {
t.Errorf("role %d %s %s refused by the wrong gate: %s", role, w.method, w.path, body)
}
}
}
}
// Doormile staff with roleid 1 get through every gate. There is no database
// in this test, so the handler's first query panics and recover answers 500 —
// which is the proof the route exists and nothing in front of it refused.
func TestAIRegistryAdminPassesEveryGate(t *testing.T) {
app := newApp()
tok := token(t, 1, 1)
for _, p := range aiReads {
if code, _ := do(t, app, http.MethodGet, p, tok, ""); code == 401 || code == 403 || code == 404 {
t.Errorf("admin GET %s = %d; a gate refused or the route is missing", p, code)
}
}
for _, w := range aiWrites {
if code, _ := do(t, app, w.method, w.path, tok, w.body); code == 401 || code == 403 || code == 404 {
t.Errorf("admin %s %s = %d; a gate refused or the route is missing", w.method, w.path, code)
}
}
// Read roles get through the read gates too.
for _, role := range []int{3, 4} {
if code, _ := do(t, app, http.MethodGet, "/api/v1/admin/ai/agents", token(t, 1, role), ""); code == 401 || code == 403 {
t.Errorf("role %d was refused a registry read (%d)", role, code)
}
}
}
func TestInternalRegistryNeedsTheInternalKey(t *testing.T) {
t.Setenv("INTERNAL_API_KEY", "engine-key-for-tests")
app := newApp()
for _, key := range []string{"", "wrong-key"} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/internal/ai/registry", nil)
if key != "" {
req.Header.Set("X-Internal-Key", key)
}
resp, err := app.Test(req, int(10*time.Second/time.Millisecond))
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusUnauthorized {
t.Errorf("internal registry with key %q = %d, want 401", key, resp.StatusCode)
}
}
// A console JWT is not an internal key.
if code, _ := do(t, app, http.MethodGet, "/api/v1/internal/ai/registry", token(t, 1, 1), ""); code != http.StatusUnauthorized {
t.Errorf("internal registry with an admin JWT = %d, want 401", code)
}
}
// The Test playground (Phase 6). With no model client wired the endpoint
// says so plainly (503 with a code the console branches on) — it never
// pretends to run. With one wired, bad input is refused before any database
// or model call.
func TestAIPlaygroundWithoutAClientIs503(t *testing.T) {
app := newApp()
prev := controllers.PlaygroundModel
controllers.PlaygroundModel = nil
defer func() { controllers.PlaygroundModel = prev }()
code, body := do(t, app, http.MethodPost, "/api/v1/admin/ai/playground/run", token(t, 1, 1),
`{"agentid":"EXCEPTION_AGENT","prompt":"hi"}`)
if code != http.StatusServiceUnavailable || !strings.Contains(body, "PLAYGROUND_NOT_CONFIGURED") {
t.Fatalf("no client: %d %s, want 503 PLAYGROUND_NOT_CONFIGURED", code, body)
}
}
type noCallModel struct{ t *testing.T }
func (m noCallModel) Next(context.Context, playground.Request) (playground.Reply, error) {
m.t.Fatal("the model was called for a request that should have been refused")
return playground.Reply{}, nil
}
func TestAIPlaygroundRefusesBadInput(t *testing.T) {
app := newApp()
prev := controllers.PlaygroundModel
controllers.PlaygroundModel = noCallModel{t}
defer func() { controllers.PlaygroundModel = prev }()
for _, b := range []string{
`{"agentid":"EXCEPTION_AGENT","prompt":" "}`,
`{"prompt":"hi"}`,
`{"agentid":"EXCEPTION_AGENT","prompt":"` + strings.Repeat("x", 2001) + `"}`,
`not json`,
} {
if code, body := do(t, app, http.MethodPost, "/api/v1/admin/ai/playground/run", token(t, 1, 1), b); code != http.StatusBadRequest {
t.Errorf("body %.40q = %d %s, want 400", b, code, body)
}
}
}

View File

@@ -0,0 +1,105 @@
package routes_test
import (
"net/http"
"strings"
"testing"
"doormile/config"
"doormile/routes"
"doormile/utils"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/recover"
)
// Client onboarding's gate, over real HTTP. Only the configured owner login —
// as Doormile staff with roleid 1 — reaches the handler; everyone else is
// refused in middleware, before any database access.
const onboardingOwner = "admin@doormile.com"
var onboardingRoutes = []struct{ method, path, body string }{
{http.MethodPost, "/api/v1/admin/clients/onboard", `{"companyname":"Acme"}`},
{http.MethodGet, "/api/v1/admin/clients/onboarded", ""},
{http.MethodGet, "/api/v1/admin/clients/cities", ""},
{http.MethodPut, "/api/v1/admin/clients/5", `{"status":"Inactive"}`},
{http.MethodDelete, "/api/v1/admin/clients/5", ""},
}
func onboardingApp() *fiber.App {
app := fiber.New()
app.Use(recover.New())
routes.RegisterRoutes(app, &config.Config{JWTSecret: jwtSecret, ClientOnboardingOwners: []string{onboardingOwner}})
return app
}
func consoleToken(t *testing.T, email string, roleID, tenantID int) string {
t.Helper()
tok, err := utils.GenerateToken(1, email, roleID, tenantID, 1, jwtSecret)
if err != nil {
t.Fatalf("mint token: %v", err)
}
return tok
}
func TestClientOnboardingNeedsALogin(t *testing.T) {
app := onboardingApp()
for _, r := range onboardingRoutes {
if code, _ := do(t, app, r.method, r.path, "", r.body); code != http.StatusUnauthorized {
t.Errorf("%s %s with no token = %d, want 401", r.method, r.path, code)
}
}
}
func TestClientOnboardingRefusesEveryoneButTheOwner(t *testing.T) {
app := onboardingApp()
cases := []struct {
name string
token string
}{
{"another Doormile admin", consoleToken(t, "suriya@doormile.com", 1, 0)},
{"owner email as a manager", consoleToken(t, onboardingOwner, 3, 0)},
{"owner email as an executive", consoleToken(t, onboardingOwner, 4, 0)},
{"owner email on a client tenant", consoleToken(t, onboardingOwner, 1, 7)},
{"a client login", consoleToken(t, "ops@acme.example", 3, 7)},
{"a miler", consoleToken(t, onboardingOwner, 5, 0)},
{"a customer", consoleToken(t, onboardingOwner, 9, 0)},
}
for _, c := range cases {
for _, r := range onboardingRoutes {
code, body := do(t, app, r.method, r.path, c.token, r.body)
if code != http.StatusForbidden {
t.Errorf("%s: %s %s = %d, want 403", c.name, r.method, r.path, code)
}
if strings.Contains(body, onboardingOwner) {
t.Errorf("%s: the refusal leaked the owner email: %s", c.name, body)
}
}
}
}
// With no owners configured, nobody gets in — not even admin@doormile.com.
func TestClientOnboardingFailsClosedWithoutOwners(t *testing.T) {
app := newApp() // config without ClientOnboardingOwners
for _, r := range onboardingRoutes {
if code, _ := do(t, app, r.method, r.path, consoleToken(t, onboardingOwner, 1, 0), r.body); code != http.StatusForbidden {
t.Errorf("%s %s with no owners configured = %d, want 403", r.method, r.path, code)
}
}
}
// The owner passes every gate. There is no database here, so the handler's
// first query panics and recover answers 500 — proof that the route exists
// and nothing in front of it refused. Email matching ignores case.
func TestClientOnboardingOwnerPassesTheGate(t *testing.T) {
app := onboardingApp()
for _, email := range []string{onboardingOwner, "Admin@Doormile.com"} {
tok := consoleToken(t, email, 1, 0)
for _, r := range onboardingRoutes {
if code, _ := do(t, app, r.method, r.path, tok, r.body); code == 401 || code == 403 || code == 404 {
t.Errorf("owner %s %s %s = %d; a gate refused or the route is missing", email, r.method, r.path, code)
}
}
}
}