Admin-gated Settings: runtime model selection, enforce is_admin (#79)
Moves the agent model out of env-only config into an admin-editable Settings
section, and enforces is_admin for the first time — it has been in the schema
since migration 0001, plumbed to the client, and checked nowhere.
Backend:
- Migration 0010: instance_settings, a single-row (CHECK id=1) table — pansy's
first instance-level state. Holds agent_model ('' = inherit env) and
agent_enabled (NULL = inherit env), version-guarded like every mutable row.
SECRETS STAY IN ENV: OLLAMA_CLOUD_API_KEY is never stored here.
- requireAdmin at the service seam (authoritative) plus a cheap middleware
early-403. Non-admin gets 403, not 404 — settings existence isn't masked.
- EffectiveAgent resolves DB-over-env (model, enabled); key always from env.
- The live Runner is hot-swapped, not built once. agentHolder holds it behind
an atomic.Pointer; the chat routes are now registered UNCONDITIONALLY and
nil-check agent.get(), so a settings change turns the assistant on/off/onto a
new model with no restart and no race against in-flight readers. /capabilities
reads the pointer, so it reports what's live, not what booted.
- internal/agentmodel is a new leaf package holding the one place that knows how
to turn a spec into a model. Both agent (to run) and service (to validate a
spec before storing it) import it; it can't live in agent, which imports
service. Settings PATCH validates the spec via Parse, so a typo is a 400 now
rather than a broken assistant on the next turn.
Frontend:
- /settings route (admin guard), a Settings page (model field, tri-state
enabled, live status), nav link shown only to admins.
- useCapabilities drops staleTime:Infinity — the assistant can now change under
a running page — and the settings save invalidates it.
Contract change: chat routes always exist, so "assistant off" is a runtime 503
+ capabilities:false, not a missing route. Updated the test that asserted the
old shape.
Verified live against the built binary: disable flips capabilities to false and
logs it; re-enable with a new model swaps it back; a bad spec is rejected 400;
the setting persists across a restart. Swap is race-clean under `go test -race`.
Docs: README (precedence + key-stays-in-env), DESIGN (decision + routes),
CLAUDE (don't re-add conditional route registration; key never in the DB).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
+35
-38
@@ -6,13 +6,13 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
sloggin "github.com/samber/slog-gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
@@ -23,9 +23,12 @@ type handlers struct {
|
||||
cfg *config.Config
|
||||
svc *service.Service
|
||||
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
|
||||
// agent is nil unless the assistant is configured; the chat routes are only
|
||||
// registered when it isn't, so a handler never has to check.
|
||||
agent *agent.Runner
|
||||
// agent holds the live Runner behind an atomic pointer. Unlike oidc it is
|
||||
// never nil — the holder is always present and its Runner may be nil when the
|
||||
// assistant is off. The chat routes are registered unconditionally and
|
||||
// nil-check agent.get(), so a settings change can turn the assistant on or off
|
||||
// at runtime (#79) rather than only at boot.
|
||||
agent *agentHolder
|
||||
}
|
||||
|
||||
// New builds the gin engine with the standard middleware stack and registers the
|
||||
@@ -126,37 +129,30 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
plantings.PATCH("/:id", h.updatePlanting)
|
||||
plantings.DELETE("/:id", h.deletePlanting)
|
||||
|
||||
// The garden assistant, registered only when it can actually be offered —
|
||||
// the same shape as OIDC. An instance with no API key serves the app
|
||||
// normally and simply doesn't have these routes.
|
||||
if !cfg.Agent.Ready() {
|
||||
// Say WHY, at startup, in the logs an operator is already looking at.
|
||||
// Someone who set the key and sees no assistant otherwise has nothing to
|
||||
// check — and "is the variable reaching the container?" is exactly the
|
||||
// question they need answered.
|
||||
slog.Info("api: garden assistant disabled",
|
||||
"enabled", cfg.Agent.Enabled,
|
||||
"hasApiKey", cfg.Agent.OllamaCloudAPIKey != "",
|
||||
"model", cfg.Agent.Model,
|
||||
"hint", "needs OLLAMA_CLOUD_API_KEY set in the container's environment (not just the stack's)")
|
||||
}
|
||||
if cfg.Agent.Ready() {
|
||||
runner, err := agent.NewRunner(svc, cfg)
|
||||
if err != nil {
|
||||
// Configured but unusable (an unresolvable model spec, say). Log it and
|
||||
// carry on without the assistant rather than refusing to start: a
|
||||
// garden planner that won't boot because of a chat feature is worse
|
||||
// than one without chat.
|
||||
slog.Error("api: garden assistant disabled", "error", err)
|
||||
} else {
|
||||
h.agent = runner
|
||||
agentGroup := v1.Group("/agent", h.requireAuth())
|
||||
agentGroup.POST("/chat", h.agentChat)
|
||||
gardens.GET("/:id/agent/history", h.getAgentHistory)
|
||||
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
|
||||
slog.Info("api: garden assistant enabled", "model", cfg.Agent.Model)
|
||||
}
|
||||
// The garden assistant. Its routes are registered UNCONDITIONALLY and the live
|
||||
// Runner sits behind an atomic pointer in the holder, so a settings change can
|
||||
// turn the assistant on or off at runtime (#79). Each handler nil-checks
|
||||
// agent.get(); a request while the assistant is off gets a clean 404/"not
|
||||
// available", not a panic and not a missing route.
|
||||
//
|
||||
// The holder resolves its initial Runner from settings + environment at
|
||||
// construction. If the key never reaches the container, the assistant is off
|
||||
// and the reason is logged below — the same operability need #72 added.
|
||||
h.agent = newAgentHolder(context.Background(), svc, cfg.Agent.OllamaCloudAPIKey)
|
||||
if cfg.Agent.OllamaCloudAPIKey == "" {
|
||||
slog.Info("api: garden assistant has no API key",
|
||||
"hint", "set OLLAMA_CLOUD_API_KEY in the container's environment (not just the stack's); the model can be chosen in Settings")
|
||||
}
|
||||
agentGroup := v1.Group("/agent", h.requireAuth())
|
||||
agentGroup.POST("/chat", h.agentChat)
|
||||
gardens.GET("/:id/agent/history", h.getAgentHistory)
|
||||
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
|
||||
|
||||
// Instance settings: admin-only, and the first thing to enforce is_admin.
|
||||
// requireAdmin runs after requireAuth (it reads the actor requireAuth stored).
|
||||
settings := v1.Group("/settings", h.requireAuth(), h.requireAdmin())
|
||||
settings.GET("", h.getSettings)
|
||||
settings.PATCH("", h.updateSettings)
|
||||
|
||||
// Undo. A change set is addressed by its own id; the service resolves the
|
||||
// owning garden for the permission check, same as objects and plantings.
|
||||
@@ -198,11 +194,12 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
// capabilities reports what this instance can actually do, so the UI offers only
|
||||
// what works.
|
||||
//
|
||||
// It reports whether the runner BUILT, not whether it was configured: a
|
||||
// configured-but-unresolvable model leaves the routes unregistered, and saying
|
||||
// "yes" there would offer a chat tab whose first message 404s.
|
||||
// It reports whether the assistant is live RIGHT NOW, not merely configured:
|
||||
// the chat routes always exist, but a request while the Runner is nil is refused,
|
||||
// so offering the tab must track the live Runner. Reading agent.get() (an atomic
|
||||
// load) means this reflects a settings-driven swap on the very next poll.
|
||||
func (h *handlers) capabilities(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"agent": h.agent != nil})
|
||||
c.JSON(http.StatusOK, gin.H{"agent": h.agent.get() != nil})
|
||||
}
|
||||
|
||||
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
|
||||
|
||||
Reference in New Issue
Block a user