Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4b348dcbc0 | ||
|
|
bfc5d9a871 | ||
|
|
84f249a774 | ||
|
|
a13acedd90 | ||
|
|
15734c9195 | ||
|
|
48057fe1f3 | ||
|
|
9ab454373b | ||
|
|
33e048cea9 | ||
|
|
4c4abe23c6 | ||
|
|
d26db3f6e3 | ||
|
|
95b9d611c6 | ||
|
|
5bdaf21828 | ||
|
|
7a6f6963f9 | ||
|
|
41c28592a2 | ||
|
|
04cdf815df | ||
|
|
3cfa72cb25 | ||
|
|
8552f1d152 | ||
|
|
7088f382bc | ||
|
|
437c535cd1 | ||
|
|
07d598cffd | ||
|
|
958b90ebc6 | ||
|
|
28af101634 | ||
|
|
70ff970672 | ||
|
|
f8929a19a8 | ||
|
|
3af0d08779 | ||
|
|
45da4b15e2 | ||
|
|
c1c873d3f0 | ||
|
|
1d2f0eba56 | ||
|
|
62604523d7 | ||
|
|
4ea0d0b262 | ||
|
|
8dbbc5439d | ||
|
|
3a3ce16fce |
@@ -85,6 +85,12 @@ Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
|
|||||||
deliberately. `ErrForbidden` means "you can see it but may not do that".
|
deliberately. `ErrForbidden` means "you can see it but may not do that".
|
||||||
- **Plops (plantings) live in their parent object's local frame**, origin at the
|
- **Plops (plantings) live in their parent object's local frame**, origin at the
|
||||||
object's center, `-y` is north. Moving or rotating a bed moves its plants free.
|
object's center, `-y` is north. Moving or rotating a bed moves its plants free.
|
||||||
|
- **A plop is a clump, not a plant.** `defaultPlopRadius` is `1.5 × spacing`, so a
|
||||||
|
plop is three spacings across and holds `π·r²/spacing²` plants. Reasoning about
|
||||||
|
fills as if one plop were one plant gets the geometry wrong every time — which
|
||||||
|
is how #75 happened: requiring the whole circle inside the bed inset the outer
|
||||||
|
row by 1.5 spacings when the horticultural rule is *half* a spacing. Spacing is
|
||||||
|
a constraint between neighbouring plants; a bed edge is nobody's neighbour.
|
||||||
- **Soft removal**: "clear bed" sets `removed_at`; the editor reads
|
- **Soft removal**: "clear bed" sets `removed_at`; the editor reads
|
||||||
`removed_at IS NULL`. Hard delete is a different operation.
|
`removed_at IS NULL`. Hard delete is a different operation.
|
||||||
- **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run
|
- **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run
|
||||||
@@ -92,6 +98,29 @@ Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
|
|||||||
- **Every service mutation lands in history** (#48). If you add one, record it —
|
- **Every service mutation lands in history** (#48). If you add one, record it —
|
||||||
see `internal/service/revisions.go`. Multi-row operations pass all their
|
see `internal/service/revisions.go`. Multi-row operations pass all their
|
||||||
changes to a single `record` call so they undo as one unit.
|
changes to a single `record` call so they undo as one unit.
|
||||||
|
- **The history write is detached from cancellation on purpose.** `commitScope`
|
||||||
|
calls `context.WithoutCancel` — that is not a mistake to tidy up. By the time
|
||||||
|
a commit runs, the rows it describes are already written, so cancelling it
|
||||||
|
cannot undo anything; it can only leave real changes with no way to undo them.
|
||||||
|
This was a live bug twice (#73): a client disconnect mid-request orphaned 18
|
||||||
|
plantings. Fixing it per-call-site is how it came back, which is why the rule
|
||||||
|
lives in `commitScope` where no caller can forget it.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Match the test to the failure it would catch:
|
||||||
|
|
||||||
|
- **Anything addressed by its own id needs an API-level test through the router.**
|
||||||
|
Service tests can't see a route that was never registered — PATCH/DELETE
|
||||||
|
`/journal/:id` once shipped fully implemented, fully unit-tested, and
|
||||||
|
completely unreachable.
|
||||||
|
- **Watch for fixtures that assert your assumptions instead of the API.** A test
|
||||||
|
for the undo message passed because the fixture I wrote populated a field the
|
||||||
|
real response leaves empty. If a test builds the thing it's testing against,
|
||||||
|
it is checking your mental model, not the system.
|
||||||
|
- Some things only real use finds. The agent's whole loop is covered by
|
||||||
|
majordomo's scriptable fake provider (`provider/fake`), which is worth using —
|
||||||
|
but the three worst v2 bugs all turned up in one live session afterwards.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
@@ -100,6 +129,20 @@ fix what's real → merge when the pipeline is green. Do not grade Gadfly findin
|
|||||||
A push to `main` builds the image and deploys to Komodo; the live instance at
|
A push to `main` builds the image and deploys to Komodo; the live instance at
|
||||||
`pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later.
|
`pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later.
|
||||||
|
|
||||||
|
**One sweep, not a loop.** Take Gadfly's *initial* review, fix what's real, and
|
||||||
|
merge once the build is green. Do NOT re-trigger Gadfly on the fix commits and
|
||||||
|
wait for it again — a re-review-per-fix loop burns ~10 min a pass and drags a
|
||||||
|
small PR out for an hour (Steve's explicit call, 2026-07-22). The initial review
|
||||||
|
is the check; your own build/test/judgment covers the fixes. Gadfly is advisory
|
||||||
|
and never blocks merge, so green build + fixes applied is enough.
|
||||||
|
|
||||||
|
(Mechanics, if you ever *do* need a manual re-run: the workflow triggers on
|
||||||
|
`opened`/`reopened`/`ready_for_review`, not `synchronize`, so pushes don't
|
||||||
|
re-review; a `@gadfly review` comment does. A comment without that exact phrase
|
||||||
|
still runs and exits green in ~2s — a skip that looks like a pass, so judge a
|
||||||
|
real review by its ~10-min duration, not its status. Gadfly edits its consensus
|
||||||
|
comment in place, so `updated_at` moves but `created_at` doesn't.)
|
||||||
|
|
||||||
Workflow- and config-only changes (CI, this file, docs) go straight to `main`
|
Workflow- and config-only changes (CI, this file, docs) go straight to `main`
|
||||||
without the PR dance.
|
without the PR dance.
|
||||||
|
|
||||||
@@ -118,6 +161,25 @@ Agent config: `OLLAMA_CLOUD_API_KEY`, `PANSY_AGENT_MODEL` (default
|
|||||||
strings pass verbatim to `majordomo.Parse`, so a comma-separated spec gives
|
strings pass verbatim to `majordomo.Parse`, so a comma-separated spec gives
|
||||||
failover for free — don't parse that grammar in pansy.
|
failover for free — don't parse that grammar in pansy.
|
||||||
|
|
||||||
|
The model and enabled flag are also **admin-editable at runtime** in Settings
|
||||||
|
(#79); the env vars are just defaults (precedence: DB setting → env → default).
|
||||||
|
Two things this makes load-bearing:
|
||||||
|
|
||||||
|
- **The live Runner is hot-swapped, not built once.** It sits behind an
|
||||||
|
`atomic.Pointer` in `internal/api` (`agentHolder`), and its routes are
|
||||||
|
registered *unconditionally* with a nil-check on `agent.get()`. Do NOT go back
|
||||||
|
to registering the chat routes only when a key is present — a settings change
|
||||||
|
has to be able to turn the assistant on without a restart, which a missing
|
||||||
|
route can't. `/capabilities` reads the pointer, so it reflects the live state.
|
||||||
|
- **`OLLAMA_CLOUD_API_KEY` stays in the environment, never the DB.** Model
|
||||||
|
selection is a setting; the key is not. A secret in `instance_settings` lands
|
||||||
|
in every backup and in the undo history's blast radius. The Settings API
|
||||||
|
reports whether a key is *present*, never its value.
|
||||||
|
- The "how to turn a spec into a model" knowledge lives once in
|
||||||
|
`internal/agentmodel`, imported by both `agent` (to run) and `service` (to
|
||||||
|
validate a spec before storing it). It can't live in `agent` — `agent` imports
|
||||||
|
`service`, so a `service`→`agent` import would cycle.
|
||||||
|
|
||||||
`majordomo` is a **real dependency** now, resolved from the Gitea instance as a
|
`majordomo` is a **real dependency** now, resolved from the Gitea instance as a
|
||||||
pseudo-version. There is no `replace` directive and there must not be one: a
|
pseudo-version. There is no `replace` directive and there must not be one: a
|
||||||
`replace` pointing at `../majordomo` builds on your laptop and breaks the Docker
|
`replace` pointing at `../majordomo` builds on your laptop and breaks the Docker
|
||||||
|
|||||||
@@ -7,9 +7,11 @@ Work is tracked in Gitea issues; the tracking epic links every piece in dependen
|
|||||||
## Decisions
|
## Decisions
|
||||||
|
|
||||||
- **Placement model:** freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle.
|
- **Placement model:** freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle.
|
||||||
|
- **Spacing is a plant-to-plant rule, so bed edges get half of it.** A bed edge is not a competitor for soil, light or water, so the outer row owes it half the spacing rather than a full one. `FillRegion` centres its lattice accordingly, and lets a plop — a *clump* three spacings across — cross the edge by up to half a spacing so its outermost plants land at that half-spacing. The rule, the square-foot-chart arithmetic behind it, and the failure mode it prevents are written out once in `hexCenters`; #75 is what getting it wrong looked like.
|
||||||
- **Stack:** Go 1.26.x backend, module `gitea.stevedudenhoeffer.com/steve/pansy`; React + TypeScript + Vite + Tailwind frontend, production build embedded via `embed.FS` → one static binary (`CGO_ENABLED=0`).
|
- **Stack:** Go 1.26.x backend, module `gitea.stevedudenhoeffer.com/steve/pansy`; React + TypeScript + Vite + Tailwind frontend, production build embedded via `embed.FS` → one static binary (`CGO_ENABLED=0`).
|
||||||
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes.
|
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes. The first registered user is `is_admin` (set race-free inside the INSERT); admin gates instance-wide Settings (`requireAdmin`), the only thing that reads that flag.
|
||||||
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
|
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
|
||||||
|
- **Instance settings (#79):** admin-editable, instance-wide config in a single-row `instance_settings` table — pansy's first DB-stored *instance* state (everything else hangs off a garden/object). Today it holds the agent model + on/off; **secrets never move here** — `OLLAMA_CLOUD_API_KEY` stays in the env so it doesn't land in backups or the undo history. Precedence: Settings value → env → default. The live agent Runner sits behind an `atomic.Pointer` in the API layer (`agentHolder`) with its routes always registered, so a settings change swaps it with no restart and no race against in-flight requests; `/capabilities` reads that pointer, so it reports what's live rather than what was configured at boot. The model/registry knowledge lives in one leaf package (`internal/agentmodel`) that both the runner and the settings validator import — agent imports service, so it can live in neither.
|
||||||
- **Agentic future:** integration with majordomo/executus via typed Go tools (`llm.DefineTool[Args]`) wrapping the same service layer the REST API uses — not MCP/OpenAPI.
|
- **Agentic future:** integration with majordomo/executus via typed Go tools (`llm.DefineTool[Args]`) wrapping the same service layer the REST API uses — not MCP/OpenAPI.
|
||||||
|
|
||||||
## Domain model
|
## Domain model
|
||||||
@@ -63,14 +65,19 @@ POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts
|
|||||||
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
|
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
|
||||||
POST /gardens/:id/objects PATCH,DELETE /objects/:id
|
POST /gardens/:id/objects PATCH,DELETE /objects/:id
|
||||||
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
|
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
|
||||||
|
POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect
|
||||||
|
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
|
||||||
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
|
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
|
||||||
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
|
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
|
||||||
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
|
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
|
||||||
GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator
|
GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator
|
||||||
POST /agent/chat ← SSE: step events, then the finished turn (editor only)
|
POST /agent/chat ← SSE: step events, then the finished turn (editor only)
|
||||||
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
|
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
|
||||||
GET /capabilities ← what this instance can do, so the UI offers only what works
|
GET /capabilities ← what this instance can do RIGHT NOW (tracks the live agent, not just config)
|
||||||
|
GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off
|
||||||
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
||||||
|
GET,POST,DELETE /gardens/:id/share-link ← the public read-only token for this garden
|
||||||
|
GET /public/gardens/:token ← UNAUTHENTICATED read-only /full; the token is the capability
|
||||||
```
|
```
|
||||||
|
|
||||||
**Sync:** plain REST + optimistic UI + last-write-wins with a version guard. Every PATCH/DELETE carries the row's `version`; the server increments on write and returns **409 + the current row** on mismatch; the client rolls back and refetches. No websockets/CRDT — the right cost for household-scale co-editing. Drags PATCH once on drop, not per frame.
|
**Sync:** plain REST + optimistic UI + last-write-wins with a version guard. Every PATCH/DELETE carries the row's `version`; the server increments on write and returns **409 + the current row** on mismatch; the client rolls back and refetches. No websockets/CRDT — the right cost for household-scale co-editing. Drags PATCH once on drop, not per frame.
|
||||||
|
|||||||
@@ -67,12 +67,16 @@ The garden assistant reads three more. Setting none of them leaves the assistant
|
|||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ----------------------- | ------------------------------ | --------------------------------------------------------------------------- |
|
| ----------------------- | ------------------------------ | --------------------------------------------------------------------------- |
|
||||||
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is disabled, not broken — the chat routes simply aren't registered. |
|
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is off, not broken. This is the one agent value that stays in the environment — it is **never** stored in the database or editable in Settings. |
|
||||||
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain, e.g. `ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud`. |
|
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Default model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain, e.g. `ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud`. An admin can override this per-instance in **Settings** without a redeploy; a blank Settings value inherits this. |
|
||||||
| `PANSY_AGENT_ENABLED` | on when a key is present | Turns the assistant off without removing the key. |
|
| `PANSY_AGENT_ENABLED` | on when a key is present | Default on/off for the assistant. Also overridable in Settings (which can inherit this default). |
|
||||||
|
|
||||||
|
The model and enabled flag can be changed at runtime by an admin under **Settings** (the gear appears in the nav for admins) — the change swaps the live assistant with no restart. The env vars above are the defaults an untouched instance uses, and the API key is intentionally not among the runtime-editable settings: a secret in the database would land in every backup. Precedence for the model and enabled flag is **Settings value, if set → env var → built-in default**.
|
||||||
|
|
||||||
The assistant acts without asking first, which is only reasonable because every turn is one undoable change set — see the History panel in the editor.
|
The assistant acts without asking first, which is only reasonable because every turn is one undoable change set — see the History panel in the editor.
|
||||||
|
|
||||||
|
**If you set the key and the assistant still doesn't appear**, check that the variable reaches the *container*, not just your orchestrator's stack config — Compose needs it listed under the service's `environment:`. pansy logs why the assistant is off at startup, and Settings shows the same status (a key present, the resolved model, and whether it's actually running).
|
||||||
|
|
||||||
Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`, `/auth/logout`, `GET /auth/me`, `GET /auth/providers`); the session is an HttpOnly cookie (`Secure` when `PANSY_BASE_URL` is https). The first account registered becomes admin, and it may register even when `PANSY_REGISTRATION=closed` to bootstrap the instance.
|
Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`, `/auth/logout`, `GET /auth/me`, `GET /auth/providers`); the session is an HttpOnly cookie (`Secure` when `PANSY_BASE_URL` is https). The first account registered becomes admin, and it may register even when `PANSY_REGISTRATION=closed` to bootstrap the instance.
|
||||||
|
|
||||||
OIDC (Authentik-first) is live too: set `PANSY_OIDC_ISSUER`, `PANSY_OIDC_CLIENT_ID`, `PANSY_OIDC_CLIENT_SECRET`, and `PANSY_BASE_URL` (needed for the redirect URI). Register `PANSY_BASE_URL` + `/api/v1/auth/oidc/callback` as the redirect URI in your IdP. `GET /auth/oidc/login` starts an authorization-code + PKCE flow; first login provisions a user just-in-time (a matching *verified* email links to an existing local account instead of duplicating it). Provider discovery is lazy, so a briefly-unreachable IdP never blocks startup or local auth. Set `PANSY_LOCAL_AUTH=false` for pure-Authentik deployments (local register/login are then rejected and hidden from `/auth/providers`).
|
OIDC (Authentik-first) is live too: set `PANSY_OIDC_ISSUER`, `PANSY_OIDC_CLIENT_ID`, `PANSY_OIDC_CLIENT_SECRET`, and `PANSY_BASE_URL` (needed for the redirect URI). Register `PANSY_BASE_URL` + `/api/v1/auth/oidc/callback` as the redirect URI in your IdP. `GET /auth/oidc/login` starts an authorization-code + PKCE flow; first login provisions a user just-in-time (a matching *verified* email links to an existing local account instead of duplicating it). Provider discovery is lazy, so a briefly-unreachable IdP never blocks startup or local auth. Set `PANSY_LOCAL_AUTH=false` for pure-Authentik deployments (local register/login are then rejected and hidden from `/auth/providers`).
|
||||||
@@ -111,7 +115,8 @@ services:
|
|||||||
# PANSY_OIDC_ISSUER: https://auth.example.com/application/o/pansy/
|
# PANSY_OIDC_ISSUER: https://auth.example.com/application/o/pansy/
|
||||||
# PANSY_OIDC_CLIENT_ID: ...
|
# PANSY_OIDC_CLIENT_ID: ...
|
||||||
# PANSY_OIDC_CLIENT_SECRET: ...
|
# PANSY_OIDC_CLIENT_SECRET: ...
|
||||||
# OLLAMA_CLOUD_API_KEY: ... # enables the garden assistant
|
# OLLAMA_CLOUD_API_KEY: ${OLLAMA_CLOUD_API_KEY} # enables the garden assistant
|
||||||
|
# PANSY_AGENT_MODEL: ollama-cloud/glm-5.2:cloud
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
pansy-data:
|
pansy-data:
|
||||||
|
|||||||
+43
-28
@@ -2,17 +2,18 @@ package agent
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
|
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
)
|
)
|
||||||
@@ -38,29 +39,23 @@ type Runner struct {
|
|||||||
model llm.Model
|
model llm.Model
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRunner resolves the configured model and returns a Runner, or an error if
|
// NewRunner resolves modelSpec against pansy's registry and returns a Runner, or
|
||||||
// the assistant can't be offered. Callers should treat an error as "no
|
// an error if the assistant can't be offered. Callers should treat an error as
|
||||||
// assistant" rather than a startup failure — an instance with no key must still
|
// "no assistant" rather than a startup failure — an instance with no key must
|
||||||
// serve the app.
|
// still serve the app.
|
||||||
func NewRunner(svc *service.Service, cfg *config.Config) (*Runner, error) {
|
//
|
||||||
if !cfg.Agent.Ready() {
|
// It takes the key and spec explicitly rather than a *config.Config so the same
|
||||||
|
// constructor serves both boot (from env) and a runtime settings change (from
|
||||||
|
// the DB) — the Runner has no idea which one configured it.
|
||||||
|
func NewRunner(svc *service.Service, apiKey, modelSpec string) (*Runner, error) {
|
||||||
|
if apiKey == "" {
|
||||||
return nil, errors.New("agent: not configured")
|
return nil, errors.New("agent: not configured")
|
||||||
}
|
}
|
||||||
|
// agentmodel.Resolve already rejects an empty/blank spec, so don't duplicate
|
||||||
// A private registry, not the package-level default: pansy passes the key it
|
// that guard here — one place decides what a valid spec is.
|
||||||
// was configured with rather than depending on ambient environment, and
|
model, err := agentmodel.Resolve(apiKey, modelSpec)
|
||||||
// majordomo's own ollama-cloud preset reads OLLAMA_API_KEY while pansy (like
|
|
||||||
// gadfly) is configured with OLLAMA_CLOUD_API_KEY. Registering the provider
|
|
||||||
// explicitly makes that bridge visible instead of a mysterious empty token.
|
|
||||||
reg := majordomo.New()
|
|
||||||
reg.RegisterProvider(ollama.Cloud(ollama.WithToken(cfg.Agent.OllamaCloudAPIKey)))
|
|
||||||
|
|
||||||
// The spec goes to Parse VERBATIM. The grammar — including comma-separated
|
|
||||||
// failover chains — is majordomo's, and re-implementing any of it here would
|
|
||||||
// only mean two places to update when it grows.
|
|
||||||
model, err := reg.Parse(cfg.Agent.Model)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("agent: resolve model %q: %w", cfg.Agent.Model, err)
|
return nil, err
|
||||||
}
|
}
|
||||||
return &Runner{svc: svc, model: model}, nil
|
return &Runner{svc: svc, model: model}, nil
|
||||||
}
|
}
|
||||||
@@ -76,8 +71,6 @@ type Turn struct {
|
|||||||
Steps int `json:"steps"`
|
Steps int `json:"steps"`
|
||||||
// Truncated is set when the run hit its step cap rather than finishing.
|
// Truncated is set when the run hit its step cap rather than finishing.
|
||||||
Truncated bool `json:"truncated,omitempty"`
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
// History is the transcript to feed back into the next turn.
|
|
||||||
History []llm.Message `json:"-"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run executes one turn against a garden, as actorID.
|
// Run executes one turn against a garden, as actorID.
|
||||||
@@ -100,6 +93,12 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message strin
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// An id for this run, stamped on the change set so a row in the history list
|
||||||
|
// can be matched to the log lines that produced it. Without it, "the agent
|
||||||
|
// did something odd on Tuesday" has no thread back to what it was thinking.
|
||||||
|
runID := newRunID()
|
||||||
|
slog.Info("agent: run start", "run", runID, "garden", gardenID, "actor", actorID)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
result *agent.Result
|
result *agent.Result
|
||||||
runErr error
|
runErr error
|
||||||
@@ -108,6 +107,7 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message strin
|
|||||||
changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{
|
changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{
|
||||||
Source: domain.SourceAgent,
|
Source: domain.SourceAgent,
|
||||||
Summary: turnSummary(message),
|
Summary: turnSummary(message),
|
||||||
|
AgentRunID: &runID,
|
||||||
}, func(ctx context.Context) error {
|
}, func(ctx context.Context) error {
|
||||||
box := NewToolbox(r.svc, actorID)
|
box := NewToolbox(r.svc, actorID)
|
||||||
a := agent.New(r.model, systemPrompt(garden),
|
a := agent.New(r.model, systemPrompt(garden),
|
||||||
@@ -143,7 +143,6 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message strin
|
|||||||
if result != nil {
|
if result != nil {
|
||||||
turn.Reply = result.Output
|
turn.Reply = result.Output
|
||||||
turn.Steps = len(result.Steps)
|
turn.Steps = len(result.Steps)
|
||||||
turn.History = result.Messages
|
|
||||||
}
|
}
|
||||||
if turn.Reply == "" {
|
if turn.Reply == "" {
|
||||||
turn.Reply = fallbackReply(turn)
|
turn.Reply = fallbackReply(turn)
|
||||||
@@ -172,13 +171,29 @@ func fallbackReply(t *Turn) string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// newRunID returns a short random identifier for one run.
|
||||||
|
func newRunID() string {
|
||||||
|
var b [8]byte
|
||||||
|
if _, err := rand.Read(b[:]); err != nil {
|
||||||
|
// The id is for correlating logs, not for security. A clock-based
|
||||||
|
// fallback is worse than random and better than an empty string.
|
||||||
|
return fmt.Sprintf("t%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(b[:])
|
||||||
|
}
|
||||||
|
|
||||||
// turnSummary is what the history list shows for this turn. The user's own words
|
// turnSummary is what the history list shows for this turn. The user's own words
|
||||||
// are the most useful label available, trimmed to fit a list row.
|
// are the most useful label available, trimmed to fit a list row.
|
||||||
|
//
|
||||||
|
// Trimmed by RUNES, not bytes: slicing a byte offset would cut a multibyte
|
||||||
|
// character in half and store invalid UTF-8 in the summary — which is not a
|
||||||
|
// hypothetical for text people type.
|
||||||
func turnSummary(message string) string {
|
func turnSummary(message string) string {
|
||||||
const max = 120
|
const max = 120
|
||||||
s := strings.Join(strings.Fields(message), " ")
|
s := strings.Join(strings.Fields(message), " ")
|
||||||
if len(s) > max {
|
runes := []rune(s)
|
||||||
s = strings.TrimSpace(s[:max]) + "…"
|
if len(runes) > max {
|
||||||
|
s = strings.TrimSpace(string(runes[:max])) + "…"
|
||||||
}
|
}
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
)
|
)
|
||||||
@@ -230,24 +231,24 @@ func TestReadOnlyTurnWritesNoChangeSet(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNewRunnerNeedsConfiguration — an instance with no key must not get a
|
// TestNewRunnerNeedsConfiguration — an instance with no key or no model must not
|
||||||
// half-built runner; the caller treats the error as "no assistant" and carries on.
|
// get a half-built runner; the caller treats the error as "no assistant" and
|
||||||
|
// carries on. (Whether the assistant is ENABLED is resolved before NewRunner is
|
||||||
|
// reached, so it isn't NewRunner's concern any more.)
|
||||||
func TestNewRunnerNeedsConfiguration(t *testing.T) {
|
func TestNewRunnerNeedsConfiguration(t *testing.T) {
|
||||||
svc, _ := newAgentTestService(t)
|
svc, _ := newAgentTestService(t)
|
||||||
for _, cfg := range []*config.Config{
|
for _, tc := range []struct{ key, model string }{
|
||||||
{Agent: config.AgentConfig{Enabled: false, OllamaCloudAPIKey: "k", Model: "ollama-cloud/x"}},
|
{"", "ollama-cloud/x"},
|
||||||
{Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "", Model: "ollama-cloud/x"}},
|
{"k", ""},
|
||||||
{Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "k", Model: ""}},
|
{"k", " "},
|
||||||
} {
|
} {
|
||||||
if _, err := NewRunner(svc, cfg); err == nil {
|
if _, err := NewRunner(svc, tc.key, tc.model); err == nil {
|
||||||
t.Errorf("NewRunner accepted %+v", cfg.Agent)
|
t.Errorf("NewRunner accepted key=%q model=%q", tc.key, tc.model)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A model spec naming a provider that doesn't exist is a configuration
|
// A model spec naming a provider that doesn't exist is a configuration
|
||||||
// error, not a panic at first use.
|
// error, not a panic at first use.
|
||||||
if _, err := NewRunner(svc, &config.Config{Agent: config.AgentConfig{
|
if _, err := NewRunner(svc, "k", "nonesuch/model"); err == nil {
|
||||||
Enabled: true, OllamaCloudAPIKey: "k", Model: "nonesuch/model",
|
|
||||||
}}); err == nil {
|
|
||||||
t.Error("NewRunner accepted an unresolvable model spec")
|
t.Error("NewRunner accepted an unresolvable model spec")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -277,3 +278,81 @@ func TestSystemPromptStatesTheCompassConvention(t *testing.T) {
|
|||||||
t.Error("system prompt doesn't state the garden's size")
|
t.Error("system prompt doesn't state the garden's size")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestPartialWorkSurvivesATimeout is the finding that mattered most on this PR.
|
||||||
|
//
|
||||||
|
// The run context carries a timeout. When it fires, WithChangeSet's recovery
|
||||||
|
// path has to record what already committed — and doing that with the SAME
|
||||||
|
// dead context would fail, losing the history for changes that really happened.
|
||||||
|
// The user-facing message says "anything I'd already changed is in History", so
|
||||||
|
// this isn't just a gap, it's a promise the code has to keep.
|
||||||
|
func TestPartialWorkSurvivesATimeout(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, owner := newAgentTestService(t)
|
||||||
|
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("garden: %v", err)
|
||||||
|
}
|
||||||
|
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
|
||||||
|
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bed: %v", err)
|
||||||
|
}
|
||||||
|
before, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||||
|
|
||||||
|
// A turn that renames the bed, then dies with the context already cancelled.
|
||||||
|
cancelled, cancel := context.WithCancel(ctx)
|
||||||
|
r := scriptedRunner(t, svc,
|
||||||
|
toolCall("move_object", map[string]any{
|
||||||
|
"objectId": bed.ID, "xCm": 600.0, "yCm": 600.0, "version": bed.Version,
|
||||||
|
}),
|
||||||
|
fake.Step{Err: context.DeadlineExceeded},
|
||||||
|
)
|
||||||
|
// Cancel once the first tool call has landed, so the failure path runs with a
|
||||||
|
// dead context — exactly the timeout case.
|
||||||
|
go func() {
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
_, err = r.Run(cancelled, owner, g.ID, "move the bed", nil, nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected the turn to fail")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The move committed, so it must be in history and undoable.
|
||||||
|
after, _, herr := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||||
|
if herr != nil {
|
||||||
|
t.Fatalf("history: %v", herr)
|
||||||
|
}
|
||||||
|
if len(after) != len(before)+1 {
|
||||||
|
t.Fatalf("the failed turn recorded %d change sets, want 1 — its work is otherwise un-undoable",
|
||||||
|
len(after)-len(before))
|
||||||
|
}
|
||||||
|
if !strings.Contains(after[0].Summary, "failed partway") {
|
||||||
|
t.Errorf("summary = %q, want it marked as partial", after[0].Summary)
|
||||||
|
}
|
||||||
|
if _, conflicts, rerr := svc.RevertChangeSet(ctx, owner, after[0].ID, domain.SourceUI); rerr != nil || len(conflicts) != 0 {
|
||||||
|
t.Fatalf("the partial turn should be undoable: err=%v conflicts=%+v", rerr, conflicts)
|
||||||
|
}
|
||||||
|
o, _ := svc.DescribeGarden(ctx, owner, g.ID)
|
||||||
|
if len(o.Objects) > 0 && o.Objects[0].XCM != bed.XCM {
|
||||||
|
t.Errorf("undo left the bed at %v, want %v", o.Objects[0].XCM, bed.XCM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTurnSummaryTrimsByRunes — slicing a byte offset would cut a multibyte
|
||||||
|
// character in half and store invalid UTF-8 in the history summary.
|
||||||
|
func TestTurnSummaryTrimsByRunes(t *testing.T) {
|
||||||
|
// 200 multibyte runes: a byte slice at 120 would land mid-character.
|
||||||
|
got := turnSummary(strings.Repeat("🌱", 200))
|
||||||
|
if !utf8.ValidString(got) {
|
||||||
|
t.Errorf("turnSummary produced invalid UTF-8: %q", got)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(got, "…") {
|
||||||
|
t.Errorf("long summary should be elided, got %q", got)
|
||||||
|
}
|
||||||
|
if n := utf8.RuneCountInString(got); n > 121 {
|
||||||
|
t.Errorf("summary is %d runes, want it trimmed", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
// Package agentmodel holds the ONE place that knows how pansy turns a model spec
|
||||||
|
// into a majordomo model: which provider to register and under which token.
|
||||||
|
//
|
||||||
|
// It exists as a leaf so both internal/agent (which builds the run loop) and
|
||||||
|
// internal/service (which validates a spec before storing it as a setting) can
|
||||||
|
// share that knowledge without an import cycle — agent imports service, so the
|
||||||
|
// shared bit can live in neither of them.
|
||||||
|
package agentmodel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registry builds the private majordomo registry pansy uses.
|
||||||
|
//
|
||||||
|
// Private, not the package-level default: pansy passes the key it was configured
|
||||||
|
// with rather than depending on ambient environment, and majordomo's own
|
||||||
|
// ollama-cloud preset reads OLLAMA_API_KEY while pansy (like gadfly) is
|
||||||
|
// configured with OLLAMA_CLOUD_API_KEY. Registering the provider explicitly
|
||||||
|
// makes that bridge visible instead of a mysterious empty token.
|
||||||
|
func registry(apiKey string) *majordomo.Registry {
|
||||||
|
reg := majordomo.New()
|
||||||
|
reg.RegisterProvider(ollama.Cloud(ollama.WithToken(apiKey)))
|
||||||
|
return reg
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve parses a model spec against pansy's registry into a live model. The
|
||||||
|
// spec goes to Parse VERBATIM — the grammar, including comma-separated failover
|
||||||
|
// chains, is majordomo's, and re-implementing any of it here would only mean two
|
||||||
|
// places to update when it grows.
|
||||||
|
func Resolve(apiKey, spec string) (llm.Model, error) {
|
||||||
|
if strings.TrimSpace(spec) == "" {
|
||||||
|
return nil, errors.New("agentmodel: empty model spec")
|
||||||
|
}
|
||||||
|
m, err := registry(apiKey).Parse(spec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("agentmodel: resolve %q: %w", spec, err)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate reports whether a spec resolves, without building anything the caller
|
||||||
|
// keeps — the cheap, deterministic check a settings save runs to reject a typo.
|
||||||
|
//
|
||||||
|
// It does NOT make a live call, so it needs no working key and won't catch a
|
||||||
|
// model that is merely absent upstream; that surfaces on first use. Parse
|
||||||
|
// resolving (known provider, well-formed spec) is the half worth doing eagerly.
|
||||||
|
func Validate(apiKey, spec string) error {
|
||||||
|
_, err := Resolve(apiKey, spec)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package agentmodel
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
// TestValidate is what the settings PATCH relies on to reject a typo at save
|
||||||
|
// time rather than on the next chat turn.
|
||||||
|
func TestValidate(t *testing.T) {
|
||||||
|
if err := Validate("k", "ollama-cloud/glm-5.2:cloud"); err != nil {
|
||||||
|
t.Errorf("valid spec rejected: %v", err)
|
||||||
|
}
|
||||||
|
// No key needed to validate — Parse resolves the provider, it doesn't call it.
|
||||||
|
if err := Validate("", "ollama-cloud/glm-5.2:cloud"); err != nil {
|
||||||
|
t.Errorf("valid spec rejected without a key: %v", err)
|
||||||
|
}
|
||||||
|
// A comma-separated failover chain is majordomo grammar and must resolve.
|
||||||
|
if err := Validate("k", "ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud"); err != nil {
|
||||||
|
t.Errorf("failover chain rejected: %v", err)
|
||||||
|
}
|
||||||
|
for _, bad := range []string{"", " ", "nonesuch/model"} {
|
||||||
|
if err := Validate("k", bad); err == nil {
|
||||||
|
t.Errorf("Validate accepted %q", bad)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResolveReturnsAModel confirms a good spec yields a usable model handle.
|
||||||
|
func TestResolveReturnsAModel(t *testing.T) {
|
||||||
|
m, err := Resolve("k", "ollama-cloud/glm-5.2:cloud")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolve: %v", err)
|
||||||
|
}
|
||||||
|
if m == nil {
|
||||||
|
t.Fatal("resolve returned a nil model with no error")
|
||||||
|
}
|
||||||
|
}
|
||||||
+154
-21
@@ -8,6 +8,8 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
mdagent "gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
mdagent "gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
@@ -24,6 +26,11 @@ import (
|
|||||||
// by everything at once, which reads as a hang — and the whole design rests on
|
// by everything at once, which reads as a hang — and the whole design rests on
|
||||||
// watching the canvas change as it happens.
|
// watching the canvas change as it happens.
|
||||||
|
|
||||||
|
// keepAliveInterval is how often a quiet stream emits a comment frame. Well
|
||||||
|
// under the 30–60s idle timeout typical of reverse proxies, which is the thing
|
||||||
|
// it exists to stay ahead of.
|
||||||
|
const keepAliveInterval = 20 * time.Second
|
||||||
|
|
||||||
// chatRequest is the body of POST /agent/chat.
|
// chatRequest is the body of POST /agent/chat.
|
||||||
type chatRequest struct {
|
type chatRequest struct {
|
||||||
GardenID int64 `json:"gardenId" binding:"required"`
|
GardenID int64 `json:"gardenId" binding:"required"`
|
||||||
@@ -38,6 +45,9 @@ type chatEvent struct {
|
|||||||
Done *agent.Turn `json:"done,omitempty"`
|
Done *agent.Turn `json:"done,omitempty"`
|
||||||
// Error is a turn that failed, in words meant for a person.
|
// Error is a turn that failed, in words meant for a person.
|
||||||
Error string `json:"error,omitempty"`
|
Error string `json:"error,omitempty"`
|
||||||
|
// Warning rides alongside Done: the turn worked, but something adjacent to it
|
||||||
|
// didn't, and saying nothing would be the quieter lie.
|
||||||
|
Warning string `json:"warning,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type stepEvent struct {
|
type stepEvent struct {
|
||||||
@@ -46,6 +56,16 @@ type stepEvent struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *handlers) agentChat(c *gin.Context) {
|
func (h *handlers) agentChat(c *gin.Context) {
|
||||||
|
// The route is always registered, so the assistant being off is a runtime
|
||||||
|
// state, not a missing route: answer it plainly rather than 404ing a path
|
||||||
|
// that exists. Loaded once here so a settings-driven swap mid-request can't
|
||||||
|
// make it flip between the guard and the Run call.
|
||||||
|
runner := h.agent.get()
|
||||||
|
if runner == nil {
|
||||||
|
writeAPIError(c, http.StatusServiceUnavailable, "AGENT_DISABLED", "the garden assistant isn't enabled on this instance")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var req chatRequest
|
var req chatRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required")
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required")
|
||||||
@@ -59,29 +79,21 @@ func (h *handlers) agentChat(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Headers before the first write, and a flush straight away: a proxy that
|
stream := openEventStream(c)
|
||||||
// buffers the response would reintroduce exactly the silence streaming is
|
send := stream.send
|
||||||
// here to remove.
|
|
||||||
c.Header("Content-Type", "text/event-stream")
|
|
||||||
c.Header("Cache-Control", "no-cache")
|
|
||||||
c.Header("X-Accel-Buffering", "no")
|
|
||||||
c.Writer.Flush()
|
|
||||||
|
|
||||||
send := func(ev chatEvent) {
|
// A model thinking hard between tool calls sends nothing for a while, and an
|
||||||
b, err := json.Marshal(ev)
|
// idle proxy will cut a quiet connection. Deferred so a panic in the run
|
||||||
if err != nil {
|
// can't leak the ticker goroutine; stopping it twice is harmless.
|
||||||
slog.Error("api: encode chat event", "error", err)
|
stopBeat := stream.keepAlive(keepAliveInterval)
|
||||||
return
|
defer stopBeat()
|
||||||
}
|
|
||||||
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", b)
|
|
||||||
c.Writer.Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
|
turn, err := runner.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
|
||||||
replayHistory(history),
|
replayHistory(history),
|
||||||
func(s mdagent.Step) {
|
func(s mdagent.Step) {
|
||||||
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
|
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
|
||||||
})
|
})
|
||||||
|
stopBeat()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// The stream is already open, so an error is an event rather than a
|
// The stream is already open, so an error is an event rather than a
|
||||||
// status code — the client has committed to reading a stream by now.
|
// status code — the client has committed to reading a stream by now.
|
||||||
@@ -89,16 +101,137 @@ func (h *handlers) agentChat(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record before announcing: if persistence fails, the user should see that
|
// The turn itself succeeded — the garden really did change — so Done goes out
|
||||||
// their turn wasn't saved rather than a clean "done" followed by a thread
|
// regardless. But if the transcript couldn't be saved, say so: a clean "done"
|
||||||
// that has forgotten it.
|
// followed by a conversation that has forgotten the exchange after a reload is
|
||||||
if _, err := h.svc.RecordAgentExchange(c.Request.Context(), actor.ID, req.GardenID,
|
// exactly the kind of quiet inconsistency that makes a tool feel unreliable.
|
||||||
|
//
|
||||||
|
// Detached from the request context, because the commonest reason this fails
|
||||||
|
// is the client having gone away — and the exchange is worth keeping either
|
||||||
|
// way, since the change set it produced certainly is.
|
||||||
|
if _, err := h.svc.RecordAgentExchange(context.WithoutCancel(c.Request.Context()), actor.ID, req.GardenID,
|
||||||
req.Message, turn.Reply, turn.ChangeSetID); err != nil {
|
req.Message, turn.Reply, turn.ChangeSetID); err != nil {
|
||||||
slog.Error("api: record agent exchange", "error", err, "garden", req.GardenID)
|
slog.Error("api: record agent exchange", "error", err, "garden", req.GardenID)
|
||||||
|
send(chatEvent{Done: turn, Warning: "I couldn't save this exchange, so it won't be here after a reload. Anything I changed is still on the canvas, and in History."})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
send(chatEvent{Done: turn})
|
send(chatEvent{Done: turn})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sseWriteTimeout bounds ONE write to the stream, not the stream itself.
|
||||||
|
//
|
||||||
|
// It is refreshed per frame, which is the only shape that satisfies both ends:
|
||||||
|
// the server's absolute WriteTimeout would cut a long turn (#78), while removing
|
||||||
|
// the deadline entirely would let a client that stops reading block a write
|
||||||
|
// forever once the socket buffer fills — pinning the run goroutine and this
|
||||||
|
// stream's mutex with it, and taking the keep-alive down too since it needs the
|
||||||
|
// same lock. Generous, because it is a backstop against a stuck peer and not a
|
||||||
|
// pacing mechanism.
|
||||||
|
//
|
||||||
|
// A var, not a const, ONLY so the test can shrink it to prove the deadline is
|
||||||
|
// refreshed per frame rather than set once — a set-once 30s deadline would pass
|
||||||
|
// a test whose whole run is under a second. Production never reassigns it.
|
||||||
|
var sseWriteTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// eventStream serializes writes to one SSE response.
|
||||||
|
//
|
||||||
|
// The mutex is load-bearing, not decoration: step events are sent from the
|
||||||
|
// agent's run goroutine while the keep-alive ticker writes from its own, and two
|
||||||
|
// goroutines writing a ResponseWriter concurrently is a data race that corrupts
|
||||||
|
// frames long before it crashes anything.
|
||||||
|
type eventStream struct {
|
||||||
|
c *gin.Context
|
||||||
|
rc *http.ResponseController
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// openEventStream puts the response into SSE mode.
|
||||||
|
//
|
||||||
|
// Headers go out before the first write and the stream is flushed immediately,
|
||||||
|
// so a proxy holding the response until it looks complete can't reintroduce
|
||||||
|
// exactly the silence streaming exists to remove.
|
||||||
|
//
|
||||||
|
// Taking the write deadline off the server's absolute WriteTimeout and onto a
|
||||||
|
// per-write one is what makes a turn longer than 30s possible at all (#78).
|
||||||
|
// WriteTimeout is an ABSOLUTE deadline from when the request header was read,
|
||||||
|
// not an idle timeout, so a streaming response is cut mid-turn however recently
|
||||||
|
// it wrote. Without this the 4-minute runTimeout is unreachable and the
|
||||||
|
// keep-alive below tops out at one tick — pacing a connection that is destroyed
|
||||||
|
// underneath it.
|
||||||
|
//
|
||||||
|
// That failure is INVISIBLE from in here: writes past the deadline return
|
||||||
|
// err == nil and their bytes are dropped, so there is nothing to detect on the
|
||||||
|
// write path. Only the client sees it, as a truncated stream it reports as a
|
||||||
|
// dropped connection. Hence a deadline set up front and refreshed per frame,
|
||||||
|
// rather than anything checked after the fact.
|
||||||
|
func openEventStream(c *gin.Context) *eventStream {
|
||||||
|
c.Header("Content-Type", "text/event-stream")
|
||||||
|
c.Header("Cache-Control", "no-cache")
|
||||||
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
s := &eventStream{c: c, rc: http.NewResponseController(c.Writer)}
|
||||||
|
// Probe once here rather than reporting per frame: a writer that can't take
|
||||||
|
// deadlines will fail identically on every write, and the operator needs to
|
||||||
|
// hear it once. If this fails the stream still works — it is just back to
|
||||||
|
// being cut at WriteTimeout, which is worth saying out loud.
|
||||||
|
if err := s.rc.SetWriteDeadline(time.Now().Add(sseWriteTimeout)); err != nil {
|
||||||
|
slog.Error("api: SSE write deadlines unavailable; long turns will be truncated at the server WriteTimeout", "error", err)
|
||||||
|
}
|
||||||
|
c.Writer.Flush()
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *eventStream) send(ev chatEvent) {
|
||||||
|
b, err := json.Marshal(ev)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("api: encode chat event", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.write(fmt.Sprintf("data: %s\n\n", b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *eventStream) write(frame string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
// Refresh for THIS write, so the stream as a whole is unbounded but no single
|
||||||
|
// write is. Error deliberately unchecked: openEventStream already reported
|
||||||
|
// whether deadlines work at all, and this call can only fail the same way, so
|
||||||
|
// checking here would log once per frame to say the same thing.
|
||||||
|
_ = s.rc.SetWriteDeadline(time.Now().Add(sseWriteTimeout))
|
||||||
|
_, _ = io.WriteString(s.c.Writer, frame)
|
||||||
|
s.c.Writer.Flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
// keepAlive writes an SSE comment frame on an interval until the returned
|
||||||
|
// function is called, so a long silence while the model thinks doesn't look like
|
||||||
|
// a dead connection to whatever sits in between. SSE ignores comment frames, so
|
||||||
|
// this costs the client nothing.
|
||||||
|
func (s *eventStream) keepAlive(every time.Duration) func() {
|
||||||
|
done := make(chan struct{})
|
||||||
|
stopped := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(stopped)
|
||||||
|
t := time.NewTicker(every)
|
||||||
|
defer t.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
case <-s.c.Request.Context().Done():
|
||||||
|
return
|
||||||
|
case <-t.C:
|
||||||
|
s.write(": keep-alive\n\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// Idempotent: the handler stops it explicitly when the run returns and again
|
||||||
|
// via defer, so a panic can't leak the goroutine.
|
||||||
|
var once sync.Once
|
||||||
|
return func() {
|
||||||
|
once.Do(func() { close(done) })
|
||||||
|
<-stopped
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// getAgentHistory returns the actor's thread for a garden.
|
// getAgentHistory returns the actor's thread for a garden.
|
||||||
func (h *handlers) getAgentHistory(c *gin.Context) {
|
func (h *handlers) getAgentHistory(c *gin.Context) {
|
||||||
gardenID, ok := parseIDParam(c, "id")
|
gardenID, ok := parseIDParam(c, "id")
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agentHolder owns the live assistant Runner and lets an admin swap it at
|
||||||
|
// runtime when the model settings change (#79).
|
||||||
|
//
|
||||||
|
// The Runner used to be a plain handlers field set once at boot, with the chat
|
||||||
|
// routes registered only when it existed. That made the assistant permanently
|
||||||
|
// whatever the environment said at startup. Now the routes are always
|
||||||
|
// registered and the Runner lives behind an atomic pointer, so a settings change
|
||||||
|
// can turn the assistant on, off, or onto a different model without a restart —
|
||||||
|
// and without a data race against in-flight requests reading the pointer.
|
||||||
|
//
|
||||||
|
// A nil pointer means "no assistant right now"; handlers nil-check get() rather
|
||||||
|
// than assuming a Runner is present.
|
||||||
|
type agentHolder struct {
|
||||||
|
svc *service.Service
|
||||||
|
|
||||||
|
ptr atomic.Pointer[agent.Runner]
|
||||||
|
// rebuildMu serializes rebuilds so two concurrent settings saves can't
|
||||||
|
// interleave into a torn "resolve A, resolve B, store A, store B" swap. The
|
||||||
|
// read path (get) stays lock-free on the atomic pointer.
|
||||||
|
rebuildMu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAgentHolder builds the holder and resolves the initial Runner from whatever
|
||||||
|
// the settings + environment currently say. A resolution failure is logged and
|
||||||
|
// left as "no assistant", never fatal: a garden planner must still boot.
|
||||||
|
func newAgentHolder(ctx context.Context, svc *service.Service) *agentHolder {
|
||||||
|
h := &agentHolder{svc: svc}
|
||||||
|
h.rebuild(ctx)
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns the current Runner, or nil if the assistant is off.
|
||||||
|
func (h *agentHolder) get() *agent.Runner { return h.ptr.Load() }
|
||||||
|
|
||||||
|
// rebuild resolves the effective agent configuration and swaps the Runner to
|
||||||
|
// match: a new one when the assistant should be on, nil when it shouldn't. It is
|
||||||
|
// safe to call at boot and from a settings save; concurrent calls serialize.
|
||||||
|
//
|
||||||
|
// It logs what it did rather than returning an error, because every caller wants
|
||||||
|
// the same thing — best-effort apply, keep serving either way — and a settings
|
||||||
|
// save must not fail just because the new model won't resolve. The save already
|
||||||
|
// validated the spec; a rebuild failure here means the environment changed under
|
||||||
|
// it, and "assistant off, with a reason in the log" is the right outcome.
|
||||||
|
func (h *agentHolder) rebuild(ctx context.Context) {
|
||||||
|
h.rebuildMu.Lock()
|
||||||
|
defer h.rebuildMu.Unlock()
|
||||||
|
|
||||||
|
eff, err := h.svc.EffectiveAgent(ctx)
|
||||||
|
if err != nil {
|
||||||
|
// EffectiveAgent errors only if the settings row can't be read — a DB fault,
|
||||||
|
// and a very transient one when it happens right after a settings write. We
|
||||||
|
// keep the current Runner rather than tear down a working assistant on a
|
||||||
|
// blip: the state is already persisted, so the next rebuild (any later save,
|
||||||
|
// or a restart) reconciles it. Loud, because a persistent failure here means
|
||||||
|
// the live assistant no longer matches stored settings.
|
||||||
|
slog.Error("api: could not resolve agent settings; leaving the assistant as-is", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !eff.Ready() {
|
||||||
|
if h.ptr.Swap(nil) != nil {
|
||||||
|
slog.Info("api: garden assistant turned off",
|
||||||
|
"enabled", eff.Enabled, "hasKey", eff.APIKey != "", "hasModel", eff.Model != "")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
runner, err := agent.NewRunner(h.svc, eff.APIKey, eff.Model)
|
||||||
|
if err != nil {
|
||||||
|
// Configured but unusable. Turn the assistant off rather than leaving a
|
||||||
|
// stale Runner on the old model — an admin who just pointed it at a broken
|
||||||
|
// spec should see it stop, not silently keep answering on the previous one.
|
||||||
|
slog.Error("api: garden assistant disabled (model won't resolve)", "error", err, "model", eff.Model)
|
||||||
|
h.ptr.Store(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.ptr.Store(runner)
|
||||||
|
slog.Info("api: garden assistant ready", "model", eff.Model)
|
||||||
|
}
|
||||||
@@ -6,24 +6,40 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestAgentRoutesAbsentWithoutAKey — an instance with no API key must start,
|
// TestAgentDisabledWithoutAKey — an instance with no API key must start, serve
|
||||||
// serve the app, and simply not offer the assistant. The routes aren't
|
// the app, and not offer the assistant.
|
||||||
// registered at all, so this is a 404 rather than a handler that apologizes:
|
//
|
||||||
// the same shape as OIDC when unconfigured.
|
// The contract CHANGED with #79: the chat route is now always registered (so a
|
||||||
func TestAgentRoutesAbsentWithoutAKey(t *testing.T) {
|
// settings change can turn the assistant on without a restart), so "off" is a
|
||||||
|
// runtime 503 rather than a missing route. capabilities reports agent:false, and
|
||||||
|
// the frontend keys the chat tab off that — so a user never reaches the 503.
|
||||||
|
func TestAgentDisabledWithoutAKey(t *testing.T) {
|
||||||
r := authEngine(t, localCfg()) // localCfg has no agent configuration
|
r := authEngine(t, localCfg()) // localCfg has no agent configuration
|
||||||
cookie := registerAndCookie(t, r, "[email protected]")
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
gid := createGardenAPI(t, r, cookie, "G")
|
gid := createGardenAPI(t, r, cookie, "G")
|
||||||
|
|
||||||
|
// Chat is refused, plainly, because there is no Runner to run.
|
||||||
w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
|
w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
|
||||||
map[string]any{"gardenId": gid, "message": "plant garlic"}, cookie)
|
map[string]any{"gardenId": gid, "message": "plant garlic"}, cookie)
|
||||||
if w.Code != http.StatusNotFound {
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
t.Errorf("chat without a key: status %d, want 404", w.Code)
|
t.Errorf("chat without a key: status %d, want 503", w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capabilities advertises the assistant as unavailable, which is what the UI
|
||||||
|
// actually consults.
|
||||||
|
w = doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("capabilities: status %d", w.Code)
|
||||||
|
}
|
||||||
|
if agent, _ := decodeMap(t, w.Body.Bytes())["agent"].(bool); agent {
|
||||||
|
t.Error("capabilities reported agent:true with no key")
|
||||||
|
}
|
||||||
|
|
||||||
|
// History is just stored data behind the ordinary garden-role check, so it
|
||||||
|
// reads fine (empty) whether or not a Runner exists — it isn't gated on one.
|
||||||
path := "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/agent/history"
|
path := "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/agent/history"
|
||||||
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusNotFound {
|
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusOK {
|
||||||
t.Errorf("history without a key: status %d, want 404", w.Code)
|
t.Errorf("history without a key: status %d, want 200 (it's data, not the model)", w.Code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// And the rest of the app is entirely unaffected.
|
// And the rest of the app is entirely unaffected.
|
||||||
|
|||||||
+47
-26
@@ -6,13 +6,13 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
sloggin "github.com/samber/slog-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/config"
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
)
|
)
|
||||||
@@ -23,9 +23,12 @@ type handlers struct {
|
|||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
svc *service.Service
|
svc *service.Service
|
||||||
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
|
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
|
||||||
// agent is nil unless the assistant is configured; the chat routes are only
|
// agent holds the live Runner behind an atomic pointer. Unlike oidc it is
|
||||||
// registered when it isn't, so a handler never has to check.
|
// never nil — the holder is always present and its Runner may be nil when the
|
||||||
agent *agent.Runner
|
// 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
|
// New builds the gin engine with the standard middleware stack and registers the
|
||||||
@@ -53,12 +56,10 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
// is set; see csrfGuard).
|
// is set; see csrfGuard).
|
||||||
v1.Use(h.csrfGuard())
|
v1.Use(h.csrfGuard())
|
||||||
v1.GET("/healthz", healthz)
|
v1.GET("/healthz", healthz)
|
||||||
// What this instance can actually do, so the UI offers only what works. The
|
// What this instance can actually do, so the UI offers only what works.
|
||||||
// agent routes 404 when unconfigured; without this the client would have to
|
// Registered after the agent below, because it reports whether the runner
|
||||||
// probe for a 404 to find that out, and a dead button is worse than no button.
|
// actually built — not merely whether it was configured to.
|
||||||
v1.GET("/capabilities", func(c *gin.Context) {
|
v1.GET("/capabilities", h.capabilities)
|
||||||
c.JSON(http.StatusOK, gin.H{"agent": cfg.Agent.Ready()})
|
|
||||||
})
|
|
||||||
|
|
||||||
// Auth endpoints are exempt from requireAuth (you can't be logged in yet);
|
// Auth endpoints are exempt from requireAuth (you can't be logged in yet);
|
||||||
// /me is the one that needs a session. Feature routers in later issues attach
|
// /me is the one that needs a session. Feature routers in later issues attach
|
||||||
@@ -121,6 +122,11 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
objects.PATCH("/:id", h.updateObject)
|
objects.PATCH("/:id", h.updateObject)
|
||||||
objects.DELETE("/:id", h.deleteObject)
|
objects.DELETE("/:id", h.deleteObject)
|
||||||
objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object
|
objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object
|
||||||
|
// Bulk ops. These wrap the same service methods the agent tools call, so an
|
||||||
|
// instance with no model configured still gets the most valuable operation in
|
||||||
|
// the app — and so "clear bed" is ONE change set rather than one per plop.
|
||||||
|
objects.POST("/:id/fill", h.fillObject)
|
||||||
|
objects.POST("/:id/clear", h.clearObject)
|
||||||
|
|
||||||
// Plantings ("plops") are addressed by their own id; the service resolves the
|
// Plantings ("plops") are addressed by their own id; the service resolves the
|
||||||
// owning object/garden for the permission check.
|
// owning object/garden for the permission check.
|
||||||
@@ -128,26 +134,30 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
plantings.PATCH("/:id", h.updatePlanting)
|
plantings.PATCH("/:id", h.updatePlanting)
|
||||||
plantings.DELETE("/:id", h.deletePlanting)
|
plantings.DELETE("/:id", h.deletePlanting)
|
||||||
|
|
||||||
// The garden assistant, registered only when it can actually be offered —
|
// The garden assistant. Its routes are registered UNCONDITIONALLY and the live
|
||||||
// the same shape as OIDC. An instance with no API key serves the app
|
// Runner sits behind an atomic pointer in the holder, so a settings change can
|
||||||
// normally and simply doesn't have these routes.
|
// turn the assistant on or off at runtime (#79). Each handler nil-checks
|
||||||
if cfg.Agent.Ready() {
|
// agent.get(); a chat request while the assistant is off gets a clean 503
|
||||||
runner, err := agent.NewRunner(svc, cfg)
|
// (AGENT_DISABLED), not a panic and not a missing route.
|
||||||
if err != nil {
|
//
|
||||||
// Configured but unusable (an unresolvable model spec, say). Log it and
|
// The holder resolves its initial Runner from settings + environment at
|
||||||
// carry on without the assistant rather than refusing to start: a
|
// construction. If the key never reaches the container, the assistant is off
|
||||||
// garden planner that won't boot because of a chat feature is worse
|
// and the reason is logged below — the same operability need #72 added.
|
||||||
// than one without chat.
|
h.agent = newAgentHolder(context.Background(), svc)
|
||||||
slog.Error("api: garden assistant disabled", "error", err)
|
if cfg.Agent.OllamaCloudAPIKey == "" {
|
||||||
} else {
|
slog.Info("api: garden assistant has no API key",
|
||||||
h.agent = runner
|
"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 := v1.Group("/agent", h.requireAuth())
|
||||||
agentGroup.POST("/chat", h.agentChat)
|
agentGroup.POST("/chat", h.agentChat)
|
||||||
gardens.GET("/:id/agent/history", h.getAgentHistory)
|
gardens.GET("/:id/agent/history", h.getAgentHistory)
|
||||||
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
|
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
|
||||||
slog.Info("api: garden assistant enabled", "model", cfg.Agent.Model)
|
|
||||||
}
|
// 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
|
// 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.
|
// owning garden for the permission check, same as objects and plantings.
|
||||||
@@ -186,6 +196,17 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// capabilities reports what this instance can actually do, so the UI offers only
|
||||||
|
// what works.
|
||||||
|
//
|
||||||
|
// 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.get() != nil})
|
||||||
|
}
|
||||||
|
|
||||||
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
|
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
|
||||||
func healthz(c *gin.Context) {
|
func healthz(c *gin.Context) {
|
||||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Bulk operations on a plantable object (#82): fill a region with one plant, and
|
||||||
|
// clear everything out of it.
|
||||||
|
//
|
||||||
|
// These were reachable only through the agent toolbox until now, which meant the
|
||||||
|
// most valuable bulk operation in a garden planner — and the one carrying the
|
||||||
|
// most carefully reasoned geometry in the codebase — did not exist at all on an
|
||||||
|
// instance with no model configured. They are thin adapters over the same
|
||||||
|
// service methods `internal/agent/tools.go` calls, so the permission checks and
|
||||||
|
// the one-change-set-per-operation guarantee come along unchanged.
|
||||||
|
|
||||||
|
// fillRect is an explicit rectangle in the object's local frame, the alternative
|
||||||
|
// to a compass name. A named type (not an inline anonymous struct) to match the
|
||||||
|
// rest of internal/api and so it can carry its own validity check.
|
||||||
|
type fillRect struct {
|
||||||
|
MinX float64 `json:"minXCm"`
|
||||||
|
MinY float64 `json:"minYCm"`
|
||||||
|
MaxX float64 `json:"maxXCm"`
|
||||||
|
MaxY float64 `json:"maxYCm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// degenerate reports whether the rect encloses no area. Such a rect (including
|
||||||
|
// the all-zeros an empty `"rect": {}` decodes to) would otherwise slip through
|
||||||
|
// and plant a single plop at the object's centre — a surprising result for what
|
||||||
|
// is really malformed input.
|
||||||
|
func (r fillRect) degenerate() bool {
|
||||||
|
return r.MaxX <= r.MinX || r.MaxY <= r.MinY
|
||||||
|
}
|
||||||
|
|
||||||
|
// objectFillRequest is the body for POST /objects/:id/fill.
|
||||||
|
//
|
||||||
|
// A region is given EITHER by compass name ("ne", "south half", "all") or as an
|
||||||
|
// explicit rect in the object's local frame. The named form is what a person
|
||||||
|
// means and what the agent uses; the rect is for a future drag-a-box affordance.
|
||||||
|
// Exactly one must be supplied — accepting both and silently preferring one
|
||||||
|
// would make a client bug look like a geometry bug.
|
||||||
|
type objectFillRequest struct {
|
||||||
|
PlantID int64 `json:"plantId" binding:"required"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Rect *fillRect `json:"rect"`
|
||||||
|
// SpacingOverrideCM plants tighter or looser than the plant's mature spacing
|
||||||
|
// without editing the catalog entry.
|
||||||
|
SpacingOverrideCM *float64 `json:"spacingOverrideCm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) fillObject(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req objectFillRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a plantId and a region are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
named, hasRect := req.Region != "", req.Rect != nil
|
||||||
|
if named == hasRect {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT",
|
||||||
|
`supply exactly one of "region" (e.g. "all", "ne", "south half") or "rect"`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
actor := mustActor(c).ID
|
||||||
|
var (
|
||||||
|
created []domain.Planting
|
||||||
|
err error
|
||||||
|
)
|
||||||
|
if rect := req.Rect; rect != nil {
|
||||||
|
// Reject a zero-area rect here rather than let it plant one stray plop.
|
||||||
|
// (Binding the pointer to `rect` also keeps the deref visibly guarded,
|
||||||
|
// instead of reading req.Rect.MinX under an invariant from a line above.)
|
||||||
|
if rect.degenerate() {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "rect must enclose a positive area")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
|
||||||
|
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM)
|
||||||
|
} else {
|
||||||
|
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 200, not 201: a fill can legitimately create nothing (the region is already
|
||||||
|
// planted), and there is no single resource to point a Location at.
|
||||||
|
c.JSON(http.StatusOK, gin.H{"plantings": created, "created": len(created)})
|
||||||
|
}
|
||||||
|
|
||||||
|
// clearObject soft-removes every active plop in an object.
|
||||||
|
//
|
||||||
|
// Distinct from deleting the object, and — unlike the client-side loop this
|
||||||
|
// replaces — it lands as ONE change set, so undoing a cleared bed is one click
|
||||||
|
// rather than one per plop.
|
||||||
|
func (h *handlers) clearObject(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n, err := h.svc.ClearObject(c.Request.Context(), mustActor(c).ID, id)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"cleared": n})
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func fillPath(id int64) string { return objectPath(id) + "/fill" }
|
||||||
|
func clearPath(id int64) string { return objectPath(id) + "/clear" }
|
||||||
|
|
||||||
|
// makeFillPlant creates a custom plant and returns its id. (A near-identical
|
||||||
|
// createPlantAPI landed alongside the seed-lot tests; consolidating the two into
|
||||||
|
// one shared helper is a fine follow-up, kept separate here only to avoid a
|
||||||
|
// merge collision on the shared symbol.)
|
||||||
|
func makeFillPlant(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
|
||||||
|
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedFillableBed makes a garden with one plantable bed and a custom plant,
|
||||||
|
// returning (gardenID, objectID, plantID).
|
||||||
|
func seedFillableBed(t *testing.T, r *gin.Engine, cookie *http.Cookie, w, h, spacing float64) (int64, int64, int64) {
|
||||||
|
t.Helper()
|
||||||
|
gid := createGardenAPI(t, r, cookie, "G")
|
||||||
|
rec := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
||||||
|
"kind": "bed", "widthCm": w, "heightCm": h, "plantable": true,
|
||||||
|
}, cookie)
|
||||||
|
if rec.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create bed: status %d, body %s", rec.Code, rec.Body.String())
|
||||||
|
}
|
||||||
|
objID := int64(decodeMap(t, rec.Body.Bytes())["id"].(float64))
|
||||||
|
plantID := makeFillPlant(t, r, cookie, "Fillable", spacing)
|
||||||
|
return gid, objID, plantID
|
||||||
|
}
|
||||||
|
|
||||||
|
// countChangeSets reads the history page and reports how many change sets exist.
|
||||||
|
func countChangeSets(t *testing.T, r *gin.Engine, cookie *http.Cookie, gardenID int64) int {
|
||||||
|
t.Helper()
|
||||||
|
w := doJSON(t, r, http.MethodGet, historyPath(gardenID), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("history: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
sets, _ := decodeMap(t, w.Body.Bytes())["changeSets"].([]any)
|
||||||
|
return len(sets)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFillAndClearAPI covers the two routes end to end through the router.
|
||||||
|
//
|
||||||
|
// These exist because both operations were previously reachable ONLY through the
|
||||||
|
// agent toolbox, so on an instance with no model configured the most valuable
|
||||||
|
// bulk operation in the app did not exist at all.
|
||||||
|
func TestFillAndClearAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
_, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
|
||||||
|
|
||||||
|
// Fill by compass name.
|
||||||
|
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
|
"plantId": plantID, "region": "all",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := decodeMap(t, w.Body.Bytes())
|
||||||
|
created := int(body["created"].(float64))
|
||||||
|
if created == 0 {
|
||||||
|
t.Fatalf("fill created nothing: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
if plops, _ := body["plantings"].([]any); len(plops) != created {
|
||||||
|
t.Errorf("created=%d but returned %d plantings", created, len(plops))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear it: one call, and it reports what it removed.
|
||||||
|
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("clear: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != created {
|
||||||
|
t.Errorf("cleared %d, want %d (everything the fill made)", n, created)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clearing an already-empty bed is a no-op, not an error.
|
||||||
|
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("second clear: status %d", w.Code)
|
||||||
|
}
|
||||||
|
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != 0 {
|
||||||
|
t.Errorf("second clear removed %d, want 0", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFillRegionSelectionAPI: exactly one of region/rect, and a rect fills only
|
||||||
|
// its own corner of the bed.
|
||||||
|
func TestFillRegionSelectionAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
_, objID, plantID := seedFillableBed(t, r, cookie, 400, 400, 20)
|
||||||
|
|
||||||
|
// Neither → 400. Both → 400. Accepting both and silently preferring one
|
||||||
|
// would make a client bug look like a geometry bug.
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{"plantId": plantID}, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("no region = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
both := map[string]any{
|
||||||
|
"plantId": plantID, "region": "all",
|
||||||
|
"rect": map[string]any{"minXCm": -50, "minYCm": -50, "maxXCm": 50, "maxYCm": 50},
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), both, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("both region and rect = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
// An unknown compass name is rejected rather than silently filling nothing.
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
|
"plantId": plantID, "region": "middle-ish",
|
||||||
|
}, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("bad region name = %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zero-area rect is malformed input, not "plant one at the centre". An empty
|
||||||
|
// `"rect": {}` decodes to all-zeros and must be caught the same way.
|
||||||
|
for _, rect := range []map[string]any{
|
||||||
|
{}, // {} → 0,0,0,0
|
||||||
|
{"minXCm": 10, "minYCm": 10, "maxXCm": 10, "maxYCm": 50}, // zero width
|
||||||
|
{"minXCm": 10, "minYCm": 50, "maxXCm": 50, "maxYCm": 50}, // zero height
|
||||||
|
{"minXCm": 50, "minYCm": 50, "maxXCm": 10, "maxYCm": 10}, // inverted
|
||||||
|
} {
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID),
|
||||||
|
map[string]any{"plantId": plantID, "rect": rect}, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("degenerate rect %v = %d, want 400", rect, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rect confined to the NE corner produces plops only there. Local frame:
|
||||||
|
// +x east, -y north.
|
||||||
|
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
|
"plantId": plantID,
|
||||||
|
"rect": map[string]any{"minXCm": 0, "minYCm": -200, "maxXCm": 200, "maxYCm": 0},
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("rect fill: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
plops, _ := decodeMap(t, w.Body.Bytes())["plantings"].([]any)
|
||||||
|
if len(plops) == 0 {
|
||||||
|
t.Fatal("rect fill created nothing")
|
||||||
|
}
|
||||||
|
for _, raw := range plops {
|
||||||
|
p := raw.(map[string]any)
|
||||||
|
if x, y := p["xCm"].(float64), p["yCm"].(float64); x < 0 || y > 0 {
|
||||||
|
t.Errorf("plop at (%v,%v) outside the NE rect", x, y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestClearObjectIsOneChangeSetAPI is the regression test for the behaviour this
|
||||||
|
// endpoint exists to restore.
|
||||||
|
//
|
||||||
|
// The UI used to clear a bed with a loop of PATCHes, and since every service
|
||||||
|
// mutation auto-scopes its own change set, clearing a 40-plop bed wrote 40 of
|
||||||
|
// them — 40 presses of Undo to put the bed back. CLAUDE.md states the rule
|
||||||
|
// directly: multi-row operations record together so they undo as one unit.
|
||||||
|
func TestClearObjectIsOneChangeSetAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
gid, objID, plantID := seedFillableBed(t, r, cookie, 300, 300, 20)
|
||||||
|
|
||||||
|
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||||
|
"plantId": plantID, "region": "all",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
created := int(decodeMap(t, w.Body.Bytes())["created"].(float64))
|
||||||
|
if created < 4 {
|
||||||
|
t.Fatalf("need several plops to make this meaningful, got %d", created)
|
||||||
|
}
|
||||||
|
|
||||||
|
before := countChangeSets(t, r, cookie, gid)
|
||||||
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("clear: status %d", w.Code)
|
||||||
|
}
|
||||||
|
if after := countChangeSets(t, r, cookie, gid); after != before+1 {
|
||||||
|
t.Errorf("clearing %d plops added %d change sets, want exactly 1", created, after-before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFillClearPermissionsAPI: a viewer may look but not fill or clear, and a
|
||||||
|
// stranger gets 404 because existence is masked.
|
||||||
|
func TestFillClearPermissionsAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
owner := registerAndCookie(t, r, "[email protected]")
|
||||||
|
viewer := registerAndCookie(t, r, "[email protected]")
|
||||||
|
stranger := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
gid, objID, plantID := seedFillableBed(t, r, owner, 200, 200, 20)
|
||||||
|
if w := doJSON(t, r, http.MethodPost, sharesPath(gid),
|
||||||
|
map[string]any{"email": "[email protected]", "role": "viewer"}, owner); w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("share as viewer: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
fillBody := map[string]any{"plantId": plantID, "region": "all"}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, viewer); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("viewer fill = %d, want 403 (they can see it but may not do that)", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, viewer); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("viewer clear = %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, stranger); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("stranger fill = %d, want 404 (existence masked)", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, stranger); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("stranger clear = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, nil); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("anonymous fill = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, nil); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("anonymous clear = %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
func seedLotPath(id int64) string {
|
||||||
|
return "/api/v1/seed-lots/" + strconv.FormatInt(id, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeList decodes a bare JSON array body. Seed lot listing returns the array
|
||||||
|
// directly rather than wrapping it (unlike /journal's {"entries": …}), so a
|
||||||
|
// helper that assumed an object would quietly read nothing.
|
||||||
|
func decodeList(t *testing.T, body []byte) []any {
|
||||||
|
t.Helper()
|
||||||
|
var out []any
|
||||||
|
if err := json.Unmarshal(body, &out); err != nil {
|
||||||
|
t.Fatalf("decode list: %v (%s)", err, body)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// createPlantAPI makes a custom plant and returns its id.
|
||||||
|
func createPlantAPI(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
|
||||||
|
t.Helper()
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
|
||||||
|
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedLotCrudAPI walks the whole seed lot lifecycle over HTTP.
|
||||||
|
//
|
||||||
|
// It exists for the reason CLAUDE.md gives: service tests cannot see a route
|
||||||
|
// that was never registered, or one registered with the wrong :param name. Every
|
||||||
|
// other handler file had a sibling API test; this group did not, which is the
|
||||||
|
// state PATCH/DELETE /journal/:id shipped in — implemented, unit-tested, and
|
||||||
|
// completely unreachable.
|
||||||
|
func TestSeedLotCrudAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
plantID := createPlantAPI(t, r, cookie, "Music Garlic", 15)
|
||||||
|
|
||||||
|
// Create.
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||||
|
"plantId": plantID, "vendor": "Johnny's", "sku": "2761",
|
||||||
|
"quantity": 100, "unit": "seeds", "packedForYear": 2026, "costCents": 495,
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
lot := decodeMap(t, w.Body.Bytes())
|
||||||
|
id := int64(lot["id"].(float64))
|
||||||
|
if lot["vendor"] != "Johnny's" || lot["unit"] != "seeds" {
|
||||||
|
t.Errorf("unexpected lot: %+v", lot)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET by id — the route most likely to be missing or mis-registered.
|
||||||
|
w = doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := decodeMap(t, w.Body.Bytes()); int64(got["id"].(float64)) != id {
|
||||||
|
t.Errorf("get returned id %v, want %d", got["id"], id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List, and the ?plantId= filter.
|
||||||
|
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if n := len(decodeList(t, w.Body.Bytes())); n != 1 {
|
||||||
|
t.Fatalf("list returned %d lots, want 1: %s", n, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
other := createPlantAPI(t, r, cookie, "Cherokee Purple", 45)
|
||||||
|
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+strconv.FormatInt(other, 10), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("filtered list: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
|
||||||
|
t.Errorf("filter by a plant with no lots returned %d, want 0", n)
|
||||||
|
}
|
||||||
|
// A bad plantId filter is 400, whether non-numeric or out of range — the
|
||||||
|
// handler rejects id < 1, not just unparseable strings.
|
||||||
|
for _, bad := range []string{"nope", "0", "-1"} {
|
||||||
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+bad, nil, cookie); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("plantId=%q filter = %d, want 400", bad, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH with the current version.
|
||||||
|
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
|
||||||
|
"vendor": "Fedco", "version": lot["version"],
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("patch: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
updated := decodeMap(t, w.Body.Bytes())
|
||||||
|
if updated["vendor"] != "Fedco" {
|
||||||
|
t.Errorf("vendor = %v, want Fedco", updated["vendor"])
|
||||||
|
}
|
||||||
|
if updated["version"].(float64) != lot["version"].(float64)+1 {
|
||||||
|
t.Errorf("patch didn't bump version: %v -> %v", lot["version"], updated["version"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stale version conflicts and carries the current row back, so the client
|
||||||
|
// can rebase without a second request.
|
||||||
|
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
|
||||||
|
"vendor": "stale", "version": lot["version"],
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("stale patch: status %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["vendor"] != "Fedco" {
|
||||||
|
t.Errorf("409 body missing the current row: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE.
|
||||||
|
if w := doJSON(t, r, http.MethodDelete, seedLotPath(id), nil, cookie); w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("get after delete = %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedLotRemainingIsDerivedAPI checks that `remaining` reflects plantings
|
||||||
|
// through the HTTP surface, not just in the service.
|
||||||
|
//
|
||||||
|
// DESIGN.md makes derivation load-bearing — "a decremented column drifts the
|
||||||
|
// moment a planting is edited behind its back" — so a route that returned a
|
||||||
|
// stored or stale figure would break the invariant silently, and the number is
|
||||||
|
// the whole reason anyone opens the seed shelf.
|
||||||
|
func TestSeedLotRemainingIsDerivedAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
plantID := createPlantAPI(t, r, cookie, "Beans", 10)
|
||||||
|
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||||
|
"plantId": plantID, "quantity": 50, "unit": "seeds",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create lot: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
|
||||||
|
gid := createGardenAPI(t, r, cookie, "G")
|
||||||
|
w = doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
||||||
|
"kind": "bed", "widthCm": 100, "heightCm": 100, "plantable": true,
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create object: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
oid := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
|
||||||
|
// Plant 12 of them against the lot.
|
||||||
|
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
|
||||||
|
"plantId": plantID, "xCm": 0, "yCm": 0, "radiusCm": 20, "count": 12, "seedLotId": lotID,
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create planting: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get lot: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
got := decodeMap(t, w.Body.Bytes())
|
||||||
|
if rem, ok := got["remaining"].(float64); !ok || rem != 38 {
|
||||||
|
t.Errorf("remaining = %v, want 38 (50 bought - 12 planted): %s", got["remaining"], w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Over-plant it: 45 more (57 total against 50 bought) drives remaining
|
||||||
|
// NEGATIVE. That's deliberate — the number is a derived truth about what
|
||||||
|
// you've committed, not a floor clamped at zero, and "you've planted more than
|
||||||
|
// you bought" is exactly the signal a gardener wants rather than a hidden -7.
|
||||||
|
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
|
||||||
|
"plantId": plantID, "xCm": 40, "yCm": 40, "radiusCm": 20, "count": 45, "seedLotId": lotID,
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("over-plant: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
|
||||||
|
if rem, ok := decodeMap(t, w.Body.Bytes())["remaining"].(float64); !ok || rem != -7 {
|
||||||
|
t.Errorf("remaining after over-planting = %v, want -7 (50 - 57)", rem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedLotsArePrivateAPI checks the ACL through the router.
|
||||||
|
//
|
||||||
|
// Lots are private to the buyer and deliberately never travel with a shared
|
||||||
|
// garden, so another user must not be able to read or edit one. Per the
|
||||||
|
// project's convention, no-access is ErrNotFound rather than ErrForbidden —
|
||||||
|
// existence is masked — so every one of these is a 404, not a 403.
|
||||||
|
func TestSeedLotsArePrivateAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
alice := registerAndCookie(t, r, "[email protected]")
|
||||||
|
bob := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
plantID := createPlantAPI(t, r, alice, "Alice's garlic", 15)
|
||||||
|
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||||
|
"plantId": plantID, "vendor": "Secret Vendor", "quantity": 10, "unit": "bulbs",
|
||||||
|
}, alice)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("alice create: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
method string
|
||||||
|
body any
|
||||||
|
}{
|
||||||
|
{"get", http.MethodGet, nil},
|
||||||
|
{"patch", http.MethodPatch, map[string]any{"vendor": "hijacked", "version": 1}},
|
||||||
|
{"delete", http.MethodDelete, nil},
|
||||||
|
} {
|
||||||
|
if w := doJSON(t, r, tc.method, seedLotPath(lotID), tc.body, bob); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("bob %s = %d, want 404 (existence is masked)", tc.name, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bob's own listing must not include it either.
|
||||||
|
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, bob)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("bob list: status %d", w.Code)
|
||||||
|
}
|
||||||
|
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
|
||||||
|
t.Errorf("bob sees %d of alice's lots, want 0: %s", n, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// And it's still intact for alice.
|
||||||
|
if w := doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, alice); w.Code != http.StatusOK {
|
||||||
|
t.Errorf("alice lost access to her own lot: %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeedLotsRequireAuthAPI: the group is behind requireAuth, and an
|
||||||
|
// unauthenticated caller gets 401 rather than an empty list.
|
||||||
|
func TestSeedLotsRequireAuthAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, path string
|
||||||
|
}{
|
||||||
|
{http.MethodGet, "/api/v1/seed-lots"},
|
||||||
|
{http.MethodPost, "/api/v1/seed-lots"},
|
||||||
|
{http.MethodGet, seedLotPath(1)},
|
||||||
|
{http.MethodPatch, seedLotPath(1)},
|
||||||
|
{http.MethodDelete, seedLotPath(1)},
|
||||||
|
} {
|
||||||
|
if w := doJSON(t, r, tc.method, tc.path, nil, nil); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("%s %s = %d, want 401", tc.method, tc.path, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Instance settings (#79): admin-only, instance-wide. The authoritative admin
|
||||||
|
// check is in the service; requireAdmin here is a cheap early 403 that also
|
||||||
|
// keeps the route group readable.
|
||||||
|
|
||||||
|
// requireAdmin rejects a non-admin actor. It runs after requireAuth, so the
|
||||||
|
// actor is already resolved and carries IsAdmin — no extra query. Returns 403
|
||||||
|
// (not 404): a logged-in user knows settings exist, they just may not touch them.
|
||||||
|
func (h *handlers) requireAdmin() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if !mustActor(c).IsAdmin {
|
||||||
|
writeAPIError(c, http.StatusForbidden, "FORBIDDEN", "admin access required")
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsResponse is what GET/PATCH /settings return. It carries the stored
|
||||||
|
// settings plus a read-only view of what's resolved and live, so the UI can show
|
||||||
|
// "inheriting ollama-cloud/glm-5.2:cloud from the environment" and whether a key
|
||||||
|
// is present — without ever exposing the key itself.
|
||||||
|
type settingsResponse struct {
|
||||||
|
Settings *domain.InstanceSettings `json:"settings"`
|
||||||
|
// Effective is the configuration actually in force after layering settings
|
||||||
|
// over the environment.
|
||||||
|
Effective effectiveView `json:"effective"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type effectiveView struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
// HasApiKey reports whether OLLAMA_CLOUD_API_KEY is set. The key itself is
|
||||||
|
// never serialized — an admin may know one exists, not what it is.
|
||||||
|
HasApiKey bool `json:"hasApiKey"`
|
||||||
|
// AgentLive is whether the assistant Runner is actually built right now. It
|
||||||
|
// can be false even when Enabled+HasApiKey are true (an unresolvable model),
|
||||||
|
// which is exactly the case the UI needs to surface.
|
||||||
|
AgentLive bool `json:"agentLive"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsPayload builds the response, or an error. It does NOT swallow an
|
||||||
|
// EffectiveAgent failure into a misleading empty "effective" view — an empty
|
||||||
|
// view would report no model and no key, which reads as "nothing configured"
|
||||||
|
// rather than "we couldn't read it". Since EffectiveAgent re-reads the same row
|
||||||
|
// GetInstanceSettings just returned, a failure here is a genuine DB fault worth
|
||||||
|
// surfacing as a 500, not papering over.
|
||||||
|
func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings) (settingsResponse, error) {
|
||||||
|
eff, err := h.svc.EffectiveAgent(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
return settingsResponse{}, err
|
||||||
|
}
|
||||||
|
return settingsResponse{
|
||||||
|
Settings: st,
|
||||||
|
Effective: effectiveView{
|
||||||
|
Model: eff.Model,
|
||||||
|
Enabled: eff.Enabled,
|
||||||
|
HasApiKey: eff.APIKey != "",
|
||||||
|
AgentLive: h.agent.get() != nil,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) getSettings(c *gin.Context) {
|
||||||
|
st, err := h.svc.GetInstanceSettings(c.Request.Context(), mustActor(c).ID)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
payload, err := h.settingsPayload(c, st)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingsUpdateRequest is the PATCH body. agentModel "" means inherit the env
|
||||||
|
// var. agentEnabled is json.RawMessage so an explicit null (inherit) is
|
||||||
|
// distinguishable from an absent field and from true/false.
|
||||||
|
type settingsUpdateRequest struct {
|
||||||
|
AgentModel string `json:"agentModel"`
|
||||||
|
AgentEnabled json.RawMessage `json:"agentEnabled"`
|
||||||
|
Version int64 `json:"version" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) updateSettings(c *gin.Context) {
|
||||||
|
var req settingsUpdateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a current version is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// agentEnabled: absent or null → inherit (nil); true/false → explicit override.
|
||||||
|
// The shared parseNullable does exactly this three-way decode; present is
|
||||||
|
// irrelevant here because absent and null both mean "inherit".
|
||||||
|
enabled, _, err := parseNullable[bool](req.AgentEnabled)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "agentEnabled must be true, false, or null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
st, err := h.svc.UpdateInstanceSettings(c.Request.Context(), mustActor(c).ID, service.InstanceSettingsPatch{
|
||||||
|
AgentModel: req.AgentModel,
|
||||||
|
AgentEnabled: enabled,
|
||||||
|
Version: req.Version,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrVersionConflict) {
|
||||||
|
writeVersionConflict(c, st)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the change to the LIVE assistant. Detached from the request context:
|
||||||
|
// the write is committed and the rebuild describes it, so a client that hangs
|
||||||
|
// up now must not leave the running Runner out of step with the stored
|
||||||
|
// settings. Mirrors the same reasoning as the history-write detachment.
|
||||||
|
h.agent.rebuild(context.WithoutCancel(c.Request.Context()))
|
||||||
|
|
||||||
|
payload, err := h.settingsPayload(c, st)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, payload)
|
||||||
|
}
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agentCfg is a config with the assistant configured — a (fake) key, on, and a
|
||||||
|
// resolvable model. The key isn't real, but ValidateAgentModel/NewRunner only
|
||||||
|
// PARSE the spec (no live call), so a Runner still builds and capabilities
|
||||||
|
// reports it live. That's enough to exercise the runtime on/off swap.
|
||||||
|
func agentCfg() *config.Config {
|
||||||
|
c := localCfg()
|
||||||
|
c.Agent = config.AgentConfig{
|
||||||
|
Model: "ollama-cloud/glm-5.2:cloud",
|
||||||
|
OllamaCloudAPIKey: "test-key",
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsVersion(t *testing.T, r *gin.Engine, cookie *http.Cookie) int64 {
|
||||||
|
t.Helper()
|
||||||
|
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get settings: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
st, _ := decodeMap(t, w.Body.Bytes())["settings"].(map[string]any)
|
||||||
|
return int64(st["version"].(float64))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsAdminOnly: the first registered user is admin and can read/write
|
||||||
|
// settings; a second user is not and gets 403 (not 404 — settings aren't a
|
||||||
|
// masked resource).
|
||||||
|
func TestSettingsAdminOnly(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
admin := registerAndCookie(t, r, "[email protected]") // first user → admin
|
||||||
|
member := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("admin GET settings: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, member); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("member GET settings: status %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "x", "version": 1}, member); w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("member PATCH settings: status %d, want 403", w.Code)
|
||||||
|
}
|
||||||
|
// Unauthenticated is 401, before the admin check.
|
||||||
|
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, nil); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("anonymous GET settings: status %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsInheritFromEnv: an untouched instance reports the env model as
|
||||||
|
// effective, and an empty stored model keeps inheriting it.
|
||||||
|
func TestSettingsInheritFromEnv(t *testing.T) {
|
||||||
|
r := authEngine(t, agentCfg())
|
||||||
|
admin := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := decodeMap(t, w.Body.Bytes())
|
||||||
|
st := body["settings"].(map[string]any)
|
||||||
|
eff := body["effective"].(map[string]any)
|
||||||
|
|
||||||
|
if st["agentModel"] != "" {
|
||||||
|
t.Errorf("stored model = %v, want empty (inherit)", st["agentModel"])
|
||||||
|
}
|
||||||
|
if eff["model"] != "ollama-cloud/glm-5.2:cloud" {
|
||||||
|
t.Errorf("effective model = %v, want the env value", eff["model"])
|
||||||
|
}
|
||||||
|
if eff["hasApiKey"] != true || eff["agentLive"] != true {
|
||||||
|
t.Errorf("effective = %+v, want a key present and the agent live", eff)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsUpdateSwapsTheRunner is the core of #79: changing settings takes
|
||||||
|
// effect on the LIVE assistant, with no restart. Driven entirely through HTTP.
|
||||||
|
func TestSettingsUpdateSwapsTheRunner(t *testing.T) {
|
||||||
|
r := authEngine(t, agentCfg())
|
||||||
|
admin := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
capsAgent := func() bool {
|
||||||
|
w := doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
|
||||||
|
return decodeMap(t, w.Body.Bytes())["agent"] == true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configured and on out of the box.
|
||||||
|
if !capsAgent() {
|
||||||
|
t.Fatal("assistant should be live at boot with a key + enabled")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn it OFF via settings → capabilities flips immediately.
|
||||||
|
v := settingsVersion(t, r, admin)
|
||||||
|
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("disable: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if capsAgent() {
|
||||||
|
t.Error("assistant still live after being disabled — the runner wasn't swapped")
|
||||||
|
}
|
||||||
|
// Chat now refuses, at runtime, on a route that still exists.
|
||||||
|
gid := createGardenAPI(t, r, admin, "G")
|
||||||
|
if w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
|
||||||
|
map[string]any{"gardenId": gid, "message": "hi"}, admin); w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("chat while disabled: status %d, want 503", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn it back ON, with an explicit model, and confirm it's live again and the
|
||||||
|
// effective model reflects the change.
|
||||||
|
v = settingsVersion(t, r, admin)
|
||||||
|
w = doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{
|
||||||
|
"agentModel": "ollama-cloud/kimi-k2.6:cloud", "agentEnabled": true, "version": v,
|
||||||
|
}, admin)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("re-enable: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if eff := decodeMap(t, w.Body.Bytes())["effective"].(map[string]any); eff["model"] != "ollama-cloud/kimi-k2.6:cloud" {
|
||||||
|
t.Errorf("effective model = %v after change, want the new one", eff["model"])
|
||||||
|
}
|
||||||
|
if !capsAgent() {
|
||||||
|
t.Error("assistant not live after being re-enabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsRejectsBadModel: a spec that won't resolve is a 400 at save time,
|
||||||
|
// not a broken assistant on the next turn.
|
||||||
|
func TestSettingsRejectsBadModel(t *testing.T) {
|
||||||
|
r := authEngine(t, agentCfg())
|
||||||
|
admin := registerAndCookie(t, r, "[email protected]")
|
||||||
|
v := settingsVersion(t, r, admin)
|
||||||
|
|
||||||
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("bad model: status %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
// agentEnabled must be a bool or null, not a string.
|
||||||
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("string agentEnabled: status %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsVersionConflict: a stale version 409s and carries the current row.
|
||||||
|
func TestSettingsVersionConflict(t *testing.T) {
|
||||||
|
r := authEngine(t, agentCfg())
|
||||||
|
admin := registerAndCookie(t, r, "[email protected]")
|
||||||
|
v := settingsVersion(t, r, admin)
|
||||||
|
|
||||||
|
// First write succeeds and bumps the version.
|
||||||
|
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin); w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("first update: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
// Reusing the old version conflicts.
|
||||||
|
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "", "agentEnabled": true, "version": v}, admin)
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Fatalf("stale update: status %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["version"].(float64) != float64(v+1) {
|
||||||
|
t.Errorf("409 body missing the current row at the bumped version: %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSettingsSwapUnderRace runs settings saves concurrently with chat requests,
|
||||||
|
// so `go test -race` proves the atomic swap of the live Runner is safe against
|
||||||
|
// in-flight readers. The whole point of the atomic.Pointer is this: without it,
|
||||||
|
// toggling the assistant while a request reads it is a data race.
|
||||||
|
func TestSettingsSwapUnderRace(t *testing.T) {
|
||||||
|
r := authEngine(t, agentCfg())
|
||||||
|
admin := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
// Readers hammer capabilities, whose h.agent.get() is the SAME atomic Load the
|
||||||
|
// chat handler does — so this races the pointer read against the writer's swap
|
||||||
|
// without ever invoking the model (a real Run would hit the network on a fake
|
||||||
|
// key). If get() is race-clean here it is race-clean in chat.
|
||||||
|
for i := 0; i < 4; i++ {
|
||||||
|
go func() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
// Writer: flip the assistant on and off, swapping the pointer each time.
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
v := settingsVersion(t, r, admin)
|
||||||
|
doJSON(t, r, http.MethodPatch, "/api/v1/settings",
|
||||||
|
map[string]any{"agentModel": "", "agentEnabled": i%2 == 0, "version": v}, admin)
|
||||||
|
}
|
||||||
|
close(done)
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// streamFrames spins up a real http.Server with the given WriteTimeout and an
|
||||||
|
// SSE handler that emits `frames` data frames, one every `tick`, then returns.
|
||||||
|
// It reports how many frames the client actually received and any read error —
|
||||||
|
// the only vantage point from which the deadline failures in #78/#87 are
|
||||||
|
// visible, since the writes themselves return nil when the bytes are dropped.
|
||||||
|
func streamFrames(t *testing.T, serverWriteTimeout, tick time.Duration, frames int) (int, error) {
|
||||||
|
t.Helper()
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
r := gin.New()
|
||||||
|
r.GET("/stream", func(c *gin.Context) {
|
||||||
|
s := openEventStream(c)
|
||||||
|
for i := 0; i < frames; i++ {
|
||||||
|
time.Sleep(tick)
|
||||||
|
s.send(chatEvent{Error: "frame"})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := httptest.NewUnstartedServer(r)
|
||||||
|
srv.Config.WriteTimeout = serverWriteTimeout
|
||||||
|
srv.Start()
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
resp, err := srv.Client().Get(srv.URL + "/stream")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
got := 0
|
||||||
|
sc := bufio.NewScanner(resp.Body)
|
||||||
|
for sc.Scan() {
|
||||||
|
if strings.HasPrefix(sc.Text(), "data: ") {
|
||||||
|
got++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return got, sc.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventStreamOutlivesServerWriteTimeout is the regression test for #78.
|
||||||
|
//
|
||||||
|
// http.Server.WriteTimeout is an ABSOLUTE deadline measured from when the
|
||||||
|
// request header was read — not an idle timeout — so a streaming response is cut
|
||||||
|
// once it passes, however recently the handler wrote. pansy sets it to 30s while
|
||||||
|
// an agent turn may run for minutes. openEventStream must override it.
|
||||||
|
//
|
||||||
|
// This has to be asserted from the CLIENT side because the failure cannot be
|
||||||
|
// observed from the handler: writes made after the deadline return err == nil
|
||||||
|
// and their bytes are silently discarded. A test that checked the return of
|
||||||
|
// io.WriteString would pass against the bug.
|
||||||
|
func TestEventStreamOutlivesServerWriteTimeout(t *testing.T) {
|
||||||
|
// sseWriteTimeout stays at its 30s default here, so the per-frame refresh
|
||||||
|
// keeps the stream alive with a huge margin — CI slowness only ever makes
|
||||||
|
// this pass more surely. The server's 300ms WriteTimeout is the thing being
|
||||||
|
// overridden; frames straddle it (300ms/600ms/900ms).
|
||||||
|
got, err := streamFrames(t, 300*time.Millisecond, 300*time.Millisecond, 3)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("client read error after %d/3 frames: %v", got, err)
|
||||||
|
}
|
||||||
|
if got != 3 {
|
||||||
|
t.Errorf("client received %d frames, want 3 — the stream was cut at the server WriteTimeout", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEventStreamRefreshesDeadlinePerFrame guards the #87 fix specifically: the
|
||||||
|
// write deadline is refreshed on EVERY frame, not set once.
|
||||||
|
//
|
||||||
|
// A set-once deadline is a plausible "simplification" and it reintroduces the
|
||||||
|
// unbounded-block risk the per-frame refresh exists to prevent — yet it would
|
||||||
|
// sail through the test above, whose whole run is far under sseWriteTimeout. So
|
||||||
|
// shrink sseWriteTimeout below the stream's total duration and send frames whose
|
||||||
|
// gap stays comfortably under it: per-frame refresh delivers them all, while a
|
||||||
|
// deadline set once at open would expire mid-stream and cut it short.
|
||||||
|
func TestEventStreamRefreshesDeadlinePerFrame(t *testing.T) {
|
||||||
|
orig := sseWriteTimeout
|
||||||
|
sseWriteTimeout = 400 * time.Millisecond
|
||||||
|
t.Cleanup(func() { sseWriteTimeout = orig })
|
||||||
|
|
||||||
|
// The server WriteTimeout is generous (5s), so it isn't the limiter — the
|
||||||
|
// per-frame sseWriteTimeout is. 8 frames at a 100ms tick span 800ms, well past
|
||||||
|
// the 400ms deadline, but each 100ms gap is a 4× margin under it.
|
||||||
|
got, err := streamFrames(t, 5*time.Second, 100*time.Millisecond, 8)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("client read error after %d/8 frames: %v", got, err)
|
||||||
|
}
|
||||||
|
if got != 8 {
|
||||||
|
t.Errorf("client received %d frames, want 8 — a set-once deadline would cut the stream at ~%v; the refresh must be per-frame",
|
||||||
|
got, sseWriteTimeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,13 +76,6 @@ type AgentConfig struct {
|
|||||||
Enabled bool
|
Enabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ready reports whether the assistant can actually be offered. Both the route
|
|
||||||
// registration and whatever advertises capabilities gate on this, so what's
|
|
||||||
// advertised always matches what's live.
|
|
||||||
func (a AgentConfig) Ready() bool {
|
|
||||||
return a.Enabled && a.OllamaCloudAPIKey != "" && a.Model != ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enabled reports whether enough OIDC config is present to attempt discovery.
|
// Enabled reports whether enough OIDC config is present to attempt discovery.
|
||||||
func (o OIDCConfig) Enabled() bool {
|
func (o OIDCConfig) Enabled() bool {
|
||||||
return o.Issuer != "" && o.ClientID != ""
|
return o.Issuer != "" && o.ClientID != ""
|
||||||
|
|||||||
@@ -242,6 +242,22 @@ const (
|
|||||||
ConflictUnsupported = "unsupported"
|
ConflictUnsupported = "unsupported"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// InstanceSettings is the single row of instance-wide, admin-editable
|
||||||
|
// configuration (#79). Secrets are deliberately absent — see migration 0010.
|
||||||
|
//
|
||||||
|
// Both agent fields express "inherit from the environment unless set":
|
||||||
|
// AgentModel == "" falls back to PANSY_AGENT_MODEL; AgentEnabled == nil inherits
|
||||||
|
// the env default. EffectiveAgent resolves them against the environment.
|
||||||
|
type InstanceSettings struct {
|
||||||
|
// AgentModel overrides PANSY_AGENT_MODEL when non-empty. Passed verbatim to
|
||||||
|
// majordomo.Parse, exactly like the env var it shadows.
|
||||||
|
AgentModel string `json:"agentModel"`
|
||||||
|
// AgentEnabled overrides PANSY_AGENT_ENABLED when non-nil. nil = inherit.
|
||||||
|
AgentEnabled *bool `json:"agentEnabled"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
// User is a pansy account. It may have a local password, OIDC identity, or both.
|
// User is a pansy account. It may have a local password, OIDC identity, or both.
|
||||||
type User struct {
|
type User struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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
|
||||||
|
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)
|
||||||
|
// Validate a non-empty spec up front. An empty one is the "inherit env"
|
||||||
|
// sentinel and needs no check — the env value was validated at boot.
|
||||||
|
if model != "" {
|
||||||
|
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, model); err != nil {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
|
||||||
|
AgentModel: model,
|
||||||
|
AgentEnabled: patch.AgentEnabled,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// settingsTestService builds a service whose env config carries the given agent
|
||||||
|
// model/enabled/key, so EffectiveAgent's env fallback can be exercised.
|
||||||
|
func settingsTestService(t *testing.T, envModel string, envEnabled bool, key string) (*Service, int64) {
|
||||||
|
t.Helper()
|
||||||
|
cfg := openConfig()
|
||||||
|
cfg.Agent = config.AgentConfig{Model: envModel, Enabled: envEnabled, OllamaCloudAPIKey: key}
|
||||||
|
s := newTestService(t, cfg)
|
||||||
|
admin := seedUser(t, s, "[email protected]") // first user is admin
|
||||||
|
return s, admin
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRequireAdmin: the first user is admin; a second is not and gets
|
||||||
|
// ErrForbidden (not ErrNotFound — settings existence isn't masked).
|
||||||
|
func TestRequireAdmin(t *testing.T) {
|
||||||
|
s, admin := settingsTestService(t, "ollama-cloud/x", true, "k")
|
||||||
|
member := seedUser(t, s, "[email protected]")
|
||||||
|
|
||||||
|
if err := s.requireAdmin(context.Background(), admin); err != nil {
|
||||||
|
t.Errorf("admin rejected: %v", err)
|
||||||
|
}
|
||||||
|
if err := s.requireAdmin(context.Background(), member); !errors.Is(err, domain.ErrForbidden) {
|
||||||
|
t.Errorf("member requireAdmin = %v, want ErrForbidden", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEffectiveAgentLayering: DB settings override env; the API key always comes
|
||||||
|
// from env; the "inherit" sentinels fall back.
|
||||||
|
func TestEffectiveAgentLayering(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, admin := settingsTestService(t, "ollama-cloud/env-model", true, "envkey")
|
||||||
|
|
||||||
|
// Untouched: everything inherits env.
|
||||||
|
eff, err := s.EffectiveAgent(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("effective: %v", err)
|
||||||
|
}
|
||||||
|
if eff.Model != "ollama-cloud/env-model" || !eff.Enabled || eff.APIKey != "envkey" {
|
||||||
|
t.Errorf("inherited effective = %+v, want the env values", eff)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Override the model only; enabled still inherits env (true).
|
||||||
|
cur, _ := s.GetInstanceSettings(ctx, admin)
|
||||||
|
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
|
||||||
|
AgentModel: "ollama-cloud/glm-5.2:cloud", Version: cur.Version,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update model: %v", err)
|
||||||
|
}
|
||||||
|
eff, _ = s.EffectiveAgent(ctx)
|
||||||
|
if eff.Model != "ollama-cloud/glm-5.2:cloud" {
|
||||||
|
t.Errorf("model = %q, want the DB override", eff.Model)
|
||||||
|
}
|
||||||
|
if !eff.Enabled {
|
||||||
|
t.Error("enabled should still inherit env (true) when unset")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now override enabled to false explicitly.
|
||||||
|
cur, _ = s.GetInstanceSettings(ctx, admin)
|
||||||
|
no := false
|
||||||
|
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
|
||||||
|
AgentModel: "ollama-cloud/glm-5.2:cloud", AgentEnabled: &no, Version: cur.Version,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("update enabled: %v", err)
|
||||||
|
}
|
||||||
|
eff, _ = s.EffectiveAgent(ctx)
|
||||||
|
if eff.Enabled {
|
||||||
|
t.Error("enabled should be the explicit false override now")
|
||||||
|
}
|
||||||
|
if eff.Ready() {
|
||||||
|
t.Error("Ready() should be false when disabled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUpdateInstanceSettingsRejectsBadModel: a spec that won't resolve is
|
||||||
|
// ErrInvalidInput, before it is stored.
|
||||||
|
func TestUpdateInstanceSettingsRejectsBadModel(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, admin := settingsTestService(t, "ollama-cloud/x", true, "k")
|
||||||
|
cur, _ := s.GetInstanceSettings(ctx, admin)
|
||||||
|
|
||||||
|
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
|
||||||
|
AgentModel: "nonesuch/model", Version: cur.Version,
|
||||||
|
}); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
t.Errorf("bad model = %v, want ErrInvalidInput", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rejected write didn't touch the row.
|
||||||
|
after, _ := s.GetInstanceSettings(ctx, admin)
|
||||||
|
if after.Version != cur.Version || after.AgentModel != "" {
|
||||||
|
t.Errorf("a rejected update changed the row: %+v", after)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInstanceSettingsAdminGate: the read/write operations are admin-gated at the
|
||||||
|
// service seam, not just in the handler.
|
||||||
|
func TestInstanceSettingsAdminGate(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s, _ := settingsTestService(t, "ollama-cloud/x", true, "k")
|
||||||
|
member := seedUser(t, s, "[email protected]")
|
||||||
|
|
||||||
|
if _, err := s.GetInstanceSettings(ctx, member); !errors.Is(err, domain.ErrForbidden) {
|
||||||
|
t.Errorf("member GetInstanceSettings = %v, want ErrForbidden", err)
|
||||||
|
}
|
||||||
|
if _, err := s.UpdateInstanceSettings(ctx, member, InstanceSettingsPatch{Version: 1}); !errors.Is(err, domain.ErrForbidden) {
|
||||||
|
t.Errorf("member UpdateInstanceSettings = %v, want ErrForbidden", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
-32
@@ -28,13 +28,9 @@ type Region struct {
|
|||||||
MinX, MinY, MaxX, MaxY float64
|
MinX, MinY, MaxX, MaxY float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// contains reports whether a local point lies in the region.
|
|
||||||
func (r Region) contains(x, y float64) bool {
|
|
||||||
return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY
|
|
||||||
}
|
|
||||||
|
|
||||||
// clampTo intersects the region with an object's local bounds (±halfW, ±halfH),
|
// clampTo intersects the region with an object's local bounds (±halfW, ±halfH),
|
||||||
// so an oversized caller-supplied region can't make hexCenters loop forever.
|
// so a fill can't plant outside the object it was aimed at. A region that misses
|
||||||
|
// the object entirely comes back empty — see empty().
|
||||||
func (r Region) clampTo(halfW, halfH float64) Region {
|
func (r Region) clampTo(halfW, halfH float64) Region {
|
||||||
return Region{
|
return Region{
|
||||||
MinX: math.Max(r.MinX, -halfW), MinY: math.Max(r.MinY, -halfH),
|
MinX: math.Max(r.MinX, -halfW), MinY: math.Max(r.MinY, -halfH),
|
||||||
@@ -42,6 +38,16 @@ func (r Region) clampTo(halfW, halfH float64) Region {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// empty reports whether the region encloses nothing.
|
||||||
|
//
|
||||||
|
// This exists because clampTo expresses "no overlap" by INVERTING the region —
|
||||||
|
// Max clamps below Min — rather than by zeroing it, which is not something a
|
||||||
|
// reader guesses. Naming it once here beats a bare `MaxX < MinX` at each place
|
||||||
|
// that has to care.
|
||||||
|
func (r Region) empty() bool {
|
||||||
|
return r.MaxX < r.MinX || r.MaxY < r.MinY
|
||||||
|
}
|
||||||
|
|
||||||
// rect builds a rectangular region.
|
// rect builds a rectangular region.
|
||||||
func rect(minX, minY, maxX, maxY float64) Region {
|
func rect(minX, minY, maxX, maxY float64) Region {
|
||||||
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
|
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
|
||||||
@@ -94,9 +100,11 @@ func defaultPlopRadius(spacingCM float64) float64 {
|
|||||||
// FillRegion lays a hex-packed field of plops of one plant across a region of a
|
// FillRegion lays a hex-packed field of plops of one plant across a region of a
|
||||||
// plantable object the actor can edit. Plop radius comes from the plant's spacing
|
// plantable object the actor can edit. Plop radius comes from the plant's spacing
|
||||||
// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2×
|
// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2×
|
||||||
// radius pitch, kept where the center is inside the region. A candidate is
|
// radius pitch, centered in the region, and set in from each edge by the plop's
|
||||||
// skipped when its plop would sit entirely inside an existing active plop (so
|
// radius less half a spacing — see hexCenters for why that half-spacing is what
|
||||||
// re-filling doesn't stack duplicates). Returns the plops it created.
|
// the edge is owed. A candidate is skipped when its plop would sit entirely
|
||||||
|
// inside an existing active plop (so re-filling doesn't stack duplicates).
|
||||||
|
// Returns the plops it created.
|
||||||
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -106,9 +114,9 @@ func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, regio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
||||||
// already loaded and authorized (roleEditor). It clamps the region to the
|
// already loaded and authorized (roleEditor). It rejects a non-finite region,
|
||||||
// object's bounds, refuses fills over maxFillPlops, and inserts the whole batch
|
// clamps the region to the object's bounds, refuses fills over maxFillPlops, and
|
||||||
// in one transaction rather than one round-trip per plop.
|
// inserts the whole batch in one transaction rather than one round-trip per plop.
|
||||||
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||||||
if !o.Plantable {
|
if !o.Plantable {
|
||||||
return nil, domain.ErrInvalidInput
|
return nil, domain.ErrInvalidInput
|
||||||
@@ -129,9 +137,21 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
|||||||
return nil, domain.ErrInvalidInput
|
return nil, domain.ErrInvalidInput
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A caller-supplied region is arbitrary floats, and non-finite ones survive
|
||||||
|
// everything downstream: clamping keeps them, the inverted-region guard can't
|
||||||
|
// see NaN (it compares false both ways), and fitAxis centres on them happily.
|
||||||
|
// Nothing corrupt reaches the table — SQLite stores NaN as NULL and the NOT
|
||||||
|
// NULL constraint refuses it — but the caller gets an opaque store error for
|
||||||
|
// NaN, and for +Inf a silent zero-plop success. Both are lies about what went
|
||||||
|
// wrong; say "bad input" here instead.
|
||||||
|
if !isFinite(region.MinX) || !isFinite(region.MinY) ||
|
||||||
|
!isFinite(region.MaxX) || !isFinite(region.MaxY) {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
|
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
|
||||||
centers := hexCenters(region, radius)
|
centers, total := hexCenters(region, radius, spacing, maxFillPlops)
|
||||||
if len(centers) > maxFillPlops {
|
if total > maxFillPlops {
|
||||||
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
|
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,32 +189,112 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
|||||||
|
|
||||||
type localPoint struct{ x, y float64 }
|
type localPoint struct{ x, y float64 }
|
||||||
|
|
||||||
// hexCenters returns hex-packed lattice centers whose center lies in the region.
|
// hexCenters returns hex-packed lattice centers filling a region: rows radius·√3
|
||||||
// Rows are spaced radius·√3 apart and every other row is offset by radius, the
|
// apart, alternate rows offset by half a pitch, at a 2×radius pitch. The lattice
|
||||||
// standard hexagonal packing at a 2×radius pitch. The lattice is anchored one
|
// is CENTERED, so the leftover is shared between opposite edges instead of piling
|
||||||
// radius inside the region's min corner so the first plop sits inside it.
|
// up against the far one.
|
||||||
func hexCenters(r Region, radius float64) []localPoint {
|
//
|
||||||
|
// # How close to the edge the outer row goes
|
||||||
|
//
|
||||||
|
// Spacing is a constraint BETWEEN NEIGHBOURING PLANTS competing for the same
|
||||||
|
// soil, light and water. A bed edge is not a competitor, so the outer row only
|
||||||
|
// owes it HALF the spacing — the half it would otherwise share with a neighbour.
|
||||||
|
// That is the arithmetic inside every square-foot-gardening chart: 4 per square
|
||||||
|
// is 6" apart and 3" from the square's edge; 9 per square is 4" apart and 2"
|
||||||
|
// from the edge. Garlic at 9 per square goes in 2" from the frame, not 6".
|
||||||
|
//
|
||||||
|
// A plop is a CLUMP, not a plant — defaultPlopRadius makes it 1.5×spacing, so
|
||||||
|
// three spacings across — and its plants sit out to its rim. So keeping the whole
|
||||||
|
// circle inside the bed would inset the outer row by a full 1.5 spacings, three
|
||||||
|
// times what the rule allows. Instead the clump may hang over the edge by up to
|
||||||
|
// half a spacing, which puts its outermost plants exactly the half-spacing from
|
||||||
|
// the edge that the rule asks for. Overhang is capped there and nowhere near the
|
||||||
|
// full radius: a clump mostly outside the bed is a drawing of plants in the path.
|
||||||
|
//
|
||||||
|
// Do not "simplify" this back to anchoring at the region's min corner. That is
|
||||||
|
// what #75 was: staggered rows start a full pitch in, and the leftover all lands
|
||||||
|
// on the far edge, where clumps hang outside a bed that nothing clips them to.
|
||||||
|
//
|
||||||
|
// # Counting before building
|
||||||
|
//
|
||||||
|
// hexCenters returns the total alongside the points, and works that total out
|
||||||
|
// BEFORE building anything: a fill large enough to be refused shouldn't allocate
|
||||||
|
// its whole lattice first just to be counted and thrown away. Over `limit` it
|
||||||
|
// returns (nil, total), so the caller can still refuse with the real number.
|
||||||
|
func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int) {
|
||||||
if radius <= 0 {
|
if radius <= 0 {
|
||||||
return nil
|
return nil, 0
|
||||||
|
}
|
||||||
|
// An empty region has no inside to plant. The old loop-until-past-MaxX form
|
||||||
|
// got this for free by never entering the loop; counting positions up front
|
||||||
|
// does not, and would site a plop off the bed.
|
||||||
|
if r.empty() {
|
||||||
|
return nil, 0
|
||||||
}
|
}
|
||||||
pitch := 2 * radius
|
pitch := 2 * radius
|
||||||
rowH := pitch * math.Sqrt(3) / 2
|
rowH := pitch * math.Sqrt(3) / 2
|
||||||
const eps = 1e-6
|
|
||||||
var pts []localPoint
|
// How far a clump's centre must stay inside the edge: its own radius, less the
|
||||||
row := 0
|
// half-spacing of overhang the rule allows. Never negative, and never past the
|
||||||
for y := r.MinY + radius; y <= r.MaxY+eps; y += rowH {
|
// centre of the clump.
|
||||||
xStart := r.MinX + radius
|
inset := math.Max(0, radius-math.Max(0, spacing)/2)
|
||||||
if row%2 == 1 {
|
|
||||||
xStart += radius
|
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
|
||||||
|
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
|
||||||
|
|
||||||
|
// Exact, not an upper bound: staggered rows hold one fewer, so rows*cols would
|
||||||
|
// over-reserve by ~12% — and, more to the point, allocating it is the thing we
|
||||||
|
// are trying to avoid when the answer is "too many".
|
||||||
|
staggered := cols
|
||||||
|
if cols > 1 {
|
||||||
|
staggered = cols - 1
|
||||||
}
|
}
|
||||||
for x := xStart; x <= r.MaxX+eps; x += pitch {
|
total := (rows+1)/2*cols + rows/2*staggered
|
||||||
if r.contains(x, y) {
|
if total > limit {
|
||||||
pts = append(pts, localPoint{x, y})
|
return nil, total
|
||||||
|
}
|
||||||
|
|
||||||
|
pts := make([]localPoint, 0, total)
|
||||||
|
for row := 0; row < rows; row++ {
|
||||||
|
y := r.MinY + y0 + float64(row)*rowH
|
||||||
|
n, x := cols, r.MinX+x0
|
||||||
|
// The stagger falls out of centering: an offset row holds one fewer plop,
|
||||||
|
// and centering THAT run puts it exactly half a pitch off its neighbours.
|
||||||
|
// A single-column region has nothing to stagger against.
|
||||||
|
if row%2 == 1 && cols > 1 {
|
||||||
|
n, x = staggered, r.MinX+x0+pitch/2
|
||||||
|
}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
pts = append(pts, localPoint{x + float64(i)*pitch, y})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
row++
|
return pts, total
|
||||||
|
}
|
||||||
|
|
||||||
|
// fitAxis returns how many lattice positions fit along a span at `step`, keeping
|
||||||
|
// at least `inset` from each end, and the offset from the span's start that
|
||||||
|
// centers them — so the leftover is split between the two edges rather than all
|
||||||
|
// landing on the far one.
|
||||||
|
//
|
||||||
|
// A span too small to hold even one position at that inset still gets one, in the
|
||||||
|
// middle: filling a bed narrower than a single plop with one plop is a better
|
||||||
|
// answer than refusing to plant it.
|
||||||
|
//
|
||||||
|
// The step<=0 half of that guard is currently unreachable — hexCenters, the only
|
||||||
|
// caller, returns early unless radius > 0, which makes both steps it passes
|
||||||
|
// positive. It stays because dividing by a non-positive step yields ±Inf and then
|
||||||
|
// a garbage int conversion, and a helper this small should not require reading
|
||||||
|
// its caller to know it is safe. Deliberate, not an oversight.
|
||||||
|
func fitAxis(length, step, inset float64) (n int, start float64) {
|
||||||
|
if step <= 0 || length < 2*inset {
|
||||||
|
return 1, length / 2
|
||||||
}
|
}
|
||||||
return pts
|
// The epsilon keeps an exact fit from being lost to floating point — a 60cm
|
||||||
|
// span at a 30cm step should give 2 positions, not 1 because the division
|
||||||
|
// landed on 0.9999999.
|
||||||
|
const eps = 1e-9
|
||||||
|
n = int(math.Floor((length-2*inset)/step+eps)) + 1
|
||||||
|
return n, (length - float64(n-1)*step) / 2
|
||||||
}
|
}
|
||||||
|
|
||||||
// coveredByExisting reports whether a new plop (center, radius) would sit
|
// coveredByExisting reports whether a new plop (center, radius) would sit
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
@@ -71,6 +73,165 @@ func TestDefaultPlopRadius(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHexCentersEdgeInset pins the spacing rule the packing exists to honour:
|
||||||
|
// spacing is a constraint between neighbouring plants, so a bed edge — which is
|
||||||
|
// nobody's neighbour — is owed half a pitch, not a whole one.
|
||||||
|
//
|
||||||
|
// The bug this guards against was visible to anyone who filled a bed: staggered
|
||||||
|
// rows began a full pitch in, leaving a bare strip a whole plop wide down one
|
||||||
|
// side of every other row, while the far edge had plops hanging off it.
|
||||||
|
func TestHexCentersEdgeInset(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
w, h, radius, spacing float64
|
||||||
|
wantRowStarts []float64 // x of the first plop in rows 0 and 1
|
||||||
|
}{
|
||||||
|
// 4ft × 8ft bed, garlic at 15cm spacing → radius 22.5, pitch 45. Three
|
||||||
|
// columns, the outer ones overhanging by 6.5cm — under the 7.5cm the rule
|
||||||
|
// allows. Anchored at the corner this row started at -38.5 and its
|
||||||
|
// staggered neighbour a full 45 further in still.
|
||||||
|
{"4ft bed of garlic", 122, 244, 22.5, 15, []float64{-45, -22.5}},
|
||||||
|
// An exact fit: 90 wide at pitch 30 → 3 columns, no overhang needed.
|
||||||
|
{"exact fit", 90, 90, 15, 10, []float64{-30, -15}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := rect(-tc.w/2, -tc.h/2, tc.w/2, tc.h/2)
|
||||||
|
pts, total := hexCenters(r, tc.radius, tc.spacing, maxFillPlops)
|
||||||
|
if len(pts) == 0 {
|
||||||
|
t.Fatal("no centers")
|
||||||
|
}
|
||||||
|
// The count is derived up front so an oversized fill is refused without
|
||||||
|
// building its lattice — which only works if it matches what gets built.
|
||||||
|
if total != len(pts) {
|
||||||
|
t.Errorf("reported total %d, built %d", total, len(pts))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A clump may cross the edge, but only by the half-spacing the rule
|
||||||
|
// allows — never enough to be mostly out in the path.
|
||||||
|
budget := tc.spacing / 2
|
||||||
|
for _, p := range pts {
|
||||||
|
over := math.Max(
|
||||||
|
math.Max(r.MinX-(p.x-tc.radius), (p.x+tc.radius)-r.MaxX),
|
||||||
|
math.Max(r.MinY-(p.y-tc.radius), (p.y+tc.radius)-r.MaxY),
|
||||||
|
)
|
||||||
|
if over > budget+1e-6 {
|
||||||
|
t.Errorf("plop at (%.1f,%.1f) overhangs by %.2f, budget %.2f", p.x, p.y, over, budget)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The margins match on opposite edges: the leftover is shared, not piled
|
||||||
|
// against the far side.
|
||||||
|
minX, maxX, minY, maxY := pts[0].x, pts[0].x, pts[0].y, pts[0].y
|
||||||
|
for _, p := range pts {
|
||||||
|
minX, maxX = math.Min(minX, p.x), math.Max(maxX, p.x)
|
||||||
|
minY, maxY = math.Min(minY, p.y), math.Max(maxY, p.y)
|
||||||
|
}
|
||||||
|
if w, e := minX-r.MinX, r.MaxX-maxX; math.Abs(w-e) > 1e-6 {
|
||||||
|
t.Errorf("lopsided horizontally: west margin %.2f, east %.2f", w, e)
|
||||||
|
}
|
||||||
|
if n, s := minY-r.MinY, r.MaxY-maxY; math.Abs(n-s) > 1e-6 {
|
||||||
|
t.Errorf("lopsided vertically: north margin %.2f, south %.2f", n, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The staggered row is offset by HALF a pitch, not a whole one.
|
||||||
|
starts := map[float64]float64{}
|
||||||
|
for _, p := range pts {
|
||||||
|
if x, ok := starts[p.y]; !ok || p.x < x {
|
||||||
|
starts[p.y] = p.x
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ys := make([]float64, 0, len(starts))
|
||||||
|
for y := range starts {
|
||||||
|
ys = append(ys, y)
|
||||||
|
}
|
||||||
|
sort.Float64s(ys)
|
||||||
|
for i, want := range tc.wantRowStarts {
|
||||||
|
if i >= len(ys) {
|
||||||
|
t.Fatalf("only %d rows, want at least %d", len(ys), len(tc.wantRowStarts))
|
||||||
|
}
|
||||||
|
if got := starts[ys[i]]; math.Abs(got-want) > 1e-6 {
|
||||||
|
t.Errorf("row %d starts at x=%.2f, want %.2f", i, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHexCentersTinyRegion covers a region too small to hold a plop at the
|
||||||
|
// half-pitch inset: planting one in the middle beats refusing to plant at all.
|
||||||
|
//
|
||||||
|
// The off-centre case earns its place — a region symmetric about the origin
|
||||||
|
// can't tell "the middle of the region" from "the origin", so on its own it
|
||||||
|
// would pass for an implementation that just returned (0,0).
|
||||||
|
func TestHexCentersTinyRegion(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
r Region
|
||||||
|
wantX, wantY float64
|
||||||
|
}{
|
||||||
|
{"centred on the origin", rect(-5, -5, 5, 5), 0, 0},
|
||||||
|
{"off in a corner", rect(20, -40, 30, -30), 25, -35},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
pts, _ := hexCenters(tc.r, 15, 10, maxFillPlops)
|
||||||
|
if len(pts) != 1 || pts[0].x != tc.wantX || pts[0].y != tc.wantY {
|
||||||
|
t.Errorf("got %+v, want one plop at (%v,%v)", pts, tc.wantX, tc.wantY)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFillRegionRejectsNonFiniteRegion: non-finite bounds survive clamping and
|
||||||
|
// the inverted-region guard (NaN compares false both ways). Without the explicit
|
||||||
|
// check, NaN surfaced as a raw store error ("NOT NULL constraint failed") and
|
||||||
|
// +Inf as a silent success that planted nothing — neither of which tells the
|
||||||
|
// caller what it actually did wrong.
|
||||||
|
func TestFillRegionRejectsNonFiniteRegion(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
|
||||||
|
bed := seedFillBed(t, s, owner, g.ID, 100, 100)
|
||||||
|
plant := seedOwnPlant(t, s, owner, 10)
|
||||||
|
|
||||||
|
nan := math.NaN()
|
||||||
|
for _, r := range []Region{
|
||||||
|
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
|
||||||
|
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
|
||||||
|
} {
|
||||||
|
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil)
|
||||||
|
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
|
||||||
|
}
|
||||||
|
for _, p := range created {
|
||||||
|
if !isFinite(p.XCM) || !isFinite(p.YCM) {
|
||||||
|
t.Errorf("persisted a plop with non-finite coordinates: %+v", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFillRegionOutsideObjectPlantsNothing covers a region that misses the object
|
||||||
|
// entirely. clampTo inverts such a region rather than emptying it, and an
|
||||||
|
// inverted region must plant nothing — not one plop at some point off the bed.
|
||||||
|
func TestFillRegionOutsideObjectPlantsNothing(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
|
||||||
|
bed := seedFillBed(t, s, owner, g.ID, 100, 100) // local bounds ±50
|
||||||
|
plant := seedOwnPlant(t, s, owner, 10)
|
||||||
|
|
||||||
|
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50.
|
||||||
|
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FillRegion: %v", err)
|
||||||
|
}
|
||||||
|
if len(created) != 0 {
|
||||||
|
t.Errorf("filled %d plops for a region outside the bed, want 0: %+v", len(created), created)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// seedFillBed makes a plantable bed of the given size centered in a big garden.
|
// seedFillBed makes a plantable bed of the given size centered in a big garden.
|
||||||
func seedFillBed(t *testing.T, s *Service, owner, gardenID int64, w, h float64) *domain.GardenObject {
|
func seedFillBed(t *testing.T, s *Service, owner, gardenID int64, w, h float64) *domain.GardenObject {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -99,16 +260,24 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("FillRegion: %v", err)
|
t.Fatalf("FillRegion: %v", err)
|
||||||
}
|
}
|
||||||
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart → 4 plops (2 rows × 2).
|
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart, centered: a row of 2
|
||||||
if len(created) != 4 {
|
// (x=±15), then a staggered row of 1 (x=0) → 3 plops.
|
||||||
t.Fatalf("filled %d plops, want 4 (60×60 bed, radius 15)", len(created))
|
//
|
||||||
|
// This was 4 while the lattice was anchored at the min corner, and the fourth
|
||||||
|
// sat at x=30 — centred ON the east edge, so half of it lay outside the bed,
|
||||||
|
// well past the half-spacing (5cm here) the rule allows. Packing one fewer
|
||||||
|
// plop is the point of the fix, not a regression in it.
|
||||||
|
if len(created) != 3 {
|
||||||
|
t.Fatalf("filled %d plops, want 3 (60×60 bed, radius 15)", len(created))
|
||||||
}
|
}
|
||||||
for _, p := range created {
|
for _, p := range created {
|
||||||
if p.RadiusCM != 15 || p.PlantedAt == nil || p.DerivedCount < 1 {
|
if p.RadiusCM != 15 || p.PlantedAt == nil || p.DerivedCount < 1 {
|
||||||
t.Errorf("unexpected created plop: %+v", p)
|
t.Errorf("unexpected created plop: %+v", p)
|
||||||
}
|
}
|
||||||
if p.XCM < -30 || p.XCM > 30 || p.YCM < -30 || p.YCM > 30 {
|
// This bed fits its lattice exactly, so nothing should need to overhang.
|
||||||
t.Errorf("plop center out of bed bounds: %+v", p)
|
if p.XCM-p.RadiusCM < -30 || p.XCM+p.RadiusCM > 30 ||
|
||||||
|
p.YCM-p.RadiusCM < -30 || p.YCM+p.RadiusCM > 30 {
|
||||||
|
t.Errorf("plop overhangs a bed it fits inside: %+v", p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
@@ -99,7 +100,8 @@ type ChangeSetOptions struct {
|
|||||||
Source string
|
Source string
|
||||||
// Summary is the one-line description shown in the history list.
|
// Summary is the one-line description shown in the history list.
|
||||||
Summary string
|
Summary string
|
||||||
// AgentRunID joins this change set back to the executus run that produced it.
|
// AgentRunID joins this change set back to the agent run that produced it,
|
||||||
|
// so a change in the history list can be correlated with the run's log lines.
|
||||||
AgentRunID *string
|
AgentRunID *string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,11 +150,19 @@ func partialSummary(summary string) string {
|
|||||||
// commitScope writes a scope's buffered revisions as one change set. revertsID is
|
// commitScope writes a scope's buffered revisions as one change set. revertsID is
|
||||||
// set only by RevertChangeSet. A scope with no revisions writes nothing — an
|
// set only by RevertChangeSet. A scope with no revisions writes nothing — an
|
||||||
// operation that changed nothing doesn't belong in history.
|
// operation that changed nothing doesn't belong in history.
|
||||||
|
//
|
||||||
|
// The write is DETACHED FROM CANCELLATION, always, and that belongs here rather
|
||||||
|
// than at each call site so no caller can be the one that forgets. By the time a
|
||||||
|
// commit runs, the data changes it describes have already been written — so
|
||||||
|
// cancelling it cannot undo anything. It can only lose the record of what
|
||||||
|
// happened and leave real changes with no way to undo them, which is the one
|
||||||
|
// thing the whole change-set design exists to prevent.
|
||||||
func (s *Service) commitScope(ctx context.Context, sc *changeScope, revertsID *int64) (*domain.ChangeSet, error) {
|
func (s *Service) commitScope(ctx context.Context, sc *changeScope, revertsID *int64) (*domain.ChangeSet, error) {
|
||||||
revs := sc.taken()
|
revs := sc.taken()
|
||||||
if len(revs) == 0 {
|
if len(revs) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
ctx = context.WithoutCancel(ctx)
|
||||||
return s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
return s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
||||||
GardenID: sc.gardenID,
|
GardenID: sc.gardenID,
|
||||||
ActorID: sc.actorID,
|
ActorID: sc.actorID,
|
||||||
@@ -193,9 +203,13 @@ func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary s
|
|||||||
sc.append(revs)
|
sc.append(revs)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
// Auto-scope: one operation, its own change set. Written through the same
|
||||||
GardenID: gardenID, ActorID: actorID, Source: domain.SourceUI, Summary: summary,
|
// detached path as everything else — a REST client that hangs up right after
|
||||||
}, revs); err != nil {
|
// its PATCH landed must not leave that change without history, and this is
|
||||||
|
// the path virtually every mutation takes.
|
||||||
|
auto := &changeScope{gardenID: gardenID, actorID: actorID, source: domain.SourceUI, summary: summary}
|
||||||
|
auto.append(revs)
|
||||||
|
if _, err := s.commitScope(ctx, auto, nil); err != nil {
|
||||||
slog.Error("service: record change set", "error", err, "garden", gardenID, "summary", summary)
|
slog.Error("service: record change set", "error", err, "garden", gardenID, "summary", summary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,6 +321,7 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
// Record what actually landed before surfacing the failure, so the
|
// Record what actually landed before surfacing the failure, so the
|
||||||
// partial revert is visible and undoable rather than orphaned.
|
// partial revert is visible and undoable rather than orphaned.
|
||||||
|
// (commitScope detaches from cancellation itself.)
|
||||||
if _, cerr := s.commitScope(ctx, sc, &target.ID); cerr != nil {
|
if _, cerr := s.commitScope(ctx, sc, &target.ID); cerr != nil {
|
||||||
slog.Error("service: partial revert could not be recorded", "error", cerr, "changeSet", changeSetID)
|
slog.Error("service: partial revert could not be recorded", "error", cerr, "changeSet", changeSetID)
|
||||||
}
|
}
|
||||||
@@ -327,9 +342,48 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
// Fill in the per-op counts. commitScope returns the freshly inserted row,
|
||||||
|
// which carries no tally — and a caller receiving a change set with an empty
|
||||||
|
// Counts cannot tell "nothing was reverted" from "the tally wasn't loaded".
|
||||||
|
// That ambiguity is not theoretical: it made the chat panel report a
|
||||||
|
// successful undo as "nothing left to undo".
|
||||||
|
if cs != nil {
|
||||||
|
cs.Counts = countRevisions(sc.taken())
|
||||||
|
}
|
||||||
return cs, conflicts, nil
|
return cs, conflicts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// countRevisions tallies revisions by (entity type, op).
|
||||||
|
//
|
||||||
|
// This deliberately reproduces in Go what ListChangeSets does in SQL, because a
|
||||||
|
// change set that has just been written has no rows to GROUP BY yet — the
|
||||||
|
// alternative is a second round trip to count what we are already holding.
|
||||||
|
// The parity is a real cross-layer contract, and TestRevertResultCarriesItsCounts
|
||||||
|
// compares the two breakdowns row for row so it can't drift silently.
|
||||||
|
func countRevisions(revs []domain.Revision) []domain.ChangeCount {
|
||||||
|
type key struct{ entityType, op string }
|
||||||
|
seen := map[key]int{}
|
||||||
|
order := []key{}
|
||||||
|
for _, r := range revs {
|
||||||
|
k := key{r.EntityType, r.Op}
|
||||||
|
if _, ok := seen[k]; !ok {
|
||||||
|
order = append(order, k)
|
||||||
|
}
|
||||||
|
seen[k]++
|
||||||
|
}
|
||||||
|
sort.Slice(order, func(i, j int) bool {
|
||||||
|
if order[i].entityType != order[j].entityType {
|
||||||
|
return order[i].entityType < order[j].entityType
|
||||||
|
}
|
||||||
|
return order[i].op < order[j].op
|
||||||
|
})
|
||||||
|
counts := make([]domain.ChangeCount, 0, len(order))
|
||||||
|
for _, k := range order {
|
||||||
|
counts = append(counts, domain.ChangeCount{EntityType: k.entityType, Op: k.op, N: seen[k]})
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}
|
||||||
|
|
||||||
// entityKey identifies a row across the entity types a revision can name.
|
// entityKey identifies a row across the entity types a revision can name.
|
||||||
type entityKey struct {
|
type entityKey struct {
|
||||||
entityType string
|
entityType string
|
||||||
|
|||||||
@@ -704,3 +704,192 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
|
|||||||
t.Errorf("cleared %d of %d with %d revisions — all three should match", n, len(before), len(revs))
|
t.Errorf("cleared %d of %d with %d revisions — all three should match", n, len(before), len(revs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRevertResultCarriesItsCounts — found by using the thing.
|
||||||
|
//
|
||||||
|
// commitScope returns the freshly inserted row, which carries no tally. A caller
|
||||||
|
// receiving a change set with empty Counts cannot tell "nothing was reverted"
|
||||||
|
// from "the tally wasn't loaded", and the chat panel read that ambiguity the
|
||||||
|
// wrong way: a successful undo reported "nothing left to undo", which is the
|
||||||
|
// worst possible thing to say about an action that just worked.
|
||||||
|
//
|
||||||
|
// It also pins the cross-layer contract that fix created. countRevisions tallies
|
||||||
|
// in Go what ListChangeSets tallies in SQL, and nothing but this test stops the
|
||||||
|
// two drifting — so it compares the FULL per-(entity, op) breakdown, not just
|
||||||
|
// the totals, which would agree even if the groupings had diverged.
|
||||||
|
func TestRevertResultCarriesItsCounts(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
g := seedGarden(t, s, owner)
|
||||||
|
bed := seedBed(t, s, owner, g.ID)
|
||||||
|
plant := seedOwnPlant(t, s, owner, 15)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
|
||||||
|
t.Fatalf("fill: %v", err)
|
||||||
|
}
|
||||||
|
// A second, different kind of change, so the breakdown has more than one row
|
||||||
|
// to get wrong.
|
||||||
|
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("North Bed")}, bed.Version); err != nil {
|
||||||
|
t.Fatalf("rename: %v", err)
|
||||||
|
}
|
||||||
|
sets := history(t, s, owner, g.ID)
|
||||||
|
rename, fill := sets[0], sets[1]
|
||||||
|
|
||||||
|
undoRename, conflicts, err := s.RevertChangeSet(ctx, owner, rename.ID, domain.SourceUI)
|
||||||
|
if err != nil || len(conflicts) != 0 {
|
||||||
|
t.Fatalf("revert rename: err=%v conflicts=%+v", err, conflicts)
|
||||||
|
}
|
||||||
|
undoFill, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI)
|
||||||
|
if err != nil || len(conflicts) != 0 {
|
||||||
|
t.Fatalf("revert fill: err=%v conflicts=%+v", err, conflicts)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
|
||||||
|
if undo == nil {
|
||||||
|
t.Fatal("a revert that did work returned no change set")
|
||||||
|
}
|
||||||
|
if len(undo.Counts) == 0 {
|
||||||
|
t.Fatalf("change set %d came back with no tally — an empty tally reads as 'nothing happened'", undo.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Undoing a fill deletes every plop it created, so the tallies must match.
|
||||||
|
if got, want := countsTotal(undoFill.Counts), countsTotal(fill.Counts); got != want {
|
||||||
|
t.Errorf("undo of the fill reports %d changes, want %d", got, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The SQL tally and the Go tally must agree, row for row.
|
||||||
|
listed := history(t, s, owner, g.ID)
|
||||||
|
byID := map[int64][]domain.ChangeCount{}
|
||||||
|
for _, cs := range listed {
|
||||||
|
byID[cs.ID] = cs.Counts
|
||||||
|
}
|
||||||
|
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
|
||||||
|
fromSQL, ok := byID[undo.ID]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("revert %d is missing from the history list", undo.ID)
|
||||||
|
}
|
||||||
|
if !sameCounts(undo.Counts, fromSQL) {
|
||||||
|
t.Errorf("change set %d: revert returned %+v, the list read says %+v — countRevisions and the SQL grouping have drifted",
|
||||||
|
undo.ID, undo.Counts, fromSQL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sameCounts compares two tallies regardless of order.
|
||||||
|
func sameCounts(a, b []domain.ChangeCount) bool {
|
||||||
|
if len(a) != len(b) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
index := func(cs []domain.ChangeCount) map[string]int {
|
||||||
|
m := map[string]int{}
|
||||||
|
for _, c := range cs {
|
||||||
|
m[c.EntityType+"/"+c.Op] = c.N
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
ia, ib := index(a), index(b)
|
||||||
|
for k, v := range ia {
|
||||||
|
if ib[k] != v {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// countsTotal sums a tally — the server-side twin of the client's totalChanges.
|
||||||
|
func countsTotal(counts []domain.ChangeCount) int {
|
||||||
|
n := 0
|
||||||
|
for _, c := range counts {
|
||||||
|
n += c.N
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSucceededTurnRecordsEvenIfTheCallerWentAway is the production bug.
|
||||||
|
//
|
||||||
|
// The failure path was detached from cancellation; the SUCCESS path was not. An
|
||||||
|
// agent turn whose client disconnects can still COMPLETE — and then the success
|
||||||
|
// path committed with a dead context, the write failed, and real changes were
|
||||||
|
// left with no change set and no way to undo them. Found live with 18 plantings
|
||||||
|
// behind no history at all.
|
||||||
|
//
|
||||||
|
// Nothing about a commit needs the caller to still be there: by the time it
|
||||||
|
// runs, the data it describes has already been written.
|
||||||
|
func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
g := seedGarden(t, s, owner)
|
||||||
|
bed := seedBed(t, s, owner, g.ID)
|
||||||
|
plant := seedOwnPlant(t, s, owner, 15)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
before := len(history(t, s, owner, g.ID))
|
||||||
|
|
||||||
|
// fn does its work and SUCCEEDS, but the caller goes away before it returns.
|
||||||
|
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
|
||||||
|
Source: domain.SourceAgent, Summary: "plant beans in the second bed",
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
cancel() // the client disconnects, mid-turn, after the work landed
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("WithChangeSet: %v", err)
|
||||||
|
}
|
||||||
|
if cs == nil {
|
||||||
|
t.Fatal("the turn changed things but produced no change set")
|
||||||
|
}
|
||||||
|
|
||||||
|
after := history(t, s, owner, g.ID)
|
||||||
|
if len(after) != before+1 {
|
||||||
|
t.Fatalf("recorded %d change sets, want 1", len(after)-before)
|
||||||
|
}
|
||||||
|
if after[0].Summary != "plant beans in the second bed" {
|
||||||
|
t.Errorf("summary = %q; a completed turn shouldn't be marked partial", after[0].Summary)
|
||||||
|
}
|
||||||
|
// And it's undoable, which is the entire point.
|
||||||
|
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, cs.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||||
|
t.Fatalf("the recorded turn should be undoable: err=%v conflicts=%+v", err, conflicts)
|
||||||
|
}
|
||||||
|
active, _ := s.store.ListActivePlantingsForObject(context.Background(), bed.ID)
|
||||||
|
if len(active) != 0 {
|
||||||
|
t.Errorf("%d plantings survived the undo", len(active))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoScopedMutationRecordsEvenIfTheCallerWentAway — the same rule for the
|
||||||
|
// path virtually every mutation takes.
|
||||||
|
//
|
||||||
|
// A plain REST PATCH auto-scopes into its own change set. If the client hangs up
|
||||||
|
// between the row landing and the change set being written, that change is
|
||||||
|
// orphaned exactly as an agent turn's was — and this path is used far more.
|
||||||
|
func TestAutoScopedMutationRecordsEvenIfTheCallerWentAway(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
g := seedGarden(t, s, owner)
|
||||||
|
bed := seedBed(t, s, owner, g.ID)
|
||||||
|
before := len(history(t, s, owner, g.ID))
|
||||||
|
|
||||||
|
// The row write and the history write share this context; cancelling after
|
||||||
|
// the mutation returns is the client hanging up mid-request.
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
|
||||||
|
t.Fatalf("UpdateObject: %v", err)
|
||||||
|
}
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
after := history(t, s, owner, g.ID)
|
||||||
|
if len(after) != before+1 {
|
||||||
|
t.Fatalf("recorded %d change sets, want 1 — the move is otherwise un-undoable", len(after)-before)
|
||||||
|
}
|
||||||
|
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, after[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||||
|
t.Fatalf("the recorded move should be undoable: err=%v conflicts=%+v", err, conflicts)
|
||||||
|
}
|
||||||
|
back, _ := s.store.GetObject(context.Background(), bed.ID)
|
||||||
|
if back.XCM != bed.XCM {
|
||||||
|
t.Errorf("undo left x at %v, want %v", back.XCM, bed.XCM)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The instance_settings row is seeded by migration 0010 and there is exactly one
|
||||||
|
// (CHECK id = 1), so reads never branch on existence and writes never insert.
|
||||||
|
|
||||||
|
const instanceSettingsColumns = `agent_model, agent_enabled, version, updated_at`
|
||||||
|
|
||||||
|
// scanInstanceSettings reads the single settings row. agent_enabled is a nullable
|
||||||
|
// INTEGER (NULL = inherit env), so it is scanned through sql.NullInt64.
|
||||||
|
func scanInstanceSettings(s scanner) (*domain.InstanceSettings, error) {
|
||||||
|
var (
|
||||||
|
out domain.InstanceSettings
|
||||||
|
enabled sql.NullInt64
|
||||||
|
)
|
||||||
|
if err := s.Scan(&out.AgentModel, &enabled, &out.Version, &out.UpdatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if enabled.Valid {
|
||||||
|
b := enabled.Int64 != 0
|
||||||
|
out.AgentEnabled = &b
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetInstanceSettings returns the single settings row.
|
||||||
|
func (d *DB) GetInstanceSettings(ctx context.Context) (*domain.InstanceSettings, error) {
|
||||||
|
s, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
|
||||||
|
`SELECT `+instanceSettingsColumns+` FROM instance_settings WHERE id = 1`))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
// The migration seeds this row, so its absence is a broken database, not a
|
||||||
|
// normal "not found" the caller should paper over.
|
||||||
|
return nil, fmt.Errorf("store: instance_settings row missing (migration 0010 not applied?)")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: get instance settings: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateInstanceSettings applies a version-guarded update to the single row,
|
||||||
|
// following the same optimistic-concurrency contract as every mutable resource:
|
||||||
|
// the updated row on success, (current row, ErrVersionConflict) on a version
|
||||||
|
// mismatch. There is no ErrNotFound path — the row always exists.
|
||||||
|
//
|
||||||
|
// agentEnabled is nil to store SQL NULL (inherit env), or a pointer to store an
|
||||||
|
// explicit 0/1.
|
||||||
|
func (d *DB) UpdateInstanceSettings(ctx context.Context, s *domain.InstanceSettings) (*domain.InstanceSettings, error) {
|
||||||
|
var enabled any
|
||||||
|
if s.AgentEnabled != nil {
|
||||||
|
enabled = boolToInt(*s.AgentEnabled)
|
||||||
|
}
|
||||||
|
updated, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
|
||||||
|
`UPDATE instance_settings
|
||||||
|
SET agent_model = ?, agent_enabled = ?,
|
||||||
|
version = version + 1,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
|
WHERE id = 1 AND version = ?
|
||||||
|
RETURNING `+instanceSettingsColumns,
|
||||||
|
s.AgentModel, enabled, s.Version,
|
||||||
|
))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
current, gerr := d.GetInstanceSettings(ctx)
|
||||||
|
if gerr != nil {
|
||||||
|
return nil, gerr
|
||||||
|
}
|
||||||
|
return current, domain.ErrVersionConflict
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: update instance settings: %w", err)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- Instance settings (#79): the first configuration that lives in the database
|
||||||
|
-- rather than the environment.
|
||||||
|
--
|
||||||
|
-- Until now every preference hung off a garden or object row; this is pansy's
|
||||||
|
-- first INSTANCE-level state. A single-row table (CHECK id = 1) is the least
|
||||||
|
-- surprising shape for "there is exactly one of these" — a key/value table would
|
||||||
|
-- invite typo'd keys and lose the column types.
|
||||||
|
--
|
||||||
|
-- Only NON-SECRET agent settings live here. OLLAMA_CLOUD_API_KEY stays in the
|
||||||
|
-- environment on purpose: a copy in SQLite would land in every backup and in the
|
||||||
|
-- blast radius of the undo history. An admin can change WHICH model runs, not
|
||||||
|
-- WHOSE account pays for it.
|
||||||
|
--
|
||||||
|
-- Both agent columns are "inherit from env unless set":
|
||||||
|
-- * agent_model = '' means fall back to PANSY_AGENT_MODEL, then the built-in
|
||||||
|
-- default. So an instance that never opens Settings behaves exactly as it
|
||||||
|
-- did before this migration, and the documented env var keeps working.
|
||||||
|
-- * agent_enabled is NULLABLE: NULL means inherit PANSY_AGENT_ENABLED's
|
||||||
|
-- behaviour (on when a key is present), 0/1 is an explicit override. A plain
|
||||||
|
-- boolean couldn't tell "admin hasn't touched this" from "admin turned it
|
||||||
|
-- off", and those must deploy differently.
|
||||||
|
--
|
||||||
|
-- version drives the same optimistic-concurrency 409 every other mutable row
|
||||||
|
-- uses, so two admins editing at once conflict rather than clobber.
|
||||||
|
CREATE TABLE instance_settings (
|
||||||
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||||
|
agent_model TEXT NOT NULL DEFAULT '',
|
||||||
|
agent_enabled INTEGER CHECK (agent_enabled IN (0, 1)),
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Seed the single row so every read is a plain SELECT with no "does it exist
|
||||||
|
-- yet" branch. Inherits everything from the environment out of the box.
|
||||||
|
INSERT INTO instance_settings (id, agent_model, agent_enabled) VALUES (1, '', NULL);
|
||||||
@@ -119,7 +119,9 @@ func (d *DB) ListChangeSets(ctx context.Context, gardenID int64, limit, offset i
|
|||||||
return sets, nil
|
return sets, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// One grouped query for the whole page rather than N per-row counts.
|
// One grouped query for the whole page rather than N per-row counts. The
|
||||||
|
// service's countRevisions mirrors this grouping for change sets it has just
|
||||||
|
// written; TestRevertResultCarriesItsCounts holds the two in step.
|
||||||
countRows, err := d.sql.QueryContext(ctx,
|
countRows, err := d.sql.QueryContext(ctx,
|
||||||
`SELECT change_set_id, entity_type, op, COUNT(*)
|
`SELECT change_set_id, entity_type, op, COUNT(*)
|
||||||
FROM revisions
|
FROM revisions
|
||||||
|
|||||||
@@ -53,6 +53,19 @@ export function AppShell() {
|
|||||||
{l.label}
|
{l.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{/* Settings is admin-only, matching the server's requireAdmin gate.
|
||||||
|
A non-admin who typed /settings still gets a 403 from the API — the
|
||||||
|
hidden link is convenience, not the security boundary. */}
|
||||||
|
{user?.isAdmin && (
|
||||||
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
className={navLinkBase}
|
||||||
|
activeProps={{ className: navLinkActive }}
|
||||||
|
inactiveProps={{ className: navLinkInactive }}
|
||||||
|
>
|
||||||
|
Settings
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{user ? (
|
{user ? (
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { useEffect, useRef, type ReactNode } from 'react'
|
import { useEffect, useRef, type ReactNode } from 'react'
|
||||||
|
|
||||||
|
// Tabbable controls inside the dialog, in DOM order. type="hidden" inputs are
|
||||||
|
// excluded — they'd match `input:not([disabled])` and, sitting at a boundary,
|
||||||
|
// break the wrap math. Hoisted out of the handler so it isn't rebuilt per Tab.
|
||||||
|
const FOCUSABLE_SELECTOR =
|
||||||
|
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
|
||||||
|
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A centered modal dialog over a dimmed backdrop. Closes on Escape or a backdrop
|
* A centered modal dialog over a dimmed backdrop. Closes on Escape or a backdrop
|
||||||
* click, unless `busy` (a mutation is in flight) — then it stays put so the
|
* click, unless `busy` (a mutation is in flight) — then it stays put so the
|
||||||
@@ -27,12 +34,57 @@ export function Modal({
|
|||||||
busyRef.current = busy
|
busyRef.current = busy
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
cardRef.current?.focus()
|
const card = cardRef.current
|
||||||
|
// Remember who opened the dialog so focus can return there on close —
|
||||||
|
// otherwise it lands on <body> and a keyboard user loses their place.
|
||||||
|
const opener = document.activeElement as HTMLElement | null
|
||||||
|
card?.focus()
|
||||||
|
|
||||||
|
const focusable = () =>
|
||||||
|
Array.from(card?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])
|
||||||
|
|
||||||
function onKey(e: KeyboardEvent) {
|
function onKey(e: KeyboardEvent) {
|
||||||
if (e.key === 'Escape' && !busyRef.current) onCloseRef.current()
|
if (e.key === 'Escape' && !busyRef.current) {
|
||||||
|
onCloseRef.current()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.key !== 'Tab') return
|
||||||
|
const items = focusable()
|
||||||
|
if (items.length === 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
card?.focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const first = items[0]
|
||||||
|
const last = items[items.length - 1]
|
||||||
|
const active = document.activeElement
|
||||||
|
// If focus is NOT inside the dialog, pull it back in rather than let Tab
|
||||||
|
// escape. This is the robust case that covers focus having fallen to
|
||||||
|
// <body> — a control that was removed (ShareGardenModal's remove-share
|
||||||
|
// button) or disabled while busy — as well as any externally-stolen focus.
|
||||||
|
if (!card || !card.contains(active)) {
|
||||||
|
e.preventDefault()
|
||||||
|
;(e.shiftKey ? last : first).focus()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (e.shiftKey && (active === first || active === card)) {
|
||||||
|
e.preventDefault()
|
||||||
|
last.focus()
|
||||||
|
} else if (!e.shiftKey && active === last) {
|
||||||
|
e.preventDefault()
|
||||||
|
first.focus()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', onKey)
|
document.addEventListener('keydown', onKey)
|
||||||
return () => document.removeEventListener('keydown', onKey)
|
return () => {
|
||||||
|
document.removeEventListener('keydown', onKey)
|
||||||
|
// Restore focus to the opener only if it's still in the document — the
|
||||||
|
// delete/clear flows this trap targets often remove the element that
|
||||||
|
// opened the dialog (a garden card, a plop row). A disconnected node's
|
||||||
|
// focus() silently no-ops and leaves focus on <body>, so fall through to
|
||||||
|
// that case explicitly rather than pretend it worked.
|
||||||
|
if (opener && opener.isConnected) opener.focus()
|
||||||
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
import { Alert } from '@/components/ui/Alert'
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { TextArea } from '@/components/ui/TextArea'
|
import { TextArea } from '@/components/ui/TextArea'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
@@ -33,12 +34,17 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
|||||||
// The turn in flight: what we sent, the steps so far, and how it ended.
|
// The turn in flight: what we sent, the steps so far, and how it ended.
|
||||||
const [pending, setPending] = useState<{ message: string; steps: AgentStep[] } | null>(null)
|
const [pending, setPending] = useState<{ message: string; steps: AgentStep[] } | null>(null)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [warning, setWarning] = useState<string | null>(null)
|
||||||
const abort = useRef<AbortController | null>(null)
|
const abort = useRef<AbortController | null>(null)
|
||||||
const bottom = useRef<HTMLDivElement>(null)
|
const bottom = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
// Abort an in-flight turn when the panel goes away, so a closed tab doesn't
|
// Deliberately NOT aborted on unmount. Selecting an object auto-switches the
|
||||||
// leave a read hanging.
|
// rail to the inspector, so aborting here would mean clicking the canvas
|
||||||
useEffect(() => () => abort.current?.abort(), [])
|
// mid-turn silently killed the turn — and the canvas is exactly what you're
|
||||||
|
// meant to be watching. The request continues, the exchange is persisted
|
||||||
|
// server-side, and coming back to this tab shows it. Only Stop aborts, and
|
||||||
|
// even that only stops us READING: the turn keeps running server-side, which
|
||||||
|
// is why its work still lands in History either way.
|
||||||
|
|
||||||
// Follow the conversation as it grows, including mid-turn as steps arrive.
|
// Follow the conversation as it grows, including mid-turn as steps arrive.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -50,6 +56,7 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
|||||||
if (!message || pending) return
|
if (!message || pending) return
|
||||||
setInput('')
|
setInput('')
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setWarning(null)
|
||||||
setPending({ message, steps: [] })
|
setPending({ message, steps: [] })
|
||||||
|
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
@@ -61,22 +68,24 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
|||||||
{
|
{
|
||||||
onStep: (step) => {
|
onStep: (step) => {
|
||||||
setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p))
|
setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p))
|
||||||
// Refresh as each step lands, not just at the end: the canvas updating
|
// The canvas updating under the conversation is the whole point of
|
||||||
// under the conversation is the whole point of putting the chat here.
|
// putting the chat here, so refresh it as each step lands — but only
|
||||||
refresh()
|
// it: nothing else can have changed until the turn commits.
|
||||||
|
refresh.canvas()
|
||||||
},
|
},
|
||||||
onDone: (turn: AgentTurn) => {
|
onDone: (turn: AgentTurn) => {
|
||||||
setPending(null)
|
setPending(null)
|
||||||
refresh()
|
refresh.everything()
|
||||||
if (turn.truncated) {
|
if (turn.truncated) {
|
||||||
setError('That turned into more steps than I should take at once — check what changed before continuing.')
|
setError('That turned into more steps than I should take at once — check what changed before continuing.')
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
onWarning: setWarning,
|
||||||
onError: (message) => {
|
onError: (message) => {
|
||||||
setPending(null)
|
setPending(null)
|
||||||
setError(message)
|
setError(message)
|
||||||
// Something may still have landed before it failed.
|
// Something may still have landed before it failed.
|
||||||
refresh()
|
refresh.everything()
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
controller.signal,
|
controller.signal,
|
||||||
@@ -92,7 +101,11 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
|||||||
{messages.length > 0 && (
|
{messages.length > 0 && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => clear.mutate()}
|
onClick={() =>
|
||||||
|
clear.mutate(undefined, {
|
||||||
|
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
|
||||||
|
})
|
||||||
|
}
|
||||||
disabled={clear.isPending}
|
disabled={clear.isPending}
|
||||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
>
|
>
|
||||||
@@ -108,6 +121,13 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||||
|
{history.isPending && <p className="text-sm text-muted">Loading the conversation…</p>}
|
||||||
|
{/* A failed load rendering as an empty thread would look like the
|
||||||
|
conversation had been lost, which is a much worse thing to believe. */}
|
||||||
|
{history.isError && (
|
||||||
|
<Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
{history.isSuccess && messages.length === 0 && !pending && (
|
{history.isSuccess && messages.length === 0 && !pending && (
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Ask for what you want and it'll do it — “change the garlic bed to cucumbers this year”, “fill the
|
Ask for what you want and it'll do it — “change the garlic bed to cucumbers this year”, “fill the
|
||||||
@@ -146,6 +166,7 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{warning && <Alert tone="info">{warning}</Alert>}
|
||||||
{error && <Alert>{error}</Alert>}
|
{error && <Alert>{error}</Alert>}
|
||||||
<div ref={bottom} />
|
<div ref={bottom} />
|
||||||
</div>
|
</div>
|
||||||
@@ -177,7 +198,7 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
|||||||
onClick={() => {
|
onClick={() => {
|
||||||
abort.current?.abort()
|
abort.current?.abort()
|
||||||
setPending(null)
|
setPending(null)
|
||||||
refresh()
|
refresh.everything()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Stop
|
Stop
|
||||||
@@ -199,7 +220,7 @@ function Bubble({
|
|||||||
}: {
|
}: {
|
||||||
role: 'user' | 'assistant'
|
role: 'user' | 'assistant'
|
||||||
body: string
|
body: string
|
||||||
children?: React.ReactNode
|
children?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const mine = role === 'user'
|
const mine = role === 'user'
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -5,23 +5,25 @@ import { useClearObject } from '@/lib/objects'
|
|||||||
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
|
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
|
||||||
* are kept with removed_at, so history survives). */
|
* are kept with removed_at, so history survives). */
|
||||||
export function ClearBedModal({
|
export function ClearBedModal({
|
||||||
|
objectId,
|
||||||
objectName,
|
objectName,
|
||||||
plops,
|
plopCount,
|
||||||
gardenId,
|
gardenId,
|
||||||
onClose,
|
onClose,
|
||||||
}: {
|
}: {
|
||||||
|
objectId: number
|
||||||
objectName: string
|
objectName: string
|
||||||
plops: { id: number; version: number }[]
|
plopCount: number
|
||||||
gardenId: number
|
gardenId: number
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}) {
|
}) {
|
||||||
const clear = useClearObject(gardenId)
|
const clear = useClearObject(gardenId)
|
||||||
const n = plops.length
|
|
||||||
return (
|
return (
|
||||||
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
|
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
<p className="text-sm text-muted">
|
<p className="text-sm text-muted">
|
||||||
Remove all <span className="font-medium text-fg">{n}</span> {n === 1 ? 'plant' : 'plants'} from{' '}
|
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
|
||||||
|
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
|
||||||
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
|
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex justify-end gap-2">
|
<div className="flex justify-end gap-2">
|
||||||
@@ -31,8 +33,8 @@ export function ClearBedModal({
|
|||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
disabled={clear.isPending || n === 0}
|
disabled={clear.isPending || plopCount === 0}
|
||||||
onClick={() => clear.mutate(plops, { onSuccess: onClose })}
|
onClick={() => clear.mutate(objectId, { onSuccess: onClose })}
|
||||||
>
|
>
|
||||||
{clear.isPending ? 'Clearing…' : 'Clear bed'}
|
{clear.isPending ? 'Clearing…' : 'Clear bed'}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -235,7 +235,13 @@ export function GardenCanvas({
|
|||||||
className="h-full w-full select-none"
|
className="h-full w-full select-none"
|
||||||
style={{ touchAction: 'none' }}
|
style={{ touchAction: 'none' }}
|
||||||
onPointerDown={onCanvasPointerDown}
|
onPointerDown={onCanvasPointerDown}
|
||||||
|
// role="application" tells a screen reader this is an interactive canvas
|
||||||
|
// to operate, not a document to read linearly. The <title> names it, and
|
||||||
|
// objects inside are individually focusable buttons (see ObjectShape).
|
||||||
|
role="application"
|
||||||
|
aria-label={`${garden.name} — garden layout. Tab between objects; Enter selects; arrow keys nudge a selection.`}
|
||||||
>
|
>
|
||||||
|
<title>{garden.name} garden layout</title>
|
||||||
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
|
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
|
||||||
{drawnGridCm != null && (
|
{drawnGridCm != null && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ function HistoryEntry({
|
|||||||
<UndoButton
|
<UndoButton
|
||||||
changeSet={changeSet}
|
changeSet={changeSet}
|
||||||
undo={undo}
|
undo={undo}
|
||||||
className="w-32 shrink-0"
|
className="w-32 shrink-0 text-right"
|
||||||
label={reverted ? 'Undo again' : 'Undo'}
|
label={reverted ? 'Undo again' : 'Undo'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { memo, type PointerEvent } from 'react'
|
import { memo, type KeyboardEvent, type PointerEvent } from 'react'
|
||||||
import { objectTransform } from './shared'
|
import { objectTransform } from './shared'
|
||||||
|
import { kindDef, objectDisplayName } from './kinds'
|
||||||
import type { EditorObject } from './types'
|
import type { EditorObject } from './types'
|
||||||
|
|
||||||
const DEFAULT_FILL = '#8a8a8a'
|
const DEFAULT_FILL = '#8a8a8a'
|
||||||
@@ -57,11 +58,50 @@ export const ObjectShape = memo(function ObjectShape({
|
|||||||
onSelect(object.id)
|
onSelect(object.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Keyboard path into selection (#84): the arrow-key nudge handler already
|
||||||
|
// exists but only ever acted on a pointer selection, so it was unreachable
|
||||||
|
// without a mouse. Enter/Space on a focused object selects it, which is the
|
||||||
|
// step that was missing.
|
||||||
|
function handleKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
onSelect(object.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const stroke = selected ? '#2f7a3e' : '#00000033'
|
const stroke = selected ? '#2f7a3e' : '#00000033'
|
||||||
const strokeWidth = selected ? 2 : 1
|
const strokeWidth = selected ? 2 : 1
|
||||||
|
|
||||||
|
// A concise accessible name: the object's label plus its kind's canonical
|
||||||
|
// label, e.g. "North Bed, In-ground" — reusing kindDef so it never diverges
|
||||||
|
// from what the UI shows (an ad-hoc kind.replace() gave "in ground"). The
|
||||||
|
// dimensions aren't included; they need the garden's unit context this
|
||||||
|
// component doesn't hold, so they're a follow-up.
|
||||||
|
const kindLabel = kindDef(object.kind)?.label ?? object.kind
|
||||||
|
const label = `${objectDisplayName(object)}, ${kindLabel}`
|
||||||
|
|
||||||
|
// Keyboard focus needs to be VISIBLE — that's the point of making the canvas
|
||||||
|
// keyboard-reachable. The `object-shape` class carries a :focus-visible rule
|
||||||
|
// (styles/index.css) that draws a dashed ring; :focus-visible means it shows
|
||||||
|
// for keyboard focus but NOT a mouse click, which is exactly what we want. CSS
|
||||||
|
// rather than React state because onFocus on an SVG <g> is unreliable and a
|
||||||
|
// presentation attribute is overridden by any CSS rule.
|
||||||
return (
|
return (
|
||||||
<g transform={objectTransform(object)} onPointerDown={handleDown} style={{ cursor: 'pointer' }}>
|
<g
|
||||||
|
className="object-shape"
|
||||||
|
transform={objectTransform(object)}
|
||||||
|
onPointerDown={handleDown}
|
||||||
|
onKeyDown={handleKey}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={label}
|
||||||
|
// aria-current, not aria-pressed: selecting an object isn't a toggle (a
|
||||||
|
// toggle is what aria-pressed means). aria-current marks it as the active
|
||||||
|
// item among the objects. Omitted, not "false", when unselected.
|
||||||
|
aria-current={selected || undefined}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
{object.shape === 'circle' ? (
|
{object.shape === 'circle' ? (
|
||||||
<ellipse
|
<ellipse
|
||||||
cx={0}
|
cx={0}
|
||||||
|
|||||||
@@ -43,8 +43,12 @@ const TONE_CLASS: Record<Exclude<UndoOutcome['tone'], 'pending'>, string> = {
|
|||||||
|
|
||||||
function OutcomeNote({ outcome }: { outcome: UndoOutcome }) {
|
function OutcomeNote({ outcome }: { outcome: UndoOutcome }) {
|
||||||
if (outcome.tone === 'pending') return null
|
if (outcome.tone === 'pending') return null
|
||||||
|
// No text alignment of its own: the container decides. The history list stacks
|
||||||
|
// this to the right of an entry, the chat panel puts it under a left-aligned
|
||||||
|
// message, and a hard-coded text-right made the chat's copy read against its
|
||||||
|
// own column.
|
||||||
return (
|
return (
|
||||||
<p role="status" className={cn('text-right text-xs', TONE_CLASS[outcome.tone])}>
|
<p role="status" className={cn('text-xs', TONE_CLASS[outcome.tone])}>
|
||||||
{outcome.message}
|
{outcome.message}
|
||||||
</p>
|
</p>
|
||||||
)
|
)
|
||||||
|
|||||||
+72
-15
@@ -13,13 +13,21 @@ import { historyKey } from './history'
|
|||||||
|
|
||||||
const capabilitiesSchema = z.object({ agent: z.boolean() })
|
const capabilitiesSchema = z.object({ agent: z.boolean() })
|
||||||
|
|
||||||
/** Whether this instance has the assistant configured. Without it the panel
|
export const capabilitiesKey = ['capabilities'] as const
|
||||||
* isn't rendered at all — a dead button is worse than no button. */
|
|
||||||
|
/** Whether the assistant is live RIGHT NOW. Without it the panel isn't rendered
|
||||||
|
* at all — a dead button is worse than no button.
|
||||||
|
*
|
||||||
|
* Not `staleTime: Infinity` any more: an admin can turn the assistant on or off
|
||||||
|
* in Settings (#79), so this must be able to change under a running page. The
|
||||||
|
* settings save invalidates this key directly; the finite staleTime just means
|
||||||
|
* another admin's change is picked up on the next focus/remount rather than
|
||||||
|
* never. */
|
||||||
export function useCapabilities() {
|
export function useCapabilities() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['capabilities'] as const,
|
queryKey: capabilitiesKey,
|
||||||
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
|
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
|
||||||
staleTime: Infinity, // server config; it doesn't change under a running page
|
staleTime: 60_000,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,10 +105,29 @@ export function describeStep(step: AgentStep): string {
|
|||||||
return [...new Set(labels)].join(', ')
|
return [...new Set(labels)].join(', ')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const chatEventSchema = z.object({
|
||||||
|
step: z.object({ index: z.number(), tools: z.array(z.string()) }).optional(),
|
||||||
|
done: z
|
||||||
|
.object({
|
||||||
|
reply: z.string(),
|
||||||
|
changeSetId: z.number().optional(),
|
||||||
|
steps: z.number(),
|
||||||
|
truncated: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
error: z.string().optional(),
|
||||||
|
// The turn worked but something adjacent to it didn't — currently, the
|
||||||
|
// exchange couldn't be saved. Dropping this on the floor would recreate
|
||||||
|
// exactly the silent swallow the server added it to avoid.
|
||||||
|
warning: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export interface StreamHandlers {
|
export interface StreamHandlers {
|
||||||
onStep: (step: AgentStep) => void
|
onStep: (step: AgentStep) => void
|
||||||
onDone: (turn: AgentTurn) => void
|
onDone: (turn: AgentTurn) => void
|
||||||
onError: (message: string) => void
|
onError: (message: string) => void
|
||||||
|
/** The turn succeeded, but something alongside it didn't. */
|
||||||
|
onWarning?: (message: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,12 +153,21 @@ export async function streamChat(
|
|||||||
signal,
|
signal,
|
||||||
})
|
})
|
||||||
} catch {
|
} catch {
|
||||||
|
// An abort here is the caller's own doing — Stop, or navigating away — not a
|
||||||
|
// failure to report back to them. The read loop below already knew this; the
|
||||||
|
// request path did not.
|
||||||
|
if (signal?.aborted) return
|
||||||
handlers.onError('Could not reach the server.')
|
handlers.onError('Could not reach the server.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!res.ok || !res.body) {
|
if (!res.ok || !res.body) {
|
||||||
|
// 503 is the assistant being turned off at runtime (#79) — the route exists,
|
||||||
|
// there's just no model behind it. Distinct from a 404, which would mean the
|
||||||
|
// whole endpoint is absent.
|
||||||
handlers.onError(
|
handlers.onError(
|
||||||
res.status === 404
|
res.status === 503
|
||||||
|
? "The assistant isn't enabled on this instance."
|
||||||
|
: res.status === 404
|
||||||
? "This instance doesn't have the assistant configured."
|
? "This instance doesn't have the assistant configured."
|
||||||
: 'The assistant is not available right now.',
|
: 'The assistant is not available right now.',
|
||||||
)
|
)
|
||||||
@@ -162,13 +198,12 @@ export async function streamChat(
|
|||||||
for (const frame of frames) {
|
for (const frame of frames) {
|
||||||
const line = frame.split('\n').find((l) => l.startsWith('data:'))
|
const line = frame.split('\n').find((l) => l.startsWith('data:'))
|
||||||
if (!line) continue
|
if (!line) continue
|
||||||
let event: unknown
|
// Parsed AND validated: a malformed or unexpected frame shouldn't kill a
|
||||||
try {
|
// working stream, and shouldn't be trusted into the UI either.
|
||||||
event = JSON.parse(line.slice(5).trim())
|
const parsed = chatEventSchema.safeParse(safeJson(line.slice(5).trim()))
|
||||||
} catch {
|
if (!parsed.success) continue
|
||||||
continue // a malformed frame shouldn't kill a working stream
|
const e = parsed.data
|
||||||
}
|
if (e.warning) handlers.onWarning?.(e.warning)
|
||||||
const e = event as { step?: AgentStep; done?: AgentTurn; error?: string }
|
|
||||||
if (e.error) handlers.onError(e.error)
|
if (e.error) handlers.onError(e.error)
|
||||||
else if (e.step) handlers.onStep(e.step)
|
else if (e.step) handlers.onStep(e.step)
|
||||||
else if (e.done) handlers.onDone(e.done)
|
else if (e.done) handlers.onDone(e.done)
|
||||||
@@ -176,13 +211,35 @@ export async function streamChat(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Refresh everything a turn may have changed: the canvas, the history list,
|
function safeJson(raw: string): unknown {
|
||||||
* and the conversation itself. */
|
try {
|
||||||
|
return JSON.parse(raw)
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Refreshes for the two moments that need different amounts of work.
|
||||||
|
*
|
||||||
|
* Mid-turn, only the canvas can have changed: the change set isn't written until
|
||||||
|
* the turn commits, and the exchange isn't stored until it finishes. Refetching
|
||||||
|
* those on every step would be up to 2×(N−1) requests per turn for data that
|
||||||
|
* cannot have moved.
|
||||||
|
*/
|
||||||
export function useAgentRefresh(gardenId: number) {
|
export function useAgentRefresh(gardenId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return () => {
|
const canvas = () => {
|
||||||
void qc.invalidateQueries({ queryKey: gardenFullKey(gardenId) })
|
void qc.invalidateQueries({ queryKey: gardenFullKey(gardenId) })
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
/** After a step: the garden may have changed under the conversation. */
|
||||||
|
canvas,
|
||||||
|
/** After a turn: the change set and the stored exchange exist now too. */
|
||||||
|
everything: () => {
|
||||||
|
canvas()
|
||||||
void qc.invalidateQueries({ queryKey: historyKey(gardenId) })
|
void qc.invalidateQueries({ queryKey: historyKey(gardenId) })
|
||||||
void qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) })
|
void qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) })
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,6 +49,14 @@ describe('describeConflict', () => {
|
|||||||
describe('describeUndo', () => {
|
describe('describeUndo', () => {
|
||||||
const target = changeSet({ counts: [{ entityType: 'object', op: 'update', n: 3 }] })
|
const target = changeSet({ counts: [{ entityType: 'object', op: 'update', n: 3 }] })
|
||||||
|
|
||||||
|
// The bug this pair exists for: a real revert response carries a change set
|
||||||
|
// whose counts the server didn't populate. Reading that as "nothing happened"
|
||||||
|
// told the user a successful undo had done nothing.
|
||||||
|
it('trusts the change set, not the tally, for whether anything happened', () => {
|
||||||
|
const out = describeUndo({ id: 5 }, { changeSet: changeSet({ id: 6, counts: [] }), conflicts: [] })
|
||||||
|
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
|
||||||
|
})
|
||||||
|
|
||||||
it('is a plain success when nothing conflicted', () => {
|
it('is a plain success when nothing conflicted', () => {
|
||||||
const out = describeUndo(target, {
|
const out = describeUndo(target, {
|
||||||
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 3 }] }),
|
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 3 }] }),
|
||||||
@@ -61,6 +69,7 @@ describe('describeUndo', () => {
|
|||||||
// already a no-op — reachable by undoing a creation whose object is gone.
|
// already a no-op — reachable by undoing a creation whose object is gone.
|
||||||
// Claiming "Undone." there reports work that didn't happen.
|
// Claiming "Undone." there reports work that didn't happen.
|
||||||
it("doesn't claim to have undone a no-op", () => {
|
it("doesn't claim to have undone a no-op", () => {
|
||||||
|
// A NULL change set — not an empty tally — is the server's no-op signal.
|
||||||
const out = describeUndo(target, { changeSet: null, conflicts: [] })
|
const out = describeUndo(target, { changeSet: null, conflicts: [] })
|
||||||
expect(out.tone).toBe('ok')
|
expect(out.tone).toBe('ok')
|
||||||
expect(out.message).toBe('Nothing left to undo — this was already reversed.')
|
expect(out.message).toBe('Nothing left to undo — this was already reversed.')
|
||||||
|
|||||||
+14
-10
@@ -113,9 +113,10 @@ export function useRevertChangeSet(gardenId: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** How many rows a change set touched, for "3 changes" in the list. */
|
/** How many rows a change set touched, for "3 changes" in the list. Undefined
|
||||||
export function totalChanges(cs: ChangeSet): number {
|
* counts total zero, which callers read as "no denominator to quote". */
|
||||||
return cs.counts.reduce((sum, c) => sum + c.n, 0)
|
export function totalChanges(cs: { counts?: ChangeCount[] }): number {
|
||||||
|
return (cs.counts ?? []).reduce((sum, c) => sum + c.n, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
const ENTITY_NOUNS: Record<ChangeCount['entityType'], [string, string]> = {
|
const ENTITY_NOUNS: Record<ChangeCount['entityType'], [string, string]> = {
|
||||||
@@ -223,20 +224,23 @@ export interface UndoOutcome {
|
|||||||
*/
|
*/
|
||||||
export function describeUndo(target: UndoTarget, result: RevertResult): UndoOutcome {
|
export function describeUndo(target: UndoTarget, result: RevertResult): UndoOutcome {
|
||||||
const skipped = result.conflicts.map(describeConflict).join('; ')
|
const skipped = result.conflicts.map(describeConflict).join('; ')
|
||||||
|
// A NULL change set is the server's signal that nothing needed doing. An empty
|
||||||
|
// `counts` is not the same thing and must not be read as one — that conflation
|
||||||
|
// made a successful undo report "nothing left to undo", which is the worst
|
||||||
|
// possible thing to tell someone about an action that just worked.
|
||||||
|
const didSomething = result.changeSet != null
|
||||||
const applied = result.changeSet ? totalChanges(result.changeSet) : 0
|
const applied = result.changeSet ? totalChanges(result.changeSet) : 0
|
||||||
if (result.conflicts.length === 0) {
|
if (result.conflicts.length === 0) {
|
||||||
// The server answers 200 with a null change set when every revision was
|
// Reachable by undoing a creation whose object is already gone.
|
||||||
// already a no-op — reachable by undoing a creation whose object is gone.
|
if (!didSomething) return { tone: 'ok', message: 'Nothing left to undo — this was already reversed.' }
|
||||||
// Claiming "Undone." there would be reporting work that didn't happen.
|
|
||||||
if (applied === 0) return { tone: 'ok', message: 'Nothing left to undo — this was already reversed.' }
|
|
||||||
return { tone: 'ok', message: 'Undone.' }
|
return { tone: 'ok', message: 'Undone.' }
|
||||||
}
|
}
|
||||||
if (applied === 0) {
|
if (!didSomething) {
|
||||||
return { tone: 'error', message: `Nothing was undone — ${skipped}.` }
|
return { tone: 'error', message: `Nothing was undone — ${skipped}.` }
|
||||||
}
|
}
|
||||||
// Only claim a denominator when we have one. "2 of 3" from a caller that
|
// Only claim a denominator when we have one. "2 of 3" from a caller that
|
||||||
// never knew the total would be a number invented to fill a sentence.
|
// never knew the total would be a number invented to fill a sentence.
|
||||||
const total = target.counts ? target.counts.reduce((sum, c) => sum + c.n, 0) : 0
|
const total = totalChanges(target)
|
||||||
const scale = total > 0 ? `${applied} of ${total} changes undone` : 'Partly undone'
|
const scale = total > 0 && applied > 0 ? `${applied} of ${total} changes undone` : 'Partly undone'
|
||||||
return { tone: 'partial', message: `${scale} — ${skipped}.` }
|
return { tone: 'partial', message: `${scale} — ${skipped}.` }
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-17
@@ -328,28 +328,28 @@ export function useUpdatePlanting(gardenId: number) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Clear a bed: soft-remove every active plop in an object (a loop of PATCHes;
|
const clearResultSchema = z.object({ cleared: z.number() })
|
||||||
* a bulk ClearObject endpoint arrives with the agent seam, #19). Invalidates
|
|
||||||
* once at the end. Pass the object's active plops (id + current version). */
|
/** Clear a bed: soft-remove every active plop in an object (#82).
|
||||||
|
*
|
||||||
|
* ONE request, and so ONE change set. This used to be a loop of PATCHes, which
|
||||||
|
* meant clearing a 40-plop bed wrote 40 change sets and took 40 presses of Undo
|
||||||
|
* to put back — while the agent's clear_object, for the identical user-facing
|
||||||
|
* action, undid in a single click. The rule it violated is stated in CLAUDE.md:
|
||||||
|
* multi-row operations record all their changes together so they undo as one
|
||||||
|
* unit. Doing it server-side also removes the partial-failure case the old loop
|
||||||
|
* had to reconcile. */
|
||||||
export function useClearObject(gardenId: number) {
|
export function useClearObject(gardenId: number) {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (plops: { id: number; version: number }[]) => {
|
mutationFn: async (objectId: number): Promise<number> => {
|
||||||
const today = new Date().toISOString().slice(0, 10)
|
// No body — clear takes none; passing undefined sends none rather than an
|
||||||
// allSettled, not all: a partial failure still soft-removed some rows
|
// empty {}. The response is just a count; validate it rather than cast.
|
||||||
// server-side, so we must reconcile the cache rather than roll everything
|
const res = clearResultSchema.parse(await api.post(`/objects/${objectId}/clear`))
|
||||||
// back. Report how many failed.
|
return res.cleared
|
||||||
const results = await Promise.allSettled(
|
|
||||||
plops.map((p) => api.patch(`/plantings/${p.id}`, { removedAt: today, version: p.version })),
|
|
||||||
)
|
|
||||||
const failed = results.filter((r) => r.status === 'rejected').length
|
|
||||||
if (failed > 0) {
|
|
||||||
throw new Error(`${failed} of ${plops.length} plants couldn't be cleared — refresh and try again.`)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
// Reconcile on success OR partial failure, so the cache matches the server.
|
|
||||||
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
|
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
|
||||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Could not clear the bed.'),
|
onError: (err) => toast.error(objectErrorMessage(err, 'Could not clear the bed.')),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// Instance settings data layer (#79): admin-only, instance-wide configuration.
|
||||||
|
//
|
||||||
|
// The GET/PATCH return both the stored settings and a read-only "effective" view
|
||||||
|
// — what's actually in force after layering the DB over the environment — so the
|
||||||
|
// form can say "inheriting ollama-cloud/glm-5.2:cloud from the environment" and
|
||||||
|
// whether the API key is present, without the key ever crossing the wire.
|
||||||
|
|
||||||
|
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { ApiError, api } from './api'
|
||||||
|
import { capabilitiesKey } from './agent'
|
||||||
|
|
||||||
|
export const instanceSettingsSchema = z.object({
|
||||||
|
// '' means "inherit the PANSY_AGENT_MODEL env var".
|
||||||
|
agentModel: z.string(),
|
||||||
|
// null means "inherit PANSY_AGENT_ENABLED"; true/false is an explicit override.
|
||||||
|
agentEnabled: z.boolean().nullable(),
|
||||||
|
version: z.number(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
})
|
||||||
|
export type InstanceSettings = z.infer<typeof instanceSettingsSchema>
|
||||||
|
|
||||||
|
export const effectiveAgentSchema = z.object({
|
||||||
|
model: z.string(),
|
||||||
|
enabled: z.boolean(),
|
||||||
|
hasApiKey: z.boolean(),
|
||||||
|
agentLive: z.boolean(),
|
||||||
|
})
|
||||||
|
export type EffectiveAgent = z.infer<typeof effectiveAgentSchema>
|
||||||
|
|
||||||
|
export const settingsResponseSchema = z.object({
|
||||||
|
settings: instanceSettingsSchema,
|
||||||
|
effective: effectiveAgentSchema,
|
||||||
|
})
|
||||||
|
export type SettingsResponse = z.infer<typeof settingsResponseSchema>
|
||||||
|
|
||||||
|
export const settingsKey = ['settings'] as const
|
||||||
|
|
||||||
|
export const settingsQueryOptions = queryOptions({
|
||||||
|
queryKey: settingsKey,
|
||||||
|
queryFn: async (): Promise<SettingsResponse> =>
|
||||||
|
settingsResponseSchema.parse(await api.get('/settings')),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useSettings() {
|
||||||
|
return useQuery(settingsQueryOptions)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsUpdate {
|
||||||
|
agentModel: string
|
||||||
|
agentEnabled: boolean | null
|
||||||
|
version: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSettings() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: SettingsUpdate): Promise<SettingsResponse> =>
|
||||||
|
settingsResponseSchema.parse(await api.patch('/settings', input)),
|
||||||
|
onSuccess: (res) => {
|
||||||
|
qc.setQueryData(settingsKey, res)
|
||||||
|
// The save may have turned the assistant on or off; the editor keys its
|
||||||
|
// chat tab off /capabilities, so make it re-read rather than trust its
|
||||||
|
// cached answer.
|
||||||
|
qc.invalidateQueries({ queryKey: capabilitiesKey })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** If err is a 409 version conflict, return the fresh settings it carries so a
|
||||||
|
* form can rebase; otherwise null. */
|
||||||
|
export function conflictSettings(err: unknown): InstanceSettings | null {
|
||||||
|
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
|
||||||
|
const current = (err.body as { current?: unknown }).current
|
||||||
|
const parsed = instanceSettingsSchema.safeParse(current)
|
||||||
|
if (parsed.success) return parsed.data
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
@@ -526,8 +526,9 @@ export function GardenEditorPage() {
|
|||||||
|
|
||||||
{clearing && focusedObject && (
|
{clearing && focusedObject && (
|
||||||
<ClearBedModal
|
<ClearBedModal
|
||||||
|
objectId={focusedObject.id}
|
||||||
objectName={objectDisplayName(focusedObject)}
|
objectName={objectDisplayName(focusedObject)}
|
||||||
plops={focusedPlops.map((p) => ({ id: p.id, version: p.version }))}
|
plopCount={focusedPlops.length}
|
||||||
gardenId={gid}
|
gardenId={gid}
|
||||||
onClose={() => setClearing(false)}
|
onClose={() => setClearing(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Select } from '@/components/ui/Select'
|
||||||
|
import { TextField } from '@/components/ui/TextField'
|
||||||
|
import { toast } from '@/components/ui/toast'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import {
|
||||||
|
conflictSettings,
|
||||||
|
useSettings,
|
||||||
|
useUpdateSettings,
|
||||||
|
type EffectiveAgent,
|
||||||
|
type InstanceSettings,
|
||||||
|
} from '@/lib/settings'
|
||||||
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
|
||||||
|
// agentEnabled is a tri-state on the wire (null = inherit env, true, false); the
|
||||||
|
// form models it as three named choices so "inherit" is a deliberate pick, not
|
||||||
|
// an empty control.
|
||||||
|
type EnabledChoice = 'inherit' | 'on' | 'off'
|
||||||
|
const toChoice = (v: boolean | null): EnabledChoice => (v === null ? 'inherit' : v ? 'on' : 'off')
|
||||||
|
const fromChoice = (c: EnabledChoice): boolean | null => (c === 'inherit' ? null : c === 'on')
|
||||||
|
|
||||||
|
/** Admin-only instance settings (#79). The one that matters today is the agent
|
||||||
|
* model; the API key stays in the environment and is only ever reported as
|
||||||
|
* present/absent, never shown or edited. */
|
||||||
|
export function SettingsPage() {
|
||||||
|
usePageTitle('Settings')
|
||||||
|
const settings = useSettings()
|
||||||
|
const update = useUpdateSettings()
|
||||||
|
|
||||||
|
if (settings.isPending) {
|
||||||
|
return <p className="text-sm text-muted">Loading settings…</p>
|
||||||
|
}
|
||||||
|
if (settings.isError) {
|
||||||
|
return <Alert>{errorMessage(settings.error, "Couldn't load settings.")}</Alert>
|
||||||
|
}
|
||||||
|
return <SettingsForm data={settings.data} update={update} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function SettingsForm({
|
||||||
|
data,
|
||||||
|
update,
|
||||||
|
}: {
|
||||||
|
data: { settings: InstanceSettings; effective: EffectiveAgent }
|
||||||
|
update: ReturnType<typeof useUpdateSettings>
|
||||||
|
}) {
|
||||||
|
const [model, setModel] = useState(data.settings.agentModel)
|
||||||
|
const [enabled, setEnabled] = useState<EnabledChoice>(toChoice(data.settings.agentEnabled))
|
||||||
|
const [version, setVersion] = useState(data.settings.version)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
// Rebase the form when the query cache updates (e.g. a successful save writes
|
||||||
|
// the new row back), so the version we submit is never stale.
|
||||||
|
useEffect(() => {
|
||||||
|
setVersion(data.settings.version)
|
||||||
|
}, [data.settings.version])
|
||||||
|
|
||||||
|
const eff = data.effective
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
setError(null)
|
||||||
|
update.mutate(
|
||||||
|
{ agentModel: model.trim(), agentEnabled: fromChoice(enabled), version },
|
||||||
|
{
|
||||||
|
onSuccess: () => toast.info('Settings saved.'),
|
||||||
|
onError: (err) => {
|
||||||
|
const current = conflictSettings(err)
|
||||||
|
if (current) {
|
||||||
|
// Someone else saved first. Adopt their row so the next attempt is
|
||||||
|
// clean, and say so rather than silently discarding this edit.
|
||||||
|
setModel(current.agentModel)
|
||||||
|
setEnabled(toChoice(current.agentEnabled))
|
||||||
|
setVersion(current.version)
|
||||||
|
setError('Someone else changed these settings just now — reloaded their version. Re-apply your change if you still want it.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(errorMessage(err, "Couldn't save settings."))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex max-w-2xl flex-col gap-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold text-fg">Settings</h1>
|
||||||
|
<p className="mt-1 text-sm text-muted">
|
||||||
|
Instance-wide, admin-only. Changes take effect immediately — no restart.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="flex flex-col gap-4 rounded-lg border border-border p-4">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold text-fg">Garden assistant</h2>
|
||||||
|
<p className="mt-1 text-xs text-muted">
|
||||||
|
The model runs against your Ollama Cloud key, which is set in the environment and never
|
||||||
|
shown here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<AgentStatus eff={eff} />
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label="Model"
|
||||||
|
name="agentModel"
|
||||||
|
placeholder={eff.model || 'ollama-cloud/glm-5.2:cloud'}
|
||||||
|
value={model}
|
||||||
|
onChange={(e) => setModel(e.target.value)}
|
||||||
|
hint={
|
||||||
|
model.trim() === ''
|
||||||
|
? `Empty — inheriting ${eff.model || 'the built-in default'} from the environment.`
|
||||||
|
: 'A majordomo model spec. A comma-separated list is a failover chain.'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
label="Enabled"
|
||||||
|
name="agentEnabled"
|
||||||
|
value={enabled}
|
||||||
|
onChange={(e) => setEnabled(e.target.value as EnabledChoice)}
|
||||||
|
options={[
|
||||||
|
{ value: 'inherit', label: 'Inherit from environment' },
|
||||||
|
{ value: 'on', label: 'On' },
|
||||||
|
{ value: 'off', label: 'Off' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={save} disabled={update.isPending}>
|
||||||
|
{update.isPending ? 'Saving…' : 'Save changes'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AgentStatus surfaces the gap the capabilities endpoint exists for: enabled +
|
||||||
|
// a key does not guarantee the assistant is actually running (an unresolvable
|
||||||
|
// model leaves it down), and that's precisely what an admin needs to see.
|
||||||
|
function AgentStatus({ eff }: { eff: EffectiveAgent }) {
|
||||||
|
const [tone, text] = eff.agentLive
|
||||||
|
? (['ok', `Live on ${eff.model}`] as const)
|
||||||
|
: !eff.hasApiKey
|
||||||
|
? (['warn', 'No API key set in the environment — the assistant is off.'] as const)
|
||||||
|
: !eff.enabled
|
||||||
|
? (['warn', 'Turned off.'] as const)
|
||||||
|
: (['warn', `Configured but not running — check the model (${eff.model}).`] as const)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
'inline-block h-2 w-2 rounded-full ' + (tone === 'ok' ? 'bg-emerald-500' : 'bg-amber-500')
|
||||||
|
}
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
<span className={tone === 'ok' ? 'text-fg' : 'text-muted'}>{text}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { GardensPage } from '@/pages/GardensPage'
|
|||||||
import { GardenEditorPage } from '@/pages/GardenEditorPage'
|
import { GardenEditorPage } from '@/pages/GardenEditorPage'
|
||||||
import { PublicGardenPage } from '@/pages/PublicGardenPage'
|
import { PublicGardenPage } from '@/pages/PublicGardenPage'
|
||||||
import { PlantsPage } from '@/pages/PlantsPage'
|
import { PlantsPage } from '@/pages/PlantsPage'
|
||||||
|
import { SettingsPage } from '@/pages/SettingsPage'
|
||||||
import { meQueryOptions } from '@/lib/auth'
|
import { meQueryOptions } from '@/lib/auth'
|
||||||
import { queryClient } from '@/lib/queryClient'
|
import { queryClient } from '@/lib/queryClient'
|
||||||
import { safeRedirectPath } from '@/lib/redirect'
|
import { safeRedirectPath } from '@/lib/redirect'
|
||||||
@@ -48,6 +49,20 @@ async function requireGuest(context: RouterContext, redirectTo: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// requireAdmin: authenticated AND admin. A non-admin who navigates to /settings
|
||||||
|
// is sent to /gardens rather than shown a page whose API calls would 403. This
|
||||||
|
// is convenience routing, not the security boundary — the server's requireAdmin
|
||||||
|
// is authoritative.
|
||||||
|
async function requireAdmin(context: RouterContext, path: string) {
|
||||||
|
const me = await context.queryClient.ensureQueryData(meQueryOptions)
|
||||||
|
if (!me) {
|
||||||
|
throw redirect({ to: '/login', search: { redirect: path } })
|
||||||
|
}
|
||||||
|
if (!me.isAdmin) {
|
||||||
|
throw redirect({ to: '/gardens' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const indexRoute = createRoute({
|
const indexRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -109,6 +124,13 @@ const plantsRoute = createRoute({
|
|||||||
component: PlantsPage,
|
component: PlantsPage,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const settingsRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: 'settings',
|
||||||
|
beforeLoad: ({ context, location }) => requireAdmin(context, location.href),
|
||||||
|
component: SettingsPage,
|
||||||
|
})
|
||||||
|
|
||||||
// Public read-only garden by share token. Deliberately has NO beforeLoad auth
|
// Public read-only garden by share token. Deliberately has NO beforeLoad auth
|
||||||
// guard, so a logged-out visitor viewing a shared link is never redirected to
|
// guard, so a logged-out visitor viewing a shared link is never redirected to
|
||||||
// /login or OIDC.
|
// /login or OIDC.
|
||||||
@@ -125,6 +147,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
gardensRoute,
|
gardensRoute,
|
||||||
gardenEditorRoute,
|
gardenEditorRoute,
|
||||||
plantsRoute,
|
plantsRoute,
|
||||||
|
settingsRoute,
|
||||||
publicGardenRoute,
|
publicGardenRoute,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,19 @@
|
|||||||
font-family: var(--font-sans);
|
font-family: var(--font-sans);
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Keyboard focus on a canvas object (#84). :focus-visible shows the ring for
|
||||||
|
keyboard focus but not a mouse click; the dashed accent ring distinguishes
|
||||||
|
"focused" from the solid ring that marks "selected". A CSS rule overrides
|
||||||
|
the shape's inline stroke presentation attributes. */
|
||||||
|
.object-shape {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
.object-shape:focus-visible :is(rect, ellipse) {
|
||||||
|
stroke: var(--color-accent-strong);
|
||||||
|
stroke-width: 2;
|
||||||
|
stroke-dasharray: 5 4;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark theme: override the same tokens so utilities recolor automatically. */
|
/* Dark theme: override the same tokens so utilities recolor automatically. */
|
||||||
|
|||||||
Reference in New Issue
Block a user