- Garden and plant dialogs keep centimeters as the source of truth (LengthField in lib/units.ts): a no-change Save no longer rewrites 900 cm as 899.922 or a 45 cm spacing as 44.958, bumping versions and writing bogus history entries on the way. - The UI stamps every date with the browser's local day (lib/dates.ts). Journal notes already did; plop placement, fill and removal now do too, so a 9 pm placement isn't "planted tomorrow". The fill endpoint gained an optional plantedAt; API and agent callers still default to UTC today. - Removing an object that holds plants asks first and says how many go with it. An empty one still goes straight away (one Undo restores it). - The expanded plant card's action row wraps instead of clipping "Delete". - Monogram lettering switches to a dark ink on pale marker colors (garlic, cabbage, marigold) instead of near-white on near-white. - Copy-as-plan proposes the next free year and warns when the typed name already exists, so two gardens can't both read as "the 2027 plan". - Plan cards show the base name with a "2027 plan" tag, so the year — the point of the name — survives truncation. - A rejected model spec now says which model and why: a wrapped ErrInvalidInput's reason reaches the client as the 400's message, and the Settings field shows it inline instead of toasting "invalid input". Also defuses a clock bomb in TestRemainingReturnsWhenAPlantingIsRemoved, which only passed while the real date was before 2026-08-01. Co-Authored-By: Claude Fable 5 <[email protected]>
183 lines
6.4 KiB
Go
183 lines
6.4 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
|
)
|
|
|
|
// Instance settings (#79): admin-editable, instance-wide configuration. The
|
|
// admin gate lives HERE, in the seam, not in the handler — the same rule every
|
|
// other permission follows. The API's requireAdmin middleware is a cheap early
|
|
// 403, not the authority.
|
|
|
|
// requireAdmin returns nil iff the actor is an admin, else ErrForbidden.
|
|
//
|
|
// ErrForbidden, not ErrNotFound: settings are not a resource whose existence is
|
|
// masked. A logged-in non-admin knows the instance has settings; they simply may
|
|
// not touch them. (Contrast objects/lots, where no-access masks existence.)
|
|
func (s *Service) requireAdmin(ctx context.Context, actorID int64) error {
|
|
u, err := s.store.GetUserByID(ctx, actorID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !u.IsAdmin {
|
|
return domain.ErrForbidden
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// GetInstanceSettings returns the instance settings for an admin.
|
|
func (s *Service) GetInstanceSettings(ctx context.Context, actorID int64) (*domain.InstanceSettings, error) {
|
|
if err := s.requireAdmin(ctx, actorID); err != nil {
|
|
return nil, err
|
|
}
|
|
return s.store.GetInstanceSettings(ctx)
|
|
}
|
|
|
|
// InstanceSettingsPatch is a full replacement of the editable fields plus the
|
|
// current version. Both agent fields carry their "inherit" sentinel: an empty
|
|
// AgentModel means fall back to env, a nil AgentEnabled means inherit.
|
|
type InstanceSettingsPatch struct {
|
|
AgentModel string
|
|
AgentEnabled *bool
|
|
VisionModel string
|
|
Version int64
|
|
}
|
|
|
|
// UpdateInstanceSettings applies an admin's change, version-guarded. It returns
|
|
// (current row, ErrVersionConflict) on a stale version, like every mutable
|
|
// resource. The model spec is validated before it is stored, so a typo is a 400
|
|
// now rather than a broken assistant on the next turn.
|
|
//
|
|
// It does NOT rebuild the running agent — that is the API layer's job, because
|
|
// the live Runner lives there. The caller rebuilds on success.
|
|
func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, patch InstanceSettingsPatch) (*domain.InstanceSettings, error) {
|
|
if err := s.requireAdmin(ctx, actorID); err != nil {
|
|
return nil, err
|
|
}
|
|
model := strings.TrimSpace(patch.AgentModel)
|
|
vision := strings.TrimSpace(patch.VisionModel)
|
|
// Validate non-empty specs up front. An empty one is the "inherit env"
|
|
// sentinel and needs no check — the env value was validated at boot. The
|
|
// reason rides on the sentinel so the 400 can show it: "unknown provider"
|
|
// is something a person can act on, "invalid input" is not.
|
|
for _, f := range []struct{ label, spec string }{{"chat model", model}, {"vision model", vision}} {
|
|
if f.spec == "" {
|
|
continue
|
|
}
|
|
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, f.spec); err != nil {
|
|
return nil, fmt.Errorf("%w: %s %q: %v", domain.ErrInvalidInput, f.label, f.spec, specReason(err))
|
|
}
|
|
}
|
|
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
|
|
AgentModel: model,
|
|
AgentEnabled: patch.AgentEnabled,
|
|
VisionModel: vision,
|
|
Version: patch.Version,
|
|
})
|
|
}
|
|
|
|
// EffectiveAgent resolves the agent configuration actually in force: DB settings
|
|
// override the environment, and the API key always comes from the environment.
|
|
//
|
|
// This is internal plumbing (the API layer calls it to build the Runner), NOT an
|
|
// admin-gated operation — resolving what's configured is not the same as editing
|
|
// it, and the agent bootstrap must work before any user is even authenticated.
|
|
type EffectiveAgent struct {
|
|
Model string
|
|
Enabled bool
|
|
APIKey string
|
|
}
|
|
|
|
// Ready mirrors config.AgentConfig.Ready: enabled, with a key and a model.
|
|
func (e EffectiveAgent) Ready() bool {
|
|
return e.Enabled && e.APIKey != "" && e.Model != ""
|
|
}
|
|
|
|
// EffectiveAgent reads the settings row and layers it over the environment.
|
|
func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) {
|
|
st, err := s.store.GetInstanceSettings(ctx)
|
|
if err != nil {
|
|
return EffectiveAgent{}, err
|
|
}
|
|
return s.agentOver(st), nil
|
|
}
|
|
|
|
// agentOver layers a settings row over the env-derived agent defaults. Split out
|
|
// so EffectiveConfig can resolve agent AND vision from a single row read instead
|
|
// of fetching the same one-row table twice.
|
|
func (s *Service) agentOver(st *domain.InstanceSettings) EffectiveAgent {
|
|
eff := EffectiveAgent{
|
|
Model: s.cfg.Agent.Model,
|
|
Enabled: s.cfg.Agent.Enabled,
|
|
APIKey: s.cfg.Agent.OllamaCloudAPIKey,
|
|
}
|
|
if st.AgentModel != "" {
|
|
eff.Model = st.AgentModel
|
|
}
|
|
if st.AgentEnabled != nil {
|
|
eff.Enabled = *st.AgentEnabled
|
|
}
|
|
return eff
|
|
}
|
|
|
|
// EffectiveVision resolves the vision configuration in force for seed-packet
|
|
// capture (#81): the model from DB-over-env, the key always from env.
|
|
type EffectiveVision struct {
|
|
Model string
|
|
APIKey string
|
|
}
|
|
|
|
// Ready reports whether packet capture can be offered: a key and a vision model.
|
|
// There's no separate enabled flag — configuring a vision model IS enabling it.
|
|
func (e EffectiveVision) Ready() bool {
|
|
return e.APIKey != "" && e.Model != ""
|
|
}
|
|
|
|
// EffectiveVision reads the settings row and layers it over the environment.
|
|
func (s *Service) EffectiveVision(ctx context.Context) (EffectiveVision, error) {
|
|
st, err := s.store.GetInstanceSettings(ctx)
|
|
if err != nil {
|
|
return EffectiveVision{}, err
|
|
}
|
|
return s.visionOver(st), nil
|
|
}
|
|
|
|
// visionOver layers a settings row over the env-derived vision defaults. See
|
|
// agentOver for why this is split from EffectiveVision.
|
|
func (s *Service) visionOver(st *domain.InstanceSettings) EffectiveVision {
|
|
eff := EffectiveVision{
|
|
Model: s.cfg.Agent.VisionModel,
|
|
APIKey: s.cfg.Agent.OllamaCloudAPIKey,
|
|
}
|
|
if st.VisionModel != "" {
|
|
eff.Model = st.VisionModel
|
|
}
|
|
return eff
|
|
}
|
|
|
|
// EffectiveConfig resolves the agent AND vision configuration from ONE settings
|
|
// read, for callers (the settings view) that need both — the single-row table
|
|
// would otherwise be fetched twice for one response.
|
|
func (s *Service) EffectiveConfig(ctx context.Context) (EffectiveAgent, EffectiveVision, error) {
|
|
st, err := s.store.GetInstanceSettings(ctx)
|
|
if err != nil {
|
|
return EffectiveAgent{}, EffectiveVision{}, err
|
|
}
|
|
return s.agentOver(st), s.visionOver(st), nil
|
|
}
|
|
|
|
// specReason strips agentmodel's own "resolve %q:" wrapping so the message
|
|
// reads "unknown provider …" rather than repeating the spec twice.
|
|
func specReason(err error) string {
|
|
if u := errors.Unwrap(err); u != nil {
|
|
return u.Error()
|
|
}
|
|
return err.Error()
|
|
}
|