updates on the ai and agent and all thse things awith onboarding
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
64
controllers/aiInsightsController.go
Normal file
64
controllers/aiInsightsController.go
Normal 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)))
|
||||
}
|
||||
127
controllers/aiPlaygroundController.go
Normal file
127
controllers/aiPlaygroundController.go
Normal 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)
|
||||
}
|
||||
215
controllers/aiRegistryController.go
Normal file
215
controllers/aiRegistryController.go
Normal 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)
|
||||
}
|
||||
566
controllers/clientOnboardingController.go
Normal file
566
controllers/clientOnboardingController.go
Normal 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)))
|
||||
}
|
||||
106
controllers/clientOnboarding_test.go
Normal file
106
controllers/clientOnboarding_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
1877
docs/customer-app-api-reference.md
Normal file
1877
docs/customer-app-api-reference.md
Normal file
File diff suppressed because it is too large
Load Diff
238
internal/ai/playground/openai_compat.go
Normal file
238
internal/ai/playground/openai_compat.go
Normal 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)
|
||||
}
|
||||
130
internal/ai/playground/openai_compat_test.go
Normal file
130
internal/ai/playground/openai_compat_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
341
internal/ai/playground/playground.go
Normal file
341
internal/ai/playground/playground.go
Normal 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
|
||||
}
|
||||
280
internal/ai/playground/playground_test.go
Normal file
280
internal/ai/playground/playground_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
78
internal/ai/playground/redact.go
Normal file
78
internal/ai/playground/redact.go
Normal 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
|
||||
}
|
||||
178
internal/ai/playground/tools.go
Normal file
178
internal/ai/playground/tools.go
Normal 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
|
||||
}
|
||||
}
|
||||
82
internal/ai/playground/tools_integration_test.go
Normal file
82
internal/ai/playground/tools_integration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
480
internal/ai/registry/registry_test.go
Normal file
480
internal/ai/registry/registry_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
134
internal/ai/registry/rules.go
Normal file
134
internal/ai/registry/rules.go
Normal 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
|
||||
}
|
||||
304
internal/ai/registry/seed.go
Normal file
304
internal/ai/registry/seed.go
Normal 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"}},
|
||||
}
|
||||
389
internal/ai/registry/store.go
Normal file
389
internal/ai/registry/store.go
Normal 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)
|
||||
}
|
||||
280
internal/ai/registry/store_integration_test.go
Normal file
280
internal/ai/registry/store_integration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
152
internal/ai/registry/thresholds.go
Normal file
152
internal/ai/registry/thresholds.go
Normal 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)
|
||||
}
|
||||
228
internal/ai/telemetry/insights.go
Normal file
228
internal/ai/telemetry/insights.go
Normal 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
|
||||
}
|
||||
146
internal/ai/telemetry/parse.go
Normal file
146
internal/ai/telemetry/parse.go
Normal 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 }
|
||||
169
internal/ai/telemetry/recorder.go
Normal file
169
internal/ai/telemetry/recorder.go
Normal 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
|
||||
135
internal/ai/telemetry/store_integration_test.go
Normal file
135
internal/ai/telemetry/store_integration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
177
internal/ai/telemetry/telemetry_test.go
Normal file
177
internal/ai/telemetry/telemetry_test.go
Normal 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
48
internal/testpg/testpg.go
Normal 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
24
main.go
@@ -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)
|
||||
|
||||
39
middlewares/onboarding_owner.go
Normal file
39
middlewares/onboarding_owner.go
Normal 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
21
middlewares/staff_only.go
Normal 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()
|
||||
}
|
||||
@@ -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
123
models/ai_registry.go
Normal 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
35
models/ai_runs.go
Normal 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" }
|
||||
@@ -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.
|
||||
|
||||
151
routes/routes_ai_registry_pg_test.go
Normal file
151
routes/routes_ai_registry_pg_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
190
routes/routes_ai_registry_test.go
Normal file
190
routes/routes_ai_registry_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
105
routes/routes_client_onboarding_test.go
Normal file
105
routes/routes_client_onboarding_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user