updates on the ai and agent and all thse things awith onboarding
This commit is contained in:
@@ -2,6 +2,7 @@ package config
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
@@ -49,6 +50,20 @@ type Config struct {
|
|||||||
SMTPUser string
|
SMTPUser string
|
||||||
SMTPPassword string
|
SMTPPassword string
|
||||||
SMTPFrom 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 {
|
func Load() *Config {
|
||||||
@@ -79,12 +94,52 @@ func Load() *Config {
|
|||||||
SMTPUser: getEnv("SMTP_USER", ""),
|
SMTPUser: getEnv("SMTP_USER", ""),
|
||||||
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
SMTPPassword: getEnv("SMTP_PASSWORD", ""),
|
||||||
SMTPFrom: getEnv("SMTP_FROM", ""),
|
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 {
|
func getEnv(key, fallback string) string {
|
||||||
if v := os.Getenv(key); v != "" {
|
if v := os.Getenv(key); v != "" {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return fallback
|
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
|
// 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.
|
// setting — an empty DB host is a service that cannot start with no clue why.
|
||||||
func TestEmptyEnvFallsBackToTheDefault(t *testing.T) {
|
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/config"
|
||||||
"doormile/controllers"
|
"doormile/controllers"
|
||||||
"doormile/db"
|
"doormile/db"
|
||||||
|
"doormile/internal/ai/playground"
|
||||||
|
"doormile/internal/ai/telemetry"
|
||||||
"doormile/internal/assignment"
|
"doormile/internal/assignment"
|
||||||
"doormile/internal/notify"
|
"doormile/internal/notify"
|
||||||
"doormile/internal/routing"
|
"doormile/internal/routing"
|
||||||
@@ -85,6 +87,14 @@ func main() {
|
|||||||
_ = godotenv.Load()
|
_ = godotenv.Load()
|
||||||
cfg := config.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...")
|
utils.Info("Starting Doormile Backend...")
|
||||||
|
|
||||||
// 2. Connect to Postgres, Redis & NATS
|
// 2. Connect to Postgres, Redis & NATS
|
||||||
@@ -220,6 +230,20 @@ func main() {
|
|||||||
// 9. Point the stop sequencer at the Route Optimization API.
|
// 9. Point the stop sequencer at the Route Optimization API.
|
||||||
routing.BaseURL = cfg.RouteOptimizerURL
|
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
|
// 7. Startup server in a background thread
|
||||||
go func() {
|
go func() {
|
||||||
utils.Info("Server starting", "port", cfg.Port)
|
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
|
package migrations
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"doormile/internal/ai/registry"
|
||||||
"doormile/models"
|
"doormile/models"
|
||||||
"doormile/utils"
|
"doormile/utils"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
@@ -60,6 +61,18 @@ func Migrate(db *gorm.DB) error {
|
|||||||
&models.BookingStageEvent{},
|
&models.BookingStageEvent{},
|
||||||
&models.CustomerRefreshToken{},
|
&models.CustomerRefreshToken{},
|
||||||
&models.CustomerDevice{},
|
&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 {
|
if err != nil {
|
||||||
@@ -69,6 +82,13 @@ func Migrate(db *gorm.DB) error {
|
|||||||
|
|
||||||
utils.Info("✅ Database migration completed successfully!")
|
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 {
|
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)
|
utils.Error("❌ Failed to add context_embedding column", "error", res.Error)
|
||||||
} else {
|
} 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.Get("/tenants/:id", controllers.GetTenantDetails)
|
||||||
adminAuth.Put("/tenants/:id", controllers.UpdateTenant)
|
adminAuth.Put("/tenants/:id", controllers.UpdateTenant)
|
||||||
adminAuth.Delete("/tenants/:id", controllers.DeleteTenant)
|
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.Get("/tenants/:id/locations", controllers.GetTenantLocations)
|
||||||
adminAuth.Post("/tenants/:id/locations", controllers.CreateTenantLocation)
|
adminAuth.Post("/tenants/:id/locations", controllers.CreateTenantLocation)
|
||||||
adminAuth.Put("/tenantlocations/:id", controllers.UpdateTenantLocation)
|
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.Get("/exceptions/:id", controllers.GetExceptionDetails)
|
||||||
adminAuth.Put("/exceptions/:id/status", controllers.ResolveException)
|
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
|
// HUB CONSOLE APIS
|
||||||
// --------------------
|
// --------------------
|
||||||
@@ -532,6 +565,8 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
|||||||
internal.Post("/agent-decisions", controllers.CreateAgentDecision)
|
internal.Post("/agent-decisions", controllers.CreateAgentDecision)
|
||||||
internal.Get("/agent-decisions/similar", controllers.FindSimilarDecisions)
|
internal.Get("/agent-decisions/similar", controllers.FindSimilarDecisions)
|
||||||
internal.Patch("/agent-decisions/:id/outcome", controllers.UpdateDecisionOutcome)
|
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
|
// Express-batch dispatch: the ExpressDispatchAgent reads a tenant's riders
|
||||||
// and the batch's bookings, then writes back the assignments it decided.
|
// 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