diff --git a/controllers/aiRegistryController.go b/controllers/aiRegistryController.go index 4f397fd..4bc9027 100644 --- a/controllers/aiRegistryController.go +++ b/controllers/aiRegistryController.go @@ -205,6 +205,9 @@ func GetInternalAIRegistry(c *fiber.Ctx) error { if !ok { return nil } + // Proof the engine is following the registry — see GetAIStatus. Counted + // for 304s too: an unchanged registry is still a successful read. + markRegistryRead() tag := registry.ETag(snap) c.Set(fiber.HeaderETag, tag) c.Set(fiber.HeaderCacheControl, "no-cache") diff --git a/controllers/aiStatusController.go b/controllers/aiStatusController.go new file mode 100644 index 0000000..01c471c --- /dev/null +++ b/controllers/aiStatusController.go @@ -0,0 +1,100 @@ +package controllers + +import ( + "context" + "strconv" + "sync/atomic" + "time" + + "doormile/db" + "doormile/internal/ai/telemetry" + "doormile/models" + "doormile/utils" + + "github.com/gofiber/fiber/v2" +) + +// GET /admin/ai/status — what is actually wired, for the banner on Settings → +// Skills & Tools. Replaces a fixed "Half wired" note that stayed on screen +// after everything was deployed, because nothing on the page checked. +// +// "AI_engine reads these settings" is observed, not assumed: every poll of +// GET /internal/ai/registry (a 200 or a 304) stamps the time in Redis, shared by +// every backend replica. The engine polls about every 30 seconds, so a stamp +// younger than engineReadFreshFor means it is following the registry now. + +const ( + registryReadKey = "ai:registry:lastread" + engineReadFreshFor = 5 * time.Minute +) + +// lastRegistryRead is the in-process copy, used when Redis is unavailable. +var lastRegistryRead atomic.Int64 + +// markRegistryRead records an AI_engine registry poll. Best effort: a Redis +// failure must never fail the poll itself. +func markRegistryRead() { + now := time.Now().Unix() + lastRegistryRead.Store(now) + if db.Rdb == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + _ = db.Rdb.Set(ctx, registryReadKey, now, 30*time.Minute).Err() +} + +// registryLastRead returns the latest poll time seen by any replica, or nil. +func registryLastRead() *time.Time { + secs := lastRegistryRead.Load() + if db.Rdb != nil { + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + if v, err := db.Rdb.Get(ctx, registryReadKey).Result(); err == nil { + if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > secs { + secs = n + } + } + } + if secs == 0 { + return nil + } + t := time.Unix(secs, 0) + return &t +} + +type aiStatus struct { + Engine struct { + ReadingSettings bool `json:"readingsettings"` + LastReadAt *time.Time `json:"lastreadat"` + Telemetry bool `json:"telemetry"` + LiveAgents int `json:"liveagents"` + } `json:"engine"` + Playground struct { + Configured bool `json:"configured"` + Model string `json:"model,omitempty"` + } `json:"playground"` +} + +// GetAIStatus — GET /admin/ai/status +func GetAIStatus(c *fiber.Ctx) error { + var s aiStatus + + s.Engine.LastReadAt = registryLastRead() + s.Engine.ReadingSettings = s.Engine.LastReadAt != nil && time.Since(*s.Engine.LastReadAt) < engineReadFreshFor + s.Engine.Telemetry = telemetry.Receiving.Load() + + var engineAgents []string + if db.DB != nil { + db.DB.Model(&models.AIAgent{}).Where("runtime = ?", "engine").Pluck("agentid", &engineAgents) + } + s.Engine.LiveAgents = len(telemetry.LiveStates(db.Rdb, engineAgents)) + + if PlaygroundModel != nil { + s.Playground.Configured = true + if named, ok := PlaygroundModel.(interface{ ModelName() string }); ok { + s.Playground.Model = named.ModelName() + } + } + return utils.OK(c, s) +} diff --git a/controllers/aiStatus_test.go b/controllers/aiStatus_test.go new file mode 100644 index 0000000..3c5243e --- /dev/null +++ b/controllers/aiStatus_test.go @@ -0,0 +1,20 @@ +package controllers + +import ( + "testing" + "time" +) + +// Without Redis (as in this test binary) the in-process stamp still works, so +// a single replica reports the engine's reads correctly. +func TestRegistryReadStampWithoutRedis(t *testing.T) { + lastRegistryRead.Store(0) + if registryLastRead() != nil { + t.Fatal("no read yet must report nil") + } + markRegistryRead() + got := registryLastRead() + if got == nil || time.Since(*got) > 5*time.Second { + t.Fatalf("stamp not recorded: %v", got) + } +} diff --git a/routes/routes.go b/routes/routes.go index f123b09..c856f3c 100644 --- a/routes/routes.go +++ b/routes/routes.go @@ -465,6 +465,9 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) { // from agent_decisions, live heartbeat from Redis. Read-only. aiRegistry.Get("/insights", controllers.GetAIInsights) aiRegistry.Get("/decisions", controllers.GetAIDecisions) + // What is actually wired (engine reading the registry, Test tab model), for + // the Skills & Tools banner. + aiRegistry.Get("/status", controllers.GetAIStatus) // 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. diff --git a/routes/routes_ai_registry_test.go b/routes/routes_ai_registry_test.go index 3071116..0c0eed7 100644 --- a/routes/routes_ai_registry_test.go +++ b/routes/routes_ai_registry_test.go @@ -35,6 +35,7 @@ var aiReads = []string{ "/api/v1/admin/ai/insights", "/api/v1/admin/ai/insights?days=30", "/api/v1/admin/ai/decisions", + "/api/v1/admin/ai/status", } var aiWrites = []struct{ method, path, body string }{