Compare commits
40
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
757ac7394d | ||
|
|
d82db48e4b | ||
|
|
20bf7ee03d | ||
|
|
cb594f34d8 | ||
|
|
74f6b9a876 | ||
|
|
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 | ||
|
|
a1bb8ec463 | ||
|
|
5d9b10d7c7 | ||
|
|
7f8b5254d0 | ||
|
|
8c5ddb21ec | ||
|
|
b96ca1ec0a |
@@ -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.
|
||||||
|
|
||||||
@@ -113,7 +156,35 @@ implemented in later per-issue sessions.
|
|||||||
the `_PATH`/`_ADDR` names you might guess. Authentik is the primary IdP;
|
the `_PATH`/`_ADDR` names you might guess. Authentik is the primary IdP;
|
||||||
OIDC-first with local passwords as fallback.
|
OIDC-first with local passwords as fallback.
|
||||||
|
|
||||||
Agent config (v2): `OLLAMA_CLOUD_API_KEY` and `PANSY_AGENT_MODEL` (default
|
Agent config: `OLLAMA_CLOUD_API_KEY`, `PANSY_AGENT_MODEL` (default
|
||||||
`ollama-cloud/glm-5.2:cloud`), set in Komodo. Model strings pass verbatim to
|
`ollama-cloud/glm-5.2:cloud`) and `PANSY_AGENT_ENABLED`, set in Komodo. Model
|
||||||
`majordomo.Parse`, so a comma-separated spec gives failover for free.
|
strings pass verbatim to `majordomo.Parse`, so a comma-separated spec gives
|
||||||
`majordomo` and `executus` are sibling repos at `../`.
|
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
|
||||||
|
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
|
||||||
|
build, which has no sibling checkout. `executus` is a sibling repo at `../` and
|
||||||
|
is not a dependency.
|
||||||
|
|
||||||
|
The `majordomo` build tag is **gone**. Don't reintroduce it — an untagged CI that
|
||||||
|
never compiles the agent is worse than a slightly larger binary.
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -23,6 +25,8 @@ SQLite, centimeters everywhere (display-side imperial conversion only), `version
|
|||||||
- **garden_objects** — one polymorphic table: `kind` (`bed`|`grow_bag`|`container`|`in_ground`|`tree`|`path`|`structure`), name, `shape` (`rect`|`circle`; `polygon` + `points` JSON reserved in schema, not in the v1 editor), center `x_cm`,`y_cm` in garden space, `width_cm`,`height_cm` (circle: width=diameter), `rotation_deg`, `z_index`, `plantable` bool, nullable `color` override, `props` JSON for kind-specific extras (bed height, tree species…), notes.
|
- **garden_objects** — one polymorphic table: `kind` (`bed`|`grow_bag`|`container`|`in_ground`|`tree`|`path`|`structure`), name, `shape` (`rect`|`circle`; `polygon` + `points` JSON reserved in schema, not in the v1 editor), center `x_cm`,`y_cm` in garden space, `width_cm`,`height_cm` (circle: width=diameter), `rotation_deg`, `z_index`, `plantable` bool, nullable `color` override, `props` JSON for kind-specific extras (bed height, tree species…), notes.
|
||||||
- **seed_lots** — one purchase: vendor, source URL, sku, lot code, purchase date, packed-for year, quantity + unit, cost, germination %. `owner_id` is on the LOT, not inherited from the plant, so you can record buying generic built-in Garlic from Johnny's and keep it private. `plantings.seed_lot_id` (ON DELETE SET NULL) attributes a plop to a purchase; **`remaining` is derived** (quantity − summed effective count of active plantings), never stored — a decremented column drifts the moment a planting is edited behind its back and can't be audited afterwards. Lots are never shared along with a garden.
|
- **seed_lots** — one purchase: vendor, source URL, sku, lot code, purchase date, packed-for year, quantity + unit, cost, germination %. `owner_id` is on the LOT, not inherited from the plant, so you can record buying generic built-in Garlic from Johnny's and keep it private. `plantings.seed_lot_id` (ON DELETE SET NULL) attributes a plop to a purchase; **`remaining` is derived** (quantity − summed effective count of active plantings), never stored — a decremented column drifts the moment a planting is edited behind its back and can't be audited afterwards. Lots are never shared along with a garden.
|
||||||
- **plants** — catalog, plus `source_url`/`vendor` provenance for the variety itself. `owner_id NULL` = built-in (~30 seeded via migration, read-only, clone to customize). name, category (`vegetable`|`herb`|`flower`|`fruit`|`tree_shrub`|`cover`), `spacing_cm` (mature spacing), `color` hex, `icon` (emoji string — zero assets in v1), nullable `days_to_maturity`, notes. Users see built-ins + their own.
|
- **plants** — catalog, plus `source_url`/`vendor` provenance for the variety itself. `owner_id NULL` = built-in (~30 seeded via migration, read-only, clone to customize). name, category (`vegetable`|`herb`|`flower`|`fruit`|`tree_shrub`|`cover`), `spacing_cm` (mature spacing), `color` hex, `icon` (emoji string — zero assets in v1), nullable `days_to_maturity`, notes. Users see built-ins + their own.
|
||||||
|
- **agent_conversations** / **agent_messages** — the assistant's chat, one thread per (user, garden). Only the user/assistant TEXT of each turn is stored, not the model's full transcript: continuity needs what was said and what came back, and replaying a stored tool call would replay a decision made against a garden that has since moved on. `agent_messages.change_set_id` is the handle the chat panel uses to offer Undo on the turn that caused it.
|
||||||
|
- **journal_entries** — the grow log: `garden_id` (NOT NULL), nullable `object_id`/`planting_id` to narrow the target, author, body, `observed_at`. `notes` on a garden/object/plant says what the thing IS and a new note overwrites the old; a journal entry says what HAPPENED and when, and entries accumulate. `garden_id` is set even for a bed- or plop-level entry so permission checks reuse `requireGardenRole` unchanged rather than inventing a second ACL path. `observed_at` is distinct from `created_at` — you write up Saturday's observations on Sunday. Deliberately **not** in the revision history: entries are already append-shaped and individually versioned.
|
||||||
- **change_sets** / **revisions** — the undo substrate. A change set groups the row-level revisions one logical operation produced (`source` ui/agent/api, `summary`, nullable `agent_run_id`, nullable `reverts_id`); a revision is `(entity_type, entity_id, op, before, after)` with full JSON row snapshots. The unit of undo is the **operation, not the row**. Revert is itself a change set, so an undo can be undone; it is version-guarded per entity and reports conflicts rather than clobbering a later edit. Garden *deletion* is deliberately not revertible (the cascade is invisible to the service, and would take the history with it).
|
- **change_sets** / **revisions** — the undo substrate. A change set groups the row-level revisions one logical operation produced (`source` ui/agent/api, `summary`, nullable `agent_run_id`, nullable `reverts_id`); a revision is `(entity_type, entity_id, op, before, after)` with full JSON row snapshots. The unit of undo is the **operation, not the row**. Revert is itself a change set, so an undo can be undone; it is version-guarded per entity and reports conflicts rather than clobbering a later edit. Garden *deletion* is deliberately not revertible (the cascade is invisible to the service, and would take the history with it).
|
||||||
- **plantings** ("plops") — object_id, plant_id, center `x_cm`,`y_cm` **in the parent object's local frame** (moving/rotating a bed moves its plants for free), `radius_cm` (a plop is a circular patch), nullable `count` (NULL = derived: `max(1, round(π·r² / spacing_cm²))`), nullable label, `planted_at`/`removed_at` dates. "Clear bed" sets `removed_at`; v1 shows rows where `removed_at IS NULL`. Year-over-year history and a future plan/scenario layer build on these dates without schema surgery.
|
- **plantings** ("plops") — object_id, plant_id, center `x_cm`,`y_cm` **in the parent object's local frame** (moving/rotating a bed moves its plants for free), `radius_cm` (a plop is a circular patch), nullable `count` (NULL = derived: `max(1, round(π·r² / spacing_cm²))`), nullable label, `planted_at`/`removed_at` dates. "Clear bed" sets `removed_at`; v1 shows rows where `removed_at IS NULL`. Year-over-year history and a future plan/scenario layer build on these dates without schema surgery.
|
||||||
|
|
||||||
@@ -54,14 +58,26 @@ POST /auth/register | /auth/login | /auth/logout GET /auth/me GET /auth
|
|||||||
GET /auth/oidc/login GET /auth/oidc/callback (authorization-code + PKCE)
|
GET /auth/oidc/login GET /auth/oidc/callback (authorization-code + PKCE)
|
||||||
GET,POST /gardens GET,PATCH,DELETE /gardens/:id
|
GET,POST /gardens GET,PATCH,DELETE /gardens/:id
|
||||||
GET /gardens/:id/full ← one-shot editor load: garden + objects + plantings + referenced plants
|
GET /gardens/:id/full ← one-shot editor load: garden + objects + plantings + referenced plants
|
||||||
|
GET /gardens/:id/full?year=YYYY ← season view: plops whose time in the ground overlapped that year
|
||||||
|
GET /gardens/:id/years ← years this garden has planting data for
|
||||||
GET /gardens/:id/history ← change sets, newest first (paginated)
|
GET /gardens/:id/history ← change sets, newest first (paginated)
|
||||||
POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts it skipped
|
POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts it skipped
|
||||||
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 /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator
|
||||||
|
POST /agent/chat ← SSE: step events, then the finished turn (editor only)
|
||||||
|
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
|
||||||
|
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.
|
||||||
@@ -74,6 +90,7 @@ GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invit
|
|||||||
cmd/pansy/main.go config load → store.Open → migrate → service → api → ListenAndServe
|
cmd/pansy/main.go config load → store.Open → migrate → service → api → ListenAndServe
|
||||||
internal/config/ PANSY_PORT, PANSY_DB, PANSY_BASE_URL, PANSY_REGISTRATION,
|
internal/config/ PANSY_PORT, PANSY_DB, PANSY_BASE_URL, PANSY_REGISTRATION,
|
||||||
PANSY_OIDC_* (issuer/client_id/secret/button label), PANSY_LOCAL_AUTH,
|
PANSY_OIDC_* (issuer/client_id/secret/button label), PANSY_LOCAL_AUTH,
|
||||||
|
PANSY_AGENT_MODEL / OLLAMA_CLOUD_API_KEY / PANSY_AGENT_ENABLED,
|
||||||
trusted proxies
|
trusted proxies
|
||||||
internal/store/ sqlite.go (open/pragmas), migrate.go, migrations/*.sql (embedded),
|
internal/store/ sqlite.go (open/pragmas), migrate.go, migrations/*.sql (embedded),
|
||||||
queries per entity (users.go, gardens.go, objects.go, plantings.go,
|
queries per entity (users.go, gardens.go, objects.go, plantings.go,
|
||||||
@@ -86,7 +103,8 @@ internal/service/ ★ THE SEAM. All permission checks + business ops. aut
|
|||||||
internal/api/ thin gin handlers (decode → service → encode), session middleware,
|
internal/api/ thin gin handlers (decode → service → encode), session middleware,
|
||||||
ginserver setup, routes.go, oidc.go, spa.go (embed fallback)
|
ginserver setup, routes.go, oidc.go, spa.go (embed fallback)
|
||||||
internal/webdist/ dist.go: //go:embed all:dist (web build copied in by Makefile)
|
internal/webdist/ dist.go: //go:embed all:dist (web build copied in by Makefile)
|
||||||
internal/agent/ later: llm.DefineTool[Args] wrappers over the SAME service methods
|
internal/agent/ tools.go (llm.DefineTool wrappers over the SAME service methods),
|
||||||
|
runtime.go (model resolution, run loop, one change set per turn)
|
||||||
web/ Vite app
|
web/ Vite app
|
||||||
Makefile (cd web && npm run build) → copy dist → CGO_ENABLED=0 go build
|
Makefile (cd web && npm run build) → copy dist → CGO_ENABLED=0 go build
|
||||||
```
|
```
|
||||||
@@ -108,7 +126,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
|
|||||||
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
|
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
|
||||||
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag.
|
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag.
|
||||||
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
|
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
|
||||||
- **One rail, tabs inside it.** The inspector, history, and later the journal and chat panel all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
|
- **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
@@ -120,13 +138,14 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
|
|||||||
6. **Plops** — focus-zoom, place/move/resize, derived counts, semantic zoom. *The core vision lands here.*
|
6. **Plops** — focus-zoom, place/move/resize, derived counts, semantic zoom. *The core vision lands here.*
|
||||||
7. **Sharing** — invite by email, roles, viewer read-only mode.
|
7. **Sharing** — invite by email, roles, viewer read-only mode.
|
||||||
8. **Polish** — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging.
|
8. **Polish** — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging.
|
||||||
9. **Agent seam** — `ops.go` bulk ops + `internal/agent` DefineTool wrappers; optional mort-style API-key agent endpoint. The agent harness itself lives in majordomo/executus, outside pansy.
|
9. **Agent seam** — `ops.go` bulk ops + `internal/agent` DefineTool wrappers.
|
||||||
|
10. **Garden assistant** — majordomo in-process, Ollama Cloud, streaming chat. Each turn runs inside ONE change set (`source='agent'`), so a turn that clears a bed and replants it undoes as one action; that is what makes acting without a confirmation prompt defensible. Bounded by a step cap and a timeout — loop safety, not spend control. The `majordomo` build tag is gone: a tag that keeps the agent out of the binary only earns its keep if you'd ship a build without it, and the agent is the point.
|
||||||
|
|
||||||
## Deliberate v1 limits
|
## Deliberate v1 limits
|
||||||
|
|
||||||
1. Plop `count` derived from area ÷ spacing², explicit override allowed.
|
1. Plop `count` derived from area ÷ spacing², explicit override allowed.
|
||||||
2. Shapes: rect + circle only (polygon reserved in schema).
|
2. Shapes: rect + circle only (polygon reserved in schema).
|
||||||
3. Seasons = planted/removed dates; a scenario/year-plan layer is future work these dates don't block.
|
3. Seasons = planted/removed dates. `?year=` filters `/full` to the plops whose `[planted_at, removed_at]` interval overlapped that calendar year, so garlic planted in October and pulled in July shows in both — and undated plops show in every year, since everything predating the feature has a null `planted_at`. Deliberately **no `seasons` table**: it would duplicate what the dates already say and create a second source of truth about when something was in the ground. Past seasons are read-only; #46's garden copy is the scenario-planning half.
|
||||||
4. Emoji plant icons (zero assets); SVG icon set later if wanted.
|
4. Emoji plant icons (zero assets); SVG icon set later if wanted.
|
||||||
5. No background/satellite image tracing (cheap to add later as a garden background field).
|
5. No background/satellite image tracing (cheap to add later as a garden background field).
|
||||||
6. 409-and-refetch conflict handling; no real-time sync.
|
6. 409-and-refetch conflict handling; no real-time sync.
|
||||||
|
|||||||
@@ -63,12 +63,19 @@ All configuration is via environment variables; every value has a default, so `.
|
|||||||
| `PANSY_OIDC_BUTTON_LABEL` | `Sign in with Authentik` | Label for the OIDC button on the login page. |
|
| `PANSY_OIDC_BUTTON_LABEL` | `Sign in with Authentik` | Label for the OIDC button on the login page. |
|
||||||
| `PANSY_TRUSTED_PROXIES` | *(none)* | Comma-separated proxy CIDRs/IPs to trust for client-IP resolution. |
|
| `PANSY_TRUSTED_PROXIES` | *(none)* | Comma-separated proxy CIDRs/IPs to trust for client-IP resolution. |
|
||||||
|
|
||||||
The garden assistant reads two more. Both are **read only once the assistant lands** ([#56](https://gitea.stevedudenhoeffer.com/steve/pansy/issues/56)); setting them earlier is harmless and setting neither leaves the assistant off.
|
The garden assistant reads three more. Setting none of them leaves the assistant off; the app runs exactly as it does without it.
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
| ----------------------- | ----------------------------- | --------------------------------------------------------------------------- |
|
| ----------------------- | ------------------------------ | --------------------------------------------------------------------------- |
|
||||||
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is disabled, not broken. |
|
| `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. |
|
| `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 | 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.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
@@ -108,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:
|
||||||
|
|||||||
@@ -3,20 +3,39 @@ module gitea.stevedudenhoeffer.com/steve/pansy
|
|||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f
|
||||||
github.com/coreos/go-oidc/v3 v3.20.0
|
github.com/coreos/go-oidc/v3 v3.20.0
|
||||||
|
github.com/gen2brain/heic v0.7.1
|
||||||
github.com/gin-gonic/gin v1.10.1
|
github.com/gin-gonic/gin v1.10.1
|
||||||
github.com/samber/slog-gin v1.15.0
|
github.com/samber/slog-gin v1.15.0
|
||||||
golang.org/x/crypto v0.31.0
|
golang.org/x/crypto v0.36.0
|
||||||
|
golang.org/x/image v0.44.0
|
||||||
golang.org/x/oauth2 v0.36.0
|
golang.org/x/oauth2 v0.36.0
|
||||||
modernc.org/sqlite v1.34.4
|
modernc.org/sqlite v1.34.4
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
cloud.google.com/go v0.116.0 // indirect
|
||||||
|
cloud.google.com/go/auth v0.9.3 // indirect
|
||||||
|
cloud.google.com/go/compute/metadata v0.5.0 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
|
github.com/google/go-cmp v0.6.0 // indirect
|
||||||
|
github.com/google/s2a-go v0.1.8 // indirect
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
go.opencensus.io v0.24.0 // indirect
|
||||||
|
google.golang.org/genai v1.59.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||||
|
google.golang.org/grpc v1.66.2 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.9 // indirect
|
github.com/bytedance/sonic v1.11.9 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/ebitengine/purego v0.10.1 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||||
@@ -35,14 +54,15 @@ require (
|
|||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/tetratelabs/wazero v1.12.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/arch v0.8.0 // indirect
|
||||||
golang.org/x/net v0.33.0 // indirect
|
golang.org/x/net v0.38.0 // indirect
|
||||||
golang.org/x/sys v0.28.0 // indirect
|
golang.org/x/sys v0.44.0 // indirect
|
||||||
golang.org/x/text v0.21.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
google.golang.org/protobuf v1.34.2 // indirect
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||||
|
|||||||
@@ -1,11 +1,24 @@
|
|||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
||||||
|
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
||||||
|
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
|
||||||
|
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
||||||
|
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
||||||
|
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
||||||
|
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f h1:Zt91pngDr+TIU14sxv3fk1QS7bMQetvccFNg8kPaltA=
|
||||||
|
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg=
|
github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg=
|
||||||
github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
||||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
|
||||||
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
@@ -13,8 +26,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||||
|
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
||||||
|
github.com/gen2brain/heic v0.7.1 h1:Aha1sZdKEeZeWl5o0xkSg7NBRhhkrlokGVCRri+2Qcc=
|
||||||
|
github.com/gen2brain/heic v0.7.1/go.mod h1:ja42wMJc4fpnKsfdUJxeZa2YqqRnes1wS0xqs5+8o5w=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
@@ -31,13 +52,40 @@ github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4
|
|||||||
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
||||||
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
|
||||||
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
|
||||||
|
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||||
|
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||||
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
@@ -65,6 +113,7 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
|
|||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
github.com/samber/slog-gin v1.15.0 h1:Kqs/ilXd9divtslWjbz5DVptmLlzyntbBiXUAta2SFg=
|
github.com/samber/slog-gin v1.15.0 h1:Kqs/ilXd9divtslWjbz5DVptmLlzyntbBiXUAta2SFg=
|
||||||
@@ -81,10 +130,14 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
|||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||||
|
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||||
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||||
@@ -92,24 +145,79 @@ go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+M
|
|||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||||
|
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||||
|
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||||
|
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||||
|
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/genai v1.59.0 h1:xp+ydkJFW8hO0hTUaAkr8TrLM9HFP3NYAwFhPd0nDqA=
|
||||||
|
google.golang.org/genai v1.59.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
|
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
|
||||||
|
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
@@ -118,6 +226,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
|
||||||
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
|
||||||
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
|
||||||
|
|||||||
+32
-14
@@ -1,18 +1,36 @@
|
|||||||
// Package agent adapts pansy's service layer to majordomo tools so an agent can
|
// Package agent is pansy's garden assistant: a majordomo toolbox over the
|
||||||
// drive a garden in natural language ("fill the NE corner with garlic, the NW
|
// service layer, plus the run loop that drives a model with it.
|
||||||
// with basil, the south half with beans"). Each tool runs as a fixed actor and
|
|
||||||
// therefore inherits pansy's ACL checks for free — a viewer's fill_region returns
|
|
||||||
// ErrForbidden, exactly as the REST API would.
|
|
||||||
//
|
//
|
||||||
// Two deliberate separations keep the core server lean:
|
// Every tool runs as a fixed actor, so the agent inherits pansy's permission
|
||||||
|
// checks for free — a viewer's fill_region returns ErrForbidden, exactly as the
|
||||||
|
// REST API would. That is the whole reason the tools are thin adapters over
|
||||||
|
// internal/service and never reach past it; the moment one does, the ACL story
|
||||||
|
// stops being automatic and becomes something to remember.
|
||||||
//
|
//
|
||||||
// - cmd/pansy does NOT import this package, so the server binary carries no
|
// # A turn is one change set
|
||||||
// agent/LLM dependencies.
|
|
||||||
// - the tool wiring (tools.go) sits behind the `majordomo` build tag, so the
|
|
||||||
// default `go build ./...` / `go test ./...` compiles without the majordomo
|
|
||||||
// module. Build the agent tools with `-tags majordomo` once majordomo is a
|
|
||||||
// dependency (`go get gitea.stevedudenhoeffer.com/steve/majordomo`).
|
|
||||||
//
|
//
|
||||||
// The agent harness itself (model loop, chat surface) lives in the
|
// Runs wrap their whole turn in a single change set (source "agent"), so
|
||||||
// majordomo/executus stack, outside this repo; this package is only the toolbox.
|
// "empty the garlic bed and plant cucumbers" — one object edit and a dozen
|
||||||
|
// planting inserts — undoes as one action rather than thirteen. That is what
|
||||||
|
// makes acting without a confirmation prompt defensible: the answer to "it did
|
||||||
|
// the wrong thing" is one click, not an archaeology exercise.
|
||||||
|
//
|
||||||
|
// # No build tag
|
||||||
|
//
|
||||||
|
// This package used to sit behind a `majordomo` build tag, with cmd/pansy
|
||||||
|
// deliberately not importing it, so the default build carried no LLM
|
||||||
|
// dependency. Both separations are gone, on purpose: a tag that keeps the agent
|
||||||
|
// out of the binary only earns its keep if you would ever ship a build without
|
||||||
|
// the agent, and the agent is the point. Keeping it would have meant an
|
||||||
|
// untagged CI that never compiled the code that matters.
|
||||||
|
//
|
||||||
|
// majordomo is stdlib-first and pure Go, so CGO_ENABLED=0 and the single static
|
||||||
|
// binary survive it.
|
||||||
|
//
|
||||||
|
// # Unconfigured instances
|
||||||
|
//
|
||||||
|
// With no API key the assistant is simply not offered: the chat route isn't
|
||||||
|
// registered and the capability isn't advertised — the same shape as OIDC
|
||||||
|
// 404ing when unconfigured. An instance without a key starts and serves the app
|
||||||
|
// exactly as it did before.
|
||||||
package agent
|
package agent
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Loop bounds. These exist so a stuck run terminates, NOT to control spend —
|
||||||
|
// pansy is a personal tool and multi-tenant cost control is explicitly not a
|
||||||
|
// concern here. A model that gets wedged calling describe_garden forever should
|
||||||
|
// stop on its own, and a hung upstream shouldn't hold a connection open all day.
|
||||||
|
const (
|
||||||
|
maxSteps = 24
|
||||||
|
// A turn that legitimately fills several beds does a lot of round trips, so
|
||||||
|
// this is generous; it is a backstop, not a budget.
|
||||||
|
runTimeout = 4 * time.Minute
|
||||||
|
// Successive all-error steps, and identical repeated calls, that end a run.
|
||||||
|
maxConsecutiveToolErrors = 4
|
||||||
|
maxSameCallRepeats = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
// Runner drives a model over pansy's toolbox. One per process; Run is safe to
|
||||||
|
// call concurrently.
|
||||||
|
type Runner struct {
|
||||||
|
svc *service.Service
|
||||||
|
model llm.Model
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRunner resolves modelSpec against pansy's registry and returns a Runner, or
|
||||||
|
// an error if the assistant can't be offered. Callers should treat an error as
|
||||||
|
// "no assistant" rather than a startup failure — an instance with no key must
|
||||||
|
// still serve the app.
|
||||||
|
//
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
// agentmodel.Resolve already rejects an empty/blank spec, so don't duplicate
|
||||||
|
// that guard here — one place decides what a valid spec is.
|
||||||
|
model, err := agentmodel.Resolve(apiKey, modelSpec)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &Runner{svc: svc, model: model}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turn is the outcome of one exchange.
|
||||||
|
type Turn struct {
|
||||||
|
// Reply is what to show the user.
|
||||||
|
Reply string `json:"reply"`
|
||||||
|
// ChangeSetID is the change set this turn produced, if it changed anything —
|
||||||
|
// the handle the UI needs to offer Undo.
|
||||||
|
ChangeSetID *int64 `json:"changeSetId,omitempty"`
|
||||||
|
// Steps is how many model round trips it took.
|
||||||
|
Steps int `json:"steps"`
|
||||||
|
// Truncated is set when the run hit its step cap rather than finishing.
|
||||||
|
Truncated bool `json:"truncated,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run executes one turn against a garden, as actorID.
|
||||||
|
//
|
||||||
|
// The whole turn runs inside ONE change set, so everything the model did undoes
|
||||||
|
// together. That is what makes acting without a confirmation prompt defensible.
|
||||||
|
// The scope is opened even for a turn that turns out to be a question — a change
|
||||||
|
// set with no revisions is never written, so asking costs nothing.
|
||||||
|
func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message string, history []llm.Message, onStep func(agent.Step)) (*Turn, error) {
|
||||||
|
message = strings.TrimSpace(message)
|
||||||
|
if message == "" {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, runTimeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
garden, err := r.svc.GetGarden(ctx, actorID, gardenID)
|
||||||
|
if err != nil {
|
||||||
|
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 (
|
||||||
|
result *agent.Result
|
||||||
|
runErr error
|
||||||
|
truncErr bool
|
||||||
|
)
|
||||||
|
changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{
|
||||||
|
Source: domain.SourceAgent,
|
||||||
|
Summary: turnSummary(message),
|
||||||
|
AgentRunID: &runID,
|
||||||
|
}, func(ctx context.Context) error {
|
||||||
|
box := NewToolbox(r.svc, actorID)
|
||||||
|
a := agent.New(r.model, systemPrompt(garden),
|
||||||
|
agent.WithMaxSteps(maxSteps),
|
||||||
|
agent.WithToolErrorLimits(maxConsecutiveToolErrors, maxSameCallRepeats),
|
||||||
|
)
|
||||||
|
a.AddToolbox(box)
|
||||||
|
|
||||||
|
opts := []agent.RunOption{agent.WithHistory(history)}
|
||||||
|
if onStep != nil {
|
||||||
|
opts = append(opts, agent.OnStep(onStep))
|
||||||
|
}
|
||||||
|
result, runErr = a.Run(ctx, message, opts...)
|
||||||
|
// A run that ran out of steps still DID things, and those things must be
|
||||||
|
// recorded and undoable. So the loop-guard errors don't fail the scope —
|
||||||
|
// they're reported to the user instead.
|
||||||
|
if runErr != nil && isLoopLimit(runErr) {
|
||||||
|
truncErr = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return runErr
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
// WithChangeSet records whatever committed before failing, so the partial
|
||||||
|
// work is undoable even though the turn errored.
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
turn := &Turn{Truncated: truncErr}
|
||||||
|
if changeSet != nil {
|
||||||
|
turn.ChangeSetID = &changeSet.ID
|
||||||
|
}
|
||||||
|
if result != nil {
|
||||||
|
turn.Reply = result.Output
|
||||||
|
turn.Steps = len(result.Steps)
|
||||||
|
}
|
||||||
|
if turn.Reply == "" {
|
||||||
|
turn.Reply = fallbackReply(turn)
|
||||||
|
}
|
||||||
|
return turn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLoopLimit reports whether an error is one of majordomo's loop guards firing
|
||||||
|
// rather than a genuine failure. Those runs have a partial result worth keeping.
|
||||||
|
func isLoopLimit(err error) bool {
|
||||||
|
return errors.Is(err, agent.ErrMaxSteps) || errors.Is(err, agent.ErrToolLoop)
|
||||||
|
}
|
||||||
|
|
||||||
|
// fallbackReply covers a run that finished with no text — a model that made its
|
||||||
|
// last tool call and then stopped. Silence reads as a failure, so say what
|
||||||
|
// happened.
|
||||||
|
func fallbackReply(t *Turn) string {
|
||||||
|
switch {
|
||||||
|
case t.Truncated:
|
||||||
|
return "I stopped partway through — that turned into more steps than I should take in one go. " +
|
||||||
|
"Have a look at what changed, and tell me what to do next."
|
||||||
|
case t.ChangeSetID != nil:
|
||||||
|
return "Done — have a look at the canvas."
|
||||||
|
default:
|
||||||
|
return "I didn't change anything."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// 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 {
|
||||||
|
const max = 120
|
||||||
|
s := strings.Join(strings.Fields(message), " ")
|
||||||
|
runes := []rune(s)
|
||||||
|
if len(runes) > max {
|
||||||
|
s = strings.TrimSpace(string(runes[:max])) + "…"
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// systemPrompt gives the model the conventions it cannot infer.
|
||||||
|
//
|
||||||
|
// The compass convention in particular is not guessable: -y is north because
|
||||||
|
// screen y grows downward, and a model that assumes otherwise plants the south
|
||||||
|
// half when asked for the north one.
|
||||||
|
func systemPrompt(g *domain.Garden) string {
|
||||||
|
units := "metric — all measurements are centimeters"
|
||||||
|
if g.UnitPref == domain.UnitImperial {
|
||||||
|
units = "imperial for display, but every measurement you send or receive is in CENTIMETERS"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf(`You are pansy's garden assistant. You help plan and edit a real garden by calling tools.
|
||||||
|
|
||||||
|
The garden you are working on is %q (id %d), %.0f x %.0f cm. The user's units are %s.
|
||||||
|
|
||||||
|
Conventions you cannot guess and must not assume:
|
||||||
|
- Positions in a garden are centimeters from its top-left corner: x grows east, y grows SOUTH.
|
||||||
|
- Inside an object (a bed), positions are relative to that object's CENTER, and -y is NORTH.
|
||||||
|
So the north half of a bed is negative y. Getting this backwards plants the wrong end.
|
||||||
|
- Objects and plantings are version-guarded. Use the version from describe_garden when editing.
|
||||||
|
|
||||||
|
How to work:
|
||||||
|
- Start from describe_garden to see what is actually there. Do not guess ids.
|
||||||
|
- Use find_plant to turn a plant name into an id. If it returns several candidates,
|
||||||
|
pick the one that matches what the user said, or ask them which they meant.
|
||||||
|
- To replant a bed with something else: clear_object, then fill_region with region "all".
|
||||||
|
- When a tool refuses (for example, the user only has view access to this garden),
|
||||||
|
explain what happened in plain words. Do not retry it.
|
||||||
|
|
||||||
|
When you are done, say briefly what you changed — the user is watching the canvas
|
||||||
|
and wants to know what to look at. If you changed nothing, say that too.`,
|
||||||
|
g.Name, g.ID, g.WidthCM, g.HeightCM, units)
|
||||||
|
}
|
||||||
@@ -0,0 +1,358 @@
|
|||||||
|
package agent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// scriptedRunner wires a Runner to a fake provider so the whole loop — tools,
|
||||||
|
// change-set scoping, loop guards — is exercised with no live model.
|
||||||
|
func scriptedRunner(t *testing.T, svc *service.Service, steps ...fake.Step) *Runner {
|
||||||
|
t.Helper()
|
||||||
|
p := fake.New("fake")
|
||||||
|
for _, s := range steps {
|
||||||
|
p.Enqueue("m", s)
|
||||||
|
}
|
||||||
|
model, err := p.Model("m")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("fake model: %v", err)
|
||||||
|
}
|
||||||
|
return &Runner{svc: svc, model: model}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toolCall scripts one model turn that calls a tool.
|
||||||
|
func toolCall(name string, args any) fake.Step {
|
||||||
|
raw, _ := json.Marshal(args)
|
||||||
|
return fake.ReplyWith(llm.Response{
|
||||||
|
FinishReason: llm.FinishToolCalls,
|
||||||
|
ToolCalls: []llm.ToolCall{{ID: "c1", Name: name, Arguments: raw}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTurnIsOneChangeSet is the acceptance criterion the whole "act freely"
|
||||||
|
// posture rests on: a turn that clears a bed and replants it — one object edit
|
||||||
|
// and many planting inserts — has to undo as ONE action, not thirteen.
|
||||||
|
func TestTurnIsOneChangeSet(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)
|
||||||
|
}
|
||||||
|
garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄")
|
||||||
|
cucumber := mustPlant(t, svc, owner, "Cucumber", 45, "🥒")
|
||||||
|
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
|
||||||
|
Kind: domain.KindBed, Name: "Garlic bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil {
|
||||||
|
t.Fatalf("seed garlic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
before, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
r := scriptedRunner(t, svc,
|
||||||
|
toolCall("clear_object", map[string]any{"objectId": bed.ID}),
|
||||||
|
toolCall("fill_region", map[string]any{"objectId": bed.ID, "region": "all", "plantId": cucumber.ID}),
|
||||||
|
fake.Reply("Cleared the garlic and replanted the bed with cucumbers."),
|
||||||
|
)
|
||||||
|
|
||||||
|
turn, err := r.Run(ctx, owner, g.ID, "change the garlic bed to cucumbers this year", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if turn.ChangeSetID == nil {
|
||||||
|
t.Fatal("a turn that changed things produced no change set")
|
||||||
|
}
|
||||||
|
if !strings.Contains(turn.Reply, "cucumbers") {
|
||||||
|
t.Errorf("reply = %q", turn.Reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exactly ONE new change set, whatever the model did inside the turn.
|
||||||
|
after, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("history: %v", err)
|
||||||
|
}
|
||||||
|
if len(after)-len(before) != 1 {
|
||||||
|
t.Fatalf("turn produced %d change sets, want exactly 1", len(after)-len(before))
|
||||||
|
}
|
||||||
|
cs := after[0]
|
||||||
|
if cs.Source != domain.SourceAgent {
|
||||||
|
t.Errorf("source = %q, want agent", cs.Source)
|
||||||
|
}
|
||||||
|
if cs.Summary != "change the garlic bed to cucumbers this year" {
|
||||||
|
t.Errorf("summary = %q, want the user's own words", cs.Summary)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bed really is cucumbers now.
|
||||||
|
full, _ := svc.GardenFull(ctx, owner, g.ID, nil)
|
||||||
|
if len(full.Plantings) == 0 {
|
||||||
|
t.Fatal("bed ended up empty")
|
||||||
|
}
|
||||||
|
for _, p := range full.Plantings {
|
||||||
|
if p.PlantID != cucumber.ID {
|
||||||
|
t.Errorf("unexpected plant %d still in the bed", p.PlantID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// And one undo puts it back.
|
||||||
|
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, *turn.ChangeSetID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||||
|
t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts)
|
||||||
|
}
|
||||||
|
full, _ = svc.GardenFull(ctx, owner, g.ID, nil)
|
||||||
|
for _, p := range full.Plantings {
|
||||||
|
if p.PlantID != garlic.ID {
|
||||||
|
t.Errorf("after undo the bed holds plant %d, want the garlic back", p.PlantID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestViewerGetsAnExplainableRefusal — the ACL story only works if the model can
|
||||||
|
// narrate the refusal, so a tool denial has to reach it as a tool RESULT it can
|
||||||
|
// read, not as a 500 that ends the run.
|
||||||
|
func TestViewerGetsAnExplainableRefusal(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, owner := newAgentTestService(t)
|
||||||
|
viewer, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "V", Password: "password123"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
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, XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||||||
|
t.Fatalf("share: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A viewer can't open a change set at all, so the turn is refused up front —
|
||||||
|
// before any model call — and the API turns that into a plain explanation.
|
||||||
|
r := scriptedRunner(t, svc, fake.Reply("unused"))
|
||||||
|
_, err = r.Run(ctx, viewer.ID, g.ID, "plant garlic in that bed", nil, nil)
|
||||||
|
if !errors.Is(err, domain.ErrForbidden) {
|
||||||
|
t.Fatalf("viewer turn err = %v, want ErrForbidden", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And at the tool layer, a refusal comes back as a readable tool result
|
||||||
|
// rather than killing the run.
|
||||||
|
box := NewToolbox(svc, viewer.ID)
|
||||||
|
raw, _ := json.Marshal(map[string]any{"objectId": bed.ID})
|
||||||
|
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "clear_object", Arguments: raw})
|
||||||
|
if !res.IsError {
|
||||||
|
t.Fatal("a viewer's clear_object succeeded")
|
||||||
|
}
|
||||||
|
if res.Content == "" {
|
||||||
|
t.Error("the refusal carried no text for the model to explain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunStopsAtTheStepCap — a model that gets wedged should stop on its own,
|
||||||
|
// and what it managed to do must still be recorded and undoable.
|
||||||
|
func TestRunStopsAtTheStepCap(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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// More describe_garden calls than the cap allows, forever.
|
||||||
|
steps := make([]fake.Step, 0, maxSteps+4)
|
||||||
|
for i := 0; i < maxSteps+4; i++ {
|
||||||
|
steps = append(steps, toolCall("describe_garden", map[string]any{"gardenId": g.ID}))
|
||||||
|
}
|
||||||
|
r := scriptedRunner(t, svc, steps...)
|
||||||
|
|
||||||
|
turn, err := r.Run(ctx, owner, g.ID, "look at the garden", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("a capped run should end cleanly, got %v", err)
|
||||||
|
}
|
||||||
|
if !turn.Truncated {
|
||||||
|
t.Error("turn.Truncated = false; the run hit the cap and should say so")
|
||||||
|
}
|
||||||
|
if turn.Reply == "" {
|
||||||
|
t.Error("a capped run said nothing; silence reads as a hang")
|
||||||
|
}
|
||||||
|
// It only read, so there is nothing to undo — and no empty change set either.
|
||||||
|
if turn.ChangeSetID != nil {
|
||||||
|
t.Errorf("a read-only turn produced change set %d", *turn.ChangeSetID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReadOnlyTurnWritesNoChangeSet — asking a question must not litter the
|
||||||
|
// history with empty entries.
|
||||||
|
func TestReadOnlyTurnWritesNoChangeSet(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)
|
||||||
|
}
|
||||||
|
before, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||||
|
|
||||||
|
r := scriptedRunner(t, svc,
|
||||||
|
toolCall("describe_garden", map[string]any{"gardenId": g.ID}),
|
||||||
|
fake.Reply("It's empty — nothing planted yet."),
|
||||||
|
)
|
||||||
|
turn, err := r.Run(ctx, owner, g.ID, "what's in the garden?", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Run: %v", err)
|
||||||
|
}
|
||||||
|
if turn.ChangeSetID != nil {
|
||||||
|
t.Errorf("a question produced change set %d", *turn.ChangeSetID)
|
||||||
|
}
|
||||||
|
after, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||||
|
if len(after) != len(before) {
|
||||||
|
t.Errorf("history grew by %d for a read-only turn", len(after)-len(before))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewRunnerNeedsConfiguration — an instance with no key or no model must not
|
||||||
|
// 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) {
|
||||||
|
svc, _ := newAgentTestService(t)
|
||||||
|
for _, tc := range []struct{ key, model string }{
|
||||||
|
{"", "ollama-cloud/x"},
|
||||||
|
{"k", ""},
|
||||||
|
{"k", " "},
|
||||||
|
} {
|
||||||
|
if _, err := NewRunner(svc, tc.key, tc.model); err == nil {
|
||||||
|
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
|
||||||
|
// error, not a panic at first use.
|
||||||
|
if _, err := NewRunner(svc, "k", "nonesuch/model"); err == nil {
|
||||||
|
t.Error("NewRunner accepted an unresolvable model spec")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestTurnSummaryFitsAHistoryRow — the user's own words are the most useful
|
||||||
|
// label for a change set, but a paragraph would wreck the list.
|
||||||
|
func TestTurnSummaryFitsAHistoryRow(t *testing.T) {
|
||||||
|
if got := turnSummary(" change the garlic bed\n to cucumbers "); got != "change the garlic bed to cucumbers" {
|
||||||
|
t.Errorf("turnSummary = %q", got)
|
||||||
|
}
|
||||||
|
long := turnSummary(strings.Repeat("plant garlic ", 40))
|
||||||
|
if len(long) > 130 || !strings.HasSuffix(long, "…") {
|
||||||
|
t.Errorf("long summary = %q (%d chars)", long, len(long))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSystemPromptStatesTheCompassConvention — -y being north is not guessable,
|
||||||
|
// and a model that assumes otherwise plants the wrong end of the bed.
|
||||||
|
func TestSystemPromptStatesTheCompassConvention(t *testing.T) {
|
||||||
|
p := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitImperial})
|
||||||
|
for _, want := range []string{"NORTH", "-y", "centimeters", "Plot", "version"} {
|
||||||
|
if !strings.Contains(p, want) {
|
||||||
|
t.Errorf("system prompt is missing %q:\n%s", want, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(p, fmt.Sprintf("%.0f", 500.0)) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-4
@@ -1,5 +1,3 @@
|
|||||||
//go:build majordomo
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -36,11 +34,36 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
|
|||||||
"Place one plop of a plant inside a plantable object, positioned in the object's LOCAL frame (0,0 = object center, -y = north).",
|
"Place one plop of a plant inside a plantable object, positioned in the object's LOCAL frame (0,0 = object center, -y = north).",
|
||||||
a.placePlanting),
|
a.placePlanting),
|
||||||
llm.DefineTool("fill_region",
|
llm.DefineTool("fill_region",
|
||||||
"Fill a named region of a plantable object with a plant, hex-packed. Region is one of nw/ne/sw/se (corners), north/south/east/west or top/bottom/left/right (halves), or all.",
|
"Fill part of a plantable object with one plant, hex-packed at the plant's spacing. "+
|
||||||
|
"region is a compass name, not coordinates: nw|ne|sw|se for the quarter corners, "+
|
||||||
|
"north|south|east|west (or top|bottom|left|right) for halves, or all for the whole thing. "+
|
||||||
|
"North is the top of the garden. Example: to replant a whole bed, clear_object then "+
|
||||||
|
"fill_region with region=all. Filling skips spots already covered by an existing plant, "+
|
||||||
|
"so it is safe to run twice.",
|
||||||
a.fillRegion),
|
a.fillRegion),
|
||||||
llm.DefineTool("clear_object",
|
llm.DefineTool("clear_object",
|
||||||
"Remove all plants from an object (soft-remove; history is kept).",
|
"Remove all plants from an object. They are soft-removed, so the planting history for past "+
|
||||||
|
"seasons is kept and the change can be undone. Use this before replanting a bed with "+
|
||||||
|
"something else.",
|
||||||
a.clearObject),
|
a.clearObject),
|
||||||
|
llm.DefineTool("find_plant",
|
||||||
|
"Look up plants in the user's catalog by name or category, to get the plantId that "+
|
||||||
|
"place_planting and fill_region need. Returns SEVERAL candidates when the query is "+
|
||||||
|
"ambiguous — \"garlic\" may match both the built-in \"Garlic\" and a custom \"German Red "+
|
||||||
|
"Garlic\" — so pick the one that fits what the user asked for, or ask them which. Each "+
|
||||||
|
"result also reports how much seed the user has left of it, when they have recorded any.",
|
||||||
|
a.findPlant),
|
||||||
|
llm.DefineTool("create_plant",
|
||||||
|
"Add a new plant to the user's own catalog, for when they name a variety that isn't in it "+
|
||||||
|
"yet. Check find_plant first — creating a duplicate of something that already exists is "+
|
||||||
|
"worse than reusing it. The plant belongs to the user, not to any garden.",
|
||||||
|
a.createPlant),
|
||||||
|
llm.DefineTool("add_journal_entry",
|
||||||
|
"Write a dated observation into the garden's grow journal — what happened, and when. "+
|
||||||
|
"Attach it to one bed with objectId when it is about that bed. This is for events "+
|
||||||
|
"(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+
|
||||||
|
"thing is. observedAt defaults to today; set it to backdate.",
|
||||||
|
a.addJournalEntry),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +131,42 @@ func (a *adapter) fillRegion(ctx context.Context, args struct {
|
|||||||
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride)
|
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *adapter) findPlant(ctx context.Context, args struct {
|
||||||
|
Query string `json:"query" description:"plant name or category to search for, e.g. \"cucumber\" or \"herb\"; empty lists the catalog"`
|
||||||
|
}) (any, error) {
|
||||||
|
return a.svc.FindPlants(ctx, a.actor, args.Query)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) createPlant(ctx context.Context, args struct {
|
||||||
|
Name string `json:"name" description:"the variety's name, e.g. \"German Red Garlic\""`
|
||||||
|
Category string `json:"category" description:"vegetable | herb | flower | fruit | tree_shrub | cover"`
|
||||||
|
SpacingCM float64 `json:"spacingCm" description:"mature in-row spacing in cm; this is what fill_region packs to"`
|
||||||
|
Color string `json:"color" description:"hex color for the plant on the canvas, e.g. #8a5a8a"`
|
||||||
|
Icon string `json:"icon" description:"a single emoji to draw it with, e.g. 🧄"`
|
||||||
|
DaysToMaturity *int `json:"daysToMaturity" description:"optional days from planting to harvest"`
|
||||||
|
SourceURL string `json:"sourceUrl" description:"optional http(s) link to where the seed came from"`
|
||||||
|
Vendor string `json:"vendor" description:"optional vendor name, e.g. \"Johnny\u0027s Selected Seeds\""`
|
||||||
|
}) (any, error) {
|
||||||
|
return a.svc.CreatePlant(ctx, a.actor, service.PlantInput{
|
||||||
|
Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM,
|
||||||
|
Color: args.Color, Icon: args.Icon, DaysToMaturity: args.DaysToMaturity,
|
||||||
|
SourceURL: args.SourceURL, Vendor: args.Vendor,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *adapter) addJournalEntry(ctx context.Context, args struct {
|
||||||
|
GardenID int64 `json:"gardenId" description:"garden the observation is about"`
|
||||||
|
ObjectID *int64 `json:"objectId" description:"optional bed the observation is about; omit for a garden-level note"`
|
||||||
|
Body string `json:"body" description:"what happened, in plain words"`
|
||||||
|
ObservedAt string `json:"observedAt" description:"optional date it happened, YYYY-MM-DD; defaults to today"`
|
||||||
|
}) (any, error) {
|
||||||
|
in := service.JournalInput{ObjectID: args.ObjectID, Body: args.Body}
|
||||||
|
if args.ObservedAt != "" {
|
||||||
|
in.ObservedAt = &args.ObservedAt
|
||||||
|
}
|
||||||
|
return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, in)
|
||||||
|
}
|
||||||
|
|
||||||
func (a *adapter) clearObject(ctx context.Context, args struct {
|
func (a *adapter) clearObject(ctx context.Context, args struct {
|
||||||
ObjectID int64 `json:"objectId" description:"object to remove all plants from"`
|
ObjectID int64 `json:"objectId" description:"object to remove all plants from"`
|
||||||
}) (any, error) {
|
}) (any, error) {
|
||||||
|
|||||||
+238
-22
@@ -1,10 +1,9 @@
|
|||||||
//go:build majordomo
|
|
||||||
|
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
@@ -24,21 +23,8 @@ import (
|
|||||||
// Build/run with: go test -tags majordomo ./internal/agent/
|
// Build/run with: go test -tags majordomo ./internal/agent/
|
||||||
func TestToolboxScenario(t *testing.T) {
|
func TestToolboxScenario(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
db, err := store.Open(":memory:")
|
svc, ownerID := newAgentTestService(t)
|
||||||
if err != nil {
|
box := NewToolbox(svc, ownerID)
|
||||||
t.Fatalf("open: %v", err)
|
|
||||||
}
|
|
||||||
t.Cleanup(func() { db.Close() })
|
|
||||||
if err := db.Migrate(ctx); err != nil {
|
|
||||||
t.Fatalf("migrate: %v", err)
|
|
||||||
}
|
|
||||||
svc := service.New(db, &config.Config{Registration: config.RegistrationOpen, LocalAuth: true})
|
|
||||||
|
|
||||||
owner, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "A", Password: "password123"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("register: %v", err)
|
|
||||||
}
|
|
||||||
box := NewToolbox(svc, owner.ID)
|
|
||||||
|
|
||||||
call := func(name string, args any) llm.ToolResult {
|
call := func(name string, args any) llm.ToolResult {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -48,13 +34,13 @@ func TestToolboxScenario(t *testing.T) {
|
|||||||
|
|
||||||
// Garden + plants are set up directly (there are no create_garden/plant tools);
|
// Garden + plants are set up directly (there are no create_garden/plant tools);
|
||||||
// the agent-facing bits — object + fills + describe — go through the toolbox.
|
// the agent-facing bits — object + fills + describe — go through the toolbox.
|
||||||
g, err := svc.CreateGarden(ctx, owner.ID, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
g, err := svc.CreateGarden(ctx, ownerID, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("garden: %v", err)
|
t.Fatalf("garden: %v", err)
|
||||||
}
|
}
|
||||||
garlic := mustPlant(t, svc, owner.ID, "Garlic", 15, "🧄")
|
garlic := mustPlant(t, svc, ownerID, "Garlic", 15, "🧄")
|
||||||
basil := mustPlant(t, svc, owner.ID, "Basil", 25, "🌿")
|
basil := mustPlant(t, svc, ownerID, "Basil", 25, "🌿")
|
||||||
beans := mustPlant(t, svc, owner.ID, "Beans", 10, "🫘")
|
beans := mustPlant(t, svc, ownerID, "Beans", 10, "🫘")
|
||||||
|
|
||||||
// create_object → a 400×400 bed.
|
// create_object → a 400×400 bed.
|
||||||
res := call("create_object", map[string]any{
|
res := call("create_object", map[string]any{
|
||||||
@@ -119,7 +105,7 @@ func TestToolboxScenario(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("register viewer: %v", err)
|
t.Fatalf("register viewer: %v", err)
|
||||||
}
|
}
|
||||||
if _, err := svc.AddShare(ctx, owner.ID, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
if _, err := svc.AddShare(ctx, ownerID, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||||||
t.Fatalf("share: %v", err)
|
t.Fatalf("share: %v", err)
|
||||||
}
|
}
|
||||||
viewerBox := NewToolbox(svc, viewerUser.ID)
|
viewerBox := NewToolbox(svc, viewerUser.ID)
|
||||||
@@ -150,3 +136,233 @@ func mustPlant(t *testing.T, svc *service.Service, owner int64, name string, spa
|
|||||||
}
|
}
|
||||||
return p
|
return p
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestGarlicBedToCucumbers is the flagship interaction from #58, driven end to
|
||||||
|
// end through the tool layer: "change the garlic garden bed to instead be
|
||||||
|
// cucumbers this year".
|
||||||
|
//
|
||||||
|
// The sequence is the one a model would actually run — describe to find the bed,
|
||||||
|
// find_plant to turn the word "cucumber" into an id, clear, refill — and until
|
||||||
|
// find_plant existed step three had no way to get its plantId, which blocked the
|
||||||
|
// whole thing on one missing tool.
|
||||||
|
func TestGarlicBedToCucumbers(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, owner := newAgentTestService(t)
|
||||||
|
box := NewToolbox(svc, owner)
|
||||||
|
|
||||||
|
call := func(name string, args any) llm.ToolResult {
|
||||||
|
t.Helper()
|
||||||
|
raw, _ := json.Marshal(args)
|
||||||
|
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: raw})
|
||||||
|
}
|
||||||
|
|
||||||
|
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("garden: %v", err)
|
||||||
|
}
|
||||||
|
garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄")
|
||||||
|
mustPlant(t, svc, owner, "Cucumber", 45, "🥒")
|
||||||
|
|
||||||
|
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
|
||||||
|
Kind: domain.KindBed, Name: "Garlic bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil {
|
||||||
|
t.Fatalf("seed the garlic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. describe_garden — "the garlic bed" resolves to an object id, because the
|
||||||
|
// description carries the NAMES of what's planted in each object.
|
||||||
|
res := call("describe_garden", map[string]any{"gardenId": g.ID})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("describe_garden: %s", res.Content)
|
||||||
|
}
|
||||||
|
if !strings.Contains(res.Content, "Garlic") {
|
||||||
|
t.Fatalf("describe_garden didn't name what's planted: %s", res.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. find_plant — the step that used to be impossible.
|
||||||
|
res = call("find_plant", map[string]any{"query": "cucumber"})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("find_plant: %s", res.Content)
|
||||||
|
}
|
||||||
|
var matches []struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(res.Content), &matches); err != nil {
|
||||||
|
t.Fatalf("decode find_plant: %v (%s)", err, res.Content)
|
||||||
|
}
|
||||||
|
if len(matches) == 0 || matches[0].Name != "Cucumber" {
|
||||||
|
t.Fatalf("find_plant(cucumber) = %+v", matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. clear_object, 4. fill_region.
|
||||||
|
if r := call("clear_object", map[string]any{"objectId": bed.ID}); r.IsError {
|
||||||
|
t.Fatalf("clear_object: %s", r.Content)
|
||||||
|
}
|
||||||
|
if r := call("fill_region", map[string]any{
|
||||||
|
"objectId": bed.ID, "region": "all", "plantId": matches[0].ID,
|
||||||
|
}); r.IsError {
|
||||||
|
t.Fatalf("fill_region: %s", r.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The bed is cucumbers, and no garlic is still growing in it.
|
||||||
|
full, err := svc.GardenFull(ctx, owner, g.ID, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GardenFull: %v", err)
|
||||||
|
}
|
||||||
|
if len(full.Plantings) == 0 {
|
||||||
|
t.Fatal("the bed ended up empty")
|
||||||
|
}
|
||||||
|
for _, p := range full.Plantings {
|
||||||
|
if p.PlantID == garlic.ID {
|
||||||
|
t.Errorf("garlic is still active in the bed")
|
||||||
|
}
|
||||||
|
if p.PlantID != matches[0].ID {
|
||||||
|
t.Errorf("unexpected plant %d in the bed", p.PlantID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindPlantReturnsCandidatesNotAGuess — "garlic" against a catalog holding
|
||||||
|
// both "Garlic" and "German Red Garlic" is genuinely ambiguous, and silently
|
||||||
|
// picking one is how the agent plants the wrong thing.
|
||||||
|
func TestFindPlantReturnsCandidatesNotAGuess(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, owner := newAgentTestService(t)
|
||||||
|
box := NewToolbox(svc, owner)
|
||||||
|
|
||||||
|
mustPlant(t, svc, owner, "German Red Garlic", 15, "🧄")
|
||||||
|
|
||||||
|
raw, _ := json.Marshal(map[string]any{"query": "garlic"})
|
||||||
|
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "find_plant", Arguments: raw})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("find_plant: %s", res.Content)
|
||||||
|
}
|
||||||
|
var matches []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(res.Content), &matches); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, m := range matches {
|
||||||
|
names[m.Name] = true
|
||||||
|
}
|
||||||
|
// The built-in catalog seeds a plain "Garlic"; the custom one is ours.
|
||||||
|
if !names["Garlic"] || !names["German Red Garlic"] {
|
||||||
|
t.Errorf("find_plant(garlic) = %+v, want both the built-in and the custom variety", matches)
|
||||||
|
}
|
||||||
|
// Exact match ranks first, so a caller taking [0] gets the least surprising one.
|
||||||
|
if len(matches) > 0 && matches[0].Name != "Garlic" {
|
||||||
|
t.Errorf("first match = %q, want the exact name", matches[0].Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCreatePlantIsUserScoped — the catalog belongs to the user, not to any
|
||||||
|
// garden, so someone with no editable garden can still name a new variety. The
|
||||||
|
// issue asks for this to be asserted rather than assumed.
|
||||||
|
func TestCreatePlantIsUserScoped(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, owner := newAgentTestService(t)
|
||||||
|
other, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "B", Password: "password123"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
box := NewToolbox(svc, other.ID)
|
||||||
|
|
||||||
|
raw, _ := json.Marshal(map[string]any{
|
||||||
|
"name": "Painted Mountain Corn", "category": "vegetable",
|
||||||
|
"spacingCm": 30.0, "color": "#c08a3f", "icon": "🌽",
|
||||||
|
})
|
||||||
|
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "create_plant", Arguments: raw})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("create_plant: %s", res.Content)
|
||||||
|
}
|
||||||
|
var created struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
OwnerID *int64 `json:"ownerId"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(res.Content), &created); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if created.OwnerID == nil || *created.OwnerID != other.ID {
|
||||||
|
t.Errorf("ownerId = %v, want the acting user %d", created.OwnerID, other.ID)
|
||||||
|
}
|
||||||
|
// And it stays theirs: the other user's catalog doesn't gain it.
|
||||||
|
ownerPlants, err := svc.ListPlants(ctx, owner)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListPlants: %v", err)
|
||||||
|
}
|
||||||
|
for _, p := range ownerPlants {
|
||||||
|
if p.Name == "Painted Mountain Corn" {
|
||||||
|
t.Error("a plant created by one user showed up in another's catalog")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalToolWritesADatedObservation — "note that the west bed has mildew"
|
||||||
|
// is squarely the kind of thing you say out loud while walking around.
|
||||||
|
func TestJournalToolWritesADatedObservation(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
svc, owner := newAgentTestService(t)
|
||||||
|
box := NewToolbox(svc, owner)
|
||||||
|
|
||||||
|
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: "West bed", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("bed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, _ := json.Marshal(map[string]any{
|
||||||
|
"gardenId": g.ID, "objectId": bed.ID,
|
||||||
|
"body": "Powdery mildew on the west bed", "observedAt": "2026-08-14",
|
||||||
|
})
|
||||||
|
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "add_journal_entry", Arguments: raw})
|
||||||
|
if res.IsError {
|
||||||
|
t.Fatalf("add_journal_entry: %s", res.Content)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, _, err := svc.ListJournal(ctx, owner, g.ID, service.JournalQuery{ObjectID: &bed.ID})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListJournal: %v", err)
|
||||||
|
}
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("got %d entries, want 1", len(entries))
|
||||||
|
}
|
||||||
|
if entries[0].Body != "Powdery mildew on the west bed" || entries[0].ObservedAt != "2026-08-14" {
|
||||||
|
t.Errorf("unexpected entry: %+v", entries[0])
|
||||||
|
}
|
||||||
|
if entries[0].AuthorID != owner {
|
||||||
|
t.Errorf("author = %d, want the acting user %d", entries[0].AuthorID, owner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAgentTestService spins up an in-memory pansy with one registered user.
|
||||||
|
func newAgentTestService(t *testing.T) (*service.Service, int64) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
db, err := store.Open(":memory:")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
if err := db.Migrate(ctx); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
svc := service.New(db, &config.Config{Registration: config.RegistrationOpen, LocalAuth: true})
|
||||||
|
owner, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "A", Password: "password123"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("register: %v", err)
|
||||||
|
}
|
||||||
|
return svc, owner.ID
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,306 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
mdagent "gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The garden assistant's chat surface (#56).
|
||||||
|
//
|
||||||
|
// Streaming, because a turn that clears a bed and replants it makes a dozen tool
|
||||||
|
// calls over tens of seconds. Without streaming that is a long silence followed
|
||||||
|
// by everything at once, which reads as a hang — and the whole design rests on
|
||||||
|
// 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.
|
||||||
|
type chatRequest struct {
|
||||||
|
GardenID int64 `json:"gardenId" binding:"required"`
|
||||||
|
Message string `json:"message" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatEvent is one server-sent event. Exactly one field is set.
|
||||||
|
type chatEvent struct {
|
||||||
|
// Step reports a completed model round trip: which tools it called.
|
||||||
|
Step *stepEvent `json:"step,omitempty"`
|
||||||
|
// Done carries the finished turn.
|
||||||
|
Done *agent.Turn `json:"done,omitempty"`
|
||||||
|
// Error is a turn that failed, in words meant for a person.
|
||||||
|
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 {
|
||||||
|
Index int `json:"index"`
|
||||||
|
Tools []string `json:"tools"`
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
actor := mustActor(c)
|
||||||
|
|
||||||
|
history, err := h.svc.AgentHistory(c.Request.Context(), actor.ID, req.GardenID)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stream := openEventStream(c)
|
||||||
|
send := stream.send
|
||||||
|
|
||||||
|
// A model thinking hard between tool calls sends nothing for a while, and an
|
||||||
|
// idle proxy will cut a quiet connection. Deferred so a panic in the run
|
||||||
|
// can't leak the ticker goroutine; stopping it twice is harmless.
|
||||||
|
stopBeat := stream.keepAlive(keepAliveInterval)
|
||||||
|
defer stopBeat()
|
||||||
|
|
||||||
|
turn, err := runner.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
|
||||||
|
replayHistory(history),
|
||||||
|
func(s mdagent.Step) {
|
||||||
|
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
|
||||||
|
})
|
||||||
|
stopBeat()
|
||||||
|
if err != nil {
|
||||||
|
// 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.
|
||||||
|
send(chatEvent{Error: chatErrorMessage(err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The turn itself succeeded — the garden really did change — so Done goes out
|
||||||
|
// regardless. But if the transcript couldn't be saved, say so: a clean "done"
|
||||||
|
// followed by a conversation that has forgotten the exchange after a reload is
|
||||||
|
// 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 {
|
||||||
|
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})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.
|
||||||
|
func (h *handlers) getAgentHistory(c *gin.Context) {
|
||||||
|
gardenID, ok := parseIDParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msgs, err := h.svc.AgentHistory(c.Request.Context(), mustActor(c).ID, gardenID)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"messages": msgs})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteAgentHistory is the "start over" escape hatch.
|
||||||
|
func (h *handlers) deleteAgentHistory(c *gin.Context) {
|
||||||
|
gardenID, ok := parseIDParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.ClearAgentHistory(c.Request.Context(), mustActor(c).ID, gardenID); err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// replayHistory turns stored text into model messages.
|
||||||
|
//
|
||||||
|
// Only the text is replayed — no stored tool calls. Continuity needs what was
|
||||||
|
// said and what came back; replaying a tool call would be replaying a decision
|
||||||
|
// made against a garden that has since moved on.
|
||||||
|
func replayHistory(msgs []domain.AgentMessage) []llm.Message {
|
||||||
|
out := make([]llm.Message, 0, len(msgs))
|
||||||
|
for _, m := range msgs {
|
||||||
|
if m.Role == domain.AgentRoleUser {
|
||||||
|
out = append(out, llm.UserText(m.Body))
|
||||||
|
} else {
|
||||||
|
out = append(out, llm.AssistantText(m.Body))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func toolNames(s mdagent.Step) []string {
|
||||||
|
names := make([]string, 0, len(s.Results))
|
||||||
|
for _, r := range s.Results {
|
||||||
|
names = append(names, r.Name)
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// chatErrorMessage turns a failure into something worth reading. Permission
|
||||||
|
// errors in particular get named: "the agent broke" and "you can only view this
|
||||||
|
// garden" want very different reactions.
|
||||||
|
func chatErrorMessage(err error) string {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, domain.ErrForbidden):
|
||||||
|
return "You can only view this garden, so I can't change anything in it."
|
||||||
|
case errors.Is(err, domain.ErrNotFound):
|
||||||
|
return "I can't find that garden."
|
||||||
|
case errors.Is(err, domain.ErrInvalidInput):
|
||||||
|
return "I didn't get a message to work from."
|
||||||
|
case errors.Is(err, io.EOF), errors.Is(err, context.Canceled):
|
||||||
|
return "The connection dropped partway through. Anything I'd already changed is on the canvas, and in History."
|
||||||
|
case errors.Is(err, context.DeadlineExceeded):
|
||||||
|
return "That took too long and I stopped. Anything I'd already changed is on the canvas, and in History."
|
||||||
|
default:
|
||||||
|
slog.Error("api: agent run failed", "error", err)
|
||||||
|
return "Something went wrong talking to the model. Anything I'd already changed is on the canvas, and in History."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAgentDisabledWithoutAKey — an instance with no API key must start, serve
|
||||||
|
// the app, and not offer the assistant.
|
||||||
|
//
|
||||||
|
// The contract CHANGED with #79: the chat route is now always registered (so a
|
||||||
|
// 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
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
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",
|
||||||
|
map[string]any{"gardenId": gid, "message": "plant garlic"}, cookie)
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
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"
|
||||||
|
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusOK {
|
||||||
|
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.
|
||||||
|
if w := doJSON(t, r, http.MethodGet, fullPath(gid), nil, cookie); w.Code != http.StatusOK {
|
||||||
|
t.Errorf("editor load: status %d, want 200 — an unconfigured agent must not break the app", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
@@ -22,6 +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 holds the live Runner behind an atomic pointer. Unlike oidc it is
|
||||||
|
// never nil — the holder is always present and its Runner may be nil when the
|
||||||
|
// assistant is off. The chat routes are registered unconditionally and
|
||||||
|
// nil-check agent.get(), so a settings change can turn the assistant on or off
|
||||||
|
// at runtime (#79) rather than only at boot.
|
||||||
|
agent *agentHolder
|
||||||
}
|
}
|
||||||
|
|
||||||
// New builds the gin engine with the standard middleware stack and registers the
|
// New builds the gin engine with the standard middleware stack and registers the
|
||||||
@@ -49,6 +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.
|
||||||
|
// Registered after the agent below, because it reports whether the runner
|
||||||
|
// actually built — not merely whether it was configured to.
|
||||||
|
v1.GET("/capabilities", h.capabilities)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -84,7 +95,13 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
|||||||
gardens.POST("/:id/copy", h.copyGarden) // duplicate a garden the actor owns
|
gardens.POST("/:id/copy", h.copyGarden) // duplicate a garden the actor owns
|
||||||
gardens.GET("/:id/full", h.getGardenFull) // one-shot editor load
|
gardens.GET("/:id/full", h.getGardenFull) // one-shot editor load
|
||||||
gardens.GET("/:id/history", h.getGardenHistory) // change sets, newest first
|
gardens.GET("/:id/history", h.getGardenHistory) // change sets, newest first
|
||||||
|
gardens.GET("/:id/years", h.getGardenYears) // years with planting data
|
||||||
gardens.POST("/:id/objects", h.createObject)
|
gardens.POST("/:id/objects", h.createObject)
|
||||||
|
// The grow journal hangs off a garden even for entries about one bed or one
|
||||||
|
// plop, so it inherits the ordinary garden-role check.
|
||||||
|
gardens.GET("/:id/journal", h.listJournal)
|
||||||
|
gardens.POST("/:id/journal", h.createJournalEntry)
|
||||||
|
gardens.GET("/:id/journal/counts", h.getJournalCounts)
|
||||||
|
|
||||||
// Sharing (owner-managed; a recipient may remove their own share).
|
// Sharing (owner-managed; a recipient may remove their own share).
|
||||||
gardens.GET("/:id/shares", h.listShares)
|
gardens.GET("/:id/shares", h.listShares)
|
||||||
@@ -105,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.
|
||||||
@@ -112,11 +134,42 @@ 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. Its routes are registered UNCONDITIONALLY and the live
|
||||||
|
// Runner sits behind an atomic pointer in the holder, so a settings change can
|
||||||
|
// turn the assistant on or off at runtime (#79). Each handler nil-checks
|
||||||
|
// agent.get(); a chat request while the assistant is off gets a clean 503
|
||||||
|
// (AGENT_DISABLED), not a panic and not a missing route.
|
||||||
|
//
|
||||||
|
// The holder resolves its initial Runner from settings + environment at
|
||||||
|
// construction. If the key never reaches the container, the assistant is off
|
||||||
|
// and the reason is logged below — the same operability need #72 added.
|
||||||
|
h.agent = newAgentHolder(context.Background(), svc)
|
||||||
|
if cfg.Agent.OllamaCloudAPIKey == "" {
|
||||||
|
slog.Info("api: garden assistant has no API key",
|
||||||
|
"hint", "set OLLAMA_CLOUD_API_KEY in the container's environment (not just the stack's); the model can be chosen in Settings")
|
||||||
|
}
|
||||||
|
agentGroup := v1.Group("/agent", h.requireAuth())
|
||||||
|
agentGroup.POST("/chat", h.agentChat)
|
||||||
|
gardens.GET("/:id/agent/history", h.getAgentHistory)
|
||||||
|
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
|
||||||
|
|
||||||
|
// Instance settings: admin-only, and the first thing to enforce is_admin.
|
||||||
|
// requireAdmin runs after requireAuth (it reads the actor requireAuth stored).
|
||||||
|
settings := v1.Group("/settings", h.requireAuth(), h.requireAdmin())
|
||||||
|
settings.GET("", h.getSettings)
|
||||||
|
settings.PATCH("", h.updateSettings)
|
||||||
|
|
||||||
// Undo. A change set is addressed by its own id; the service resolves the
|
// 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.
|
||||||
changeSets := v1.Group("/change-sets", h.requireAuth())
|
changeSets := v1.Group("/change-sets", h.requireAuth())
|
||||||
changeSets.POST("/:id/revert", h.revertChangeSet)
|
changeSets.POST("/:id/revert", h.revertChangeSet)
|
||||||
|
|
||||||
|
// Journal entries are addressed by their own id; the service resolves the
|
||||||
|
// owning garden for the permission check, same as objects and plantings.
|
||||||
|
journal := v1.Group("/journal", h.requireAuth())
|
||||||
|
journal.PATCH("/:id", h.updateJournalEntry)
|
||||||
|
journal.DELETE("/:id", h.deleteJournalEntry)
|
||||||
|
|
||||||
// Plant catalog: built-ins (seeded, read-only) plus the actor's own rows.
|
// Plant catalog: built-ins (seeded, read-only) plus the actor's own rows.
|
||||||
plants := v1.Group("/plants", h.requireAuth())
|
plants := v1.Group("/plants", h.requireAuth())
|
||||||
plants.GET("", h.listPlants)
|
plants.GET("", h.listPlants)
|
||||||
@@ -143,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,163 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Grow journal (#52). Entries hang off a garden even when they're about one bed
|
||||||
|
// or one plop, which is what lets the permission check be the ordinary garden
|
||||||
|
// role check with nothing new invented.
|
||||||
|
|
||||||
|
// journalResponse is the body of GET /gardens/:id/journal.
|
||||||
|
type journalResponse struct {
|
||||||
|
Entries []domain.JournalEntry `json:"entries"`
|
||||||
|
HasMore bool `json:"hasMore"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalCreateRequest is the body for POST /gardens/:id/journal. observedAt
|
||||||
|
// defaults to today when omitted — the common case is writing about now.
|
||||||
|
type journalCreateRequest struct {
|
||||||
|
ObjectID *int64 `json:"objectId"`
|
||||||
|
PlantingID *int64 `json:"plantingId"`
|
||||||
|
Body string `json:"body" binding:"required"`
|
||||||
|
ObservedAt *string `json:"observedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalUpdateRequest is the body for PATCH /journal/:id. Only the text and the
|
||||||
|
// date it describes are editable; an entry's target and author are fixed.
|
||||||
|
type journalUpdateRequest struct {
|
||||||
|
Body *string `json:"body"`
|
||||||
|
ObservedAt *string `json:"observedAt"`
|
||||||
|
Version int64 `json:"version" binding:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) listJournal(c *gin.Context) {
|
||||||
|
gardenID, ok := parseIDParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
q := service.JournalQuery{
|
||||||
|
Limit: intQuery(c, "limit", 0),
|
||||||
|
Offset: intQuery(c, "offset", 0),
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if q.ObjectID, err = optionalIDQuery(c, "objectId"); err != nil {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid objectId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if q.PlantingID, err = optionalIDQuery(c, "plantingId"); err != nil {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid plantingId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if from := c.Query("from"); from != "" {
|
||||||
|
q.From = &from
|
||||||
|
}
|
||||||
|
if to := c.Query("to"); to != "" {
|
||||||
|
q.To = &to
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, hasMore, err := h.svc.ListJournal(c.Request.Context(), mustActor(c).ID, gardenID, q)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, journalResponse{Entries: entries, HasMore: hasMore})
|
||||||
|
}
|
||||||
|
|
||||||
|
// getJournalCounts powers the "there are notes about this bed" indicator, which
|
||||||
|
// has to work while the journal panel is closed — exactly when nothing else has
|
||||||
|
// loaded the entries. Key "0" is the garden itself.
|
||||||
|
func (h *handlers) getJournalCounts(c *gin.Context) {
|
||||||
|
gardenID, ok := parseIDParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
counts, err := h.svc.JournalCounts(c.Request.Context(), mustActor(c).ID, gardenID)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// JSON object keys are strings; the client parses them back to ids.
|
||||||
|
out := make(map[string]int, len(counts))
|
||||||
|
for id, n := range counts {
|
||||||
|
out[strconv.FormatInt(id, 10)] = n
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"counts": out})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) createJournalEntry(c *gin.Context) {
|
||||||
|
gardenID, ok := parseIDParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req journalCreateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid entry: a body is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e, err := h.svc.CreateJournalEntry(c.Request.Context(), mustActor(c).ID, gardenID, service.JournalInput{
|
||||||
|
ObjectID: req.ObjectID, PlantingID: req.PlantingID, Body: req.Body, ObservedAt: req.ObservedAt,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusCreated, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) updateJournalEntry(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req journalUpdateRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid update: a current version is required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
e, err := h.svc.UpdateJournalEntry(c.Request.Context(), mustActor(c).ID, id,
|
||||||
|
service.JournalPatch{Body: req.Body, ObservedAt: req.ObservedAt}, req.Version)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, domain.ErrVersionConflict) {
|
||||||
|
writeVersionConflict(c, e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *handlers) deleteJournalEntry(c *gin.Context) {
|
||||||
|
id, ok := parseIDParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.svc.DeleteJournalEntry(c.Request.Context(), mustActor(c).ID, id); err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionalIDQuery reads a positive int64 query parameter, or nil when absent.
|
||||||
|
// A present-but-malformed value is an error rather than a silent nil, so a typo
|
||||||
|
// doesn't quietly widen a filter to the whole garden.
|
||||||
|
func optionalIDQuery(c *gin.Context, name string) (*int64, error) {
|
||||||
|
raw := c.Query(name)
|
||||||
|
if raw == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || id < 1 {
|
||||||
|
return nil, errors.New("invalid id")
|
||||||
|
}
|
||||||
|
return &id, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func journalPath(gardenID int64) string {
|
||||||
|
return "/api/v1/gardens/" + strconv.FormatInt(gardenID, 10) + "/journal"
|
||||||
|
}
|
||||||
|
|
||||||
|
func journalEntryPath(id int64) string {
|
||||||
|
return "/api/v1/journal/" + strconv.FormatInt(id, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalCrudAPI walks the whole entry lifecycle over HTTP.
|
||||||
|
//
|
||||||
|
// It exists because the service-level tests could not see that PATCH and DELETE
|
||||||
|
// were never registered on the router: the handlers were written, tested through
|
||||||
|
// the service, and completely unreachable. Anything addressed by its own id
|
||||||
|
// needs at least one test that goes through the router.
|
||||||
|
func TestJournalCrudAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
gid := createGardenAPI(t, r, cookie, "G")
|
||||||
|
|
||||||
|
w := doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{
|
||||||
|
"body": "First frost tonight", "observedAt": "2026-10-12",
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("create: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
entry := decodeMap(t, w.Body.Bytes())
|
||||||
|
id := int64(entry["id"].(float64))
|
||||||
|
if entry["observedAt"] != "2026-10-12" || entry["body"] != "First frost tonight" {
|
||||||
|
t.Errorf("unexpected entry: %+v", entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List it back.
|
||||||
|
w = doJSON(t, r, http.MethodGet, journalPath(gid), nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
body := decodeMap(t, w.Body.Bytes())
|
||||||
|
entries, _ := body["entries"].([]any)
|
||||||
|
if len(entries) != 1 {
|
||||||
|
t.Fatalf("got %d entries, want 1: %s", len(entries), w.Body.String())
|
||||||
|
}
|
||||||
|
if first := entries[0].(map[string]any); first["authorName"] == "" {
|
||||||
|
t.Error("author name missing from the list read")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH — the route this test exists for.
|
||||||
|
w = doJSON(t, r, http.MethodPatch, journalEntryPath(id), map[string]any{
|
||||||
|
"body": "First frost, tomatoes done", "version": entry["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["body"] != "First frost, tomatoes done" {
|
||||||
|
t.Errorf("body = %v", updated["body"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stale version conflicts, carrying the current row.
|
||||||
|
w = doJSON(t, r, http.MethodPatch, journalEntryPath(id), map[string]any{
|
||||||
|
"body": "stale", "version": entry["version"],
|
||||||
|
}, cookie)
|
||||||
|
if w.Code != http.StatusConflict {
|
||||||
|
t.Errorf("stale patch: status %d, want 409", w.Code)
|
||||||
|
}
|
||||||
|
if current, _ := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); current == nil {
|
||||||
|
t.Error("409 should carry the current row")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE — likewise.
|
||||||
|
w = doJSON(t, r, http.MethodDelete, journalEntryPath(id), nil, cookie)
|
||||||
|
if w.Code != http.StatusNoContent {
|
||||||
|
t.Fatalf("delete: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w = doJSON(t, r, http.MethodGet, journalPath(gid), nil, cookie)
|
||||||
|
if entries, _ := decodeMap(t, w.Body.Bytes())["entries"].([]any); len(entries) != 0 {
|
||||||
|
t.Errorf("%d entries survived the delete", len(entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalFiltersAPI covers the query parameters, including a malformed one.
|
||||||
|
func TestJournalFiltersAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
gid := createGardenAPI(t, r, cookie, "G")
|
||||||
|
|
||||||
|
w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
||||||
|
"kind": "bed", "name": "North Bed", "xCm": 100, "yCm": 100, "widthCm": 100, "heightCm": 100,
|
||||||
|
}, cookie)
|
||||||
|
objectID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
|
||||||
|
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{
|
||||||
|
"body": "garden level", "observedAt": "2026-05-01",
|
||||||
|
}, cookie)
|
||||||
|
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{
|
||||||
|
"objectId": objectID, "body": "bed level", "observedAt": "2026-06-01",
|
||||||
|
}, cookie)
|
||||||
|
|
||||||
|
countAt := func(query string) int {
|
||||||
|
t.Helper()
|
||||||
|
w := doJSON(t, r, http.MethodGet, journalPath(gid)+query, nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("list%s: status %d, body %s", query, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
entries, _ := decodeMap(t, w.Body.Bytes())["entries"].([]any)
|
||||||
|
return len(entries)
|
||||||
|
}
|
||||||
|
if n := countAt(""); n != 2 {
|
||||||
|
t.Errorf("unfiltered = %d, want 2", n)
|
||||||
|
}
|
||||||
|
if n := countAt("?objectId=" + strconv.FormatInt(objectID, 10)); n != 1 {
|
||||||
|
t.Errorf("by object = %d, want 1", n)
|
||||||
|
}
|
||||||
|
if n := countAt("?from=2026-05-15&to=2026-12-31"); n != 1 {
|
||||||
|
t.Errorf("by date range = %d, want 1", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A malformed filter is an error, not a silently ignored one that widens the
|
||||||
|
// query back to the whole garden.
|
||||||
|
w = doJSON(t, r, http.MethodGet, journalPath(gid)+"?objectId=abc", nil, cookie)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("malformed objectId: status %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
w = doJSON(t, r, http.MethodGet, journalPath(gid)+"?from=nonsense", nil, cookie)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("malformed from: status %d, want 400", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalRequiresAccessAPI — anonymous is 401, a stranger is 404.
|
||||||
|
func TestJournalRequiresAccessAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
owner := registerAndCookie(t, r, "[email protected]")
|
||||||
|
gid := createGardenAPI(t, r, owner, "G")
|
||||||
|
stranger := registerAndCookie(t, r, "[email protected]")
|
||||||
|
|
||||||
|
if w := doJSON(t, r, http.MethodGet, journalPath(gid), nil, nil); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("anonymous list: status %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodGet, journalPath(gid), nil, stranger); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("stranger list: status %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
w := doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"body": "nope"}, stranger)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("stranger write: status %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
if w := doJSON(t, r, http.MethodDelete, journalEntryPath(1), nil, stranger); w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("stranger delete: status %d, want 404", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalCountsAPI — the indicator that makes the log discoverable has to
|
||||||
|
// work while the journal panel is closed, so it's its own endpoint.
|
||||||
|
func TestJournalCountsAPI(t *testing.T) {
|
||||||
|
r := authEngine(t, localCfg())
|
||||||
|
cookie := registerAndCookie(t, r, "[email protected]")
|
||||||
|
gid := createGardenAPI(t, r, cookie, "G")
|
||||||
|
|
||||||
|
w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
||||||
|
"kind": "bed", "name": "North Bed", "xCm": 100, "yCm": 100, "widthCm": 100, "heightCm": 100,
|
||||||
|
}, cookie)
|
||||||
|
objectID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||||
|
|
||||||
|
counts := func() map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
w := doJSON(t, r, http.MethodGet, journalPath(gid)+"/counts", nil, cookie)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("counts: status %d, body %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
c, _ := decodeMap(t, w.Body.Bytes())["counts"].(map[string]any)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
if len(counts()) != 0 {
|
||||||
|
t.Errorf("a garden with no entries should report no counts")
|
||||||
|
}
|
||||||
|
|
||||||
|
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"body": "garden note"}, cookie)
|
||||||
|
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"objectId": objectID, "body": "bed note"}, cookie)
|
||||||
|
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"objectId": objectID, "body": "another"}, cookie)
|
||||||
|
|
||||||
|
c := counts()
|
||||||
|
// Key "0" is the garden itself; anything else is an object id.
|
||||||
|
if c["0"] != float64(1) {
|
||||||
|
t.Errorf("garden-level count = %v, want 1", c["0"])
|
||||||
|
}
|
||||||
|
if c[strconv.FormatInt(objectID, 10)] != float64(2) {
|
||||||
|
t.Errorf("bed count = %v, want 2", c[strconv.FormatInt(objectID, 10)])
|
||||||
|
}
|
||||||
|
|
||||||
|
if w := doJSON(t, r, http.MethodGet, journalPath(gid)+"/counts", nil, nil); w.Code != http.StatusUnauthorized {
|
||||||
|
t.Errorf("anonymous counts: status %d, want 401", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
+34
-1
@@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -171,15 +172,47 @@ func (h *handlers) deleteObject(c *gin.Context) {
|
|||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getGardenFull serves the editor's one-shot load. ?year=YYYY switches it to the
|
||||||
|
// season view: every plop whose time in the ground overlapped that calendar
|
||||||
|
// year, INCLUDING ones since removed.
|
||||||
|
//
|
||||||
|
// Undated plantings are returned for every year. Everything planted before this
|
||||||
|
// feature existed has a null planted_at, so excluding them would empty every
|
||||||
|
// garden the moment a year was picked — technically defensible, useless in
|
||||||
|
// practice. Without the param the behaviour is exactly what it has always been.
|
||||||
func (h *handlers) getGardenFull(c *gin.Context) {
|
func (h *handlers) getGardenFull(c *gin.Context) {
|
||||||
gardenID, ok := parseIDParam(c, "id")
|
gardenID, ok := parseIDParam(c, "id")
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
full, err := h.svc.GardenFull(c.Request.Context(), mustActor(c).ID, gardenID)
|
var year *int
|
||||||
|
if raw := c.Query("year"); raw != "" {
|
||||||
|
y, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "year must be a four-digit year")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
year = &y
|
||||||
|
}
|
||||||
|
full, err := h.svc.GardenFull(c.Request.Context(), mustActor(c).ID, gardenID, year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeServiceError(c, err)
|
writeServiceError(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, full)
|
c.JSON(http.StatusOK, full)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getGardenYears lists the years this garden has planting data for, so the year
|
||||||
|
// selector can offer only years that hold something.
|
||||||
|
func (h *handlers) getGardenYears(c *gin.Context) {
|
||||||
|
gardenID, ok := parseIDParam(c, "id")
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
years, err := h.svc.GardenYears(c.Request.Context(), mustActor(c).ID, gardenID)
|
||||||
|
if err != nil {
|
||||||
|
writeServiceError(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"years": years})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,9 @@ const (
|
|||||||
RegistrationClosed = "closed"
|
RegistrationClosed = "closed"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// DefaultAgentModel is the assistant's model when PANSY_AGENT_MODEL is unset.
|
||||||
|
const DefaultAgentModel = "ollama-cloud/glm-5.2:cloud"
|
||||||
|
|
||||||
// Config is the fully-resolved runtime configuration.
|
// Config is the fully-resolved runtime configuration.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
// Port is the TCP port the HTTP server listens on (PANSY_PORT, default 8080).
|
// Port is the TCP port the HTTP server listens on (PANSY_PORT, default 8080).
|
||||||
@@ -37,6 +40,8 @@ type Config struct {
|
|||||||
LocalAuth bool
|
LocalAuth bool
|
||||||
// OIDC holds the optional OpenID Connect provider settings.
|
// OIDC holds the optional OpenID Connect provider settings.
|
||||||
OIDC OIDCConfig
|
OIDC OIDCConfig
|
||||||
|
// Agent holds the garden-assistant settings.
|
||||||
|
Agent AgentConfig
|
||||||
// TrustedProxies is the set of proxy CIDRs/IPs gin trusts for client IP
|
// TrustedProxies is the set of proxy CIDRs/IPs gin trusts for client IP
|
||||||
// resolution (PANSY_TRUSTED_PROXIES, comma-separated). Empty trusts none.
|
// resolution (PANSY_TRUSTED_PROXIES, comma-separated). Empty trusts none.
|
||||||
TrustedProxies []string
|
TrustedProxies []string
|
||||||
@@ -51,6 +56,26 @@ type OIDCConfig struct {
|
|||||||
ButtonLabel string // PANSY_OIDC_BUTTON_LABEL — login-page button text
|
ButtonLabel string // PANSY_OIDC_BUTTON_LABEL — login-page button text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AgentConfig holds the garden assistant's settings.
|
||||||
|
type AgentConfig struct {
|
||||||
|
// Model is the majordomo model spec, passed VERBATIM to majordomo.Parse
|
||||||
|
// (PANSY_AGENT_MODEL). The grammar is majordomo's, not pansy's — parsing or
|
||||||
|
// validating it here would only mean two places to update when it grows. A
|
||||||
|
// comma-separated spec is a failover chain, so
|
||||||
|
// "ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud" gets a fallback
|
||||||
|
// for free.
|
||||||
|
Model string
|
||||||
|
// OllamaCloudAPIKey authenticates against Ollama Cloud
|
||||||
|
// (OLLAMA_CLOUD_API_KEY — the same secret name gadfly uses, which is why
|
||||||
|
// it isn't majordomo's own OLLAMA_API_KEY; pansy passes it explicitly rather
|
||||||
|
// than relying on ambient environment).
|
||||||
|
OllamaCloudAPIKey string
|
||||||
|
// Enabled turns the assistant on (PANSY_AGENT_ENABLED). Defaults to on when
|
||||||
|
// a key is present, so an instance with no key starts cleanly and simply
|
||||||
|
// doesn't offer the agent — the same shape as OIDC 404ing when unconfigured.
|
||||||
|
Enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
// 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 != ""
|
||||||
@@ -87,6 +112,16 @@ func Load() *Config {
|
|||||||
TrustedProxies: envList("PANSY_TRUSTED_PROXIES"),
|
TrustedProxies: envList("PANSY_TRUSTED_PROXIES"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
agentKey := envStr("OLLAMA_CLOUD_API_KEY", "")
|
||||||
|
cfg.Agent = AgentConfig{
|
||||||
|
Model: envStr("PANSY_AGENT_MODEL", DefaultAgentModel),
|
||||||
|
OllamaCloudAPIKey: agentKey,
|
||||||
|
// Default on when a key is present: having configured the key IS the
|
||||||
|
// opt-in, and making people set a second flag to use what they just
|
||||||
|
// configured is a papercut with no upside.
|
||||||
|
Enabled: envBool("PANSY_AGENT_ENABLED", agentKey != ""),
|
||||||
|
}
|
||||||
|
|
||||||
if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
|
if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
|
||||||
slog.Warn("config: invalid PANSY_REGISTRATION, defaulting to open", "value", cfg.Registration)
|
slog.Warn("config: invalid PANSY_REGISTRATION, defaulting to open", "value", cfg.Registration)
|
||||||
cfg.Registration = RegistrationOpen
|
cfg.Registration = RegistrationOpen
|
||||||
|
|||||||
@@ -165,6 +165,54 @@ type Revision struct {
|
|||||||
After *string `json:"after,omitempty"`
|
After *string `json:"after,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JournalEntry is one time-stamped observation: what happened, and when.
|
||||||
|
// Distinct from the mutable `notes` on a garden/object/plant, which says what
|
||||||
|
// the thing IS — writing a new note overwrites the old one, while entries
|
||||||
|
// accumulate.
|
||||||
|
//
|
||||||
|
// GardenID is set even when the entry is about a bed or a plop, so permission
|
||||||
|
// checks reuse the garden role machinery unchanged; ObjectID/PlantingID narrow
|
||||||
|
// the target without inventing a second ACL path.
|
||||||
|
type JournalEntry struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
GardenID int64 `json:"gardenId"`
|
||||||
|
ObjectID *int64 `json:"objectId,omitempty"`
|
||||||
|
PlantingID *int64 `json:"plantingId,omitempty"`
|
||||||
|
AuthorID int64 `json:"authorId"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
// ObservedAt is when it happened ('YYYY-MM-DD'), which is not when it was
|
||||||
|
// written down: you write up Saturday's observations on Sunday.
|
||||||
|
ObservedAt string `json:"observedAt"`
|
||||||
|
Version int64 `json:"version"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
|
||||||
|
// AuthorName is resolved for display by the service, never persisted.
|
||||||
|
AuthorName string `json:"authorName,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Roles a stored agent message can have.
|
||||||
|
const (
|
||||||
|
AgentRoleUser = "user"
|
||||||
|
AgentRoleAssistant = "assistant"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentMessage is one side of a chat exchange with the garden assistant. Only
|
||||||
|
// the text is kept, not the model's full transcript with its tool calls:
|
||||||
|
// continuity needs what was said and what came back, and replaying a stored tool
|
||||||
|
// call would be replaying a decision made against a garden that has since moved
|
||||||
|
// on.
|
||||||
|
type AgentMessage struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ConversationID int64 `json:"conversationId"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
// ChangeSetID is what this turn changed, if anything — the handle the chat
|
||||||
|
// panel needs to offer Undo on the message that caused it.
|
||||||
|
ChangeSetID *int64 `json:"changeSetId,omitempty"`
|
||||||
|
CreatedAt string `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
// RevertConflict reports one entity a revert deliberately left alone, because
|
// RevertConflict reports one entity a revert deliberately left alone, because
|
||||||
// reverting it would have discarded a change made after the change set being
|
// reverting it would have discarded a change made after the change set being
|
||||||
// reverted. The rest of the change set still reverts.
|
// reverted. The rest of the change set still reverts.
|
||||||
@@ -194,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,207 @@
|
|||||||
|
// Package imagenorm normalizes an uploaded image to a JPEG the rest of pansy
|
||||||
|
// (and majordomo's vision path) can rely on, decoding the formats a phone
|
||||||
|
// actually produces.
|
||||||
|
//
|
||||||
|
// # Why this exists
|
||||||
|
//
|
||||||
|
// majordomo's own media pipeline is stdlib-based, so it cannot decode HEIC or
|
||||||
|
// WebP — and HEIC is the iPhone camera default. The seed-packet feature's very
|
||||||
|
// first input is "a photo from my phone", so without this the feature fails on
|
||||||
|
// the exact device that motivates it. Normalizing at the upload boundary means
|
||||||
|
// everything downstream only ever sees JPEG.
|
||||||
|
//
|
||||||
|
// # The CGO constraint
|
||||||
|
//
|
||||||
|
// pansy is CGO_ENABLED=0 (a single static binary — the reason modernc/sqlite was
|
||||||
|
// chosen over the C one), so a libheif *binding* is out. github.com/gen2brain/heic
|
||||||
|
// runs libheif as WebAssembly via wazero: pure Go, no cgo, and it registers with
|
||||||
|
// image.Decode like any other format. golang.org/x/image/webp is pure Go too.
|
||||||
|
//
|
||||||
|
// # Import-driven registry
|
||||||
|
//
|
||||||
|
// Go's image decoders register via blank imports, and the failure mode is
|
||||||
|
// backwards from intuition: forget "image/png" and PNG uploads fail with
|
||||||
|
// "unknown format" while the exotic HEIC still works. So the blank imports below
|
||||||
|
// are load-bearing, and TestNormalizeAllFormats exercises all four formats to
|
||||||
|
// keep them so.
|
||||||
|
package imagenorm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
|
||||||
|
// Decoders, registered with image.Decode by side effect. All four matter:
|
||||||
|
// jpeg/png are the common cases, heic is the iPhone default, webp is common
|
||||||
|
// on the web. Dropping any one silently breaks that format's uploads.
|
||||||
|
_ "image/jpeg"
|
||||||
|
_ "image/png"
|
||||||
|
|
||||||
|
_ "github.com/gen2brain/heic"
|
||||||
|
_ "golang.org/x/image/webp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Defaults chosen for the seed-packet path against ollama-cloud's limits (8
|
||||||
|
// images, 20 MiB, 2048px, jpeg+png). We re-encode to JPEG well under all of them.
|
||||||
|
const (
|
||||||
|
// DefaultMaxDim is the longest-edge ceiling. 2048 matches ollama-cloud's
|
||||||
|
// MaxDim; anything larger is downscaled. A packet photo has plenty of detail
|
||||||
|
// left at 2048.
|
||||||
|
DefaultMaxDim = 2048
|
||||||
|
// DefaultMaxBytes caps the *input* we will read. A phone photo is 3–8 MB; 25
|
||||||
|
// MiB leaves headroom for a large HEIC without inviting a decompression bomb
|
||||||
|
// as an unbounded read. The re-encoded output is far smaller.
|
||||||
|
DefaultMaxBytes = 25 << 20
|
||||||
|
// maxDecodePixels bounds the DECODED bitmap regardless of input byte size, so
|
||||||
|
// a small file claiming enormous dimensions (a decompression bomb) is refused
|
||||||
|
// before its ~4-bytes/px bitmap is allocated. 50 MP ≈ 200 MB peak — above any
|
||||||
|
// current phone camera (a 48 MP sensor is 48 MP) while capping the
|
||||||
|
// amplification a hostile header can force. maxDecodePixels and maxDimension
|
||||||
|
// are internal safety floors, not knobs — unlike MaxDim/MaxBytes there's no
|
||||||
|
// reason for a caller to raise them.
|
||||||
|
maxDecodePixels = 50_000_000
|
||||||
|
// maxDimension caps EACH side independently. It exists to make the pixel-count
|
||||||
|
// check overflow-safe: without it, a header claiming ~2^32 on a side could
|
||||||
|
// wrap int64(w)*int64(h) negative and slip past maxDecodePixels. No real image
|
||||||
|
// is 50k px on a side.
|
||||||
|
maxDimension = 50_000
|
||||||
|
// jpegQuality for the normalized output. 85 is visually clean and keeps the
|
||||||
|
// file small; the model reads text off it, not fine gradients.
|
||||||
|
jpegQuality = 85
|
||||||
|
)
|
||||||
|
|
||||||
|
// Options tunes Normalize. The zero value uses the Default* constants.
|
||||||
|
type Options struct {
|
||||||
|
MaxDim int // longest edge; 0 → DefaultMaxDim
|
||||||
|
MaxBytes int // input read cap; 0 → DefaultMaxBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Options) maxDim() int {
|
||||||
|
if o.MaxDim > 0 {
|
||||||
|
return o.MaxDim
|
||||||
|
}
|
||||||
|
return DefaultMaxDim
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o Options) maxBytes() int {
|
||||||
|
if o.MaxBytes > 0 {
|
||||||
|
return o.MaxBytes
|
||||||
|
}
|
||||||
|
return DefaultMaxBytes
|
||||||
|
}
|
||||||
|
|
||||||
|
// ErrTooLarge means the input exceeded the byte cap or decoded to an absurd
|
||||||
|
// pixel count. ErrUnsupported means the bytes weren't a decodable image format —
|
||||||
|
// or a decoder panicked on them (see the recover in Normalize).
|
||||||
|
var (
|
||||||
|
ErrTooLarge = errors.New("imagenorm: image too large")
|
||||||
|
ErrUnsupported = errors.New("imagenorm: unsupported or corrupt image")
|
||||||
|
)
|
||||||
|
|
||||||
|
// Normalize reads an image of any supported format (JPEG, PNG, HEIC, WebP),
|
||||||
|
// downscales it to fit opts.MaxDim on its longest edge, and returns it re-encoded
|
||||||
|
// as JPEG, plus the decoded format name (e.g. "heic") — handy for logging what a
|
||||||
|
// phone actually sent. On any error the returned bytes are nil and format is "".
|
||||||
|
//
|
||||||
|
// Errors, by cause:
|
||||||
|
// - input over opts.MaxBytes, or a decoded canvas over maxDecodePixels /
|
||||||
|
// maxDimension → ErrTooLarge, refused before the bitmap is allocated;
|
||||||
|
// - bytes that aren't a decodable image, or a decoder that panics on them →
|
||||||
|
// ErrUnsupported;
|
||||||
|
// - a genuine read or JPEG-encode I/O failure → a wrapped error (not a
|
||||||
|
// sentinel), since those are the caller's stream/environment, not the image.
|
||||||
|
//
|
||||||
|
// It bounds work against a hostile upload three ways: the byte cap, the
|
||||||
|
// pre-decode pixel/dimension check, and a recover around the third-party decoders
|
||||||
|
// (a malformed HEIC/WebP shouldn't take the process down).
|
||||||
|
//
|
||||||
|
// Two known gaps, both deferred to the upload handler that wires this in (#81):
|
||||||
|
// - EXIF ORIENTATION is not applied, so a portrait phone photo tagged
|
||||||
|
// "rotate 90°" comes out sideways. That's best fixed and tested with a real
|
||||||
|
// oriented photo end-to-end, which the library has no consumer for yet.
|
||||||
|
// - There is no context: image.Decode is CPU-bound and not cancellable
|
||||||
|
// mid-decode, so a caller that needs a hard deadline should run Normalize
|
||||||
|
// under its own timeout. The size guards keep the work finite regardless.
|
||||||
|
func Normalize(r io.Reader, opts Options) (out []byte, format string, err error) {
|
||||||
|
// Cap the read at MaxBytes+1 so we can tell "exactly at the cap" from "over".
|
||||||
|
// maxBytes() is always a sane positive (default 25 MiB); guard the +1 anyway.
|
||||||
|
byteCap := opts.maxBytes()
|
||||||
|
limit := int64(byteCap) + 1
|
||||||
|
if limit < 1 {
|
||||||
|
limit = int64(DefaultMaxBytes) + 1
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(io.LimitReader(r, limit))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("imagenorm: read: %w", err)
|
||||||
|
}
|
||||||
|
if len(raw) > byteCap {
|
||||||
|
return nil, "", ErrTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check dimensions BEFORE a full decode, so a decompression bomb is refused
|
||||||
|
// before it allocates its bitmap. The per-side maxDimension check runs first
|
||||||
|
// so the pixel-count multiply below can't overflow.
|
||||||
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", ErrUnsupported
|
||||||
|
}
|
||||||
|
if cfg.Width <= 0 || cfg.Height <= 0 ||
|
||||||
|
cfg.Width > maxDimension || cfg.Height > maxDimension ||
|
||||||
|
int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
|
||||||
|
return nil, "", ErrTooLarge
|
||||||
|
}
|
||||||
|
|
||||||
|
img, format, err := decodeSafely(raw)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
img = downscale(img, opts.maxDim())
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
|
||||||
|
return nil, "", fmt.Errorf("imagenorm: encode jpeg: %w", err)
|
||||||
|
}
|
||||||
|
return buf.Bytes(), format, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeSafely decodes raw, converting both a decode error and a decoder PANIC
|
||||||
|
// into ErrUnsupported. The recover matters because the image comes from an
|
||||||
|
// untrusted upload and the HEIC/WebP decoders are third-party (libheif via WASM,
|
||||||
|
// x/image/webp): a malformed file that panics one of them must fail this one
|
||||||
|
// request, not crash the process.
|
||||||
|
func decodeSafely(raw []byte) (img image.Image, format string, err error) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
img, format, err = nil, "", ErrUnsupported
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
img, format, err = image.Decode(bytes.NewReader(raw))
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", ErrUnsupported
|
||||||
|
}
|
||||||
|
return img, format, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// downscale returns img shrunk so its longest edge is at most maxDim, preserving
|
||||||
|
// aspect ratio. An image already within bounds is returned unchanged (no
|
||||||
|
// re-sampling, no quality loss beyond the JPEG round-trip). Uses Catmull-Rom for
|
||||||
|
// a sharp result on text, which is what a packet photo is mostly made of.
|
||||||
|
func downscale(img image.Image, maxDim int) image.Image {
|
||||||
|
b := img.Bounds()
|
||||||
|
w, h := b.Dx(), b.Dy()
|
||||||
|
longest := max(w, h)
|
||||||
|
if longest <= maxDim || longest == 0 {
|
||||||
|
return img
|
||||||
|
}
|
||||||
|
scale := float64(maxDim) / float64(longest)
|
||||||
|
nw, nh := max(int(float64(w)*scale), 1), max(int(float64(h)*scale), 1)
|
||||||
|
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||||
|
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
||||||
|
return dst
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package imagenorm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/binary"
|
||||||
|
"hash/crc32"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// pngBytes and jpegBytes generate in-memory fixtures for the two formats Go can
|
||||||
|
// encode; heic/webp come from testdata (Go has no encoder for them).
|
||||||
|
func pngBytes(t *testing.T, w, h int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
for y := range h {
|
||||||
|
for x := range w {
|
||||||
|
m.Pix[m.PixOffset(x, y)+0] = uint8(x)
|
||||||
|
m.Pix[m.PixOffset(x, y)+3] = 255
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := png.Encode(&b, m); err != nil {
|
||||||
|
t.Fatalf("encode png: %v", err)
|
||||||
|
}
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func jpegBytes(t *testing.T, w, h int) []byte {
|
||||||
|
t.Helper()
|
||||||
|
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||||
|
var b bytes.Buffer
|
||||||
|
if err := jpeg.Encode(&b, m, nil); err != nil {
|
||||||
|
t.Fatalf("encode jpeg: %v", err)
|
||||||
|
}
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func readTestdata(t *testing.T, name string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile("testdata/" + name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read %s: %v", name, err)
|
||||||
|
}
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeAllFormats is the load-bearing test: every format pansy claims to
|
||||||
|
// accept must round-trip to a valid JPEG. It exists specifically to catch a
|
||||||
|
// dropped blank import — the failure mode where the common format (PNG) breaks
|
||||||
|
// while the exotic one (HEIC) works, because someone deleted `_ "image/png"`.
|
||||||
|
func TestNormalizeAllFormats(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
input []byte
|
||||||
|
wantFormat string
|
||||||
|
}{
|
||||||
|
{"png", pngBytes(t, 120, 90), "png"},
|
||||||
|
{"jpeg", jpegBytes(t, 120, 90), "jpeg"},
|
||||||
|
{"heic", readTestdata(t, "sample.heic"), "heic"},
|
||||||
|
{"webp", readTestdata(t, "sample.webp"), "webp"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
out, format, err := Normalize(bytes.NewReader(tc.input), Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize(%s): %v", tc.name, err)
|
||||||
|
}
|
||||||
|
if format != tc.wantFormat {
|
||||||
|
t.Errorf("format = %q, want %q", format, tc.wantFormat)
|
||||||
|
}
|
||||||
|
// The output must itself be a decodable JPEG.
|
||||||
|
_, outFormat, err := image.Decode(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("output isn't a valid image: %v", err)
|
||||||
|
}
|
||||||
|
if outFormat != "jpeg" {
|
||||||
|
t.Errorf("output format = %q, want jpeg", outFormat)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeDownscales checks a large image is shrunk to fit MaxDim on its
|
||||||
|
// longest edge with aspect ratio preserved, and a small one is left alone.
|
||||||
|
func TestNormalizeDownscales(t *testing.T) {
|
||||||
|
// A 2:1 image twice as wide as DefaultMaxDim → clamped to DefaultMaxDim on the
|
||||||
|
// long edge with aspect preserved. Derived from the constant, not hard-coded,
|
||||||
|
// so the test tracks the default rather than silently asserting a magic number.
|
||||||
|
longEdge := DefaultMaxDim * 2
|
||||||
|
big := pngBytes(t, longEdge, longEdge/2)
|
||||||
|
out, _, err := Normalize(bytes.NewReader(big), Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize: %v", err)
|
||||||
|
}
|
||||||
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode out: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Width != DefaultMaxDim {
|
||||||
|
t.Errorf("width = %d, want %d (longest edge clamped)", cfg.Width, DefaultMaxDim)
|
||||||
|
}
|
||||||
|
if cfg.Height != DefaultMaxDim/2 {
|
||||||
|
t.Errorf("height = %d, want %d (aspect preserved)", cfg.Height, DefaultMaxDim/2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A small image within bounds keeps its dimensions.
|
||||||
|
small := pngBytes(t, 100, 80)
|
||||||
|
out, _, err = Normalize(bytes.NewReader(small), Options{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Normalize small: %v", err)
|
||||||
|
}
|
||||||
|
cfg, _, err = image.DecodeConfig(bytes.NewReader(out))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("decode small out: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.Width != 100 || cfg.Height != 80 {
|
||||||
|
t.Errorf("small image resized to %dx%d, want 100x80", cfg.Width, cfg.Height)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeRejectsOversizeInput: an input past the byte cap is ErrTooLarge,
|
||||||
|
// refused without a full decode.
|
||||||
|
func TestNormalizeRejectsOversizeInput(t *testing.T) {
|
||||||
|
big := pngBytes(t, 500, 500)
|
||||||
|
_, _, err := Normalize(bytes.NewReader(big), Options{MaxBytes: 100})
|
||||||
|
if err != ErrTooLarge {
|
||||||
|
t.Errorf("over-cap input err = %v, want ErrTooLarge", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeRejectsGarbage: unreadable-as-image bytes and a truncated image
|
||||||
|
// both fail cleanly with ErrUnsupported, not a panic.
|
||||||
|
func TestNormalizeRejectsGarbage(t *testing.T) {
|
||||||
|
_, _, err := Normalize(bytes.NewReader([]byte("not an image at all")), Options{})
|
||||||
|
if err != ErrUnsupported {
|
||||||
|
t.Errorf("garbage err = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
// A truncated image (valid header, cut body) also fails cleanly, not a panic.
|
||||||
|
png := pngBytes(t, 100, 100)
|
||||||
|
_, _, err = Normalize(bytes.NewReader(png[:len(png)/2]), Options{})
|
||||||
|
if err != ErrUnsupported {
|
||||||
|
t.Errorf("truncated image err = %v, want ErrUnsupported", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pngHeader builds a valid PNG signature + IHDR chunk (with a correct CRC, which
|
||||||
|
// DecodeConfig verifies) for the given dimensions, and nothing else. It's enough
|
||||||
|
// for image.DecodeConfig to report width/height without a real bitmap — exactly
|
||||||
|
// what's needed to exercise the pre-decode size guard with a tiny input.
|
||||||
|
func pngHeader(w, h uint32) []byte {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
buf.Write([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})
|
||||||
|
ihdr := make([]byte, 13)
|
||||||
|
binary.BigEndian.PutUint32(ihdr[0:], w)
|
||||||
|
binary.BigEndian.PutUint32(ihdr[4:], h)
|
||||||
|
ihdr[8] = 8 // bit depth
|
||||||
|
ihdr[9] = 6 // colour type: RGBA
|
||||||
|
// compression/filter/interlace already 0.
|
||||||
|
binary.Write(&buf, binary.BigEndian, uint32(len(ihdr)))
|
||||||
|
chunk := append([]byte("IHDR"), ihdr...)
|
||||||
|
buf.Write(chunk)
|
||||||
|
binary.Write(&buf, binary.BigEndian, crc32.ChecksumIEEE(chunk))
|
||||||
|
return buf.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNormalizeRejectsPixelBomb is the guard the review found untested: a small
|
||||||
|
// input (a bare ~40-byte PNG header) claiming an enormous canvas is refused with
|
||||||
|
// ErrTooLarge from DecodeConfig alone, before image.Decode allocates anything.
|
||||||
|
// Covers both the per-side maxDimension trip and the pixel-count trip — and, via
|
||||||
|
// the near-2^16-per-side case, that the count math doesn't overflow.
|
||||||
|
func TestNormalizeRejectsPixelBomb(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
w, h uint32
|
||||||
|
}{
|
||||||
|
{"huge single side", 60000, 10}, // > maxDimension on width
|
||||||
|
{"huge area within side cap", 40000, 40000}, // sides < cap, area 1.6 GP > maxDecodePixels
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
hdr := pngHeader(tc.w, tc.h)
|
||||||
|
if len(hdr) > 100 {
|
||||||
|
t.Fatalf("header unexpectedly large (%d bytes) — not a bomb test", len(hdr))
|
||||||
|
}
|
||||||
|
// Sanity: the header really does decode to those dimensions.
|
||||||
|
cfg, _, err := image.DecodeConfig(bytes.NewReader(hdr))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("crafted PNG header didn't parse: %v", err)
|
||||||
|
}
|
||||||
|
if uint32(cfg.Width) != tc.w || uint32(cfg.Height) != tc.h {
|
||||||
|
t.Fatalf("header reports %dx%d, want %dx%d", cfg.Width, cfg.Height, tc.w, tc.h)
|
||||||
|
}
|
||||||
|
if _, _, err := Normalize(bytes.NewReader(hdr), Options{}); err != ErrTooLarge {
|
||||||
|
t.Errorf("pixel bomb %dx%d err = %v, want ErrTooLarge", tc.w, tc.h, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# imagenorm test fixtures
|
||||||
|
|
||||||
|
Go has no encoder for HEIC or WebP, so these small samples are committed rather
|
||||||
|
than generated at test time. They are used by `TestNormalizeAllFormats` to prove
|
||||||
|
every accepted format round-trips to JPEG (and, mainly, to catch a dropped blank
|
||||||
|
import — see the package doc).
|
||||||
|
|
||||||
|
- `sample.heic` — a 240×160 gradient, created from a Go-generated PNG with macOS
|
||||||
|
`sips -s format heic`. HEVC still-image profile.
|
||||||
|
- `sample.webp` — a 16×16 image copied from CPython's stdlib test corpus
|
||||||
|
(`Lib/test/test_email/data/python.webp`), verified to decode with
|
||||||
|
`golang.org/x/image/webp` before committing. Used only as a decode fixture.
|
||||||
|
|
||||||
|
Neither contains anything meaningful; they exist purely to be decoded.
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
|
After Width: | Height: | Size: 432 B |
@@ -0,0 +1,62 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Chat persistence for the garden assistant (#56). The run loop itself lives in
|
||||||
|
// internal/agent; this is the part that needs pansy's permission checks.
|
||||||
|
|
||||||
|
// maxRetainedTurns caps how much of a thread is kept in context. Retained turns
|
||||||
|
// are a working memory, not an archive — a season of chat replayed into every
|
||||||
|
// prompt would cost more than it informs.
|
||||||
|
const maxRetainedTurns = 20
|
||||||
|
|
||||||
|
// AgentHistory returns the recent messages of the actor's own thread for a
|
||||||
|
// garden they can edit, oldest first.
|
||||||
|
//
|
||||||
|
// Requires EDITOR, not viewer: the assistant acts, so being able to talk to it
|
||||||
|
// about a garden is the same permission as being able to change it. A viewer
|
||||||
|
// asking it to plant something would get a refusal from every tool anyway, and
|
||||||
|
// offering the conversation would be offering something that cannot work.
|
||||||
|
func (s *Service) AgentHistory(ctx context.Context, actorID, gardenID int64) ([]domain.AgentMessage, error) {
|
||||||
|
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conv, err := s.store.EnsureConversation(ctx, gardenID, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.store.ListAgentMessages(ctx, conv, maxRetainedTurns*2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordAgentExchange stores one user message and the assistant's reply, and
|
||||||
|
// returns the reply row. Called by the runtime after a turn completes.
|
||||||
|
func (s *Service) RecordAgentExchange(ctx context.Context, actorID, gardenID int64, userMessage, reply string, changeSetID *int64) (*domain.AgentMessage, error) {
|
||||||
|
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
conv, err := s.store.EnsureConversation(ctx, gardenID, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if _, err := s.store.AppendAgentMessage(ctx, &domain.AgentMessage{
|
||||||
|
ConversationID: conv, Role: domain.AgentRoleUser, Body: userMessage,
|
||||||
|
}); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.store.AppendAgentMessage(ctx, &domain.AgentMessage{
|
||||||
|
ConversationID: conv, Role: domain.AgentRoleAssistant, Body: reply, ChangeSetID: changeSetID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAgentHistory drops the actor's thread for a garden — the "start over"
|
||||||
|
// escape hatch when a conversation has gone somewhere unhelpful.
|
||||||
|
func (s *Service) ClearAgentHistory(ctx context.Context, actorID, gardenID int64) error {
|
||||||
|
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.store.DeleteConversation(ctx, gardenID, actorID)
|
||||||
|
}
|
||||||
@@ -293,7 +293,7 @@ func TestCopyGardenDuplicatesContents(t *testing.T) {
|
|||||||
t.Errorf("settings not carried over: %+v vs source %+v", dup, src)
|
t.Errorf("settings not carried over: %+v vs source %+v", dup, src)
|
||||||
}
|
}
|
||||||
|
|
||||||
full, err := s.GardenFull(ctx, owner, dup.ID)
|
full, err := s.GardenFull(ctx, owner, dup.ID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GardenFull(copy): %v", err)
|
t.Fatalf("GardenFull(copy): %v", err)
|
||||||
}
|
}
|
||||||
@@ -328,7 +328,7 @@ func TestCopyGardenDuplicatesContents(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// The source is untouched by the copy.
|
// The source is untouched by the copy.
|
||||||
srcFull, err := s.GardenFull(ctx, owner, src.ID)
|
srcFull, err := s.GardenFull(ctx, owner, src.ID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GardenFull(source): %v", err)
|
t.Fatalf("GardenFull(source): %v", err)
|
||||||
}
|
}
|
||||||
@@ -365,7 +365,7 @@ func TestCopyGardenSkipsRemovedPlantings(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CopyGarden: %v", err)
|
t.Fatalf("CopyGarden: %v", err)
|
||||||
}
|
}
|
||||||
full, err := s.GardenFull(ctx, owner, dup.ID)
|
full, err := s.GardenFull(ctx, owner, dup.ID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GardenFull: %v", err)
|
t.Fatalf("GardenFull: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// The grow journal (#52): what happened, and when. Distinct from the mutable
|
||||||
|
// `notes` on a garden/object/plant, which says what the thing IS — a new note
|
||||||
|
// overwrites the old one, while entries accumulate across a season.
|
||||||
|
//
|
||||||
|
// Deliberately NOT wired into the revision history (#48). Entries are already
|
||||||
|
// append-shaped and individually versioned, and undoing a note is just deleting
|
||||||
|
// it; running them through change sets would add a layer that buys nothing.
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxJournalBodyLen = 10_000
|
||||||
|
maxJournalPageSize = 200
|
||||||
|
defaultJournalPageSize = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
// JournalInput is the payload for writing an entry. ObjectID/PlantingID are
|
||||||
|
// optional and narrow the target; ObservedAt defaults to today.
|
||||||
|
type JournalInput struct {
|
||||||
|
ObjectID *int64
|
||||||
|
PlantingID *int64
|
||||||
|
Body string
|
||||||
|
ObservedAt *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalPatch is a partial update. Only the text and the date it describes are
|
||||||
|
// editable — see UpdateJournalEntry.
|
||||||
|
type JournalPatch struct {
|
||||||
|
Body *string
|
||||||
|
ObservedAt *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalQuery narrows a garden's journal for reading.
|
||||||
|
type JournalQuery struct {
|
||||||
|
ObjectID *int64
|
||||||
|
PlantingID *int64
|
||||||
|
From *string
|
||||||
|
To *string
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListJournal returns a garden's entries, most recently observed first, for an
|
||||||
|
// actor who can at least view the garden.
|
||||||
|
func (s *Service) ListJournal(ctx context.Context, actorID, gardenID int64, q JournalQuery) ([]domain.JournalEntry, bool, error) {
|
||||||
|
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if !validDatePtr(q.From) || !validDatePtr(q.To) {
|
||||||
|
return nil, false, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
limit := q.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultJournalPageSize
|
||||||
|
}
|
||||||
|
if limit > maxJournalPageSize {
|
||||||
|
limit = maxJournalPageSize
|
||||||
|
}
|
||||||
|
offset := q.Offset
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
// One row past the page answers hasMore without a second COUNT (same shape as
|
||||||
|
// GardenHistory).
|
||||||
|
entries, err := s.store.ListJournalEntries(ctx, gardenID, store.JournalFilter{
|
||||||
|
ObjectID: q.ObjectID, PlantingID: q.PlantingID, From: q.From, To: q.To,
|
||||||
|
Limit: limit + 1, Offset: offset,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if len(entries) > limit {
|
||||||
|
return entries[:limit], true, nil
|
||||||
|
}
|
||||||
|
return entries, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalCounts returns how many entries a garden holds per object (key 0 for
|
||||||
|
// entries about the garden itself), for an actor who can at least view it.
|
||||||
|
func (s *Service) JournalCounts(ctx context.Context, actorID, gardenID int64) (map[int64]int, error) {
|
||||||
|
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.store.JournalCounts(ctx, gardenID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateJournalEntry writes an entry against a garden the actor can edit.
|
||||||
|
//
|
||||||
|
// An entry may target the garden, one of its beds, or one plop in it — and the
|
||||||
|
// target is checked to actually belong to this garden. Without that, a valid
|
||||||
|
// object id from someone else's garden would be accepted and then leak through
|
||||||
|
// the list read, since the garden anchors the permission check.
|
||||||
|
func (s *Service) CreateJournalEntry(ctx context.Context, actorID, gardenID int64, in JournalInput) (*domain.JournalEntry, error) {
|
||||||
|
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
objectID, err := s.resolveJournalTarget(ctx, gardenID, in.ObjectID, in.PlantingID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
e := &domain.JournalEntry{
|
||||||
|
GardenID: gardenID,
|
||||||
|
ObjectID: objectID,
|
||||||
|
PlantingID: in.PlantingID,
|
||||||
|
AuthorID: actorID,
|
||||||
|
Body: strings.TrimSpace(in.Body),
|
||||||
|
}
|
||||||
|
if in.ObservedAt != nil {
|
||||||
|
e.ObservedAt = *in.ObservedAt
|
||||||
|
} else {
|
||||||
|
e.ObservedAt = s.now().UTC().Format(dateLayout)
|
||||||
|
}
|
||||||
|
if err := finalizeJournalEntry(e); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s.store.CreateJournalEntry(ctx, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveJournalTarget validates an entry's target and returns the object id to
|
||||||
|
// store against it.
|
||||||
|
//
|
||||||
|
// A plop-level entry gets its parent object filled in even when the caller
|
||||||
|
// didn't name one. Without that, "notes about this bed" would silently exclude
|
||||||
|
// every note written about a plant IN the bed — the bed filter and the plop
|
||||||
|
// filter would disagree about what an entry is about, which is exactly the
|
||||||
|
// question the reader is asking.
|
||||||
|
//
|
||||||
|
// A store failure that isn't "no such row" is passed through rather than
|
||||||
|
// flattened to ErrInvalidInput: a database problem is not the caller's bad
|
||||||
|
// request, and mapping it to a 400 would hide it from the logs entirely.
|
||||||
|
func (s *Service) resolveJournalTarget(ctx context.Context, gardenID int64, objectID, plantingID *int64) (*int64, error) {
|
||||||
|
if objectID != nil {
|
||||||
|
o, err := s.store.GetObject(ctx, *objectID)
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if o.GardenID != gardenID {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if plantingID == nil {
|
||||||
|
return objectID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
pl, err := s.store.GetPlanting(ctx, *plantingID)
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
o, err := s.store.GetObject(ctx, pl.ObjectID)
|
||||||
|
if errors.Is(err, domain.ErrNotFound) {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if o.GardenID != gardenID {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
// A plop-level entry that ALSO names an object must name the right one.
|
||||||
|
if objectID != nil && *objectID != pl.ObjectID {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return &pl.ObjectID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// journalEntryForWrite loads an entry and enforces who may change it: the author
|
||||||
|
// may edit their own, and the garden's OWNER may delete any — the same shape as
|
||||||
|
// sharing, where the owner is the backstop for content in their garden. A
|
||||||
|
// non-editor of the garden can't reach it at all, and an entry in a garden the
|
||||||
|
// actor can't see is ErrNotFound (existence masked, as everywhere else).
|
||||||
|
func (s *Service) journalEntryForWrite(ctx context.Context, actorID, entryID int64, ownerMayAct bool) (*domain.JournalEntry, error) {
|
||||||
|
e, err := s.store.GetJournalEntry(ctx, entryID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err // ErrNotFound
|
||||||
|
}
|
||||||
|
g, err := s.requireGardenRole(ctx, actorID, e.GardenID, roleEditor)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if e.AuthorID == actorID {
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
if ownerMayAct && g.OwnerID == actorID {
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
// Visible (they can edit this garden) but not theirs to change.
|
||||||
|
return nil, domain.ErrForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateJournalEntry edits the text or the observed date of the actor's OWN
|
||||||
|
// entry. Deliberately author-only, including for the garden owner: rewriting
|
||||||
|
// somebody else's observation under their name is a different thing from
|
||||||
|
// removing it.
|
||||||
|
func (s *Service) UpdateJournalEntry(ctx context.Context, actorID, entryID int64, patch JournalPatch, version int64) (*domain.JournalEntry, error) {
|
||||||
|
e, err := s.journalEntryForWrite(ctx, actorID, entryID, false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if patch.Body != nil {
|
||||||
|
e.Body = strings.TrimSpace(*patch.Body)
|
||||||
|
}
|
||||||
|
if patch.ObservedAt != nil {
|
||||||
|
e.ObservedAt = *patch.ObservedAt
|
||||||
|
}
|
||||||
|
if err := finalizeJournalEntry(e); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
e.Version = version
|
||||||
|
return s.store.UpdateJournalEntry(ctx, e)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteJournalEntry removes an entry. The author may delete their own; the
|
||||||
|
// garden's owner may delete any entry in their garden.
|
||||||
|
func (s *Service) DeleteJournalEntry(ctx context.Context, actorID, entryID int64) error {
|
||||||
|
e, err := s.journalEntryForWrite(ctx, actorID, entryID, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.store.DeleteJournalEntry(ctx, e.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// finalizeJournalEntry validates a built/merged entry. Shared by create and
|
||||||
|
// update so both enforce the same invariants.
|
||||||
|
func finalizeJournalEntry(e *domain.JournalEntry) error {
|
||||||
|
if e.Body == "" || len(e.Body) > maxJournalBodyLen {
|
||||||
|
return domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
if !validDatePtr(&e.ObservedAt) {
|
||||||
|
return domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,325 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func writeEntry(t *testing.T, s *Service, actor, gardenID int64, in JournalInput) *domain.JournalEntry {
|
||||||
|
t.Helper()
|
||||||
|
e, err := s.CreateJournalEntry(context.Background(), actor, gardenID, in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateJournalEntry: %v", err)
|
||||||
|
}
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func listJournal(t *testing.T, s *Service, actor, gardenID int64, q JournalQuery) []domain.JournalEntry {
|
||||||
|
t.Helper()
|
||||||
|
entries, _, err := s.ListJournal(context.Background(), actor, gardenID, q)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListJournal: %v", err)
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntriesAttachToGardenBedOrPlanting — the three targets, each listing under
|
||||||
|
// itself and under the garden that anchors it.
|
||||||
|
func TestEntriesAttachToGardenBedOrPlanting(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()
|
||||||
|
|
||||||
|
plop, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
||||||
|
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePlanting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gardenEntry := writeEntry(t, s, owner, g.ID, JournalInput{Body: "First frost tonight"})
|
||||||
|
bedEntry := writeEntry(t, s, owner, g.ID, JournalInput{ObjectID: &bed.ID, Body: "Powdery mildew on the west bed"})
|
||||||
|
plopEntry := writeEntry(t, s, owner, g.ID, JournalInput{
|
||||||
|
ObjectID: &bed.ID, PlantingID: &plop.ID, Body: "Scapes forming",
|
||||||
|
})
|
||||||
|
|
||||||
|
// The garden sees all three: it anchors every entry.
|
||||||
|
if all := listJournal(t, s, owner, g.ID, JournalQuery{}); len(all) != 3 {
|
||||||
|
t.Errorf("garden journal has %d entries, want 3", len(all))
|
||||||
|
}
|
||||||
|
byBed := listJournal(t, s, owner, g.ID, JournalQuery{ObjectID: &bed.ID})
|
||||||
|
if len(byBed) != 2 {
|
||||||
|
t.Errorf("bed journal has %d entries, want 2 (the bed's and the plop's)", len(byBed))
|
||||||
|
}
|
||||||
|
byPlop := listJournal(t, s, owner, g.ID, JournalQuery{PlantingID: &plop.ID})
|
||||||
|
if len(byPlop) != 1 || byPlop[0].ID != plopEntry.ID {
|
||||||
|
t.Errorf("plop journal = %+v, want just the plop entry", byPlop)
|
||||||
|
}
|
||||||
|
if gardenEntry.ObjectID != nil || bedEntry.PlantingID != nil {
|
||||||
|
t.Error("targets leaked between entries")
|
||||||
|
}
|
||||||
|
if gardenEntry.AuthorID != owner {
|
||||||
|
t.Error("author not recorded")
|
||||||
|
}
|
||||||
|
// The author's name is resolved for display without a lookup per row.
|
||||||
|
if all := listJournal(t, s, owner, g.ID, JournalQuery{}); all[0].AuthorName == "" {
|
||||||
|
t.Error("author name not resolved on the list read")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDeletingABedRemovesItsEntriesOnly — the cascade is scoped: losing a bed
|
||||||
|
// must not take the garden's own season notes with it.
|
||||||
|
func TestDeletingABedRemovesItsEntriesOnly(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)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
writeEntry(t, s, owner, g.ID, JournalInput{Body: "Garden-level note"})
|
||||||
|
writeEntry(t, s, owner, g.ID, JournalInput{ObjectID: &bed.ID, Body: "Bed-level note"})
|
||||||
|
|
||||||
|
if err := s.DeleteObject(ctx, owner, bed.ID); err != nil {
|
||||||
|
t.Fatalf("DeleteObject: %v", err)
|
||||||
|
}
|
||||||
|
left := listJournal(t, s, owner, g.ID, JournalQuery{})
|
||||||
|
if len(left) != 1 || left[0].Body != "Garden-level note" {
|
||||||
|
t.Errorf("after deleting the bed the journal is %+v, want just the garden note", left)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackdatedEntriesOrderByObservationNotWriting — you write up Saturday's
|
||||||
|
// observations on Sunday, and Saturday is the date that matters.
|
||||||
|
func TestBackdatedEntriesOrderByObservationNotWriting(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
g := seedGarden(t, s, owner)
|
||||||
|
|
||||||
|
// Written second, observed first.
|
||||||
|
writeEntry(t, s, owner, g.ID, JournalInput{Body: "Sunday", ObservedAt: strPtr("2026-06-14")})
|
||||||
|
writeEntry(t, s, owner, g.ID, JournalInput{Body: "Saturday", ObservedAt: strPtr("2026-06-13")})
|
||||||
|
writeEntry(t, s, owner, g.ID, JournalInput{Body: "Monday", ObservedAt: strPtr("2026-06-15")})
|
||||||
|
|
||||||
|
got := listJournal(t, s, owner, g.ID, JournalQuery{})
|
||||||
|
want := []string{"Monday", "Sunday", "Saturday"}
|
||||||
|
for i, w := range want {
|
||||||
|
if got[i].Body != w {
|
||||||
|
t.Errorf("position %d = %q, want %q (ordered by observation, newest first)", i, got[i].Body, w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A date range filters on observation too.
|
||||||
|
from, to := "2026-06-14", "2026-06-14"
|
||||||
|
only := listJournal(t, s, owner, g.ID, JournalQuery{From: &from, To: &to})
|
||||||
|
if len(only) != 1 || only[0].Body != "Sunday" {
|
||||||
|
t.Errorf("date range returned %+v, want just Sunday", only)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestObservedAtDefaultsToToday
|
||||||
|
func TestObservedAtDefaultsToToday(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
g := seedGarden(t, s, owner)
|
||||||
|
|
||||||
|
e := writeEntry(t, s, owner, g.ID, JournalInput{Body: "No date given"})
|
||||||
|
if e.ObservedAt != s.now().UTC().Format(dateLayout) {
|
||||||
|
t.Errorf("observedAt = %q, want today", e.ObservedAt)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateJournalEntry(context.Background(), owner, g.ID, JournalInput{
|
||||||
|
Body: "Bad date", ObservedAt: strPtr("14/06/2026"),
|
||||||
|
}); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
t.Errorf("malformed observedAt accepted: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateJournalEntry(context.Background(), owner, g.ID, JournalInput{Body: " "}); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
t.Errorf("empty body accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalPermissions — viewer reads and cannot write; stranger sees nothing;
|
||||||
|
// an author owns their own text, and the garden owner can remove anything.
|
||||||
|
func TestJournalPermissions(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
editor := seedUser(t, s, "[email protected]")
|
||||||
|
viewer := seedUser(t, s, "[email protected]")
|
||||||
|
stranger := seedUser(t, s, "[email protected]")
|
||||||
|
g := seedGarden(t, s, owner)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleEditor); err != nil {
|
||||||
|
t.Fatalf("AddShare editor: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
|
||||||
|
t.Fatalf("AddShare viewer: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
byEditor := writeEntry(t, s, editor, g.ID, JournalInput{Body: "Editor's observation"})
|
||||||
|
|
||||||
|
// Viewer: reads, cannot write.
|
||||||
|
if entries := listJournal(t, s, viewer, g.ID, JournalQuery{}); len(entries) != 1 {
|
||||||
|
t.Errorf("viewer sees %d entries, want 1", len(entries))
|
||||||
|
}
|
||||||
|
if _, err := s.CreateJournalEntry(ctx, viewer, g.ID, JournalInput{Body: "nope"}); !errors.Is(err, domain.ErrForbidden) {
|
||||||
|
t.Errorf("viewer write err = %v, want ErrForbidden", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stranger: existence stays masked.
|
||||||
|
if _, _, err := s.ListJournal(ctx, stranger, g.ID, JournalQuery{}); !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
t.Errorf("stranger read err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
if err := s.DeleteJournalEntry(ctx, stranger, byEditor.ID); !errors.Is(err, domain.ErrNotFound) {
|
||||||
|
t.Errorf("stranger delete err = %v, want ErrNotFound", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The author edits their own.
|
||||||
|
updated, err := s.UpdateJournalEntry(ctx, editor, byEditor.ID, JournalPatch{Body: strPtr("Revised")}, byEditor.Version)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("author edit: %v", err)
|
||||||
|
}
|
||||||
|
if updated.Body != "Revised" {
|
||||||
|
t.Errorf("body = %q", updated.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The garden OWNER may not rewrite somebody else's observation under their
|
||||||
|
// name — that's a different act from removing it.
|
||||||
|
if _, err := s.UpdateJournalEntry(ctx, owner, byEditor.ID, JournalPatch{Body: strPtr("Owner rewrote this")}, updated.Version); !errors.Is(err, domain.ErrForbidden) {
|
||||||
|
t.Errorf("owner edit of another's entry err = %v, want ErrForbidden", err)
|
||||||
|
}
|
||||||
|
// But may delete it: the owner is the backstop for content in their garden.
|
||||||
|
if err := s.DeleteJournalEntry(ctx, owner, byEditor.ID); err != nil {
|
||||||
|
t.Errorf("owner delete of another's entry: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestEntryTargetMustBelongToTheGarden — the garden anchors permission, so an
|
||||||
|
// object id from somebody else's garden must not be storable against this one.
|
||||||
|
func TestEntryTargetMustBelongToTheGarden(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
other := seedUser(t, s, "[email protected]")
|
||||||
|
mine := seedGarden(t, s, owner)
|
||||||
|
theirs := seedGarden(t, s, other)
|
||||||
|
theirBed := seedBed(t, s, other, theirs.ID)
|
||||||
|
myBed := seedBed(t, s, owner, mine.ID)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := s.CreateJournalEntry(ctx, owner, mine.ID, JournalInput{
|
||||||
|
ObjectID: &theirBed.ID, Body: "about someone else's bed",
|
||||||
|
}); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
t.Errorf("foreign object accepted: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plop-level entry naming the wrong object is refused too, or the object
|
||||||
|
// and planting filters would disagree about what an entry is about.
|
||||||
|
plant := seedOwnPlant(t, s, owner, 15)
|
||||||
|
otherBed, err := s.CreateObject(ctx, owner, mine.ID, ObjectInput{
|
||||||
|
Kind: domain.KindBed, Name: "Other", XCM: 200, YCM: 200, WidthCM: 100, HeightCM: 100,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateObject: %v", err)
|
||||||
|
}
|
||||||
|
plop, err := s.CreatePlanting(ctx, owner, myBed.ID, PlantingInput{
|
||||||
|
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePlanting: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreateJournalEntry(ctx, owner, mine.ID, JournalInput{
|
||||||
|
ObjectID: &otherBed.ID, PlantingID: &plop.ID, Body: "mismatched",
|
||||||
|
}); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
t.Errorf("mismatched object/planting pair accepted: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalVersionGuardAndPaging
|
||||||
|
func TestJournalVersionGuardAndPaging(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
g := seedGarden(t, s, owner)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
e := writeEntry(t, s, owner, g.ID, JournalInput{Body: "One"})
|
||||||
|
if _, err := s.UpdateJournalEntry(ctx, owner, e.ID, JournalPatch{Body: strPtr("Two")}, e.Version); err != nil {
|
||||||
|
t.Fatalf("first update: %v", err)
|
||||||
|
}
|
||||||
|
current, err := s.UpdateJournalEntry(ctx, owner, e.ID, JournalPatch{Body: strPtr("Three")}, e.Version)
|
||||||
|
if !errors.Is(err, domain.ErrVersionConflict) {
|
||||||
|
t.Fatalf("stale update err = %v, want ErrVersionConflict", err)
|
||||||
|
}
|
||||||
|
if current == nil || current.Body != "Two" {
|
||||||
|
t.Errorf("conflict should carry the current row, got %+v", current)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
writeEntry(t, s, owner, g.ID, JournalInput{Body: "filler"})
|
||||||
|
}
|
||||||
|
page, hasMore, err := s.ListJournal(ctx, owner, g.ID, JournalQuery{Limit: 2})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListJournal: %v", err)
|
||||||
|
}
|
||||||
|
if len(page) != 2 || !hasMore {
|
||||||
|
t.Errorf("page = %d entries, hasMore = %v; want 2 and true", len(page), hasMore)
|
||||||
|
}
|
||||||
|
last, hasMore, err := s.ListJournal(ctx, owner, g.ID, JournalQuery{Limit: 50, Offset: 0})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListJournal: %v", err)
|
||||||
|
}
|
||||||
|
if len(last) != 6 || hasMore {
|
||||||
|
t.Errorf("full page = %d entries, hasMore = %v; want 6 and false", len(last), hasMore)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestJournalStaysOutOfRevisionHistory — decided deliberately (#52): entries are
|
||||||
|
// append-shaped and individually versioned, and undoing a note is deleting it.
|
||||||
|
func TestJournalStaysOutOfRevisionHistory(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
g := seedGarden(t, s, owner)
|
||||||
|
|
||||||
|
before := len(history(t, s, owner, g.ID))
|
||||||
|
e := writeEntry(t, s, owner, g.ID, JournalInput{Body: "An observation"})
|
||||||
|
if _, err := s.UpdateJournalEntry(context.Background(), owner, e.ID, JournalPatch{Body: strPtr("Revised")}, e.Version); err != nil {
|
||||||
|
t.Fatalf("update: %v", err)
|
||||||
|
}
|
||||||
|
if got := len(history(t, s, owner, g.ID)); got != before {
|
||||||
|
t.Errorf("journal writes produced %d change sets; they should produce none", got-before)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPlopEntryIsVisibleUnderItsBed — an entry about a plant IN a bed is an entry
|
||||||
|
// about that bed. Without deriving the parent object, "notes about this bed"
|
||||||
|
// would silently exclude every note written about something growing in it.
|
||||||
|
func TestPlopEntryIsVisibleUnderItsBed(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()
|
||||||
|
|
||||||
|
plop, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
||||||
|
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePlanting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the plop is named — no objectId.
|
||||||
|
e := writeEntry(t, s, owner, g.ID, JournalInput{PlantingID: &plop.ID, Body: "Scapes forming"})
|
||||||
|
if e.ObjectID == nil || *e.ObjectID != bed.ID {
|
||||||
|
t.Fatalf("objectId = %v, want the plop's parent bed %d", e.ObjectID, bed.ID)
|
||||||
|
}
|
||||||
|
if byBed := listJournal(t, s, owner, g.ID, JournalQuery{ObjectID: &bed.ID}); len(byBed) != 1 {
|
||||||
|
t.Errorf("the bed filter found %d entries, want the plop's", len(byBed))
|
||||||
|
}
|
||||||
|
if byPlop := listJournal(t, s, owner, g.ID, JournalQuery{PlantingID: &plop.ID}); len(byPlop) != 1 {
|
||||||
|
t.Errorf("the plop filter found %d entries, want 1", len(byPlop))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
@@ -197,28 +198,80 @@ func (s *Service) DeleteObject(ctx context.Context, actorID, objectID int64) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GardenFull returns the whole editor payload for a garden the actor can view.
|
// GardenFull returns the whole editor payload for a garden the actor can view.
|
||||||
func (s *Service) GardenFull(ctx context.Context, actorID, gardenID int64) (*FullGarden, error) {
|
// year nil means "what's planted now"; a year means the season view (#54).
|
||||||
|
func (s *Service) GardenFull(ctx context.Context, actorID, gardenID int64, year *int) (*FullGarden, error) {
|
||||||
g, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer)
|
g, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return s.assembleFull(ctx, g)
|
if year != nil && (*year < minSeasonYear || *year > maxSeasonYear) {
|
||||||
|
return nil, domain.ErrInvalidInput
|
||||||
|
}
|
||||||
|
return s.assembleFullFor(ctx, g, year)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seasons are bounded to keep a typo'd year from producing a confidently empty
|
||||||
|
// garden; the range is wide enough to cover any real record.
|
||||||
|
const (
|
||||||
|
minSeasonYear = 1900
|
||||||
|
maxSeasonYear = 2200
|
||||||
|
)
|
||||||
|
|
||||||
|
// GardenYears lists the calendar years a garden has planting data for, newest
|
||||||
|
// first, always including the current one.
|
||||||
|
//
|
||||||
|
// This exists so the year selector can offer only years that actually hold
|
||||||
|
// something. A free numeric field invites a typo and an empty view that looks
|
||||||
|
// like data loss rather than a mistake.
|
||||||
|
func (s *Service) GardenYears(ctx context.Context, actorID, gardenID int64) ([]int, error) {
|
||||||
|
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
years, err := s.store.GardenPlantingYears(ctx, gardenID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
current := s.now().UTC().Year()
|
||||||
|
for _, y := range years {
|
||||||
|
if y == current {
|
||||||
|
return years, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Newest first, and this year is newest unless the data runs into the future.
|
||||||
|
out := append([]int{current}, years...)
|
||||||
|
sort.Sort(sort.Reverse(sort.IntSlice(out)))
|
||||||
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// assembleFull builds the read-only /full payload for an already-authorized
|
// assembleFull builds the read-only /full payload for an already-authorized
|
||||||
// garden: its objects, active plops (with DerivedCount filled in), and the
|
// garden as it stands NOW. Shared by the unauthenticated PublicGarden read,
|
||||||
// plants those plops reference. Shared by the authenticated GardenFull and the
|
// which never offers a season view.
|
||||||
// unauthenticated PublicGarden read, so both return the identical shape.
|
|
||||||
func (s *Service) assembleFull(ctx context.Context, g *domain.Garden) (*FullGarden, error) {
|
func (s *Service) assembleFull(ctx context.Context, g *domain.Garden) (*FullGarden, error) {
|
||||||
|
return s.assembleFullFor(ctx, g, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// assembleFullFor builds the /full payload: objects, plops (with DerivedCount
|
||||||
|
// filled in), and the plants those plops reference.
|
||||||
|
//
|
||||||
|
// year nil is today's behaviour — active plops only. A year widens it to every
|
||||||
|
// plop whose time in the ground overlapped that year, which necessarily includes
|
||||||
|
// plops since removed, so the referenced-plant lookup has to widen with it or
|
||||||
|
// those plops would render with no icon and no colour.
|
||||||
|
func (s *Service) assembleFullFor(ctx context.Context, g *domain.Garden, year *int) (*FullGarden, error) {
|
||||||
objects, err := s.store.ListObjectsForGarden(ctx, g.ID)
|
objects, err := s.store.ListObjectsForGarden(ctx, g.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
plantings, err := s.store.ListActivePlantingsForGarden(ctx, g.ID)
|
var plantings []domain.Planting
|
||||||
|
if year != nil {
|
||||||
|
plantings, err = s.store.ListPlantingsForGardenYear(ctx, g.ID, *year)
|
||||||
|
} else {
|
||||||
|
plantings, err = s.store.ListActivePlantingsForGarden(ctx, g.ID)
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
plants, err := s.store.ListReferencedPlants(ctx, g.ID)
|
plants, err := s.store.ListReferencedPlants(ctx, g.ID, year)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ func TestObjectCrossUserIsNotFound(t *testing.T) {
|
|||||||
if err := s.DeleteObject(context.Background(), bob, o.ID); !errors.Is(err, domain.ErrNotFound) {
|
if err := s.DeleteObject(context.Background(), bob, o.ID); !errors.Is(err, domain.ErrNotFound) {
|
||||||
t.Errorf("bob delete err = %v, want ErrNotFound", err)
|
t.Errorf("bob delete err = %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
if _, err := s.GardenFull(context.Background(), bob, g.ID); !errors.Is(err, domain.ErrNotFound) {
|
if _, err := s.GardenFull(context.Background(), bob, g.ID, nil); !errors.Is(err, domain.ErrNotFound) {
|
||||||
t.Errorf("bob /full err = %v, want ErrNotFound", err)
|
t.Errorf("bob /full err = %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +236,7 @@ func TestDeleteObject(t *testing.T) {
|
|||||||
if err := s.DeleteObject(context.Background(), owner, o.ID); err != nil {
|
if err := s.DeleteObject(context.Background(), owner, o.ID); err != nil {
|
||||||
t.Fatalf("DeleteObject: %v", err)
|
t.Fatalf("DeleteObject: %v", err)
|
||||||
}
|
}
|
||||||
full, err := s.GardenFull(context.Background(), owner, g.ID)
|
full, err := s.GardenFull(context.Background(), owner, g.ID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GardenFull: %v", err)
|
t.Fatalf("GardenFull: %v", err)
|
||||||
}
|
}
|
||||||
@@ -252,7 +252,7 @@ func TestGardenFullShape(t *testing.T) {
|
|||||||
s.CreateObject(context.Background(), owner, g.ID, ObjectInput{Kind: domain.KindBed, XCM: 300, YCM: 300, WidthCM: 100, HeightCM: 100})
|
s.CreateObject(context.Background(), owner, g.ID, ObjectInput{Kind: domain.KindBed, XCM: 300, YCM: 300, WidthCM: 100, HeightCM: 100})
|
||||||
s.CreateObject(context.Background(), owner, g.ID, ObjectInput{Kind: domain.KindContainer, Shape: domain.ShapeCircle, XCM: 600, YCM: 600, WidthCM: 60, HeightCM: 60})
|
s.CreateObject(context.Background(), owner, g.ID, ObjectInput{Kind: domain.KindContainer, Shape: domain.ShapeCircle, XCM: 600, YCM: 600, WidthCM: 60, HeightCM: 60})
|
||||||
|
|
||||||
full, err := s.GardenFull(context.Background(), owner, g.ID)
|
full, err := s.GardenFull(context.Background(), owner, g.ID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GardenFull: %v", err)
|
t.Fatalf("GardenFull: %v", err)
|
||||||
}
|
}
|
||||||
@@ -270,3 +270,221 @@ func TestGardenFullShape(t *testing.T) {
|
|||||||
t.Errorf("plants = %v, want empty non-nil", full.Plants)
|
t.Errorf("plants = %v, want empty non-nil", full.Plants)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSeasonViewIncludesRemovedAndSpanningPlantings — a season is a date range
|
||||||
|
// over data that already existed; the point is seeing what WAS there.
|
||||||
|
func TestSeasonViewIncludesRemovedAndSpanningPlantings(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()
|
||||||
|
|
||||||
|
// Planted and pulled inside 2025.
|
||||||
|
past, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
||||||
|
PlantID: plant.ID, XCM: -50, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2025-04-01"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePlanting: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.UpdatePlanting(ctx, owner, past.ID, PlantingPatch{
|
||||||
|
SetRemovedAt: true, RemovedAt: strPtr("2025-09-01"),
|
||||||
|
}, past.Version); err != nil {
|
||||||
|
t.Fatalf("remove: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garlic: in the ground October 2025, pulled July 2026. Belongs to both.
|
||||||
|
spanning, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
||||||
|
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2025-10-15"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePlanting: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.UpdatePlanting(ctx, owner, spanning.ID, PlantingPatch{
|
||||||
|
SetRemovedAt: true, RemovedAt: strPtr("2026-07-10"),
|
||||||
|
}, spanning.Version); err != nil {
|
||||||
|
t.Fatalf("remove: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Undated: predates the feature, belongs to every year.
|
||||||
|
undated, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
||||||
|
PlantID: plant.ID, XCM: 50, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2025-01-01"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePlanting: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.UpdatePlanting(ctx, owner, undated.ID, PlantingPatch{
|
||||||
|
SetPlantedAt: true, PlantedAt: nil,
|
||||||
|
}, undated.Version); err != nil {
|
||||||
|
t.Fatalf("clear planted_at: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ids := func(year int) map[int64]bool {
|
||||||
|
t.Helper()
|
||||||
|
full, err := s.GardenFull(ctx, owner, g.ID, &year)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GardenFull(%d): %v", year, err)
|
||||||
|
}
|
||||||
|
out := map[int64]bool{}
|
||||||
|
for _, p := range full.Plantings {
|
||||||
|
out[p.ID] = true
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
y2025 := ids(2025)
|
||||||
|
if !y2025[past.ID] || !y2025[spanning.ID] || !y2025[undated.ID] {
|
||||||
|
t.Errorf("2025 = %v, want all three", y2025)
|
||||||
|
}
|
||||||
|
y2026 := ids(2026)
|
||||||
|
if y2026[past.ID] {
|
||||||
|
t.Error("a planting pulled in 2025 showed up in 2026")
|
||||||
|
}
|
||||||
|
if !y2026[spanning.ID] {
|
||||||
|
t.Error("a planting spanning the year boundary is missing from 2026")
|
||||||
|
}
|
||||||
|
if !y2026[undated.ID] {
|
||||||
|
t.Error("an undated planting must appear in every year")
|
||||||
|
}
|
||||||
|
y2024 := ids(2024)
|
||||||
|
if y2024[past.ID] || y2024[spanning.ID] {
|
||||||
|
t.Errorf("2024 predates both dated plantings but returned %v", y2024)
|
||||||
|
}
|
||||||
|
if !y2024[undated.ID] {
|
||||||
|
t.Error("an undated planting must appear in every year, including 2024")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Without a year, /full is exactly what it always was: active plops only.
|
||||||
|
now, err := s.GardenFull(ctx, owner, g.ID, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GardenFull(now): %v", err)
|
||||||
|
}
|
||||||
|
if len(now.Plantings) != 1 || now.Plantings[0].ID != undated.ID {
|
||||||
|
t.Errorf("live view = %+v, want only the one still in the ground", now.Plantings)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A past season's plops must still render, so their plants come along even
|
||||||
|
// though none of them is active.
|
||||||
|
if len(ids(2025)) > 0 {
|
||||||
|
y := 2025
|
||||||
|
full, _ := s.GardenFull(ctx, owner, g.ID, &y)
|
||||||
|
if len(full.Plants) == 0 {
|
||||||
|
t.Error("season view returned plops with no plants to render them")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGardenYearsOffersOnlyYearsWithData
|
||||||
|
func TestGardenYearsOffersOnlyYearsWithData(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()
|
||||||
|
|
||||||
|
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
||||||
|
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2023-05-01"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePlanting: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.UpdatePlanting(ctx, owner, pl.ID, PlantingPatch{
|
||||||
|
SetRemovedAt: true, RemovedAt: strPtr("2024-06-01"),
|
||||||
|
}, pl.Version); err != nil {
|
||||||
|
t.Fatalf("remove: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
years, err := s.GardenYears(ctx, owner, g.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GardenYears: %v", err)
|
||||||
|
}
|
||||||
|
current := s.now().UTC().Year()
|
||||||
|
want := map[int]bool{2023: true, 2024: true, current: true}
|
||||||
|
if len(years) != len(want) {
|
||||||
|
t.Fatalf("years = %v, want exactly %v", years, want)
|
||||||
|
}
|
||||||
|
for _, y := range years {
|
||||||
|
if !want[y] {
|
||||||
|
t.Errorf("unexpected year %d in %v", y, years)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Newest first, so the selector reads in the order people scan.
|
||||||
|
for i := 1; i < len(years); i++ {
|
||||||
|
if years[i-1] < years[i] {
|
||||||
|
t.Errorf("years not newest-first: %v", years)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeasonYearIsBounded — a typo'd year should be refused, not answered with a
|
||||||
|
// confidently empty garden.
|
||||||
|
func TestSeasonYearIsBounded(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
g := seedGarden(t, s, owner)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, y := range []int{0, 202, 20260, -5} {
|
||||||
|
if _, err := s.GardenFull(ctx, owner, g.ID, &y); !errors.Is(err, domain.ErrInvalidInput) {
|
||||||
|
t.Errorf("year %d accepted (err=%v)", y, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSeasonPlantsAreScopedToTheYear — the plant list has to match the plops it
|
||||||
|
// is there to render. A garden with a decade of history shouldn't load a decade
|
||||||
|
// of catalog to draw one season, and a season shouldn't carry plants that had
|
||||||
|
// nothing in the ground that year.
|
||||||
|
func TestSeasonPlantsAreScopedToTheYear(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)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
oldPlant := seedOwnPlant(t, s, owner, 15)
|
||||||
|
newPlant, err := s.CreatePlant(ctx, owner, PlantInput{
|
||||||
|
Name: "Later", Category: domain.CategoryHerb, SpacingCM: 20, Color: "#4a7c3f", Icon: "🌱",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePlant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
plant2020, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
||||||
|
PlantID: oldPlant.ID, XCM: -40, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2020-04-01"),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePlanting: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.UpdatePlanting(ctx, owner, plant2020.ID, PlantingPatch{
|
||||||
|
SetRemovedAt: true, RemovedAt: strPtr("2020-09-01"),
|
||||||
|
}, plant2020.Version); err != nil {
|
||||||
|
t.Fatalf("remove: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
|
||||||
|
PlantID: newPlant.ID, XCM: 40, YCM: 0, RadiusCM: 20, PlantedAt: strPtr("2026-04-01"),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreatePlanting: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
names := func(year int) []string {
|
||||||
|
t.Helper()
|
||||||
|
full, err := s.GardenFull(ctx, owner, g.ID, &year)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GardenFull(%d): %v", year, err)
|
||||||
|
}
|
||||||
|
out := []string{}
|
||||||
|
for _, p := range full.Plants {
|
||||||
|
out = append(out, p.Name)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
if got := names(2020); len(got) != 1 || got[0] != oldPlant.Name {
|
||||||
|
t.Errorf("2020 plants = %v, want just %q", got, oldPlant.Name)
|
||||||
|
}
|
||||||
|
if got := names(2026); len(got) != 1 || got[0] != "Later" {
|
||||||
|
t.Errorf("2026 plants = %v, want just \"Later\"", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+133
-33
@@ -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
|
||||||
@@ -318,7 +418,7 @@ type DescribePlanting struct {
|
|||||||
// object's active plantings (plant, effective count, rough location) — for a
|
// object's active plantings (plant, effective count, rough location) — for a
|
||||||
// garden the actor can view. Built on GardenFull so it inherits the ACL check.
|
// garden the actor can view. Built on GardenFull so it inherits the ACL check.
|
||||||
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) {
|
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) {
|
||||||
full, err := s.GardenFull(ctx, actorID, gardenID)
|
full, err := s.GardenFull(ctx, actorID, gardenID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,7 +338,7 @@ func TestClearObject(t *testing.T) {
|
|||||||
if n < 1 {
|
if n < 1 {
|
||||||
t.Fatalf("cleared %d, want ≥ 1", n)
|
t.Fatalf("cleared %d, want ≥ 1", n)
|
||||||
}
|
}
|
||||||
full, _ := s.GardenFull(ctx, owner, g.ID)
|
full, _ := s.GardenFull(ctx, owner, g.ID, nil)
|
||||||
if len(full.Plantings) != 0 {
|
if len(full.Plantings) != 0 {
|
||||||
t.Errorf("plantings after clear = %d, want 0", len(full.Plantings))
|
t.Errorf("plantings after clear = %d, want 0", len(full.Plantings))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -227,7 +227,7 @@ func TestPlantingSoftRemoveLeavesFull(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// It shows in /full, with a derived count.
|
// It shows in /full, with a derived count.
|
||||||
full, _ := s.GardenFull(context.Background(), owner, g.ID)
|
full, _ := s.GardenFull(context.Background(), owner, g.ID, nil)
|
||||||
if len(full.Plantings) != 1 {
|
if len(full.Plantings) != 1 {
|
||||||
t.Fatalf("full.Plantings = %d, want 1", len(full.Plantings))
|
t.Fatalf("full.Plantings = %d, want 1", len(full.Plantings))
|
||||||
}
|
}
|
||||||
@@ -244,7 +244,7 @@ func TestPlantingSoftRemoveLeavesFull(t *testing.T) {
|
|||||||
PlantingPatch{SetRemovedAt: true, RemovedAt: &rm}, pl.Version); err != nil {
|
PlantingPatch{SetRemovedAt: true, RemovedAt: &rm}, pl.Version); err != nil {
|
||||||
t.Fatalf("soft-remove: %v", err)
|
t.Fatalf("soft-remove: %v", err)
|
||||||
}
|
}
|
||||||
full, _ = s.GardenFull(context.Background(), owner, g.ID)
|
full, _ = s.GardenFull(context.Background(), owner, g.ID, nil)
|
||||||
if len(full.Plantings) != 0 {
|
if len(full.Plantings) != 0 {
|
||||||
t.Errorf("removed plop still in /full: %d", len(full.Plantings))
|
t.Errorf("removed plop still in /full: %d", len(full.Plantings))
|
||||||
}
|
}
|
||||||
@@ -371,7 +371,7 @@ func TestDeletePlanting(t *testing.T) {
|
|||||||
if err := s.DeletePlanting(context.Background(), owner, pl.ID); err != nil {
|
if err := s.DeletePlanting(context.Background(), owner, pl.ID); err != nil {
|
||||||
t.Fatalf("DeletePlanting: %v", err)
|
t.Fatalf("DeletePlanting: %v", err)
|
||||||
}
|
}
|
||||||
full, _ := s.GardenFull(context.Background(), owner, g.ID)
|
full, _ := s.GardenFull(context.Background(), owner, g.ID, nil)
|
||||||
if len(full.Plantings) != 0 {
|
if len(full.Plantings) != 0 {
|
||||||
t.Errorf("planting still present after delete: %d", len(full.Plantings))
|
t.Errorf("planting still present after delete: %d", len(full.Plantings))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
@@ -69,6 +70,124 @@ func (s *Service) ListPlants(ctx context.Context, actorID int64) ([]domain.Plant
|
|||||||
return s.store.ListPlantsForActor(ctx, actorID)
|
return s.store.ListPlantsForActor(ctx, actorID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PlantMatch is a candidate from FindPlants: the plant, plus what seed the actor
|
||||||
|
// has left of it, so an agent can say "you only have enough for half that bed"
|
||||||
|
// instead of confidently planting seed that doesn't exist.
|
||||||
|
type PlantMatch struct {
|
||||||
|
domain.Plant
|
||||||
|
// SeedRemaining is the total left across the actor's lots of this plant, and
|
||||||
|
// SeedUnit the unit they're counted in. Both are omitted when there are no
|
||||||
|
// lots — and also when the lots disagree about the unit, because adding
|
||||||
|
// grams to packets produces a number that means nothing. SeedLots still
|
||||||
|
// reports how many there are, so "several lots, no single total" is
|
||||||
|
// distinguishable from "no seed at all".
|
||||||
|
SeedRemaining *float64 `json:"seedRemaining,omitempty"`
|
||||||
|
SeedUnit string `json:"seedUnit,omitempty"`
|
||||||
|
SeedLots int `json:"seedLots,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxPlantMatches caps FindPlants. A model given fifty candidates is not being
|
||||||
|
// helped; if the query is that broad the answer is to ask a better one.
|
||||||
|
const maxPlantMatches = 10
|
||||||
|
|
||||||
|
// FindPlants returns the plants in the actor's visible catalog (built-ins plus
|
||||||
|
// their own) whose name or category matches the query, best match first.
|
||||||
|
//
|
||||||
|
// It deliberately returns SEVERAL candidates rather than one guess. "garlic"
|
||||||
|
// against a catalog holding both "Garlic" and "German Red Garlic" is genuinely
|
||||||
|
// ambiguous, and a caller with the surrounding conversation is far better placed
|
||||||
|
// to disambiguate than a fuzzy-match heuristic here. An empty query returns the
|
||||||
|
// catalog head rather than nothing, so a caller can browse.
|
||||||
|
func (s *Service) FindPlants(ctx context.Context, actorID int64, query string) ([]PlantMatch, error) {
|
||||||
|
plants, err := s.store.ListPlantsForActor(ctx, actorID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
q := strings.ToLower(strings.TrimSpace(query))
|
||||||
|
|
||||||
|
type scored struct {
|
||||||
|
plant domain.Plant
|
||||||
|
rank int
|
||||||
|
}
|
||||||
|
ranked := make([]scored, 0, len(plants))
|
||||||
|
for _, p := range plants {
|
||||||
|
name := strings.ToLower(p.Name)
|
||||||
|
switch {
|
||||||
|
case q == "":
|
||||||
|
ranked = append(ranked, scored{p, 3})
|
||||||
|
case name == q:
|
||||||
|
ranked = append(ranked, scored{p, 0})
|
||||||
|
case strings.HasPrefix(name, q):
|
||||||
|
ranked = append(ranked, scored{p, 1})
|
||||||
|
case strings.Contains(name, q):
|
||||||
|
ranked = append(ranked, scored{p, 2})
|
||||||
|
case strings.Contains(strings.ToLower(p.Category), q):
|
||||||
|
ranked = append(ranked, scored{p, 3})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Stable by rank then name, so the same query always answers the same way —
|
||||||
|
// a tool whose results reshuffle between calls is one a model can't reason
|
||||||
|
// about across turns.
|
||||||
|
sort.SliceStable(ranked, func(i, j int) bool {
|
||||||
|
if ranked[i].rank != ranked[j].rank {
|
||||||
|
return ranked[i].rank < ranked[j].rank
|
||||||
|
}
|
||||||
|
return ranked[i].plant.Name < ranked[j].plant.Name
|
||||||
|
})
|
||||||
|
if len(ranked) > maxPlantMatches {
|
||||||
|
ranked = ranked[:maxPlantMatches]
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := make([]PlantMatch, 0, len(ranked))
|
||||||
|
for _, r := range ranked {
|
||||||
|
matches = append(matches, PlantMatch{Plant: r.plant})
|
||||||
|
}
|
||||||
|
if err := s.attachSeedRemaining(ctx, actorID, matches); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return matches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// attachSeedRemaining fills SeedRemaining/SeedUnit from the actor's lots.
|
||||||
|
func (s *Service) attachSeedRemaining(ctx context.Context, actorID int64, matches []PlantMatch) error {
|
||||||
|
if len(matches) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lots, err := s.ListSeedLots(ctx, actorID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
byPlant := map[int64][]domain.SeedLot{}
|
||||||
|
for _, l := range lots {
|
||||||
|
byPlant[l.PlantID] = append(byPlant[l.PlantID], l)
|
||||||
|
}
|
||||||
|
for i := range matches {
|
||||||
|
ls := byPlant[matches[i].ID]
|
||||||
|
if len(ls) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches[i].SeedLots = len(ls)
|
||||||
|
unit := ls[0].Unit
|
||||||
|
mixed := false
|
||||||
|
total := 0.0
|
||||||
|
for _, l := range ls {
|
||||||
|
total += l.Remaining
|
||||||
|
if l.Unit != unit {
|
||||||
|
mixed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mixed {
|
||||||
|
// Dropping only the LABEL would leave a number that reads as a
|
||||||
|
// quantity and isn't one. Drop the total with it; SeedLots still says
|
||||||
|
// there is seed here, just not one figure for it.
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches[i].SeedRemaining = &total
|
||||||
|
matches[i].SeedUnit = unit
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// CreatePlant adds a plant owned by the actor. To "clone" a built-in the client
|
// CreatePlant adds a plant owned by the actor. To "clone" a built-in the client
|
||||||
// simply POSTs a copy — there is no dedicated endpoint, and the copy is owned by
|
// simply POSTs a copy — there is no dedicated endpoint, and the copy is owned by
|
||||||
// (and editable by) the actor.
|
// (and editable by) the actor.
|
||||||
|
|||||||
@@ -237,3 +237,138 @@ func TestDeletePlantInUseIsBlocked(t *testing.T) {
|
|||||||
t.Errorf("delete freed plant: %v", err)
|
t.Errorf("delete freed plant: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFindPlantsRanksAndDisambiguates — FindPlants deliberately returns several
|
||||||
|
// candidates rather than one guess, because "garlic" against a catalog holding
|
||||||
|
// both "Garlic" and "German Red Garlic" is genuinely ambiguous and a caller with
|
||||||
|
// the surrounding context is better placed to choose than a heuristic here.
|
||||||
|
func TestFindPlantsRanksAndDisambiguates(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
custom, err := s.CreatePlant(ctx, owner, PlantInput{
|
||||||
|
Name: "German Red Garlic", Category: domain.CategoryVegetable, SpacingCM: 15,
|
||||||
|
Color: "#8a5a8a", Icon: "🧄",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreatePlant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.FindPlants(ctx, owner, "garlic")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindPlants: %v", err)
|
||||||
|
}
|
||||||
|
names := map[string]bool{}
|
||||||
|
for _, m := range got {
|
||||||
|
names[m.Name] = true
|
||||||
|
}
|
||||||
|
if !names["Garlic"] || !names[custom.Name] {
|
||||||
|
t.Errorf("got %v, want both the built-in and the custom variety", names)
|
||||||
|
}
|
||||||
|
// Exact match first, so a caller taking [0] gets the least surprising one.
|
||||||
|
if got[0].Name != "Garlic" {
|
||||||
|
t.Errorf("first match = %q, want the exact name", got[0].Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefix beats substring: "german" should surface the custom one first.
|
||||||
|
pre, err := s.FindPlants(ctx, owner, "german")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindPlants: %v", err)
|
||||||
|
}
|
||||||
|
if len(pre) == 0 || pre[0].Name != custom.Name {
|
||||||
|
t.Errorf("FindPlants(german) = %+v, want the custom variety first", pre)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Category search works, and a query matching nothing says so plainly.
|
||||||
|
if herbs, _ := s.FindPlants(ctx, owner, "herb"); len(herbs) == 0 {
|
||||||
|
t.Error("category search found nothing")
|
||||||
|
}
|
||||||
|
if none, _ := s.FindPlants(ctx, owner, "zzzzz"); len(none) != 0 {
|
||||||
|
t.Errorf("FindPlants(zzzzz) = %+v, want nothing", none)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindPlantsReportsSeedRemaining — so a caller can say "you only have enough
|
||||||
|
// for half that bed" instead of confidently planting seed that doesn't exist.
|
||||||
|
func TestFindPlantsReportsSeedRemaining(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
other := seedUser(t, s, "[email protected]")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
plant := seedOwnPlant(t, s, owner, 15)
|
||||||
|
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
|
||||||
|
PlantID: plant.ID, Quantity: 100, Unit: domain.UnitSeeds,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreateSeedLot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.FindPlants(ctx, owner, plant.Name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindPlants: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) == 0 || got[0].SeedRemaining == nil || *got[0].SeedRemaining != 100 {
|
||||||
|
t.Fatalf("seedRemaining = %+v, want 100", got[0].SeedRemaining)
|
||||||
|
}
|
||||||
|
if got[0].SeedUnit != domain.UnitSeeds {
|
||||||
|
t.Errorf("seedUnit = %q", got[0].SeedUnit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A plant with no lots reports nothing rather than a misleading zero.
|
||||||
|
builtin, err := s.FindPlants(ctx, owner, "Garlic")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindPlants: %v", err)
|
||||||
|
}
|
||||||
|
if len(builtin) > 0 && builtin[0].SeedRemaining != nil {
|
||||||
|
t.Errorf("a plant with no lots reported %v remaining", *builtin[0].SeedRemaining)
|
||||||
|
}
|
||||||
|
|
||||||
|
// And lots are private: another user searching the same plant sees no seed.
|
||||||
|
// (They can't see the custom plant at all, so search a built-in instead.)
|
||||||
|
if _, err := s.CreateSeedLot(ctx, other, SeedLotInput{
|
||||||
|
PlantID: builtinPlantID(t, s, other), Quantity: 50, Unit: domain.UnitSeeds,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("CreateSeedLot(other): %v", err)
|
||||||
|
}
|
||||||
|
mine, _ := s.FindPlants(ctx, owner, "Garlic")
|
||||||
|
for _, m := range mine {
|
||||||
|
if m.SeedRemaining != nil {
|
||||||
|
t.Errorf("saw another user's seed count on %q", m.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFindPlantsOmitsATotalItCannotHonestlyGive — lots in different units can't
|
||||||
|
// be summed. Dropping only the unit LABEL would leave a number that reads as a
|
||||||
|
// quantity and isn't one; the count of lots still says there is seed here.
|
||||||
|
func TestFindPlantsOmitsATotalItCannotHonestlyGive(t *testing.T) {
|
||||||
|
s := newTestService(t, openConfig())
|
||||||
|
owner := seedUser(t, s, "[email protected]")
|
||||||
|
plant := seedOwnPlant(t, s, owner, 15)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, u := range []string{domain.UnitSeeds, domain.UnitGrams} {
|
||||||
|
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{PlantID: plant.ID, Quantity: 50, Unit: u}); err != nil {
|
||||||
|
t.Fatalf("CreateSeedLot(%s): %v", u, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.FindPlants(ctx, owner, plant.Name)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FindPlants: %v", err)
|
||||||
|
}
|
||||||
|
if len(got) == 0 {
|
||||||
|
t.Fatal("plant not found")
|
||||||
|
}
|
||||||
|
if got[0].SeedRemaining != nil {
|
||||||
|
t.Errorf("reported %v across mismatched units; that number means nothing", *got[0].SeedRemaining)
|
||||||
|
}
|
||||||
|
if got[0].SeedUnit != "" {
|
||||||
|
t.Errorf("seedUnit = %q, want empty", got[0].SeedUnit)
|
||||||
|
}
|
||||||
|
// But "several lots, no single total" must stay distinguishable from "none".
|
||||||
|
if got[0].SeedLots != 2 {
|
||||||
|
t.Errorf("seedLots = %d, want 2", got[0].SeedLots)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -461,7 +461,7 @@ func TestCopiedGardenDoesNotInheritSeedLots(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CopyGarden: %v", err)
|
t.Fatalf("CopyGarden: %v", err)
|
||||||
}
|
}
|
||||||
full, err := s.GardenFull(ctx, owner, copied.ID)
|
full, err := s.GardenFull(ctx, owner, copied.ID, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("GardenFull: %v", err)
|
t.Fatalf("GardenFull: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ func TestGardenACLMatrix(t *testing.T) {
|
|||||||
actor int64
|
actor int64
|
||||||
want error
|
want error
|
||||||
}{{owner, nil}, {editor, nil}, {viewer, nil}, {stranger, domain.ErrNotFound}} {
|
}{{owner, nil}, {editor, nil}, {viewer, nil}, {stranger, domain.ErrNotFound}} {
|
||||||
_, err := s.GardenFull(ctx, tc.actor, g.ID)
|
_, err := s.GardenFull(ctx, tc.actor, g.ID, nil)
|
||||||
wantErr(t, "readFull", err, tc.want)
|
wantErr(t, "readFull", err, tc.want)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,7 +194,7 @@ func TestUpdateAndRemoveShare(t *testing.T) {
|
|||||||
t.Errorf("self-leave: %v", err)
|
t.Errorf("self-leave: %v", err)
|
||||||
}
|
}
|
||||||
// After leaving, the garden is invisible again.
|
// After leaving, the garden is invisible again.
|
||||||
if _, err := s.GardenFull(ctx, friend, g.ID); !errors.Is(err, domain.ErrNotFound) {
|
if _, err := s.GardenFull(ctx, friend, g.ID, nil); !errors.Is(err, domain.ErrNotFound) {
|
||||||
t.Errorf("after leaving, read = %v, want ErrNotFound", err)
|
t.Errorf("after leaving, read = %v, want ErrNotFound", err)
|
||||||
}
|
}
|
||||||
// A non-participant can't remove someone else's share.
|
// A non-participant can't remove someone else's share.
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// agentMessageColumns lists agent_messages columns in scan order.
|
||||||
|
const agentMessageColumns = `id, conversation_id, role, body, change_set_id, created_at`
|
||||||
|
|
||||||
|
// EnsureConversation returns the (garden, user) conversation id, creating it on
|
||||||
|
// first use. One thread per person per garden: two people editing a shared
|
||||||
|
// garden hold separate dialogues, and merging them would put words in someone's
|
||||||
|
// mouth.
|
||||||
|
func (d *DB) EnsureConversation(ctx context.Context, gardenID, userID int64) (int64, error) {
|
||||||
|
var id int64
|
||||||
|
err := d.sql.QueryRowContext(ctx,
|
||||||
|
`SELECT id FROM agent_conversations WHERE garden_id = ? AND user_id = ?`,
|
||||||
|
gardenID, userID).Scan(&id)
|
||||||
|
if err == nil {
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
if err := d.sql.QueryRowContext(ctx,
|
||||||
|
`INSERT INTO agent_conversations (garden_id, user_id) VALUES (?, ?)
|
||||||
|
ON CONFLICT (garden_id, user_id) DO UPDATE SET updated_at = updated_at
|
||||||
|
RETURNING id`,
|
||||||
|
gardenID, userID).Scan(&id); err != nil {
|
||||||
|
return 0, fmt.Errorf("store: ensure conversation: %w", err)
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendAgentMessage records one side of an exchange.
|
||||||
|
func (d *DB) AppendAgentMessage(ctx context.Context, m *domain.AgentMessage) (*domain.AgentMessage, error) {
|
||||||
|
var out domain.AgentMessage
|
||||||
|
if err := d.sql.QueryRowContext(ctx,
|
||||||
|
`INSERT INTO agent_messages (conversation_id, role, body, change_set_id)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
RETURNING `+agentMessageColumns,
|
||||||
|
m.ConversationID, m.Role, m.Body, m.ChangeSetID,
|
||||||
|
).Scan(&out.ID, &out.ConversationID, &out.Role, &out.Body, &out.ChangeSetID, &out.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: append agent message: %w", err)
|
||||||
|
}
|
||||||
|
// Touch the conversation so a "most recent thread" read is possible later.
|
||||||
|
if _, err := d.sql.ExecContext(ctx,
|
||||||
|
`UPDATE agent_conversations SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?`,
|
||||||
|
m.ConversationID); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: touch conversation: %w", err)
|
||||||
|
}
|
||||||
|
return &out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListAgentMessages returns the last `limit` messages of a conversation in
|
||||||
|
// chronological order.
|
||||||
|
//
|
||||||
|
// Reading the TAIL rather than the head is the point: an old opening exchange is
|
||||||
|
// the least useful context for "now do the same to the north bed", and an
|
||||||
|
// uncapped read would grow without bound over a season.
|
||||||
|
func (d *DB) ListAgentMessages(ctx context.Context, conversationID int64, limit int) ([]domain.AgentMessage, error) {
|
||||||
|
rows, err := d.sql.QueryContext(ctx,
|
||||||
|
`SELECT `+agentMessageColumns+` FROM (
|
||||||
|
SELECT `+agentMessageColumns+` FROM agent_messages
|
||||||
|
WHERE conversation_id = ? ORDER BY id DESC LIMIT ?
|
||||||
|
) ORDER BY id`,
|
||||||
|
conversationID, limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list agent messages: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
msgs := []domain.AgentMessage{}
|
||||||
|
for rows.Next() {
|
||||||
|
var m domain.AgentMessage
|
||||||
|
if err := rows.Scan(&m.ID, &m.ConversationID, &m.Role, &m.Body, &m.ChangeSetID, &m.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan agent message: %w", err)
|
||||||
|
}
|
||||||
|
msgs = append(msgs, m)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: iterate agent messages: %w", err)
|
||||||
|
}
|
||||||
|
return msgs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteConversation clears a thread (the messages cascade).
|
||||||
|
func (d *DB) DeleteConversation(ctx context.Context, gardenID, userID int64) error {
|
||||||
|
if _, err := d.sql.ExecContext(ctx,
|
||||||
|
`DELETE FROM agent_conversations WHERE garden_id = ? AND user_id = ?`,
|
||||||
|
gardenID, userID); err != nil {
|
||||||
|
return fmt.Errorf("store: delete conversation: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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,200 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
// journalColumns lists journal_entries columns in the order scanJournalEntry
|
||||||
|
// expects. Used unqualified for direct selects; the list read qualifies with j.
|
||||||
|
const journalColumns = `id, garden_id, object_id, planting_id, author_id, body,
|
||||||
|
observed_at, version, created_at, updated_at`
|
||||||
|
|
||||||
|
// journalScanTargets returns the scan destinations for journalColumns, in order.
|
||||||
|
// The list read appends one more for the joined author name, so the two readers
|
||||||
|
// share a single definition of the column order rather than each repeating it.
|
||||||
|
func journalScanTargets(e *domain.JournalEntry) []any {
|
||||||
|
return []any{
|
||||||
|
&e.ID, &e.GardenID, &e.ObjectID, &e.PlantingID, &e.AuthorID, &e.Body,
|
||||||
|
&e.ObservedAt, &e.Version, &e.CreatedAt, &e.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scanJournalEntry(s scanner) (*domain.JournalEntry, error) {
|
||||||
|
var e domain.JournalEntry
|
||||||
|
if err := s.Scan(journalScanTargets(&e)...); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalFilter narrows a garden's journal. A nil field means "don't filter on
|
||||||
|
// this". ObjectID/PlantingID select entries about one bed or one plop; the date
|
||||||
|
// range is inclusive and matches observed_at, not created_at.
|
||||||
|
type JournalFilter struct {
|
||||||
|
ObjectID *int64
|
||||||
|
PlantingID *int64
|
||||||
|
From *string
|
||||||
|
To *string
|
||||||
|
Limit int
|
||||||
|
Offset int
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateJournalEntry inserts an entry (fields already validated by the service).
|
||||||
|
func (d *DB) CreateJournalEntry(ctx context.Context, e *domain.JournalEntry) (*domain.JournalEntry, error) {
|
||||||
|
created, err := scanJournalEntry(d.sql.QueryRowContext(ctx,
|
||||||
|
`INSERT INTO journal_entries (garden_id, object_id, planting_id, author_id, body, observed_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)
|
||||||
|
RETURNING `+journalColumns,
|
||||||
|
e.GardenID, e.ObjectID, e.PlantingID, e.AuthorID, e.Body, e.ObservedAt,
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: insert journal entry: %w", err)
|
||||||
|
}
|
||||||
|
return created, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetJournalEntry returns an entry by id, or domain.ErrNotFound. Permission is
|
||||||
|
// the service's business, via the entry's garden.
|
||||||
|
func (d *DB) GetJournalEntry(ctx context.Context, id int64) (*domain.JournalEntry, error) {
|
||||||
|
e, err := scanJournalEntry(d.sql.QueryRowContext(ctx,
|
||||||
|
`SELECT `+journalColumns+` FROM journal_entries WHERE id = ?`, id))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, domain.ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: get journal entry: %w", err)
|
||||||
|
}
|
||||||
|
return e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListJournalEntries returns a garden's entries, most recently OBSERVED first,
|
||||||
|
// narrowed by the filter. Each carries its author's display name, so the list
|
||||||
|
// renders without a lookup per row. Always a non-nil slice.
|
||||||
|
func (d *DB) ListJournalEntries(ctx context.Context, gardenID int64, f JournalFilter) ([]domain.JournalEntry, error) {
|
||||||
|
query := `SELECT ` + qualifyColumns("j", journalColumns) + `, u.display_name
|
||||||
|
FROM journal_entries j
|
||||||
|
JOIN users u ON u.id = j.author_id
|
||||||
|
WHERE j.garden_id = ?`
|
||||||
|
args := []any{gardenID}
|
||||||
|
|
||||||
|
if f.ObjectID != nil {
|
||||||
|
query += ` AND j.object_id = ?`
|
||||||
|
args = append(args, *f.ObjectID)
|
||||||
|
}
|
||||||
|
if f.PlantingID != nil {
|
||||||
|
query += ` AND j.planting_id = ?`
|
||||||
|
args = append(args, *f.PlantingID)
|
||||||
|
}
|
||||||
|
if f.From != nil {
|
||||||
|
query += ` AND j.observed_at >= ?`
|
||||||
|
args = append(args, *f.From)
|
||||||
|
}
|
||||||
|
if f.To != nil {
|
||||||
|
query += ` AND j.observed_at <= ?`
|
||||||
|
args = append(args, *f.To)
|
||||||
|
}
|
||||||
|
// id DESC breaks ties within a day, so several entries observed on the same
|
||||||
|
// date still read newest-first rather than in arbitrary order.
|
||||||
|
query += ` ORDER BY j.observed_at DESC, j.id DESC LIMIT ? OFFSET ?`
|
||||||
|
args = append(args, f.Limit, f.Offset)
|
||||||
|
|
||||||
|
rows, err := d.sql.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: list journal entries: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
entries := []domain.JournalEntry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var e domain.JournalEntry
|
||||||
|
if err := rows.Scan(append(journalScanTargets(&e), &e.AuthorName)...); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan journal entry: %w", err)
|
||||||
|
}
|
||||||
|
entries = append(entries, e)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: iterate journal entries: %w", err)
|
||||||
|
}
|
||||||
|
return entries, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// JournalCounts returns how many entries a garden holds, keyed by the object
|
||||||
|
// they are about — key 0 for entries about the garden itself.
|
||||||
|
//
|
||||||
|
// A count per object rather than a flag, and one query rather than one per bed:
|
||||||
|
// the indicator has to be available when the journal panel is closed, which is
|
||||||
|
// exactly when nothing else has loaded the entries.
|
||||||
|
func (d *DB) JournalCounts(ctx context.Context, gardenID int64) (map[int64]int, error) {
|
||||||
|
rows, err := d.sql.QueryContext(ctx,
|
||||||
|
`SELECT COALESCE(object_id, 0), COUNT(*)
|
||||||
|
FROM journal_entries WHERE garden_id = ?
|
||||||
|
GROUP BY COALESCE(object_id, 0)`,
|
||||||
|
gardenID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: journal counts: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
counts := map[int64]int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var objectID int64
|
||||||
|
var n int
|
||||||
|
if err := rows.Scan(&objectID, &n); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan journal count: %w", err)
|
||||||
|
}
|
||||||
|
counts[objectID] = n
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: iterate journal counts: %w", err)
|
||||||
|
}
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateJournalEntry applies a version-guarded update of the mutable columns.
|
||||||
|
// Same contract as every other mutable resource. The target (garden/object/
|
||||||
|
// planting) and the author are immutable: an entry is a record of an observation,
|
||||||
|
// so re-pointing it at a different bed would be rewriting the observation rather
|
||||||
|
// than correcting the text.
|
||||||
|
func (d *DB) UpdateJournalEntry(ctx context.Context, e *domain.JournalEntry) (*domain.JournalEntry, error) {
|
||||||
|
updated, err := scanJournalEntry(d.sql.QueryRowContext(ctx,
|
||||||
|
`UPDATE journal_entries
|
||||||
|
SET body = ?, observed_at = ?,
|
||||||
|
version = version + 1,
|
||||||
|
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
|
||||||
|
WHERE id = ? AND version = ?
|
||||||
|
RETURNING `+journalColumns,
|
||||||
|
e.Body, e.ObservedAt, e.ID, e.Version,
|
||||||
|
))
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
current, gerr := d.GetJournalEntry(ctx, e.ID)
|
||||||
|
if gerr != nil {
|
||||||
|
return nil, gerr
|
||||||
|
}
|
||||||
|
return current, domain.ErrVersionConflict
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: update journal entry: %w", err)
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteJournalEntry removes an entry. Returns domain.ErrNotFound if none was.
|
||||||
|
func (d *DB) DeleteJournalEntry(ctx context.Context, id int64) error {
|
||||||
|
res, err := d.sql.ExecContext(ctx, `DELETE FROM journal_entries WHERE id = ?`, id)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: delete journal entry: %w", err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("store: journal delete rows: %w", err)
|
||||||
|
}
|
||||||
|
if n == 0 {
|
||||||
|
return domain.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Grow journal (#52): time-stamped notes on gardens, beds and plantings.
|
||||||
|
--
|
||||||
|
-- There is already free-text `notes` on gardens, objects and plants, but it is a
|
||||||
|
-- single mutable field: writing "powdery mildew on the west bed" overwrites what
|
||||||
|
-- you wrote in June. The distinction worth keeping is that `notes` is WHAT THIS
|
||||||
|
-- THING IS, while a journal entry is WHAT HAPPENED, AND WHEN. Both stay.
|
||||||
|
--
|
||||||
|
-- garden_id is NOT NULL even when the entry is about a bed or a single plop, and
|
||||||
|
-- that is the whole trick: permission checks reuse requireGardenRole unchanged
|
||||||
|
-- and no new ACL path is invented. object_id/planting_id narrow the target; the
|
||||||
|
-- garden always anchors it.
|
||||||
|
--
|
||||||
|
-- observed_at is deliberately distinct from created_at — you write up Saturday's
|
||||||
|
-- observations on Sunday, and the date that matters is Saturday's.
|
||||||
|
CREATE TABLE journal_entries (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
|
||||||
|
object_id INTEGER REFERENCES garden_objects (id) ON DELETE CASCADE,
|
||||||
|
planting_id INTEGER REFERENCES plantings (id) ON DELETE CASCADE,
|
||||||
|
author_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
observed_at TEXT NOT NULL, -- 'YYYY-MM-DD'
|
||||||
|
version INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The journal's only list query: one garden, most recently observed first.
|
||||||
|
CREATE INDEX idx_journal_garden ON journal_entries (garden_id, observed_at DESC);
|
||||||
|
|
||||||
|
-- Narrowing to one bed or one plop.
|
||||||
|
CREATE INDEX idx_journal_object ON journal_entries (object_id) WHERE object_id IS NOT NULL;
|
||||||
|
CREATE INDEX idx_journal_planting ON journal_entries (planting_id) WHERE planting_id IS NOT NULL;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- Agent conversations (#56): multi-turn chat with the garden assistant.
|
||||||
|
--
|
||||||
|
-- "Now do the same to the north bed" needs the previous exchange, and history
|
||||||
|
-- held only by the client would be lost on a refresh — which is exactly when
|
||||||
|
-- someone reloads to see whether the agent's change actually landed.
|
||||||
|
--
|
||||||
|
-- One conversation per (user, garden). Two people editing a shared garden get
|
||||||
|
-- separate threads: a conversation is a dialogue with one person, and merging
|
||||||
|
-- them would put words in someone's mouth.
|
||||||
|
CREATE TABLE agent_conversations (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
|
||||||
|
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
|
||||||
|
UNIQUE (garden_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Only the user/assistant TEXT of each turn is kept, not the full model
|
||||||
|
-- transcript with its tool calls. Continuity needs what was said and what came
|
||||||
|
-- back; replaying a stored tool call would be replaying a decision made against
|
||||||
|
-- a garden that has since moved on. It also keeps the schema free of majordomo's
|
||||||
|
-- message shape, which is a dependency's business, not the database's.
|
||||||
|
CREATE TABLE agent_messages (
|
||||||
|
conversation_id INTEGER NOT NULL REFERENCES agent_conversations (id) ON DELETE CASCADE,
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
-- The change set this turn produced, so the panel can offer Undo on the
|
||||||
|
-- message that caused it. SET NULL rather than CASCADE: losing the history
|
||||||
|
-- entry must not delete what was said about it.
|
||||||
|
change_set_id INTEGER REFERENCES change_sets (id) ON DELETE SET NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_agent_messages_conversation ON agent_messages (conversation_id, id);
|
||||||
@@ -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);
|
||||||
@@ -62,6 +62,68 @@ func queryPlantings(ctx context.Context, q queryer, query string, args ...any) (
|
|||||||
return plantings, nil
|
return plantings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ListPlantingsForGardenYear returns every plop in a garden whose time in the
|
||||||
|
// ground overlaps the given calendar year — the season view (#54).
|
||||||
|
//
|
||||||
|
// The interval is [planted_at, removed_at]: planted before the year ended, and
|
||||||
|
// either still in the ground or removed after the year began. Garlic planted in
|
||||||
|
// October and pulled the following July therefore appears in BOTH years, which
|
||||||
|
// is what actually happened.
|
||||||
|
//
|
||||||
|
// Undated plantings are always included. Every planting that predates this
|
||||||
|
// feature has a null planted_at, and a rule that excluded them would empty every
|
||||||
|
// existing garden the moment a year was selected — an unhelpful way to be
|
||||||
|
// technically correct.
|
||||||
|
func (d *DB) ListPlantingsForGardenYear(ctx context.Context, gardenID int64, year int) ([]domain.Planting, error) {
|
||||||
|
start := fmt.Sprintf("%04d-01-01", year)
|
||||||
|
end := fmt.Sprintf("%04d-12-31", year)
|
||||||
|
return queryPlantings(ctx, d.sql,
|
||||||
|
`SELECT `+qualifyColumns("pl", plantingColumns)+` FROM plantings pl
|
||||||
|
JOIN garden_objects o ON o.id = pl.object_id
|
||||||
|
WHERE o.garden_id = ?
|
||||||
|
AND (pl.planted_at IS NULL OR pl.planted_at <= ?)
|
||||||
|
AND (pl.removed_at IS NULL OR pl.removed_at >= ?)
|
||||||
|
ORDER BY pl.id`,
|
||||||
|
gardenID, end, start)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GardenPlantingYears lists the calendar years a garden has planting data for,
|
||||||
|
// newest first — every year touched by a [planted_at, removed_at] interval.
|
||||||
|
//
|
||||||
|
// Undated plantings contribute no year, deliberately: they belong to every year
|
||||||
|
// the selector offers, so adding one here would be noise.
|
||||||
|
func (d *DB) GardenPlantingYears(ctx context.Context, gardenID int64) ([]int, error) {
|
||||||
|
rows, err := d.sql.QueryContext(ctx,
|
||||||
|
`SELECT DISTINCT CAST(substr(y, 1, 4) AS INTEGER) AS year FROM (
|
||||||
|
SELECT pl.planted_at AS y FROM plantings pl
|
||||||
|
JOIN garden_objects o ON o.id = pl.object_id
|
||||||
|
WHERE o.garden_id = ? AND pl.planted_at IS NOT NULL
|
||||||
|
UNION
|
||||||
|
SELECT pl.removed_at AS y FROM plantings pl
|
||||||
|
JOIN garden_objects o ON o.id = pl.object_id
|
||||||
|
WHERE o.garden_id = ? AND pl.removed_at IS NOT NULL
|
||||||
|
)
|
||||||
|
ORDER BY year DESC`,
|
||||||
|
gardenID, gardenID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("store: garden planting years: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
years := []int{}
|
||||||
|
for rows.Next() {
|
||||||
|
var y int
|
||||||
|
if err := rows.Scan(&y); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: scan planting year: %w", err)
|
||||||
|
}
|
||||||
|
years = append(years, y)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("store: iterate planting years: %w", err)
|
||||||
|
}
|
||||||
|
return years, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListActivePlantingsForObject returns an object's currently-planted plops
|
// ListActivePlantingsForObject returns an object's currently-planted plops
|
||||||
// (removed_at IS NULL). Always a non-nil slice. Used by FillRegion to avoid
|
// (removed_at IS NULL). Always a non-nil slice. Used by FillRegion to avoid
|
||||||
// stacking new plops inside existing ones.
|
// stacking new plops inside existing ones.
|
||||||
|
|||||||
@@ -24,18 +24,30 @@ func scanPlant(s scanner) (*domain.Plant, error) {
|
|||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListReferencedPlants returns the distinct plants used by a garden's active
|
// ListReferencedPlants returns the distinct plants used by a garden's plantings
|
||||||
// plantings — the catalog subset the editor needs to render them. Always a
|
// — the catalog subset the editor needs to render them. Always a non-nil slice.
|
||||||
// non-nil slice (empty when the garden has no active plantings). This is the
|
//
|
||||||
// /full read side; full plant CRUD/seeding lives in plants.go / plantings.go.
|
// year nil means the active plops only. A year widens it to the plops that were
|
||||||
func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64) ([]domain.Plant, error) {
|
// in the ground THAT YEAR, which is what a past season needs: a plant pulled
|
||||||
|
// last July is not active, but its plops still have to render with the right
|
||||||
|
// icon and colour. It is scoped to the same year as the plops rather than to the
|
||||||
|
// garden's whole history, so the two selections always agree and a decade-old
|
||||||
|
// garden doesn't load a decade of catalog to draw one season.
|
||||||
|
func (d *DB) ListReferencedPlants(ctx context.Context, gardenID int64, year *int) ([]domain.Plant, error) {
|
||||||
|
where := ` AND pl.removed_at IS NULL`
|
||||||
|
args := []any{gardenID}
|
||||||
|
if year != nil {
|
||||||
|
where = ` AND (pl.planted_at IS NULL OR pl.planted_at <= ?)
|
||||||
|
AND (pl.removed_at IS NULL OR pl.removed_at >= ?)`
|
||||||
|
args = append(args, fmt.Sprintf("%04d-12-31", *year), fmt.Sprintf("%04d-01-01", *year))
|
||||||
|
}
|
||||||
rows, err := d.sql.QueryContext(ctx,
|
rows, err := d.sql.QueryContext(ctx,
|
||||||
`SELECT DISTINCT `+qualifyColumns("p", plantColumns)+` FROM plants p
|
`SELECT DISTINCT `+qualifyColumns("p", plantColumns)+` FROM plants p
|
||||||
JOIN plantings pl ON pl.plant_id = p.id
|
JOIN plantings pl ON pl.plant_id = p.id
|
||||||
JOIN garden_objects o ON o.id = pl.object_id
|
JOIN garden_objects o ON o.id = pl.object_id
|
||||||
WHERE o.garden_id = ? AND pl.removed_at IS NULL
|
WHERE o.garden_id = ?`+where+`
|
||||||
ORDER BY p.id`,
|
ORDER BY p.id`,
|
||||||
gardenID,
|
args...,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("store: list referenced plants: %w", err)
|
return nil, fmt.Errorf("store: list referenced plants: %w", err)
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Modal } from '@/components/ui/Modal'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retire a lot. Worth confirming because it's the one place cost and germination
|
||||||
|
* data lives — and worth saying plainly that the plantings survive it, since
|
||||||
|
* "will this wipe my garden" is the reasonable fear.
|
||||||
|
*/
|
||||||
|
export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: () => void }) {
|
||||||
|
const del = useDeleteSeedLot()
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title="Retire this seed lot?" onClose={onClose} busy={del.isPending}>
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<p className="text-sm text-fg">
|
||||||
|
{formatQuantity(lot.quantity)} {lot.unit}
|
||||||
|
{lot.vendor ? ` from ${lot.vendor}` : ''}
|
||||||
|
{lot.packedForYear != null ? `, packed for ${lot.packedForYear}` : ''}.
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
Anything planted from it stays exactly where it is — it just stops being attributed to this purchase.
|
||||||
|
</p>
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div className="mt-1 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={del.isPending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="danger"
|
||||||
|
disabled={del.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
del.mutate(lot.id, {
|
||||||
|
onSuccess: onClose,
|
||||||
|
onError: (err) => setError(errorMessage(err, 'Could not retire the lot.')),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{del.isPending ? 'Retiring…' : 'Retire lot'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { PlantIcon } from './PlantIcon'
|
import { PlantIcon } from './PlantIcon'
|
||||||
|
import { LotStateChip, SeedLotList } from './SeedLotList'
|
||||||
|
import { SourceLink } from './SourceLink'
|
||||||
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
|
||||||
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
|
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
|
||||||
|
import { formatQuantity, summarizeLots, type SeedLot } from '@/lib/seedLots'
|
||||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -11,17 +15,30 @@ import { formatSpacing, type UnitPref } from '@/lib/units'
|
|||||||
export function PlantCard({
|
export function PlantCard({
|
||||||
plant,
|
plant,
|
||||||
unit,
|
unit,
|
||||||
|
lots,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
|
onAddLot,
|
||||||
|
onEditLot,
|
||||||
|
onDeleteLot,
|
||||||
}: {
|
}: {
|
||||||
plant: Plant
|
plant: Plant
|
||||||
unit: UnitPref
|
unit: UnitPref
|
||||||
|
/** This plant's purchases. A lot may reference a built-in, so even a built-in
|
||||||
|
* card can carry seed. */
|
||||||
|
lots: SeedLot[]
|
||||||
onEdit: () => void
|
onEdit: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
onDuplicate: () => void
|
onDuplicate: () => void
|
||||||
|
onAddLot: () => void
|
||||||
|
onEditLot: (lot: SeedLot) => void
|
||||||
|
onDeleteLot: (lot: SeedLot) => void
|
||||||
}) {
|
}) {
|
||||||
const builtin = isBuiltin(plant)
|
const builtin = isBuiltin(plant)
|
||||||
|
const [showLots, setShowLots] = useState(false)
|
||||||
|
const summary = summarizeLots(lots)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col rounded-xl border border-border bg-surface">
|
<div className="flex flex-col rounded-xl border border-border bg-surface">
|
||||||
<div className="flex items-start gap-3 p-4">
|
<div className="flex items-start gap-3 p-4">
|
||||||
@@ -38,6 +55,12 @@ export function PlantCard({
|
|||||||
<p className="mt-0.5 text-sm text-muted">
|
<p className="mt-0.5 text-sm text-muted">
|
||||||
{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
|
{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
|
||||||
</p>
|
</p>
|
||||||
|
{(plant.vendor || plant.sourceUrl) && (
|
||||||
|
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted">
|
||||||
|
{plant.vendor && <span>{plant.vendor}</span>}
|
||||||
|
<SourceLink url={plant.sourceUrl} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}
|
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
@@ -46,7 +69,34 @@ export function PlantCard({
|
|||||||
title={plant.color}
|
title={plant.color}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-end gap-1 border-t border-border px-2 py-1.5">
|
{showLots && (
|
||||||
|
<div className="border-t border-border px-3 py-2">
|
||||||
|
<SeedLotList lots={lots} canEdit onAdd={onAddLot} onEdit={onEditLot} onDelete={onDeleteLot} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center justify-end gap-1 border-t border-border px-2 py-1.5">
|
||||||
|
{/* The seed count sits with the actions rather than in the body: it's
|
||||||
|
what you scan for down a list of twenty packets, so it wants a fixed
|
||||||
|
place on the card. */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowLots((v) => !v)}
|
||||||
|
className={`${cardActionClass} mr-auto flex items-center gap-1.5`}
|
||||||
|
aria-expanded={showLots}
|
||||||
|
>
|
||||||
|
{lots.length === 0 ? (
|
||||||
|
<span className="text-muted">No seed</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatQuantity(summary.remaining)}
|
||||||
|
{summary.unit ? ` ${summary.unit}` : ''} left
|
||||||
|
</span>
|
||||||
|
<LotStateChip state={summary.state} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
<button type="button" onClick={onDuplicate} className={cardActionClass}>
|
<button type="button" onClick={onDuplicate} className={cardActionClass}>
|
||||||
Duplicate
|
Duplicate
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type PlantCategory,
|
type PlantCategory,
|
||||||
type PlantInput,
|
type PlantInput,
|
||||||
} from '@/lib/plants'
|
} from '@/lib/plants'
|
||||||
|
import { safeExternalUrl } from '@/lib/seedLots'
|
||||||
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
|
||||||
|
|
||||||
const DEFAULT_COLOR = '#4a7c3f'
|
const DEFAULT_COLOR = '#4a7c3f'
|
||||||
@@ -61,6 +62,8 @@ export function PlantFormModal({
|
|||||||
const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR))
|
const [color, setColor] = useState(expandHex(source?.color ?? DEFAULT_COLOR))
|
||||||
const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON)
|
const [icon, setIcon] = useState(source?.icon ?? DEFAULT_ICON)
|
||||||
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
|
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
|
||||||
|
const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '')
|
||||||
|
const [vendor, setVendor] = useState(source?.vendor ?? '')
|
||||||
const [notes, setNotes] = useState(source?.notes ?? '')
|
const [notes, setNotes] = useState(source?.notes ?? '')
|
||||||
const [version, setVersion] = useState(plant?.version ?? 0)
|
const [version, setVersion] = useState(plant?.version ?? 0)
|
||||||
const [conflict, setConflict] = useState<string | null>(null)
|
const [conflict, setConflict] = useState<string | null>(null)
|
||||||
@@ -96,6 +99,14 @@ export function PlantFormModal({
|
|||||||
daysToMaturity = d
|
daysToMaturity = d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The server refuses anything that isn't http(s) with a host, but say so here
|
||||||
|
// rather than letting a paste of "johnnyseeds.com" come back as a generic
|
||||||
|
// error with no hint about which field or why.
|
||||||
|
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
|
||||||
|
setFormError('The source link needs to be a full http:// or https:// address.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const input: PlantInput = {
|
const input: PlantInput = {
|
||||||
name: name.trim(),
|
name: name.trim(),
|
||||||
category,
|
category,
|
||||||
@@ -103,6 +114,8 @@ export function PlantFormModal({
|
|||||||
color,
|
color,
|
||||||
icon: icon.trim(),
|
icon: icon.trim(),
|
||||||
daysToMaturity,
|
daysToMaturity,
|
||||||
|
sourceUrl: sourceUrl.trim(),
|
||||||
|
vendor: vendor.trim(),
|
||||||
notes: notes.trim(),
|
notes: notes.trim(),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -194,6 +207,27 @@ export function PlantFormModal({
|
|||||||
onChange={(e) => setDays(e.target.value)}
|
onChange={(e) => setDays(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Provenance for the variety itself. What you bought and what's left
|
||||||
|
of it is a seed lot, added from the plant card. */}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TextField
|
||||||
|
label="Vendor"
|
||||||
|
name="vendor"
|
||||||
|
placeholder="Johnny's Selected Seeds"
|
||||||
|
value={vendor}
|
||||||
|
onChange={(e) => setVendor(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Source link"
|
||||||
|
name="sourceUrl"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
placeholder="https://…"
|
||||||
|
value={sourceUrl}
|
||||||
|
onChange={(e) => setSourceUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
<TextArea label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
|
||||||
{formError && <Alert>{formError}</Alert>}
|
{formError && <Alert>{formError}</Alert>}
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import { SourceLink } from './SourceLink'
|
||||||
|
import {
|
||||||
|
formatCost,
|
||||||
|
formatQuantity,
|
||||||
|
formatUnitCost,
|
||||||
|
lotState,
|
||||||
|
type LotState,
|
||||||
|
type SeedLot,
|
||||||
|
} from '@/lib/seedLots'
|
||||||
|
|
||||||
|
const STATE_LABEL: Record<LotState, string> = {
|
||||||
|
over: 'over-planted',
|
||||||
|
empty: 'empty',
|
||||||
|
low: 'low',
|
||||||
|
ok: 'in stock',
|
||||||
|
unknown: 'no count',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encoded in colour AND words, because this is the thing you skim down a list of
|
||||||
|
// twenty packets deciding what to order — a bare number doesn't survive that.
|
||||||
|
const STATE_CLASS: Record<LotState, string> = {
|
||||||
|
// "over" is a discrepancy to look at rather than a shortage to act on, so it
|
||||||
|
// reads as a distinct warning rather than sharing "low"'s styling.
|
||||||
|
over: 'bg-orange-500/25 text-orange-900 dark:text-orange-200',
|
||||||
|
empty: 'bg-red-500/15 text-red-800 dark:text-red-300',
|
||||||
|
low: 'bg-amber-500/20 text-amber-800 dark:text-amber-300',
|
||||||
|
ok: 'bg-accent/20 text-accent-strong',
|
||||||
|
unknown: 'bg-border/60 text-muted',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LotStateChip({ state, className }: { state: LotState; className?: string }) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
'shrink-0 rounded px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide',
|
||||||
|
STATE_CLASS[state],
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{STATE_LABEL[state]}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A proportional bar for how much of a lot is left, so the state reads before
|
||||||
|
* any of the text does. Omitted when there's no quantity to be a fraction of. */
|
||||||
|
function RemainingBar({ lot }: { lot: SeedLot }) {
|
||||||
|
if (lot.quantity <= 0) return null
|
||||||
|
const pct = Math.max(0, Math.min(100, (lot.remaining / lot.quantity) * 100))
|
||||||
|
const state = lotState(lot)
|
||||||
|
return (
|
||||||
|
<div className="mt-1 h-1 w-full overflow-hidden rounded-full bg-border/60">
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'h-full rounded-full',
|
||||||
|
state === 'ok' ? 'bg-accent' : state === 'low' ? 'bg-amber-500' : 'bg-red-500',
|
||||||
|
)}
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A plant's purchases: what came from where, and what's left of each.
|
||||||
|
*
|
||||||
|
* Two lots of the same variety are two separate rows with independent counts,
|
||||||
|
* which is the whole reason inventory lives on the purchase rather than on the
|
||||||
|
* plant (#50).
|
||||||
|
*/
|
||||||
|
export function SeedLotList({
|
||||||
|
lots,
|
||||||
|
canEdit,
|
||||||
|
onAdd,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
lots: SeedLot[]
|
||||||
|
canEdit: boolean
|
||||||
|
onAdd: () => void
|
||||||
|
onEdit: (lot: SeedLot) => void
|
||||||
|
onDelete: (lot: SeedLot) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{lots.length === 0 && (
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
No seed recorded. Add a lot to track what you bought and how much is left.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{lots.map((lot) => (
|
||||||
|
<LotRow key={lot.id} lot={lot} canEdit={canEdit} onEdit={() => onEdit(lot)} onDelete={() => onDelete(lot)} />
|
||||||
|
))}
|
||||||
|
{canEdit && (
|
||||||
|
<Button variant="ghost" className="self-start px-2 py-1 text-xs" onClick={onAdd}>
|
||||||
|
+ Add seed lot
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LotRow({
|
||||||
|
lot,
|
||||||
|
canEdit,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: {
|
||||||
|
lot: SeedLot
|
||||||
|
canEdit: boolean
|
||||||
|
onEdit: () => void
|
||||||
|
onDelete: () => void
|
||||||
|
}) {
|
||||||
|
const cost = formatCost(lot.costCents)
|
||||||
|
const unitCost = formatUnitCost(lot)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-border px-2 py-1.5">
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="flex flex-wrap items-center gap-1.5 text-sm text-fg">
|
||||||
|
<span className="font-medium tabular-nums">
|
||||||
|
{formatQuantity(lot.remaining)} / {formatQuantity(lot.quantity)} {lot.unit}
|
||||||
|
</span>
|
||||||
|
<LotStateChip state={lotState(lot)} />
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-xs text-muted">
|
||||||
|
{lot.vendor && <span>{lot.vendor}</span>}
|
||||||
|
{lot.packedForYear != null && <span>packed for {lot.packedForYear}</span>}
|
||||||
|
{lot.purchasedAt && <span>bought {lot.purchasedAt}</span>}
|
||||||
|
{lot.germinationPct != null && <span>{lot.germinationPct}% germ.</span>}
|
||||||
|
{cost && <span>{unitCost ? `${cost} (${unitCost})` : cost}</span>}
|
||||||
|
<SourceLink url={lot.sourceUrl} />
|
||||||
|
</p>
|
||||||
|
<RemainingBar lot={lot} />
|
||||||
|
</div>
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex shrink-0 flex-col items-end">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onEdit}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDelete}
|
||||||
|
className="rounded px-1.5 py-0.5 text-xs text-red-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-red-400"
|
||||||
|
>
|
||||||
|
Retire
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{lot.notes && <p className="mt-1 text-xs text-muted">{lot.notes}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { Modal } from '@/components/ui/Modal'
|
||||||
|
import { Select } from '@/components/ui/Select'
|
||||||
|
import { TextArea } from '@/components/ui/TextArea'
|
||||||
|
import { TextField } from '@/components/ui/TextField'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import {
|
||||||
|
conflictSeedLot,
|
||||||
|
LOT_UNITS,
|
||||||
|
safeExternalUrl,
|
||||||
|
useCreateSeedLot,
|
||||||
|
useUpdateSeedLot,
|
||||||
|
type LotUnit,
|
||||||
|
type SeedLot,
|
||||||
|
} from '@/lib/seedLots'
|
||||||
|
import type { Plant } from '@/lib/plants'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record a purchase, or correct one. Everything except quantity and unit is
|
||||||
|
* optional — the point is to make writing it down cheap enough to bother with,
|
||||||
|
* and a form that demands a SKU and a lot code gets skipped.
|
||||||
|
*/
|
||||||
|
export function SeedLotModal({
|
||||||
|
plant,
|
||||||
|
lot,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
plant: Plant
|
||||||
|
/** Editing an existing lot, or undefined to record a new one. */
|
||||||
|
lot?: SeedLot
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const isEdit = !!lot
|
||||||
|
const create = useCreateSeedLot()
|
||||||
|
const update = useUpdateSeedLot()
|
||||||
|
const pending = create.isPending || update.isPending
|
||||||
|
|
||||||
|
// A new lot inherits the plant's vendor and source link, since the usual case
|
||||||
|
// is buying the variety you already recorded from the place you recorded it.
|
||||||
|
const [vendor, setVendor] = useState(lot?.vendor ?? plant.vendor ?? '')
|
||||||
|
const [sourceUrl, setSourceUrl] = useState(lot?.sourceUrl ?? plant.sourceUrl ?? '')
|
||||||
|
const [quantity, setQuantity] = useState(lot ? String(lot.quantity) : '')
|
||||||
|
const [unit, setUnit] = useState<LotUnit>(lot?.unit ?? 'seeds')
|
||||||
|
const [purchasedAt, setPurchasedAt] = useState(lot?.purchasedAt ?? '')
|
||||||
|
const [packedForYear, setPackedForYear] = useState(lot?.packedForYear != null ? String(lot.packedForYear) : '')
|
||||||
|
const [cost, setCost] = useState(lot?.costCents != null ? (lot.costCents / 100).toFixed(2) : '')
|
||||||
|
const [germination, setGermination] = useState(lot?.germinationPct != null ? String(lot.germinationPct) : '')
|
||||||
|
const [sku, setSku] = useState(lot?.sku ?? '')
|
||||||
|
const [lotCode, setLotCode] = useState(lot?.lotCode ?? '')
|
||||||
|
const [notes, setNotes] = useState(lot?.notes ?? '')
|
||||||
|
const [version, setVersion] = useState(lot?.version ?? 0)
|
||||||
|
const [conflict, setConflict] = useState<string | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
async function onSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setConflict(null)
|
||||||
|
|
||||||
|
const qty = quantity.trim() === '' ? 0 : Number(quantity)
|
||||||
|
if (!Number.isFinite(qty) || qty < 0) {
|
||||||
|
setError('Quantity must be a number, or left blank.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
|
||||||
|
setError('The source link needs to be a full http:// or https:// address.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let year: number | null = null
|
||||||
|
if (packedForYear.trim()) {
|
||||||
|
const y = Number(packedForYear)
|
||||||
|
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
|
||||||
|
setError('Packed-for year should be a four-digit year.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
year = y
|
||||||
|
}
|
||||||
|
let costCents: number | null = null
|
||||||
|
if (cost.trim()) {
|
||||||
|
const c = Number(cost)
|
||||||
|
if (!Number.isFinite(c) || c < 0) {
|
||||||
|
setError('Cost must be an amount, or left blank.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
costCents = Math.round(c * 100)
|
||||||
|
}
|
||||||
|
let germinationPct: number | null = null
|
||||||
|
if (germination.trim()) {
|
||||||
|
const g = Number(germination)
|
||||||
|
if (!Number.isFinite(g) || g < 0 || g > 100) {
|
||||||
|
setError('Germination is a percentage between 0 and 100.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
germinationPct = g
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
plantId: plant.id,
|
||||||
|
vendor: vendor.trim(),
|
||||||
|
sourceUrl: sourceUrl.trim(),
|
||||||
|
sku: sku.trim(),
|
||||||
|
lotCode: lotCode.trim(),
|
||||||
|
purchasedAt: purchasedAt.trim() === '' ? null : purchasedAt.trim(),
|
||||||
|
packedForYear: year,
|
||||||
|
quantity: qty,
|
||||||
|
unit,
|
||||||
|
costCents,
|
||||||
|
germinationPct,
|
||||||
|
notes: notes.trim(),
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isEdit) {
|
||||||
|
await update.mutateAsync({ id: lot.id, version, ...input })
|
||||||
|
} else {
|
||||||
|
await create.mutateAsync(input)
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
const current = conflictSeedLot(err)
|
||||||
|
if (current) {
|
||||||
|
// Someone edited this lot elsewhere. Rebase onto the fresh row so a
|
||||||
|
// re-save applies, rather than making them retype everything — the same
|
||||||
|
// contract every other version-guarded form here honours.
|
||||||
|
setVersion(current.version)
|
||||||
|
setVendor(current.vendor)
|
||||||
|
setSourceUrl(current.sourceUrl)
|
||||||
|
setQuantity(String(current.quantity))
|
||||||
|
setUnit(current.unit)
|
||||||
|
setPurchasedAt(current.purchasedAt ?? '')
|
||||||
|
setPackedForYear(current.packedForYear != null ? String(current.packedForYear) : '')
|
||||||
|
setCost(current.costCents != null ? (current.costCents / 100).toFixed(2) : '')
|
||||||
|
setGermination(current.germinationPct != null ? String(current.germinationPct) : '')
|
||||||
|
setSku(current.sku)
|
||||||
|
setLotCode(current.lotCode)
|
||||||
|
setNotes(current.notes)
|
||||||
|
setConflict('This lot changed elsewhere. The latest values are shown — review and save again.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(errorMessage(err, isEdit ? 'Could not save the lot.' : 'Could not record the lot.'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal title={isEdit ? 'Edit seed lot' : `Seed lot — ${plant.name}`} onClose={onClose} busy={pending}>
|
||||||
|
<form onSubmit={onSubmit} className="flex flex-col gap-3">
|
||||||
|
{conflict && <Alert tone="info">{conflict}</Alert>}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TextField
|
||||||
|
label="Quantity"
|
||||||
|
name="quantity"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Unit"
|
||||||
|
name="unit"
|
||||||
|
value={unit}
|
||||||
|
onChange={(e) => setUnit(e.target.value as LotUnit)}
|
||||||
|
options={LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
|
||||||
|
<TextField
|
||||||
|
label="Source link"
|
||||||
|
name="sourceUrl"
|
||||||
|
type="url"
|
||||||
|
inputMode="url"
|
||||||
|
placeholder="https://…"
|
||||||
|
value={sourceUrl}
|
||||||
|
onChange={(e) => setSourceUrl(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TextField
|
||||||
|
label="Purchased"
|
||||||
|
name="purchasedAt"
|
||||||
|
type="date"
|
||||||
|
value={purchasedAt}
|
||||||
|
onChange={(e) => setPurchasedAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Packed for"
|
||||||
|
name="packedForYear"
|
||||||
|
type="number"
|
||||||
|
inputMode="numeric"
|
||||||
|
placeholder="2026"
|
||||||
|
value={packedForYear}
|
||||||
|
onChange={(e) => setPackedForYear(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TextField
|
||||||
|
label="Cost"
|
||||||
|
name="cost"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="4.99"
|
||||||
|
value={cost}
|
||||||
|
onChange={(e) => setCost(e.target.value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="Germination %"
|
||||||
|
name="germination"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
step="any"
|
||||||
|
min="0"
|
||||||
|
max="100"
|
||||||
|
value={germination}
|
||||||
|
onChange={(e) => setGermination(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
|
||||||
|
<TextField label="Lot code" name="lotCode" value={lotCode} onChange={(e) => setLotCode(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TextArea label="Notes" name="lotNotes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
|
||||||
|
<div className="mt-1 flex justify-end gap-2">
|
||||||
|
<Button type="button" variant="ghost" onClick={onClose} disabled={pending}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={pending}>
|
||||||
|
{pending ? 'Saving…' : isEdit ? 'Save' : 'Record lot'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { safeExternalUrl } from '@/lib/seedLots'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A link out to where seed came from.
|
||||||
|
*
|
||||||
|
* The href is a URL somebody pasted, so it is re-checked here before rendering
|
||||||
|
* and carries rel="noopener noreferrer" — the server scheme-checks it too (#50),
|
||||||
|
* but a link is rendered from whatever the client was handed, and "the backend
|
||||||
|
* validated it" is not a reason to hand javascript: to an anchor tag.
|
||||||
|
*
|
||||||
|
* Renders nothing at all when the URL is absent or unsafe, so callers don't each
|
||||||
|
* have to remember to guard.
|
||||||
|
*/
|
||||||
|
export function SourceLink({ url, label = 'source' }: { url: string; label?: string }) {
|
||||||
|
const safe = safeExternalUrl(url)
|
||||||
|
if (!safe) return null
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={safe}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline decoration-dotted underline-offset-2 hover:text-fg"
|
||||||
|
>
|
||||||
|
{label} ↗
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,9 +17,16 @@ interface ToastState {
|
|||||||
|
|
||||||
let nextId = 1
|
let nextId = 1
|
||||||
|
|
||||||
|
// Error toasts no longer auto-dismiss (#85), so a burst of failures could grow
|
||||||
|
// the stack without bound and push older ones off-screen. Cap it: keep the most
|
||||||
|
// recent MAX_TOASTS and drop the oldest, so the newest — the one that just
|
||||||
|
// happened — is always visible.
|
||||||
|
const MAX_TOASTS = 4
|
||||||
|
|
||||||
export const useToastStore = create<ToastState>((set) => ({
|
export const useToastStore = create<ToastState>((set) => ({
|
||||||
toasts: [],
|
toasts: [],
|
||||||
push: (message, tone = 'info') => set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }] })),
|
push: (message, tone = 'info') =>
|
||||||
|
set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }].slice(-MAX_TOASTS) })),
|
||||||
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -32,21 +39,34 @@ export const toast = {
|
|||||||
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
|
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
|
||||||
function ToastItem({ item }: { item: Toast }) {
|
function ToastItem({ item }: { item: Toast }) {
|
||||||
const dismiss = useToastStore((s) => s.dismiss)
|
const dismiss = useToastStore((s) => s.dismiss)
|
||||||
|
const isError = item.tone === 'error'
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Error toasts are the primary report that a mutation failed, so they do NOT
|
||||||
|
// auto-dismiss — a user who looked away at second 4 would otherwise lose the
|
||||||
|
// only notice, with nothing to retrieve (#85). Info toasts still time out.
|
||||||
|
if (isError) return
|
||||||
const t = setTimeout(() => dismiss(item.id), 4000)
|
const t = setTimeout(() => dismiss(item.id), 4000)
|
||||||
return () => clearTimeout(t)
|
return () => clearTimeout(t)
|
||||||
}, [item.id, dismiss])
|
}, [item.id, dismiss, isError])
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role={item.tone === 'error' ? 'alert' : 'status'}
|
role={isError ? 'alert' : 'status'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'pointer-events-auto rounded-md border px-3 py-2 text-sm shadow-md',
|
'pointer-events-auto flex items-start gap-2 rounded-md border px-3 py-2 text-sm shadow-md',
|
||||||
item.tone === 'error'
|
isError
|
||||||
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
|
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
|
||||||
: 'border-border bg-surface text-fg',
|
: 'border-border bg-surface text-fg',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{item.message}
|
<span className="flex-1">{item.message}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => dismiss(item.id)}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className="-mr-1 shrink-0 rounded px-1 text-current opacity-60 outline-none hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { TextArea } from '@/components/ui/TextArea'
|
||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
import {
|
||||||
|
describeStep,
|
||||||
|
streamChat,
|
||||||
|
useAgentHistory,
|
||||||
|
useAgentRefresh,
|
||||||
|
useClearAgentHistory,
|
||||||
|
type AgentStep,
|
||||||
|
type AgentTurn,
|
||||||
|
} from '@/lib/agent'
|
||||||
|
import { useUndo } from '@/lib/history'
|
||||||
|
import { UndoButton } from './UndoButton'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Talk to the garden assistant, in the editor beside the canvas.
|
||||||
|
*
|
||||||
|
* Here rather than on its own page because watching the garden change as the
|
||||||
|
* agent works IS the confirmation — which is what makes acting without asking
|
||||||
|
* first tolerable. It also means the agent never has to guess which garden you
|
||||||
|
* mean.
|
||||||
|
*/
|
||||||
|
export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: boolean }) {
|
||||||
|
const history = useAgentHistory(gardenId, true)
|
||||||
|
const clear = useClearAgentHistory(gardenId)
|
||||||
|
const refresh = useAgentRefresh(gardenId)
|
||||||
|
const undo = useUndo(gardenId)
|
||||||
|
|
||||||
|
const [input, setInput] = useState('')
|
||||||
|
// 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 [error, setError] = useState<string | null>(null)
|
||||||
|
const [warning, setWarning] = useState<string | null>(null)
|
||||||
|
const abort = useRef<AbortController | null>(null)
|
||||||
|
const bottom = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Deliberately NOT aborted on unmount. Selecting an object auto-switches the
|
||||||
|
// rail to the inspector, so aborting here would mean clicking the canvas
|
||||||
|
// 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.
|
||||||
|
useEffect(() => {
|
||||||
|
bottom.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
|
||||||
|
}, [history.data, pending])
|
||||||
|
|
||||||
|
const send = () => {
|
||||||
|
const message = input.trim()
|
||||||
|
if (!message || pending) return
|
||||||
|
setInput('')
|
||||||
|
setError(null)
|
||||||
|
setWarning(null)
|
||||||
|
setPending({ message, steps: [] })
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
abort.current = controller
|
||||||
|
|
||||||
|
void streamChat(
|
||||||
|
gardenId,
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
onStep: (step) => {
|
||||||
|
setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p))
|
||||||
|
// The canvas updating under the conversation is the whole point of
|
||||||
|
// putting the chat here, so refresh it as each step lands — but only
|
||||||
|
// it: nothing else can have changed until the turn commits.
|
||||||
|
refresh.canvas()
|
||||||
|
},
|
||||||
|
onDone: (turn: AgentTurn) => {
|
||||||
|
setPending(null)
|
||||||
|
refresh.everything()
|
||||||
|
if (turn.truncated) {
|
||||||
|
setError('That turned into more steps than I should take at once — check what changed before continuing.')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onWarning: setWarning,
|
||||||
|
onError: (message) => {
|
||||||
|
setPending(null)
|
||||||
|
setError(message)
|
||||||
|
// Something may still have landed before it failed.
|
||||||
|
refresh.everything()
|
||||||
|
},
|
||||||
|
},
|
||||||
|
controller.signal,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages = history.data ?? []
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="text-sm font-semibold text-fg">Assistant</h2>
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
clear.mutate(undefined, {
|
||||||
|
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Start over
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!canEdit && (
|
||||||
|
<p className="rounded-md bg-border/40 px-2 py-1 text-xs text-muted">
|
||||||
|
You can only view this garden, so the assistant can't change anything in it.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<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 && (
|
||||||
|
<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
|
||||||
|
north half of the west bed with beans”, “what's in here?”. Everything it does lands as one change you
|
||||||
|
can undo.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{messages.map((m) => (
|
||||||
|
<Bubble key={m.id} role={m.role} body={m.body}>
|
||||||
|
{/* Undo on the turn itself, so the common case never involves
|
||||||
|
opening the History panel. Same hook #49 uses, not a second
|
||||||
|
implementation. */}
|
||||||
|
{m.role === 'assistant' && m.changeSetId != null && canEdit && (
|
||||||
|
<UndoButton changeSet={{ id: m.changeSetId }} undo={undo} className="mt-1 items-start" />
|
||||||
|
)}
|
||||||
|
</Bubble>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{pending && (
|
||||||
|
<>
|
||||||
|
<Bubble role="user" body={pending.message} />
|
||||||
|
<div className="rounded-lg border border-border px-2.5 py-2 text-sm">
|
||||||
|
<ol className="flex flex-col gap-0.5">
|
||||||
|
{pending.steps.map((s) => (
|
||||||
|
<li key={s.index} className="text-xs text-muted">
|
||||||
|
{describeStep(s)}…
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted">
|
||||||
|
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
|
||||||
|
{pending.steps.length === 0 ? 'Thinking…' : 'Working…'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{warning && <Alert tone="info">{warning}</Alert>}
|
||||||
|
{error && <Alert>{error}</Alert>}
|
||||||
|
<div ref={bottom} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-2 border-t border-border pt-2">
|
||||||
|
<TextArea
|
||||||
|
label="Message"
|
||||||
|
name="agentMessage"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Change the garlic bed to cucumbers this year"
|
||||||
|
value={input}
|
||||||
|
disabled={!!pending}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
// Enter sends, Shift+Enter breaks the line — the convention every
|
||||||
|
// chat box uses, and typing a newline by accident mid-thought is a
|
||||||
|
// worse failure than the reverse.
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
send()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{pending && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
abort.current?.abort()
|
||||||
|
setPending(null)
|
||||||
|
refresh.everything()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Stop
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button className="px-3 py-1.5 text-sm" disabled={!!pending || input.trim() === ''} onClick={send}>
|
||||||
|
{pending ? 'Working…' : 'Send'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bubble({
|
||||||
|
role,
|
||||||
|
body,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
role: 'user' | 'assistant'
|
||||||
|
body: string
|
||||||
|
children?: ReactNode
|
||||||
|
}) {
|
||||||
|
const mine = role === 'user'
|
||||||
|
return (
|
||||||
|
<div className={cn('flex flex-col', mine ? 'items-end' : 'items-start')}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'max-w-[90%] whitespace-pre-wrap rounded-lg px-2.5 py-2 text-sm',
|
||||||
|
mine ? 'bg-accent/15 text-fg' : 'border border-border text-fg',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -4,9 +4,9 @@ import { cn } from '@/lib/cn'
|
|||||||
/**
|
/**
|
||||||
* The editor's side rail, and the answer to "four things want one rail".
|
* The editor's side rail, and the answer to "four things want one rail".
|
||||||
*
|
*
|
||||||
* The inspector, history, and (later) the journal and chat panel all want the
|
* The inspector, history, journal, and (later) the chat panel all want the same
|
||||||
* same strip of screen. Rather than each bolting on its own chrome, they are
|
* strip of screen. Rather than each bolting on its own chrome, they are tabs in
|
||||||
* tabs in one rail — which keeps the canvas one width instead of a different
|
* one rail — which keeps the canvas one width instead of a different
|
||||||
* width per panel, and means adding the journal or chat is adding a tab.
|
* width per panel, and means adding the journal or chat is adding a tab.
|
||||||
*
|
*
|
||||||
* Two constraints shaped it:
|
* Two constraints shaped it:
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ export function GardenCanvas({
|
|||||||
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
|
const focusedObjectId = useEditorStore((s) => s.focusedObjectId)
|
||||||
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
||||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||||
|
const armedLotId = useEditorStore((s) => s.armedLotId)
|
||||||
const liveObject = useEditorStore((s) => s.liveObject)
|
const liveObject = useEditorStore((s) => s.liveObject)
|
||||||
const livePlanting = useEditorStore((s) => s.livePlanting)
|
const livePlanting = useEditorStore((s) => s.livePlanting)
|
||||||
const { fitToRect } = useViewport(svgRef)
|
const { fitToRect } = useViewport(svgRef)
|
||||||
@@ -200,7 +201,14 @@ export function GardenCanvas({
|
|||||||
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
|
const radiusCm = Math.max(1.5 * armedPlant.spacingCm, 15)
|
||||||
// Stay armed for repeat-placement; don't select (the placement sheet covers
|
// Stay armed for repeat-placement; don't select (the placement sheet covers
|
||||||
// the object, so a selection would be hidden until placement ends anyway).
|
// the object, so a selection would be hidden until placement ends anyway).
|
||||||
createPlanting.mutate({ objectId: focusedObject.id, plantId: armedPlant.id, xCm: x, yCm: y, radiusCm })
|
createPlanting.mutate({
|
||||||
|
objectId: focusedObject.id,
|
||||||
|
plantId: armedPlant.id,
|
||||||
|
xCm: x,
|
||||||
|
yCm: y,
|
||||||
|
radiusCm,
|
||||||
|
seedLotId: armedLotId ?? undefined,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
|
const halfFW = focusedObject ? focusedObject.widthCm / 2 : 0
|
||||||
|
|||||||
@@ -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'}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -31,12 +31,20 @@ export function Inspector({
|
|||||||
gardenId,
|
gardenId,
|
||||||
unit,
|
unit,
|
||||||
onFocus,
|
onFocus,
|
||||||
|
onAddNote,
|
||||||
|
noteCount = 0,
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
}: {
|
}: {
|
||||||
object: EditorObject
|
object: EditorObject
|
||||||
gardenId: number
|
gardenId: number
|
||||||
unit: UnitPref
|
unit: UnitPref
|
||||||
onFocus?: () => void
|
onFocus?: () => void
|
||||||
|
/** Opens the journal scoped to this object. Two taps from a selected bed to
|
||||||
|
* typing is the bar; anything more and the log stays empty. */
|
||||||
|
onAddNote?: () => void
|
||||||
|
/** How many journal entries are about this object, so the log is discoverable
|
||||||
|
* from the thing it's about rather than being a panel you have to remember. */
|
||||||
|
noteCount?: number
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
}) {
|
}) {
|
||||||
const update = useUpdateObject(gardenId)
|
const update = useUpdateObject(gardenId)
|
||||||
@@ -134,6 +142,16 @@ export function Inspector({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onAddNote && (
|
||||||
|
<Button variant="ghost" onClick={onAddNote} className="w-full text-sm">
|
||||||
|
{noteCount > 0
|
||||||
|
? `📝 ${noteCount} ${noteCount === 1 ? 'note' : 'notes'}`
|
||||||
|
: readOnly
|
||||||
|
? '📝 No notes'
|
||||||
|
: '📝 Add note'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* A disabled fieldset makes every control below read-only for viewers in
|
{/* A disabled fieldset makes every control below read-only for viewers in
|
||||||
one shot (no per-input disabled). */}
|
one shot (no per-input disabled). */}
|
||||||
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
|
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { Alert } from '@/components/ui/Alert'
|
||||||
|
import { Button } from '@/components/ui/Button'
|
||||||
|
import { TextArea } from '@/components/ui/TextArea'
|
||||||
|
import { TextField } from '@/components/ui/TextField'
|
||||||
|
import { errorMessage } from '@/lib/api'
|
||||||
|
import {
|
||||||
|
formatObservedAt,
|
||||||
|
today,
|
||||||
|
useCreateJournalEntry,
|
||||||
|
useDeleteJournalEntry,
|
||||||
|
useJournal,
|
||||||
|
useUpdateJournalEntry,
|
||||||
|
type JournalEntry,
|
||||||
|
} from '@/lib/journal'
|
||||||
|
import type { EditorObject } from './types'
|
||||||
|
import { objectDisplayName } from './kinds'
|
||||||
|
|
||||||
|
// Shared styling for the small From/To date inputs, so the two stay in step and
|
||||||
|
// don't drift from each other.
|
||||||
|
const dateInputClass =
|
||||||
|
'rounded-md border border-border bg-surface px-1.5 py-1 text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The garden's journal: write an entry, read the season back.
|
||||||
|
*
|
||||||
|
* Notes get written standing in the garden holding a phone, usually one-handed.
|
||||||
|
* If it takes more than a couple of taps from looking at a bed to typing a
|
||||||
|
* sentence, the log stays empty — so the composer is open by default rather than
|
||||||
|
* behind an "add" button, and selecting a bed pre-scopes it to that bed.
|
||||||
|
*/
|
||||||
|
export function JournalPanel({
|
||||||
|
gardenId,
|
||||||
|
canEdit,
|
||||||
|
currentUserId,
|
||||||
|
isOwner,
|
||||||
|
objects,
|
||||||
|
scopeObjectId,
|
||||||
|
onScopeChange,
|
||||||
|
}: {
|
||||||
|
gardenId: number
|
||||||
|
canEdit: boolean
|
||||||
|
currentUserId?: number
|
||||||
|
isOwner: boolean
|
||||||
|
objects: EditorObject[]
|
||||||
|
/** Which bed the panel is filtered to, if any. */
|
||||||
|
scopeObjectId: number | null
|
||||||
|
onScopeChange: (id: number | null) => void
|
||||||
|
}) {
|
||||||
|
// Date-range narrowing (#85): the backend and JournalFilter already supported
|
||||||
|
// from/to; they just had no UI. Empty inputs don't filter.
|
||||||
|
const [from, setFrom] = useState('')
|
||||||
|
const [to, setTo] = useState('')
|
||||||
|
const filter = {
|
||||||
|
...(scopeObjectId != null ? { objectId: scopeObjectId } : {}),
|
||||||
|
...(from ? { from } : {}),
|
||||||
|
...(to ? { to } : {}),
|
||||||
|
}
|
||||||
|
const journal = useJournal(gardenId, filter)
|
||||||
|
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
|
||||||
|
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<h2 className="text-sm font-semibold text-fg">Journal</h2>
|
||||||
|
{objects.length > 0 && (
|
||||||
|
<select
|
||||||
|
value={scopeObjectId ?? ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = Number(e.target.value)
|
||||||
|
onScopeChange(e.target.value === '' || !Number.isFinite(id) ? null : id)
|
||||||
|
}}
|
||||||
|
className="max-w-[9rem] truncate rounded-md border border-border bg-surface px-1.5 py-1 text-xs text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
<option value="">Whole garden</option>
|
||||||
|
{objects.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{objectDisplayName(o)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted">
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<span>From</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={from}
|
||||||
|
max={to || undefined}
|
||||||
|
onChange={(e) => setFrom(e.target.value)}
|
||||||
|
className={dateInputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1">
|
||||||
|
<span>To</span>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={to}
|
||||||
|
min={from || undefined}
|
||||||
|
onChange={(e) => setTo(e.target.value)}
|
||||||
|
className={dateInputClass}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
{(from || to) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setFrom('')
|
||||||
|
setTo('')
|
||||||
|
}}
|
||||||
|
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<Composer
|
||||||
|
gardenId={gardenId}
|
||||||
|
objectId={scopeObjectId}
|
||||||
|
scopeLabel={scopedObject ? objectDisplayName(scopedObject) : null}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{journal.isPending && <p className="text-sm text-muted">Loading…</p>}
|
||||||
|
{journal.isError && entries.length === 0 && (
|
||||||
|
<Alert>{errorMessage(journal.error, 'Could not load the journal.')}</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{journal.isSuccess && entries.length === 0 && (
|
||||||
|
<p className="text-sm text-muted">
|
||||||
|
{scopedObject
|
||||||
|
? `Nothing written about ${objectDisplayName(scopedObject)} yet.`
|
||||||
|
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ol className="flex flex-col gap-2">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<Entry
|
||||||
|
key={e.id}
|
||||||
|
entry={e}
|
||||||
|
gardenId={gardenId}
|
||||||
|
objects={objects}
|
||||||
|
canDelete={canEdit && (e.authorId === currentUserId || isOwner)}
|
||||||
|
canRewrite={canEdit && e.authorId === currentUserId}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{journal.hasNextPage && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="text-sm"
|
||||||
|
disabled={journal.isFetchingNextPage}
|
||||||
|
onClick={() => void journal.fetchNextPage()}
|
||||||
|
>
|
||||||
|
{journal.isFetchingNextPage ? 'Loading…' : 'Load older'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{journal.isError && entries.length > 0 && (
|
||||||
|
<p className="text-xs text-red-700 dark:text-red-400">
|
||||||
|
{errorMessage(journal.error, "Couldn't load older entries.")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Composer({
|
||||||
|
gardenId,
|
||||||
|
objectId,
|
||||||
|
scopeLabel,
|
||||||
|
}: {
|
||||||
|
gardenId: number
|
||||||
|
objectId: number | null
|
||||||
|
scopeLabel: string | null
|
||||||
|
}) {
|
||||||
|
const create = useCreateJournalEntry(gardenId)
|
||||||
|
const [body, setBody] = useState('')
|
||||||
|
// Editable so an observation can be backdated: you write up Saturday on Sunday.
|
||||||
|
const [observedAt, setObservedAt] = useState(today())
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const submit = () => {
|
||||||
|
const text = body.trim()
|
||||||
|
if (!text) return
|
||||||
|
setError(null)
|
||||||
|
create.mutate(
|
||||||
|
{ body: text, observedAt, objectId: objectId ?? undefined },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setBody('')
|
||||||
|
setObservedAt(today())
|
||||||
|
},
|
||||||
|
onError: (err) => setError(errorMessage(err, "Couldn't save that note.")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2 rounded-lg border border-border p-2">
|
||||||
|
<TextArea
|
||||||
|
label={scopeLabel ? `Note about ${scopeLabel}` : 'Note'}
|
||||||
|
name="journalBody"
|
||||||
|
rows={3}
|
||||||
|
placeholder="What happened?"
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => setBody(e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<TextField
|
||||||
|
label="Observed"
|
||||||
|
name="observedAt"
|
||||||
|
type="date"
|
||||||
|
value={observedAt}
|
||||||
|
onChange={(e) => setObservedAt(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button className="shrink-0" disabled={create.isPending || body.trim() === ''} onClick={submit}>
|
||||||
|
{create.isPending ? 'Saving…' : 'Save note'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && <p className="text-xs text-red-700 dark:text-red-400">{error}</p>}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Entry({
|
||||||
|
entry,
|
||||||
|
gardenId,
|
||||||
|
objects,
|
||||||
|
canDelete,
|
||||||
|
canRewrite,
|
||||||
|
}: {
|
||||||
|
entry: JournalEntry
|
||||||
|
gardenId: number
|
||||||
|
objects: EditorObject[]
|
||||||
|
/** May remove it: the author, or the garden owner. */
|
||||||
|
canDelete: boolean
|
||||||
|
/** May rewrite the text: the author only — rewriting someone else's
|
||||||
|
* observation under their name is a different act from removing it. */
|
||||||
|
canRewrite: boolean
|
||||||
|
}) {
|
||||||
|
const update = useUpdateJournalEntry(gardenId)
|
||||||
|
const del = useDeleteJournalEntry(gardenId)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [draft, setDraft] = useState(entry.body)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const about = objects.find((o) => o.id === entry.objectId) ?? null
|
||||||
|
|
||||||
|
// Re-sync the draft when the entry changes underneath — a refetch after
|
||||||
|
// someone else's edit, or this entry's own successful save. Skipped while
|
||||||
|
// editing so a background refetch can't clobber what's being typed; the
|
||||||
|
// version guard is what catches a genuine collision.
|
||||||
|
const [syncedBody, setSyncedBody] = useState(entry.body)
|
||||||
|
if (!editing && entry.body !== syncedBody) {
|
||||||
|
setSyncedBody(entry.body)
|
||||||
|
setDraft(entry.body)
|
||||||
|
}
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
const text = draft.trim()
|
||||||
|
if (!text) {
|
||||||
|
// Silence here reads as a broken button; say why nothing happened.
|
||||||
|
setError('An entry needs some text. Delete it instead if that\'s what you meant.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(null)
|
||||||
|
update.mutate(
|
||||||
|
{ id: entry.id, version: entry.version, body: text },
|
||||||
|
{
|
||||||
|
onSuccess: () => {
|
||||||
|
setEditing(false)
|
||||||
|
setError(null)
|
||||||
|
},
|
||||||
|
onError: (err) => setError(errorMessage(err, "Couldn't save that edit.")),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="rounded-lg border border-border px-2.5 py-2 text-sm">
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<TextArea label="Note" name={`entry-${entry.id}`} rows={3} value={draft} onChange={(e) => setDraft(e.target.value)} />
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="px-2 py-1 text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setDraft(entry.body)
|
||||||
|
setEditing(false)
|
||||||
|
setError(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button className="px-2 py-1 text-xs" disabled={update.isPending} onClick={save}>
|
||||||
|
{update.isPending ? 'Saving…' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="whitespace-pre-wrap text-fg">{entry.body}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
|
||||||
|
<time dateTime={entry.observedAt}>{formatObservedAt(entry.observedAt)}</time>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span>{entry.authorName}</span>
|
||||||
|
{about && (
|
||||||
|
<>
|
||||||
|
<span aria-hidden>·</span>
|
||||||
|
<span className="rounded bg-border/60 px-1 py-px">{objectDisplayName(about)}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{entry.plantingId != null && <span className="rounded bg-border/60 px-1 py-px">planting</span>}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{(canRewrite || canDelete) && !editing && (
|
||||||
|
<div className="mt-1 flex justify-end gap-1">
|
||||||
|
{canRewrite && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{canDelete && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={del.isPending}
|
||||||
|
onClick={() => {
|
||||||
|
setError(null) // clear a previous failure so a retry isn't read as another
|
||||||
|
del.mutate(entry.id, {
|
||||||
|
onError: (err) => setError(errorMessage(err, "Couldn't delete that note.")),
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="rounded px-1.5 py-0.5 text-xs text-red-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-red-400"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <p className="mt-1 text-xs text-red-700 dark:text-red-400">{error}</p>}
|
||||||
|
</li>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,6 +4,16 @@ import { fieldControlClass } from '@/components/ui/field'
|
|||||||
import { CategoryChips } from '@/components/plants/CategoryChips'
|
import { CategoryChips } from '@/components/plants/CategoryChips'
|
||||||
import { PlantIcon } from '@/components/plants/PlantIcon'
|
import { PlantIcon } from '@/components/plants/PlantIcon'
|
||||||
import { CATEGORY_LABELS, filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
import { CATEGORY_LABELS, filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
||||||
|
import {
|
||||||
|
attributableLots,
|
||||||
|
formatQuantity,
|
||||||
|
lotsByPlant,
|
||||||
|
lotState,
|
||||||
|
summarizeLots,
|
||||||
|
useSeedLots,
|
||||||
|
type SeedLot,
|
||||||
|
} from '@/lib/seedLots'
|
||||||
|
import { LotStateChip } from '@/components/plants/SeedLotList'
|
||||||
import { formatSpacing, type UnitPref } from '@/lib/units'
|
import { formatSpacing, type UnitPref } from '@/lib/units'
|
||||||
|
|
||||||
const RECENT_KEY = 'pansy:recent-plants'
|
const RECENT_KEY = 'pansy:recent-plants'
|
||||||
@@ -41,11 +51,20 @@ export function PlantPicker({
|
|||||||
onClose,
|
onClose,
|
||||||
unit = 'metric',
|
unit = 'metric',
|
||||||
}: {
|
}: {
|
||||||
onSelect: (plant: Plant) => void
|
// The chosen plant, and the lot it should be attributed to when that isn't
|
||||||
|
// ambiguous. Undefined lot means "don't attribute" — which is the honest
|
||||||
|
// answer when there are no lots, and the deliberate one when the user skips.
|
||||||
|
onSelect: (plant: Plant, lot?: SeedLot) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
unit?: UnitPref
|
unit?: UnitPref
|
||||||
}) {
|
}) {
|
||||||
const plants = usePlants()
|
const plants = usePlants()
|
||||||
|
const seedLots = useSeedLots()
|
||||||
|
const lotsFor = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||||
|
// Set when a plant with SEVERAL lots is picked: which packet did this come out
|
||||||
|
// of? One lot auto-attributes and zero lots stays silent, because forcing that
|
||||||
|
// question on every placement is how a nicety becomes an obstacle.
|
||||||
|
const [choosingLotFor, setChoosingLotFor] = useState<Plant | null>(null)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [category, setCategory] = useState<CategoryFilter>('all')
|
const [category, setCategory] = useState<CategoryFilter>('all')
|
||||||
const [recent, setRecent] = useState<number[]>(() => loadRecent())
|
const [recent, setRecent] = useState<number[]>(() => loadRecent())
|
||||||
@@ -74,8 +93,21 @@ export function PlantPicker({
|
|||||||
}, [all, recent, query])
|
}, [all, recent, query])
|
||||||
|
|
||||||
function choose(p: Plant) {
|
function choose(p: Plant) {
|
||||||
|
const lots = attributableLots(lotsFor.get(p.id) ?? [])
|
||||||
|
if (lots.length > 1) {
|
||||||
|
setChoosingLotFor(p)
|
||||||
|
return
|
||||||
|
}
|
||||||
setRecent(recordRecent(p.id))
|
setRecent(recordRecent(p.id))
|
||||||
onSelect(p)
|
// A single lot attributes even when its count says empty. The count records
|
||||||
|
// what was written down, not a fact about the packet — dropping attribution
|
||||||
|
// there would make a wrong number harder to correct rather than easier.
|
||||||
|
onSelect(p, lots[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseLot(p: Plant, lot?: SeedLot) {
|
||||||
|
setRecent(recordRecent(p.id))
|
||||||
|
onSelect(p, lot)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A plant option row. keyPrefix namespaces the key so a plant appearing in
|
// A plant option row. keyPrefix namespaces the key so a plant appearing in
|
||||||
@@ -92,11 +124,22 @@ export function PlantPicker({
|
|||||||
<span className="block truncate font-medium text-fg">{p.name}</span>
|
<span className="block truncate font-medium text-fg">{p.name}</span>
|
||||||
<span className="block text-xs text-muted">
|
<span className="block text-xs text-muted">
|
||||||
{CATEGORY_LABELS[p.category]} · {formatSpacing(p.spacingCm, unit)}
|
{CATEGORY_LABELS[p.category]} · {formatSpacing(p.spacingCm, unit)}
|
||||||
|
{remainingLabel(lotsFor.get(p.id) ?? [])}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// What's left, shown where you're deciding what to plant. Silent when there
|
||||||
|
// are no lots — most plants won't have any, and "0 left" on all of them would
|
||||||
|
// be noise that trains you to ignore the number. summarizeLots owns the
|
||||||
|
// unit-agreement rule; a second copy of it here would drift.
|
||||||
|
function remainingLabel(lots: SeedLot[]): string {
|
||||||
|
if (lots.length === 0) return ''
|
||||||
|
const { remaining, unit } = summarizeLots(lots)
|
||||||
|
return ` · ${formatQuantity(remaining)}${unit ? ` ${unit}` : ''} left`
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center sm:p-4"
|
className="fixed inset-0 z-50 flex items-end justify-center bg-black/40 sm:items-center sm:p-4"
|
||||||
@@ -132,7 +175,41 @@ export function PlantPicker({
|
|||||||
<CategoryChips value={category} onChange={setCategory} size="sm" />
|
<CategoryChips value={category} onChange={setCategory} size="sm" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{choosingLotFor && (
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
||||||
|
<p className="px-3 pb-1 pt-2 text-xs font-semibold uppercase tracking-wide text-muted">
|
||||||
|
Which lot of {choosingLotFor.name}?
|
||||||
|
</p>
|
||||||
|
{attributableLots(lotsFor.get(choosingLotFor.id) ?? []).map((lot) => (
|
||||||
|
<button
|
||||||
|
key={lot.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => chooseLot(choosingLotFor, lot)}
|
||||||
|
className="flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-left outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate text-sm font-medium text-fg">
|
||||||
|
{lot.vendor || 'Unnamed lot'}
|
||||||
|
{lot.packedForYear != null ? ` · ${lot.packedForYear}` : ''}
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-muted">
|
||||||
|
{formatQuantity(lot.remaining)} of {formatQuantity(lot.quantity)} {lot.unit} left
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<LotStateChip state={lotState(lot)} />
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => chooseLot(choosingLotFor, undefined)}
|
||||||
|
className="w-full rounded-lg px-3 py-2.5 text-left text-sm text-muted outline-none transition-colors hover:bg-border/50 focus-visible:bg-border/50"
|
||||||
|
>
|
||||||
|
Don't attribute to a lot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={cn('min-h-0 flex-1 overflow-y-auto p-2', choosingLotFor && 'hidden')}>
|
||||||
{plants.isPending && <p className="p-4 text-sm text-muted">Loading plants…</p>}
|
{plants.isPending && <p className="p-4 text-sm text-muted">Loading plants…</p>}
|
||||||
{plants.isError && <p className="p-4 text-sm text-red-600 dark:text-red-400">Couldn't load plants.</p>}
|
{plants.isError && <p className="p-4 text-sm text-red-600 dark:text-red-400">Couldn't load plants.</p>}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { cn } from '@/lib/cn'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which season the canvas is showing.
|
||||||
|
*
|
||||||
|
* "Now" is the live, editable garden — what is in the ground today. A year is a
|
||||||
|
* read-only view of everything whose time in the ground overlapped that calendar
|
||||||
|
* year, plops since removed included. They are genuinely different questions:
|
||||||
|
* "what's growing" versus "what did I grow", and only the first one is editable.
|
||||||
|
*
|
||||||
|
* Only years the garden holds data for are offered. A free numeric field invites
|
||||||
|
* a typo, and a typo'd year produces a confidently empty garden that reads as
|
||||||
|
* data loss rather than a mistake.
|
||||||
|
*/
|
||||||
|
export function SeasonPicker({
|
||||||
|
years,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
years: number[]
|
||||||
|
value: number | null
|
||||||
|
onChange: (year: number | null) => void
|
||||||
|
}) {
|
||||||
|
if (years.length === 0) return null
|
||||||
|
return (
|
||||||
|
<label className="flex items-center gap-1.5 text-xs text-muted">
|
||||||
|
<span>Season</span>
|
||||||
|
<select
|
||||||
|
value={value ?? ''}
|
||||||
|
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-border bg-surface px-1.5 py-1 text-xs text-fg outline-none',
|
||||||
|
'focus-visible:ring-2 focus-visible:ring-accent/40',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<option value="">Now</option>
|
||||||
|
{years.map((y) => (
|
||||||
|
<option key={y} value={y}>
|
||||||
|
{y}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The banner that stops you thinking you're looking at now. Editing the past by
|
||||||
|
* accident is the failure mode this whole feature introduces, so the state is
|
||||||
|
* stated rather than implied by a dropdown you set a while ago — with the way
|
||||||
|
* back to the live garden right next to it.
|
||||||
|
*/
|
||||||
|
export function SeasonBanner({ year, onExit }: { year: number; onExit: () => void }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-2 py-1.5 text-sm">
|
||||||
|
<span className="font-medium text-amber-800 dark:text-amber-300">Viewing {year}</span>
|
||||||
|
<span className="text-xs text-amber-800/80 dark:text-amber-300/80">
|
||||||
|
read-only, including plantings since removed
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onExit}
|
||||||
|
className="ml-auto rounded px-1.5 py-0.5 text-xs font-medium text-amber-900 underline outline-none hover:no-underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-amber-200"
|
||||||
|
>
|
||||||
|
Back to now
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/Button'
|
import { Button } from '@/components/ui/Button'
|
||||||
import { cn } from '@/lib/cn'
|
import { cn } from '@/lib/cn'
|
||||||
import type { ChangeSet, UndoOutcome, useUndo } from '@/lib/history'
|
import type { UndoOutcome, UndoTarget, useUndo } from '@/lib/history'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Undo, plus what happened. Paired with useUndo so the history list and the
|
* Undo, plus what happened. Paired with useUndo so the history list and the
|
||||||
@@ -14,7 +14,7 @@ export function UndoButton({
|
|||||||
className,
|
className,
|
||||||
label = 'Undo',
|
label = 'Undo',
|
||||||
}: {
|
}: {
|
||||||
changeSet: ChangeSet
|
changeSet: UndoTarget
|
||||||
undo: ReturnType<typeof useUndo>
|
undo: ReturnType<typeof useUndo>
|
||||||
className?: string
|
className?: string
|
||||||
label?: string
|
label?: string
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
+27
-2
@@ -32,10 +32,25 @@ interface EditorState {
|
|||||||
railTab: string | null
|
railTab: string | null
|
||||||
setRailTab: (tab: string | null) => void
|
setRailTab: (tab: string | null) => void
|
||||||
|
|
||||||
|
// Which season the canvas is showing: null is "now" (live and editable), a
|
||||||
|
// year is a read-only view of what was in the ground that year. Ephemeral —
|
||||||
|
// which year you were last looking at is not a property of the garden.
|
||||||
|
seasonYear: number | null
|
||||||
|
setSeasonYear: (year: number | null) => void
|
||||||
|
|
||||||
|
// Which bed the journal tab is filtered to, or null for the whole garden.
|
||||||
|
// Separate from selectedId deliberately: you can scope the journal to a bed
|
||||||
|
// and then select something else without the list moving under you.
|
||||||
|
journalObjectId: number | null
|
||||||
|
setJournalObjectId: (id: number | null) => void
|
||||||
|
|
||||||
// The plant armed for placing plops (set after the PlantPicker choice); stays
|
// The plant armed for placing plops (set after the PlantPicker choice); stays
|
||||||
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
// armed for repeat-placement until cleared (Escape / done). null = not placing.
|
||||||
armedPlant: Plant | null
|
armedPlant: Plant | null
|
||||||
setArmedPlant: (p: Plant | null) => void
|
// Which seed lot placements should be attributed to, when the armed plant has
|
||||||
|
// one worth naming. Cleared with the plant.
|
||||||
|
armedLotId: number | null
|
||||||
|
setArmedPlant: (p: Plant | null, lotId?: number | null) => void
|
||||||
|
|
||||||
// During a move/resize/rotate, the object's live geometry is held here so the
|
// During a move/resize/rotate, the object's live geometry is held here so the
|
||||||
// canvas renders it instantly; the PATCH fires only on gesture end.
|
// canvas renders it instantly; the PATCH fires only on gesture end.
|
||||||
@@ -79,8 +94,15 @@ export const useEditorStore = create<EditorState>((set) => ({
|
|||||||
railTab: null,
|
railTab: null,
|
||||||
setRailTab: (tab) => set({ railTab: tab }),
|
setRailTab: (tab) => set({ railTab: tab }),
|
||||||
|
|
||||||
|
seasonYear: null,
|
||||||
|
setSeasonYear: (year) => set({ seasonYear: year }),
|
||||||
|
|
||||||
|
journalObjectId: null,
|
||||||
|
setJournalObjectId: (id) => set({ journalObjectId: id }),
|
||||||
|
|
||||||
armedPlant: null,
|
armedPlant: null,
|
||||||
setArmedPlant: (p) => set({ armedPlant: p }),
|
armedLotId: null,
|
||||||
|
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
|
||||||
|
|
||||||
liveObject: null,
|
liveObject: null,
|
||||||
setLiveObject: (o) => set({ liveObject: o }),
|
setLiveObject: (o) => set({ liveObject: o }),
|
||||||
@@ -100,9 +122,12 @@ export const useEditorStore = create<EditorState>((set) => ({
|
|||||||
selectedPlantingId: null,
|
selectedPlantingId: null,
|
||||||
focusedObjectId: null,
|
focusedObjectId: null,
|
||||||
armedPlant: null,
|
armedPlant: null,
|
||||||
|
armedLotId: null,
|
||||||
armedKind: null,
|
armedKind: null,
|
||||||
liveObject: null,
|
liveObject: null,
|
||||||
livePlanting: null,
|
livePlanting: null,
|
||||||
railTab: null,
|
railTab: null,
|
||||||
|
seasonYear: null,
|
||||||
|
journalObjectId: null,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { describeStep } from './agent'
|
||||||
|
|
||||||
|
describe('describeStep', () => {
|
||||||
|
// Raw tool names tell you the agent is busy; these tell you what it's busy
|
||||||
|
// DOING, which is the difference between the panel feeling alive and hung.
|
||||||
|
it("names tools in the app's own vocabulary", () => {
|
||||||
|
expect(describeStep({ index: 0, tools: ['clear_object'] })).toBe('Clearing a bed')
|
||||||
|
expect(describeStep({ index: 1, tools: ['find_plant'] })).toBe('Looking up a plant')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('collapses repeats within one step', () => {
|
||||||
|
expect(describeStep({ index: 0, tools: ['fill_region', 'fill_region'] })).toBe('Filling a bed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('joins distinct tools', () => {
|
||||||
|
expect(describeStep({ index: 0, tools: ['clear_object', 'fill_region'] })).toBe('Clearing a bed, Filling a bed')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says something for a step with no tool calls', () => {
|
||||||
|
expect(describeStep({ index: 0, tools: [] })).toBe('Thinking')
|
||||||
|
})
|
||||||
|
|
||||||
|
// A tool added server-side before the client knows about it should degrade to
|
||||||
|
// something readable rather than showing snake_case at the user.
|
||||||
|
it('falls back readably for an unknown tool', () => {
|
||||||
|
expect(describeStep({ index: 0, tools: ['prune_orchard'] })).toBe('prune orchard')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,254 @@
|
|||||||
|
// The garden assistant's client (#57).
|
||||||
|
//
|
||||||
|
// The chat lives in the editor, not on its own page, because watching the canvas
|
||||||
|
// change as the agent works IS the confirmation — which is what makes acting
|
||||||
|
// without asking first tolerable. So this module's job is as much about
|
||||||
|
// surfacing progress as it is about sending a message.
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { API_BASE, api } from './api'
|
||||||
|
import { gardenFullKey } from './objects'
|
||||||
|
import { historyKey } from './history'
|
||||||
|
|
||||||
|
const capabilitiesSchema = z.object({ agent: z.boolean() })
|
||||||
|
|
||||||
|
export const capabilitiesKey = ['capabilities'] as const
|
||||||
|
|
||||||
|
/** 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() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: capabilitiesKey,
|
||||||
|
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
|
||||||
|
staleTime: 60_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const agentMessageSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
conversationId: z.number(),
|
||||||
|
role: z.enum(['user', 'assistant']),
|
||||||
|
body: z.string(),
|
||||||
|
changeSetId: z.number().optional(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
})
|
||||||
|
export type AgentMessage = z.infer<typeof agentMessageSchema>
|
||||||
|
|
||||||
|
const historySchema = z.object({ messages: z.array(agentMessageSchema) })
|
||||||
|
|
||||||
|
export function agentHistoryKey(gardenId: number) {
|
||||||
|
return ['gardens', gardenId, 'agent-history'] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The stored conversation, so a reload doesn't lose the thread — which is
|
||||||
|
* exactly when someone reloads, to check whether a change actually landed. */
|
||||||
|
export function useAgentHistory(gardenId: number, enabled: boolean) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: agentHistoryKey(gardenId),
|
||||||
|
enabled,
|
||||||
|
queryFn: async (): Promise<AgentMessage[]> =>
|
||||||
|
historySchema.parse(await api.get(`/gardens/${gardenId}/agent/history`)).messages,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClearAgentHistory(gardenId: number) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (): Promise<void> => {
|
||||||
|
await api.delete(`/gardens/${gardenId}/agent/history`)
|
||||||
|
},
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) }),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One completed turn, as the server reports it. */
|
||||||
|
export interface AgentTurn {
|
||||||
|
reply: string
|
||||||
|
changeSetId?: number
|
||||||
|
steps: number
|
||||||
|
truncated?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A step the model just finished, named in the app's own vocabulary. */
|
||||||
|
export interface AgentStep {
|
||||||
|
index: number
|
||||||
|
tools: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// What each tool is doing, in words. Raw tool names ("fill_region") tell you the
|
||||||
|
// agent is busy; these tell you what it's busy DOING, which is the difference
|
||||||
|
// between the panel feeling alive and feeling hung.
|
||||||
|
const TOOL_LABELS: Record<string, string> = {
|
||||||
|
list_gardens: 'Looking at your gardens',
|
||||||
|
describe_garden: 'Reading the garden',
|
||||||
|
create_object: 'Adding a bed',
|
||||||
|
move_object: 'Moving a bed',
|
||||||
|
place_planting: 'Planting',
|
||||||
|
fill_region: 'Filling a bed',
|
||||||
|
clear_object: 'Clearing a bed',
|
||||||
|
find_plant: 'Looking up a plant',
|
||||||
|
create_plant: 'Adding a plant to your catalog',
|
||||||
|
add_journal_entry: 'Writing a journal note',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function describeStep(step: AgentStep): string {
|
||||||
|
if (step.tools.length === 0) return 'Thinking'
|
||||||
|
const labels = step.tools.map((t) => TOOL_LABELS[t] ?? t.replace(/_/g, ' '))
|
||||||
|
// Repeated tools in one step read as one action, not a list of identical ones.
|
||||||
|
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 {
|
||||||
|
onStep: (step: AgentStep) => void
|
||||||
|
onDone: (turn: AgentTurn) => void
|
||||||
|
onError: (message: string) => void
|
||||||
|
/** The turn succeeded, but something alongside it didn't. */
|
||||||
|
onWarning?: (message: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a message and stream the reply.
|
||||||
|
*
|
||||||
|
* Hand-rolled rather than EventSource, which can only issue GETs — this needs a
|
||||||
|
* POST body. The wire format is still SSE so a proxy that understands it doesn't
|
||||||
|
* buffer, and so switching to EventSource later wouldn't change the server.
|
||||||
|
*/
|
||||||
|
export async function streamChat(
|
||||||
|
gardenId: number,
|
||||||
|
message: string,
|
||||||
|
handlers: StreamHandlers,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<void> {
|
||||||
|
let res: Response
|
||||||
|
try {
|
||||||
|
res = await fetch(`${API_BASE}/agent/chat`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ gardenId, message }),
|
||||||
|
credentials: 'same-origin',
|
||||||
|
signal,
|
||||||
|
})
|
||||||
|
} 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.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (res.status === 401) {
|
||||||
|
// Session expired mid-conversation (#85). Reporting this as "the assistant is
|
||||||
|
// unavailable" would send the user chasing a config problem that isn't there.
|
||||||
|
// Send them to sign in again, preserving where they were.
|
||||||
|
handlers.onError('Your session has expired — please sign in again.')
|
||||||
|
const back = encodeURIComponent(location.pathname + location.search)
|
||||||
|
window.location.assign(`/login?redirect=${back}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
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(
|
||||||
|
res.status === 503
|
||||||
|
? "The assistant isn't enabled on this instance."
|
||||||
|
: res.status === 404
|
||||||
|
? "This instance doesn't have the assistant configured."
|
||||||
|
: 'The assistant is not available right now.',
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = res.body.getReader()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let buffer = ''
|
||||||
|
for (;;) {
|
||||||
|
let chunk: ReadableStreamReadResult<Uint8Array>
|
||||||
|
try {
|
||||||
|
chunk = await reader.read()
|
||||||
|
} catch {
|
||||||
|
// An aborted read is the caller navigating away, not a failure worth
|
||||||
|
// reporting back to them.
|
||||||
|
if (signal?.aborted) return
|
||||||
|
handlers.onError('The connection dropped partway through.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (chunk.done) break
|
||||||
|
buffer += decoder.decode(chunk.value, { stream: true })
|
||||||
|
|
||||||
|
// SSE frames are separated by a blank line; anything after the last one is
|
||||||
|
// a partial frame to keep for the next chunk.
|
||||||
|
const frames = buffer.split('\n\n')
|
||||||
|
buffer = frames.pop() ?? ''
|
||||||
|
for (const frame of frames) {
|
||||||
|
const line = frame.split('\n').find((l) => l.startsWith('data:'))
|
||||||
|
if (!line) continue
|
||||||
|
// Parsed AND validated: a malformed or unexpected frame shouldn't kill a
|
||||||
|
// working stream, and shouldn't be trusted into the UI either.
|
||||||
|
const parsed = chatEventSchema.safeParse(safeJson(line.slice(5).trim()))
|
||||||
|
if (!parsed.success) continue
|
||||||
|
const e = parsed.data
|
||||||
|
if (e.warning) handlers.onWarning?.(e.warning)
|
||||||
|
if (e.error) handlers.onError(e.error)
|
||||||
|
else if (e.step) handlers.onStep(e.step)
|
||||||
|
else if (e.done) handlers.onDone(e.done)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeJson(raw: string): unknown {
|
||||||
|
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) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const canvas = () => {
|
||||||
|
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: 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.')
|
||||||
@@ -113,3 +122,29 @@ describe('totalChanges', () => {
|
|||||||
).toBe(13)
|
).toBe(13)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('describeUndo with an unknown total', () => {
|
||||||
|
// The chat panel offers Undo on a turn knowing only its change set id — the
|
||||||
|
// agent's reply carries the id, not the tally. Inventing a denominator to
|
||||||
|
// fill the sentence would be worse than not having one.
|
||||||
|
it("doesn't invent a denominator it was never given", () => {
|
||||||
|
const out = describeUndo(
|
||||||
|
{ id: 5 },
|
||||||
|
{
|
||||||
|
changeSet: changeSet({ id: 6, counts: [{ entityType: 'planting', op: 'delete', n: 2 }] }),
|
||||||
|
conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
expect(out.tone).toBe('partial')
|
||||||
|
expect(out.message).toBe('Partly undone — “North Bed” was edited since, so it was left alone.')
|
||||||
|
expect(out.message).not.toMatch(/\d+ of \d+/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still reports a clean undo the same way', () => {
|
||||||
|
const out = describeUndo(
|
||||||
|
{ id: 5 },
|
||||||
|
{ changeSet: changeSet({ id: 6, counts: [{ entityType: 'object', op: 'update', n: 1 }] }), conflicts: [] },
|
||||||
|
)
|
||||||
|
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|||||||
+31
-11
@@ -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]> = {
|
||||||
@@ -171,7 +172,7 @@ export function useUndo(gardenId: number) {
|
|||||||
const revert = useRevertChangeSet(gardenId)
|
const revert = useRevertChangeSet(gardenId)
|
||||||
const [outcomes, setOutcomes] = useState<Record<number, UndoOutcome>>({})
|
const [outcomes, setOutcomes] = useState<Record<number, UndoOutcome>>({})
|
||||||
|
|
||||||
const undo = (cs: ChangeSet) => {
|
const undo = (cs: UndoTarget) => {
|
||||||
setOutcomes((prev) => ({ ...prev, [cs.id]: { tone: 'pending', message: 'Undoing…' } }))
|
setOutcomes((prev) => ({ ...prev, [cs.id]: { tone: 'pending', message: 'Undoing…' } }))
|
||||||
revert.mutate(cs.id, {
|
revert.mutate(cs.id, {
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
@@ -197,6 +198,19 @@ export function useUndo(gardenId: number) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What undo needs to know about a change set.
|
||||||
|
*
|
||||||
|
* `counts` is optional because the chat panel offers Undo on a turn knowing only
|
||||||
|
* its change set id — the agent's reply carries the id, not the tally. Fabricating
|
||||||
|
* counts to satisfy a type would produce a confidently wrong "1 of 1 changes
|
||||||
|
* undone"; leaving them out lets describeUndo say what it actually knows.
|
||||||
|
*/
|
||||||
|
export interface UndoTarget {
|
||||||
|
id: number
|
||||||
|
counts?: ChangeCount[]
|
||||||
|
}
|
||||||
|
|
||||||
export interface UndoOutcome {
|
export interface UndoOutcome {
|
||||||
tone: 'pending' | 'ok' | 'partial' | 'error'
|
tone: 'pending' | 'ok' | 'partial' | 'error'
|
||||||
message: string
|
message: string
|
||||||
@@ -208,19 +222,25 @@ export interface UndoOutcome {
|
|||||||
* useful thing to report: a bare failure would be a lie about the two that did
|
* useful thing to report: a bare failure would be a lie about the two that did
|
||||||
* apply, and a bare success would hide the one that didn't.
|
* apply, and a bare success would hide the one that didn't.
|
||||||
*/
|
*/
|
||||||
export function describeUndo(target: ChangeSet, 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
|
||||||
|
// never knew the total would be a number invented to fill a sentence.
|
||||||
const total = totalChanges(target)
|
const total = totalChanges(target)
|
||||||
return { tone: 'partial', message: `${applied} of ${total} changes undone — ${skipped}.` }
|
const scale = total > 0 && applied > 0 ? `${applied} of ${total} changes undone` : 'Partly undone'
|
||||||
|
return { tone: 'partial', message: `${scale} — ${skipped}.` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
// Grow journal data layer (#53): entries about a garden, a bed, or one plop.
|
||||||
|
//
|
||||||
|
// An entry is what HAPPENED and when, as opposed to the `notes` field on a
|
||||||
|
// garden/object/plant, which is what the thing IS. Entries accumulate; notes
|
||||||
|
// overwrite.
|
||||||
|
|
||||||
|
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { ApiError, api } from './api'
|
||||||
|
|
||||||
|
export const journalEntrySchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
gardenId: z.number(),
|
||||||
|
objectId: z.number().optional(),
|
||||||
|
plantingId: z.number().optional(),
|
||||||
|
authorId: z.number(),
|
||||||
|
authorName: z.string().default(''),
|
||||||
|
body: z.string(),
|
||||||
|
// When it happened, which is not when it was written down.
|
||||||
|
observedAt: z.string(),
|
||||||
|
version: z.number(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
})
|
||||||
|
export type JournalEntry = z.infer<typeof journalEntrySchema>
|
||||||
|
|
||||||
|
const journalPageSchema = z.object({
|
||||||
|
entries: z.array(journalEntrySchema),
|
||||||
|
hasMore: z.boolean(),
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Narrows the journal. Undefined fields don't filter. */
|
||||||
|
export interface JournalFilter {
|
||||||
|
objectId?: number
|
||||||
|
plantingId?: number
|
||||||
|
from?: string
|
||||||
|
to?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const PAGE_SIZE = 30
|
||||||
|
|
||||||
|
export function journalKey(gardenId: number, filter: JournalFilter = {}) {
|
||||||
|
return ['gardens', gardenId, 'journal', filter] as const
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useJournal(gardenId: number, filter: JournalFilter = {}, enabled = true) {
|
||||||
|
return useInfiniteQuery({
|
||||||
|
queryKey: journalKey(gardenId, filter),
|
||||||
|
enabled,
|
||||||
|
initialPageParam: 0,
|
||||||
|
queryFn: async ({ pageParam }) =>
|
||||||
|
journalPageSchema.parse(
|
||||||
|
await api.get(`/gardens/${gardenId}/journal`, {
|
||||||
|
params: { ...filter, limit: PAGE_SIZE, offset: pageParam },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
getNextPageParam: (last, pages) => (last.hasMore ? pages.length * PAGE_SIZE : undefined),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const countsSchema = z.object({ counts: z.record(z.string(), z.number().int().nonnegative()) })
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How many entries each object has, keyed by object id — 0 for the garden
|
||||||
|
* itself. Its own query rather than derived from the list, because the
|
||||||
|
* indicator has to work while the journal panel is closed, which is exactly when
|
||||||
|
* the list hasn't loaded.
|
||||||
|
*/
|
||||||
|
export function useJournalCounts(gardenId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['gardens', gardenId, 'journal-counts'] as const,
|
||||||
|
queryFn: async (): Promise<Map<number, number>> => {
|
||||||
|
const { counts } = countsSchema.parse(await api.get(`/gardens/${gardenId}/journal/counts`))
|
||||||
|
return new Map(Object.entries(counts).map(([id, n]) => [Number(id), n]))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JournalInput {
|
||||||
|
objectId?: number
|
||||||
|
plantingId?: number
|
||||||
|
body: string
|
||||||
|
observedAt?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every journal query for this garden, whatever its filter — a new entry may
|
||||||
|
// belong to several of them at once (the garden's, its bed's, a date range's),
|
||||||
|
// and working out which is more effort than refetching a short list.
|
||||||
|
function invalidateJournal(qc: ReturnType<typeof useQueryClient>, gardenId: number) {
|
||||||
|
void qc.invalidateQueries({ queryKey: ['gardens', gardenId, 'journal'] })
|
||||||
|
void qc.invalidateQueries({ queryKey: ['gardens', gardenId, 'journal-counts'] })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateJournalEntry(gardenId: number) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: JournalInput): Promise<JournalEntry> =>
|
||||||
|
journalEntrySchema.parse(await api.post(`/gardens/${gardenId}/journal`, input)),
|
||||||
|
onSuccess: () => invalidateJournal(qc, gardenId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateJournalEntry(gardenId: number) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (vars: { id: number; version: number; body?: string; observedAt?: string }): Promise<JournalEntry> => {
|
||||||
|
const { id, ...rest } = vars
|
||||||
|
return journalEntrySchema.parse(await api.patch(`/journal/${id}`, rest))
|
||||||
|
},
|
||||||
|
onSuccess: () => invalidateJournal(qc, gardenId),
|
||||||
|
onError: (err) => {
|
||||||
|
// A 409 means the row moved on. Refetch so the component receives the
|
||||||
|
// current version — otherwise a retry resends the stale one and conflicts
|
||||||
|
// forever, which reads as a button that simply doesn't work.
|
||||||
|
if (err instanceof ApiError && err.isConflict) invalidateJournal(qc, gardenId)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteJournalEntry(gardenId: number) {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/journal/${id}`)
|
||||||
|
},
|
||||||
|
onSuccess: () => invalidateJournal(qc, gardenId),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Today as YYYY-MM-DD in the viewer's own timezone — "today" means the day you
|
||||||
|
* are standing in the garden, not the day it is in UTC. */
|
||||||
|
export function today(): string {
|
||||||
|
const now = new Date()
|
||||||
|
const pad = (n: number) => String(n).padStart(2, '0')
|
||||||
|
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A date-only string as a short human label, without dragging the value
|
||||||
|
* through a Date (which would shift it by the timezone offset). */
|
||||||
|
export function formatObservedAt(iso: string): string {
|
||||||
|
const [y, m, d] = iso.split('-').map(Number)
|
||||||
|
// Out-of-range parts would silently roll over — 2026-13-45 becoming February
|
||||||
|
// 2027 — so show the raw string instead of a confidently wrong date.
|
||||||
|
if (!y || !m || !d || m < 1 || m > 12 || d < 1 || d > 31) return iso
|
||||||
|
return new Date(y, m - 1, d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||||
|
}
|
||||||
+51
-17
@@ -86,6 +86,38 @@ export function useGardenFull(gardenId: number) {
|
|||||||
return useQuery(gardenFullQueryOptions(gardenId))
|
return useQuery(gardenFullQueryOptions(gardenId))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A past season is a SEPARATE query under its own key, deliberately. The
|
||||||
|
// optimistic mutations below all patch gardenFullKey(gardenId); if a year were
|
||||||
|
// folded into that key they would write into whichever season happened to be on
|
||||||
|
// screen. Season views are read-only, so they never need that machinery — and
|
||||||
|
// keeping them out of it means they can't accidentally join it.
|
||||||
|
export const gardenSeasonKey = (gardenId: number, year: number) =>
|
||||||
|
['garden-season', gardenId, year] as const
|
||||||
|
|
||||||
|
/** A garden as it stood in a given calendar year: every plop whose time in the
|
||||||
|
* ground overlapped it, including ones since removed. Read-only. */
|
||||||
|
export function useGardenSeason(gardenId: number, year: number | null) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: gardenSeasonKey(gardenId, year ?? 0),
|
||||||
|
enabled: year !== null,
|
||||||
|
queryFn: async (): Promise<FullGarden> =>
|
||||||
|
fullGardenSchema.parse(await api.get(`/gardens/${gardenId}/full`, { params: { year } })),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const yearsSchema = z.object({ years: z.array(z.number().int()) })
|
||||||
|
|
||||||
|
/** The years this garden holds planting data for, newest first, always
|
||||||
|
* including the current one. Offering only years with data keeps the selector
|
||||||
|
* from inviting a typo that produces a confidently empty garden. */
|
||||||
|
export function useGardenYears(gardenId: number) {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ['garden-years', gardenId] as const,
|
||||||
|
queryFn: async (): Promise<number[]> =>
|
||||||
|
yearsSchema.parse(await api.get(`/gardens/${gardenId}/years`)).years,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure a plant is present in the /full cache's `plants` list. The full payload
|
* Ensure a plant is present in the /full cache's `plants` list. The full payload
|
||||||
* carries only the garden's *referenced* plants (ListReferencedPlants), so a
|
* carries only the garden's *referenced* plants (ListReferencedPlants), so a
|
||||||
@@ -226,6 +258,8 @@ export interface PlantingCreate {
|
|||||||
radiusCm: number
|
radiusCm: number
|
||||||
count?: number | null
|
count?: number | null
|
||||||
label?: string | null
|
label?: string | null
|
||||||
|
/** Attributes the plop to a purchase, so that lot can report what's left. */
|
||||||
|
seedLotId?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useCreatePlanting(gardenId: number) {
|
export function useCreatePlanting(gardenId: number) {
|
||||||
@@ -294,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.')),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ export const plantSchema = z.object({
|
|||||||
color: z.string(),
|
color: z.string(),
|
||||||
icon: z.string(),
|
icon: z.string(),
|
||||||
daysToMaturity: z.number().nullable().optional(),
|
daysToMaturity: z.number().nullable().optional(),
|
||||||
|
// Provenance for the VARIETY — the page you'd go back to to buy it again.
|
||||||
|
// What you actually bought, and how much is left, lives in seedLots.ts.
|
||||||
|
sourceUrl: z.string().default(''),
|
||||||
|
vendor: z.string().default(''),
|
||||||
notes: z.string(),
|
notes: z.string(),
|
||||||
version: z.number(),
|
version: z.number(),
|
||||||
createdAt: z.string(),
|
createdAt: z.string(),
|
||||||
@@ -75,6 +79,8 @@ export interface PlantInput {
|
|||||||
color: string
|
color: string
|
||||||
icon: string
|
icon: string
|
||||||
daysToMaturity: number | null
|
daysToMaturity: number | null
|
||||||
|
sourceUrl: string
|
||||||
|
vendor: string
|
||||||
notes: string
|
notes: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import {
|
||||||
|
attributableLots,
|
||||||
|
formatCost,
|
||||||
|
formatQuantity,
|
||||||
|
formatUnitCost,
|
||||||
|
lotState,
|
||||||
|
lotsByPlant,
|
||||||
|
safeExternalUrl,
|
||||||
|
summarizeLots,
|
||||||
|
type SeedLot,
|
||||||
|
} from './seedLots'
|
||||||
|
|
||||||
|
function lot(over: Partial<SeedLot> = {}): SeedLot {
|
||||||
|
return {
|
||||||
|
id: 1,
|
||||||
|
ownerId: 1,
|
||||||
|
plantId: 1,
|
||||||
|
vendor: '',
|
||||||
|
sourceUrl: '',
|
||||||
|
sku: '',
|
||||||
|
lotCode: '',
|
||||||
|
quantity: 100,
|
||||||
|
unit: 'seeds',
|
||||||
|
notes: '',
|
||||||
|
version: 1,
|
||||||
|
createdAt: '2026-01-01T00:00:00Z',
|
||||||
|
updatedAt: '2026-01-01T00:00:00Z',
|
||||||
|
used: 0,
|
||||||
|
remaining: 100,
|
||||||
|
...over,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('safeExternalUrl', () => {
|
||||||
|
// The server already scheme-checks this, but a link is rendered from whatever
|
||||||
|
// the client was handed. "The backend validated it" is not a reason to hand
|
||||||
|
// javascript: to an anchor tag.
|
||||||
|
it('accepts http and https', () => {
|
||||||
|
expect(safeExternalUrl('https://www.johnnyseeds.com/x')).toBe('https://www.johnnyseeds.com/x')
|
||||||
|
expect(safeExternalUrl('http://example.com/')).toBe('http://example.com/')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('refuses everything else', () => {
|
||||||
|
for (const bad of [
|
||||||
|
'',
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'JavaScript:alert(1)',
|
||||||
|
'data:text/html,<script>alert(1)</script>',
|
||||||
|
'file:///etc/passwd',
|
||||||
|
'//evil.example.com',
|
||||||
|
'/seeds/garlic',
|
||||||
|
'not a url',
|
||||||
|
]) {
|
||||||
|
expect(safeExternalUrl(bad)).toBeNull()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lotState', () => {
|
||||||
|
it('reads the state, not just the number', () => {
|
||||||
|
expect(lotState(lot({ quantity: 100, remaining: 100 }))).toBe('ok')
|
||||||
|
expect(lotState(lot({ quantity: 100, remaining: 20 }))).toBe('low')
|
||||||
|
expect(lotState(lot({ quantity: 100, remaining: 0 }))).toBe('empty')
|
||||||
|
// Planting more than you recorded buying is real, and worth showing plainly
|
||||||
|
// rather than clamping to zero.
|
||||||
|
expect(lotState(lot({ quantity: 100, remaining: -5 }))).toBe('over')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has nothing to say about a lot with no recorded quantity', () => {
|
||||||
|
expect(lotState(lot({ quantity: 0, remaining: 0 }))).toBe('unknown')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('summarizeLots', () => {
|
||||||
|
it('totals remaining and reports the worst state', () => {
|
||||||
|
const s = summarizeLots([
|
||||||
|
lot({ id: 1, quantity: 100, remaining: 80 }),
|
||||||
|
lot({ id: 2, quantity: 50, remaining: 5 }),
|
||||||
|
])
|
||||||
|
expect(s.remaining).toBe(85)
|
||||||
|
expect(s.unit).toBe('seeds')
|
||||||
|
expect(s.state).toBe('low') // the one that needs attention wins
|
||||||
|
})
|
||||||
|
|
||||||
|
it("drops the unit when lots don't agree, rather than summing dishonestly", () => {
|
||||||
|
const s = summarizeLots([
|
||||||
|
lot({ id: 1, unit: 'seeds', quantity: 100, remaining: 100 }),
|
||||||
|
lot({ id: 2, unit: 'grams', quantity: 10, remaining: 10 }),
|
||||||
|
])
|
||||||
|
expect(s.unit).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('says nothing for a plant with no lots', () => {
|
||||||
|
expect(summarizeLots([])).toEqual({ remaining: 0, unit: null, state: 'unknown' })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('cost formatting', () => {
|
||||||
|
it('renders whole currency from cents', () => {
|
||||||
|
expect(formatCost(499)).toBe('$4.99')
|
||||||
|
expect(formatCost(undefined)).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('gives per-unit cost extra precision when it would round to nothing', () => {
|
||||||
|
expect(formatUnitCost(lot({ costCents: 499, quantity: 100 }))).toBe('$0.050/seed')
|
||||||
|
expect(formatUnitCost(lot({ costCents: 1200, quantity: 4, unit: 'packets' }))).toBe('$3.00/packet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('has no per-unit cost without both halves', () => {
|
||||||
|
expect(formatUnitCost(lot({ costCents: undefined }))).toBeNull()
|
||||||
|
expect(formatUnitCost(lot({ costCents: 499, quantity: 0 }))).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('lotsByPlant', () => {
|
||||||
|
it('groups, and copes with nothing', () => {
|
||||||
|
const grouped = lotsByPlant([lot({ id: 1, plantId: 7 }), lot({ id: 2, plantId: 7 }), lot({ id: 3, plantId: 9 })])
|
||||||
|
expect(grouped.get(7)?.length).toBe(2)
|
||||||
|
expect(grouped.get(9)?.length).toBe(1)
|
||||||
|
expect(lotsByPlant(undefined).size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('attributableLots', () => {
|
||||||
|
// An exhausted lot is still offered: the count records what was written down,
|
||||||
|
// not a fact about the packet, so refusing to attribute a planting because the
|
||||||
|
// number says zero would make a wrong number harder to correct, not easier.
|
||||||
|
it('offers exhausted lots, just not first', () => {
|
||||||
|
const empty = lot({ id: 1, remaining: 0 })
|
||||||
|
const full = lot({ id: 2, remaining: 100 })
|
||||||
|
expect(attributableLots([empty, full]).map((l) => l.id)).toEqual([2, 1])
|
||||||
|
expect(attributableLots([empty]).length).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("doesn't mutate its input", () => {
|
||||||
|
const lots = [lot({ id: 1, remaining: 0 }), lot({ id: 2, remaining: 100 })]
|
||||||
|
attributableLots(lots)
|
||||||
|
expect(lots.map((l) => l.id)).toEqual([1, 2])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('formatQuantity', () => {
|
||||||
|
it('keeps whole counts whole and fractional ones readable', () => {
|
||||||
|
expect(formatQuantity(100)).toBe('100')
|
||||||
|
expect(formatQuantity(12.5)).toBe('12.5')
|
||||||
|
expect(formatQuantity(-15)).toBe('-15') // over-planted lots go negative
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// Seed lots (#51): what you actually bought, and what's left of it.
|
||||||
|
//
|
||||||
|
// A lot is one purchase of one variety. Inventory belongs to the purchase rather
|
||||||
|
// than to the variety because the same garlic from two vendors in two years is
|
||||||
|
// two different things — see #50 for the reasoning behind the shape.
|
||||||
|
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { ApiError, api } from './api'
|
||||||
|
|
||||||
|
export const lotUnitSchema = z.enum(['seeds', 'grams', 'ounces', 'packets', 'bulbs', 'plants'])
|
||||||
|
export type LotUnit = z.infer<typeof lotUnitSchema>
|
||||||
|
|
||||||
|
export const LOT_UNITS: { value: LotUnit; label: string }[] = [
|
||||||
|
{ value: 'seeds', label: 'seeds' },
|
||||||
|
{ value: 'grams', label: 'grams' },
|
||||||
|
{ value: 'ounces', label: 'ounces' },
|
||||||
|
{ value: 'packets', label: 'packets' },
|
||||||
|
{ value: 'bulbs', label: 'bulbs' },
|
||||||
|
{ value: 'plants', label: 'plants' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const seedLotSchema = z.object({
|
||||||
|
id: z.number(),
|
||||||
|
ownerId: z.number(),
|
||||||
|
plantId: z.number(),
|
||||||
|
vendor: z.string(),
|
||||||
|
sourceUrl: z.string(),
|
||||||
|
sku: z.string(),
|
||||||
|
lotCode: z.string(),
|
||||||
|
purchasedAt: z.string().optional(),
|
||||||
|
packedForYear: z.number().optional(),
|
||||||
|
quantity: z.number(),
|
||||||
|
unit: lotUnitSchema,
|
||||||
|
costCents: z.number().optional(),
|
||||||
|
germinationPct: z.number().optional(),
|
||||||
|
notes: z.string(),
|
||||||
|
version: z.number(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string(),
|
||||||
|
// Computed server-side: used is the summed effective count of active
|
||||||
|
// plantings attributed to this lot; remaining is quantity - used, and may be
|
||||||
|
// NEGATIVE when more was planted than the lot recorded buying.
|
||||||
|
used: z.number(),
|
||||||
|
remaining: z.number(),
|
||||||
|
})
|
||||||
|
export type SeedLot = z.infer<typeof seedLotSchema>
|
||||||
|
|
||||||
|
const seedLotsKey = ['seed-lots'] as const
|
||||||
|
|
||||||
|
export function useSeedLots() {
|
||||||
|
return useQuery({
|
||||||
|
queryKey: seedLotsKey,
|
||||||
|
queryFn: async (): Promise<SeedLot[]> => z.array(seedLotSchema).parse(await api.get('/seed-lots')),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeedLotInput {
|
||||||
|
plantId: number
|
||||||
|
vendor: string
|
||||||
|
sourceUrl: string
|
||||||
|
sku: string
|
||||||
|
lotCode: string
|
||||||
|
purchasedAt: string | null
|
||||||
|
packedForYear: number | null
|
||||||
|
quantity: number
|
||||||
|
unit: LotUnit
|
||||||
|
costCents: number | null
|
||||||
|
germinationPct: number | null
|
||||||
|
notes: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function invalidate(qc: ReturnType<typeof useQueryClient>) {
|
||||||
|
void qc.invalidateQueries({ queryKey: seedLotsKey })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useCreateSeedLot() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (input: SeedLotInput): Promise<SeedLot> =>
|
||||||
|
seedLotSchema.parse(await api.post('/seed-lots', input)),
|
||||||
|
onSuccess: () => invalidate(qc),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useUpdateSeedLot() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (vars: Partial<SeedLotInput> & { id: number; version: number }): Promise<SeedLot> => {
|
||||||
|
const { id, ...rest } = vars
|
||||||
|
return seedLotSchema.parse(await api.patch(`/seed-lots/${id}`, rest))
|
||||||
|
},
|
||||||
|
onSuccess: () => invalidate(qc),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteSeedLot() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (id: number): Promise<void> => {
|
||||||
|
await api.delete(`/seed-lots/${id}`)
|
||||||
|
},
|
||||||
|
onSuccess: () => invalidate(qc),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lots a placement could be attributed to, best first.
|
||||||
|
*
|
||||||
|
* Lots with seed left come first, but an exhausted one is still offered: the
|
||||||
|
* count is a record of what you wrote down, not a fact about the packet, and
|
||||||
|
* refusing to attribute a planting because the number says zero would make the
|
||||||
|
* number harder to correct rather than easier.
|
||||||
|
*/
|
||||||
|
export function attributableLots(lots: SeedLot[]): SeedLot[] {
|
||||||
|
return [...lots].sort((a, b) => Number(b.remaining > 0) - Number(a.remaining > 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current server row carried by a 409, so an edit form can rebase onto it
|
||||||
|
* and the user can re-save rather than losing what they typed. Every other
|
||||||
|
* entity's form does this; a lot is no different. */
|
||||||
|
export function conflictSeedLot(err: unknown): SeedLot | null {
|
||||||
|
if (!(err instanceof ApiError) || !err.isConflict) return null
|
||||||
|
const parsed = seedLotSchema.safeParse((err.body as { current?: unknown })?.current)
|
||||||
|
return parsed.success ? parsed.data : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Group lots by the plant they belong to. */
|
||||||
|
export function lotsByPlant(lots: SeedLot[] | undefined): Map<number, SeedLot[]> {
|
||||||
|
const map = new Map<number, SeedLot[]>()
|
||||||
|
for (const lot of lots ?? []) {
|
||||||
|
const list = map.get(lot.plantId)
|
||||||
|
if (list) list.push(lot)
|
||||||
|
else map.set(lot.plantId, [lot])
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a lot is doing, as a state rather than a number — this is the thing you
|
||||||
|
* scan for when deciding what to order, and a number alone doesn't survive being
|
||||||
|
* skimmed down a list.
|
||||||
|
*
|
||||||
|
* "over" means more was planted than the lot recorded buying. That's a real
|
||||||
|
* situation (a miscounted packet, a lot entered after the fact) and worth
|
||||||
|
* showing plainly rather than clamping to zero and hiding the discrepancy.
|
||||||
|
*/
|
||||||
|
export type LotState = 'over' | 'empty' | 'low' | 'ok' | 'unknown'
|
||||||
|
|
||||||
|
const LOW_FRACTION = 0.2
|
||||||
|
|
||||||
|
export function lotState(lot: SeedLot): LotState {
|
||||||
|
if (lot.quantity <= 0) return 'unknown' // no quantity recorded: nothing to be low on
|
||||||
|
if (lot.remaining < 0) return 'over'
|
||||||
|
if (lot.remaining === 0) return 'empty'
|
||||||
|
if (lot.remaining / lot.quantity <= LOW_FRACTION) return 'low'
|
||||||
|
return 'ok'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The total remaining across a plant's lots, and the worst state among them —
|
||||||
|
* what a plant card shows before you open it. */
|
||||||
|
export function summarizeLots(lots: SeedLot[]): { remaining: number; unit: LotUnit | null; state: LotState } {
|
||||||
|
if (lots.length === 0) return { remaining: 0, unit: null, state: 'unknown' }
|
||||||
|
// Mixed units can't be summed honestly; report the majority unit's total only
|
||||||
|
// when they all agree, else leave the unit off and let the per-lot rows speak.
|
||||||
|
const unit = lots.every((l) => l.unit === lots[0].unit) ? lots[0].unit : null
|
||||||
|
const remaining = lots.reduce((sum, l) => sum + l.remaining, 0)
|
||||||
|
const order: LotState[] = ['over', 'empty', 'low', 'ok', 'unknown']
|
||||||
|
const state = order.find((s) => lots.some((l) => lotState(l) === s)) ?? 'unknown'
|
||||||
|
return { remaining, unit, state }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Quantities are stored as REAL because grams and ounces are fractional, but a
|
||||||
|
* whole number of seeds shouldn't render as "100.0". */
|
||||||
|
export function formatQuantity(n: number): string {
|
||||||
|
return Number.isInteger(n) ? String(n) : n.toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whole currency from cents, e.g. 499 → "$4.99". */
|
||||||
|
export function formatCost(cents: number | undefined): string | null {
|
||||||
|
if (cents == null) return null
|
||||||
|
return new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD' }).format(cents / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cost per unit, when both are known and it says something useful. */
|
||||||
|
export function formatUnitCost(lot: SeedLot): string | null {
|
||||||
|
if (lot.costCents == null || lot.quantity <= 0) return null
|
||||||
|
const per = lot.costCents / 100 / lot.quantity
|
||||||
|
return `${new Intl.NumberFormat(undefined, { style: 'currency', currency: 'USD', minimumFractionDigits: per < 0.1 ? 3 : 2 }).format(per)}/${singular(lot.unit)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function singular(unit: LotUnit): string {
|
||||||
|
return unit.endsWith('s') ? unit.slice(0, -1) : unit
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a stored URL is safe to render as a link.
|
||||||
|
*
|
||||||
|
* The server already scheme-checks this (#50), but a link is rendered from
|
||||||
|
* whatever the client was handed — an old row, a different server version, a
|
||||||
|
* response someone tampered with — and "the backend validated it" is not a
|
||||||
|
* reason to hand `javascript:` to an anchor tag. Cheap to check twice.
|
||||||
|
*/
|
||||||
|
export function safeExternalUrl(raw: string): string | null {
|
||||||
|
if (!raw) return null
|
||||||
|
try {
|
||||||
|
const u = new URL(raw)
|
||||||
|
return u.protocol === 'http:' || u.protocol === 'https:' ? u.toString() : null
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/Button'
|
|||||||
import { GardenCanvas } from '@/editor/GardenCanvas'
|
import { GardenCanvas } from '@/editor/GardenCanvas'
|
||||||
import { EditorRail, type RailTab } from '@/editor/EditorRail'
|
import { EditorRail, type RailTab } from '@/editor/EditorRail'
|
||||||
import { HistoryPanel } from '@/editor/HistoryPanel'
|
import { HistoryPanel } from '@/editor/HistoryPanel'
|
||||||
|
import { ChatPanel } from '@/editor/ChatPanel'
|
||||||
|
import { JournalPanel } from '@/editor/JournalPanel'
|
||||||
import { Inspector } from '@/editor/Inspector'
|
import { Inspector } from '@/editor/Inspector'
|
||||||
import { PlopInspector } from '@/editor/PlopInspector'
|
import { PlopInspector } from '@/editor/PlopInspector'
|
||||||
import { PlantPicker } from '@/editor/PlantPicker'
|
import { PlantPicker } from '@/editor/PlantPicker'
|
||||||
@@ -12,14 +14,26 @@ import { Palette } from '@/editor/Palette'
|
|||||||
import { SeedTray } from '@/editor/SeedTray'
|
import { SeedTray } from '@/editor/SeedTray'
|
||||||
import { ClearBedModal } from '@/editor/ClearBedModal'
|
import { ClearBedModal } from '@/editor/ClearBedModal'
|
||||||
import { EditorHint } from '@/editor/EditorHint'
|
import { EditorHint } from '@/editor/EditorHint'
|
||||||
|
import { SeasonBanner, SeasonPicker } from '@/editor/SeasonPicker'
|
||||||
import { objectDisplayName } from '@/editor/kinds'
|
import { objectDisplayName } from '@/editor/kinds'
|
||||||
import { useEditorStore } from '@/editor/store'
|
import { useEditorStore } from '@/editor/store'
|
||||||
import type { EditorGarden } from '@/editor/types'
|
import type { EditorGarden } from '@/editor/types'
|
||||||
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
import { ShareGardenModal } from '@/components/gardens/ShareGardenModal'
|
||||||
import { useMe } from '@/lib/auth'
|
import { useMe } from '@/lib/auth'
|
||||||
import { toEditorObject, useEnsurePlantInFull, useGardenFull, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
|
import {
|
||||||
|
toEditorObject,
|
||||||
|
useEnsurePlantInFull,
|
||||||
|
useGardenFull,
|
||||||
|
useGardenSeason,
|
||||||
|
useGardenYears,
|
||||||
|
useUpdateObject,
|
||||||
|
useUpdatePlanting,
|
||||||
|
} from '@/lib/objects'
|
||||||
import { toEditorPlanting } from '@/lib/plantings'
|
import { toEditorPlanting } from '@/lib/plantings'
|
||||||
import type { Plant } from '@/lib/plants'
|
import type { Plant } from '@/lib/plants'
|
||||||
|
import { useCapabilities } from '@/lib/agent'
|
||||||
|
import { useJournalCounts } from '@/lib/journal'
|
||||||
|
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||||
import { useSeedTray } from '@/lib/seedTray'
|
import { useSeedTray } from '@/lib/seedTray'
|
||||||
import { usePageTitle } from '@/lib/usePageTitle'
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
|
||||||
@@ -30,7 +44,14 @@ export function GardenEditorPage() {
|
|||||||
const gid = Number(gardenId)
|
const gid = Number(gardenId)
|
||||||
const { focus } = routeApi.useSearch()
|
const { focus } = routeApi.useSearch()
|
||||||
const navigate = routeApi.useNavigate()
|
const navigate = routeApi.useNavigate()
|
||||||
const full = useGardenFull(gid)
|
const seasonYear = useEditorStore((s) => s.seasonYear)
|
||||||
|
const setSeasonYear = useEditorStore((s) => s.setSeasonYear)
|
||||||
|
// Two queries, one shown. The live one stays mounted so returning to "now" is
|
||||||
|
// instant and so the optimistic mutation cache it owns is never displaced.
|
||||||
|
const live = useGardenFull(gid)
|
||||||
|
const season = useGardenSeason(gid, seasonYear)
|
||||||
|
const full = seasonYear === null ? live : season
|
||||||
|
const years = useGardenYears(gid)
|
||||||
const me = useMe()
|
const me = useMe()
|
||||||
usePageTitle(full.data?.garden.name ?? 'Garden')
|
usePageTitle(full.data?.garden.name ?? 'Garden')
|
||||||
|
|
||||||
@@ -42,6 +63,14 @@ export function GardenEditorPage() {
|
|||||||
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
|
||||||
const railTab = useEditorStore((s) => s.railTab)
|
const railTab = useEditorStore((s) => s.railTab)
|
||||||
const setRailTab = useEditorStore((s) => s.setRailTab)
|
const setRailTab = useEditorStore((s) => s.setRailTab)
|
||||||
|
const journalObjectId = useEditorStore((s) => s.journalObjectId)
|
||||||
|
const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId)
|
||||||
|
const journalCounts = useJournalCounts(gid)
|
||||||
|
const capabilities = useCapabilities()
|
||||||
|
const journalTotal = useMemo(
|
||||||
|
() => [...(journalCounts.data?.values() ?? [])].reduce((a, b) => a + b, 0),
|
||||||
|
[journalCounts.data],
|
||||||
|
)
|
||||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||||
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
|
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
|
||||||
|
|
||||||
@@ -49,6 +78,8 @@ export function GardenEditorPage() {
|
|||||||
const updateObject = useUpdateObject(gid)
|
const updateObject = useUpdateObject(gid)
|
||||||
const ensurePlant = useEnsurePlantInFull(gid)
|
const ensurePlant = useEnsurePlantInFull(gid)
|
||||||
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
|
const { trayPlants, add: addToTray, remove: removeFromTray } = useSeedTray(gid)
|
||||||
|
const seedLots = useSeedLots()
|
||||||
|
const lotsByPlantId = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||||
|
|
||||||
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
|
// Which plant-picker flow is open: 'place' arms a plant for repeat placement;
|
||||||
// 'change' swaps the selected plop's plant.
|
// 'change' swaps the selected plop's plant.
|
||||||
@@ -68,7 +99,11 @@ export function GardenEditorPage() {
|
|||||||
// Role gating, computed before the effects/returns so the nudge handler can use
|
// Role gating, computed before the effects/returns so the nudge handler can use
|
||||||
// it. Ownership is the authoritative ownerId==me check.
|
// it. Ownership is the authoritative ownerId==me check.
|
||||||
const gd = full.data?.garden
|
const gd = full.data?.garden
|
||||||
const canEdit = gd != null && (gd.ownerId === me.data?.id || gd.myRole === 'editor')
|
// A past season is read-only: it is a record of what happened, and editing it
|
||||||
|
// would be editing the past. This is the single gate — the palette, inspector,
|
||||||
|
// nudging, placement and drag handles all key off canEdit.
|
||||||
|
const canEdit =
|
||||||
|
seasonYear === null && gd != null && (gd.ownerId === me.data?.id || gd.myRole === 'editor')
|
||||||
const isOwner = gd != null && me.data != null && gd.ownerId === me.data.id
|
const isOwner = gd != null && me.data != null && gd.ownerId === me.data.id
|
||||||
|
|
||||||
// Latest values for the mount-once nudge keydown handler to read, so its effect
|
// Latest values for the mount-once nudge keydown handler to read, so its effect
|
||||||
@@ -248,9 +283,18 @@ export function GardenEditorPage() {
|
|||||||
// Arm a plant for tap-to-place. The full catalog carries plants not yet in this
|
// Arm a plant for tap-to-place. The full catalog carries plants not yet in this
|
||||||
// garden's /full payload, so make sure the chosen one is in the cache first —
|
// garden's /full payload, so make sure the chosen one is in the cache first —
|
||||||
// otherwise its placed plops render without an icon/color until a refetch.
|
// otherwise its placed plops render without an icon/color until a refetch.
|
||||||
function armPlant(plant: Plant) {
|
// The Seed Tray arms a plant directly, without going through the picker, so it
|
||||||
|
// has to do the picker's single-lot auto-attribution itself — otherwise the
|
||||||
|
// quickest way to place a plant is the one path that silently loses which
|
||||||
|
// packet it came from. Several lots is genuinely ambiguous, so that case stays
|
||||||
|
// unattributed rather than guessing; the picker is where you choose.
|
||||||
|
function armPlant(plant: Plant, lot?: SeedLot) {
|
||||||
ensurePlant(plant)
|
ensurePlant(plant)
|
||||||
setArmedPlant(plant)
|
if (lot === undefined) {
|
||||||
|
const lots = attributableLots(lotsByPlantId.get(plant.id) ?? [])
|
||||||
|
lot = lots.length === 1 ? lots[0] : undefined
|
||||||
|
}
|
||||||
|
setArmedPlant(plant, lot?.id ?? null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Removing the armed plant from the tray also disarms it, so placement doesn't
|
// Removing the armed plant from the tray also disarms it, so placement doesn't
|
||||||
@@ -265,14 +309,14 @@ export function GardenEditorPage() {
|
|||||||
// a not-yet-placed plant isn't in that map, which used to silently abort the
|
// a not-yet-placed plant isn't in that map, which used to silently abort the
|
||||||
// pick (nothing armed, picker left open). Picking (place OR change) makes sure
|
// pick (nothing armed, picker left open). Picking (place OR change) makes sure
|
||||||
// the plant renders and drops it into this garden's tray for quick reuse.
|
// the plant renders and drops it into this garden's tray for quick reuse.
|
||||||
function onPickPlant(plant: Plant) {
|
function onPickPlant(plant: Plant, lot?: SeedLot) {
|
||||||
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
|
if (!canEdit) return // defense in depth: viewers can't reach the picker anyway
|
||||||
ensurePlant(plant)
|
ensurePlant(plant)
|
||||||
addToTray(plant)
|
addToTray(plant)
|
||||||
if (picker === 'change' && selectedPlop) {
|
if (picker === 'change' && selectedPlop) {
|
||||||
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
|
updatePlanting.mutate({ id: selectedPlop.id, version: selectedPlop.version, plantId: plant.id })
|
||||||
} else {
|
} else {
|
||||||
setArmedPlant(plant) // 'place' mode: arm for repeat placement
|
setArmedPlant(plant, lot?.id ?? null) // 'place' mode: arm for repeat placement
|
||||||
}
|
}
|
||||||
setPicker(null)
|
setPicker(null)
|
||||||
}
|
}
|
||||||
@@ -296,6 +340,13 @@ export function GardenEditorPage() {
|
|||||||
setFocusedObject(selectedObject.id)
|
setFocusedObject(selectedObject.id)
|
||||||
select(null)
|
select(null)
|
||||||
}}
|
}}
|
||||||
|
noteCount={journalCounts.data?.get(selectedObject.id) ?? 0}
|
||||||
|
onAddNote={() => {
|
||||||
|
// Two taps from a selected bed to typing: scope the journal to it
|
||||||
|
// and switch tabs. The composer is already open in there.
|
||||||
|
setJournalObjectId(selectedObject.id)
|
||||||
|
setRailTab('journal')
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
) : selectedPlop ? (
|
) : selectedPlop ? (
|
||||||
<PlopInspector
|
<PlopInspector
|
||||||
@@ -312,6 +363,24 @@ export function GardenEditorPage() {
|
|||||||
<p className="text-sm text-muted">Select a bed or a planting to edit it.</p>
|
<p className="text-sm text-muted">Select a bed or a planting to edit it.</p>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'journal',
|
||||||
|
label: 'Journal',
|
||||||
|
// The total on the tab is the discoverability half: a garden with a
|
||||||
|
// season's notes in it shouldn't look identical to an empty one.
|
||||||
|
badge: journalTotal,
|
||||||
|
render: () => (
|
||||||
|
<JournalPanel
|
||||||
|
gardenId={gid}
|
||||||
|
canEdit={canEdit}
|
||||||
|
currentUserId={me.data?.id}
|
||||||
|
isOwner={isOwner}
|
||||||
|
objects={objects}
|
||||||
|
scopeObjectId={journalObjectId}
|
||||||
|
onScopeChange={setJournalObjectId}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'history',
|
id: 'history',
|
||||||
label: 'History',
|
label: 'History',
|
||||||
@@ -319,8 +388,21 @@ export function GardenEditorPage() {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Only when the instance actually has the assistant configured. A tab that
|
||||||
|
// opens onto an apology is worse than no tab.
|
||||||
|
if (capabilities.data?.agent) {
|
||||||
|
railTabs.push({
|
||||||
|
id: 'chat',
|
||||||
|
label: 'Assistant',
|
||||||
|
render: () => <ChatPanel gardenId={gid} canEdit={canEdit} />,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 100dvh, not 100vh: on mobile Safari/Chrome 100vh is the *largest* viewport
|
||||||
|
// (URL bar hidden), so with the bar showing the editor overflowed and pushed
|
||||||
|
// the canvas bottom + Fit button under the browser chrome (#85).
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
|
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row">
|
||||||
<div className="shrink-0 md:w-40">
|
<div className="shrink-0 md:w-40">
|
||||||
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||||
{garden.name}
|
{garden.name}
|
||||||
@@ -330,20 +412,45 @@ export function GardenEditorPage() {
|
|||||||
Share
|
Share
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{!canEdit && (
|
{!canEdit && seasonYear === null && (
|
||||||
<p className="mb-2 rounded-md bg-border/40 px-2 py-1 text-xs text-muted">👁 View only</p>
|
<p className="mb-2 rounded-md bg-border/40 px-2 py-1 text-xs text-muted">👁 View only</p>
|
||||||
)}
|
)}
|
||||||
|
{years.data && years.data.length > 0 && (
|
||||||
|
<div className="mb-2">
|
||||||
|
<SeasonPicker years={years.data} value={seasonYear} onChange={setSeasonYear} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{focusedObjectId == null && canEdit && <Palette />}
|
{focusedObjectId == null && canEdit && <Palette />}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="mt-2 w-full text-sm"
|
className="mt-2 w-full text-sm"
|
||||||
|
onClick={() => {
|
||||||
|
setJournalObjectId(null)
|
||||||
|
setRailTab(railTab === 'journal' ? null : 'journal')
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Journal
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="mt-1 w-full text-sm"
|
||||||
onClick={() => setRailTab(railTab === 'history' ? null : 'history')}
|
onClick={() => setRailTab(railTab === 'history' ? null : 'history')}
|
||||||
>
|
>
|
||||||
History
|
History
|
||||||
</Button>
|
</Button>
|
||||||
|
{capabilities.data?.agent && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="mt-1 w-full text-sm"
|
||||||
|
onClick={() => setRailTab(railTab === 'chat' ? null : 'chat')}
|
||||||
|
>
|
||||||
|
💬 Assistant
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative min-h-0 flex-1">
|
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
|
||||||
|
{seasonYear !== null && <SeasonBanner year={seasonYear} onExit={() => setSeasonYear(null)} />}
|
||||||
{focusedObject && (
|
{focusedObject && (
|
||||||
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur">
|
<div className="absolute left-2 top-2 z-20 flex flex-wrap items-center gap-2 rounded-lg border border-border bg-surface/90 px-2 py-1.5 text-sm shadow-sm backdrop-blur">
|
||||||
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
|
<button type="button" onClick={exitFocus} className="rounded px-1.5 py-0.5 font-medium text-accent-strong hover:underline">
|
||||||
@@ -380,7 +487,9 @@ export function GardenEditorPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<div className="relative min-h-0 flex-1">
|
||||||
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
<GardenCanvas garden={garden} objects={objects} plantings={plantings} plantsById={plantsById} canEdit={canEdit} />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Empty-state hints (non-interactive overlays). */}
|
{/* Empty-state hints (non-interactive overlays). */}
|
||||||
{canEdit && focusedObjectId == null && objects.length === 0 && (
|
{canEdit && focusedObjectId == null && objects.length === 0 && (
|
||||||
@@ -420,8 +529,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)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ import { CategoryChips } from '@/components/plants/CategoryChips'
|
|||||||
import { PlantCard } from '@/components/plants/PlantCard'
|
import { PlantCard } from '@/components/plants/PlantCard'
|
||||||
import { PlantFormModal } from '@/components/plants/PlantFormModal'
|
import { PlantFormModal } from '@/components/plants/PlantFormModal'
|
||||||
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
|
import { DeletePlantModal } from '@/components/plants/DeletePlantModal'
|
||||||
|
import { SeedLotModal } from '@/components/plants/SeedLotModal'
|
||||||
|
import { DeleteSeedLotModal } from '@/components/plants/DeleteSeedLotModal'
|
||||||
import { PlantPicker } from '@/editor/PlantPicker'
|
import { PlantPicker } from '@/editor/PlantPicker'
|
||||||
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
import { filterPlants, usePlants, type CategoryFilter, type Plant } from '@/lib/plants'
|
||||||
|
import { lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
|
||||||
import type { UnitPref } from '@/lib/units'
|
import type { UnitPref } from '@/lib/units'
|
||||||
import { usePageTitle } from '@/lib/usePageTitle'
|
import { usePageTitle } from '@/lib/usePageTitle'
|
||||||
|
|
||||||
@@ -19,6 +22,9 @@ type Dialog =
|
|||||||
| { kind: 'duplicate'; plant: Plant }
|
| { kind: 'duplicate'; plant: Plant }
|
||||||
| { kind: 'delete'; plant: Plant }
|
| { kind: 'delete'; plant: Plant }
|
||||||
| { kind: 'picker' }
|
| { kind: 'picker' }
|
||||||
|
| { kind: 'addLot'; plant: Plant }
|
||||||
|
| { kind: 'editLot'; plant: Plant; lot: SeedLot }
|
||||||
|
| { kind: 'deleteLot'; lot: SeedLot }
|
||||||
| null
|
| null
|
||||||
|
|
||||||
// There's no per-user unit preference server-side (only per-garden), so the
|
// There's no per-user unit preference server-side (only per-garden), so the
|
||||||
@@ -35,6 +41,8 @@ function loadUnit(): UnitPref {
|
|||||||
export function PlantsPage() {
|
export function PlantsPage() {
|
||||||
usePageTitle('Plants')
|
usePageTitle('Plants')
|
||||||
const plants = usePlants()
|
const plants = usePlants()
|
||||||
|
const seedLots = useSeedLots()
|
||||||
|
const lots = useMemo(() => lotsByPlant(seedLots.data), [seedLots.data])
|
||||||
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
|
const [unit, setUnit] = useState<UnitPref>(() => loadUnit())
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [category, setCategory] = useState<CategoryFilter>('all')
|
const [category, setCategory] = useState<CategoryFilter>('all')
|
||||||
@@ -108,9 +116,13 @@ export function PlantsPage() {
|
|||||||
key={p.id}
|
key={p.id}
|
||||||
plant={p}
|
plant={p}
|
||||||
unit={unit}
|
unit={unit}
|
||||||
|
lots={lots.get(p.id) ?? []}
|
||||||
onEdit={() => setDialog({ kind: 'edit', plant: p })}
|
onEdit={() => setDialog({ kind: 'edit', plant: p })}
|
||||||
onDelete={() => setDialog({ kind: 'delete', plant: p })}
|
onDelete={() => setDialog({ kind: 'delete', plant: p })}
|
||||||
onDuplicate={() => setDialog({ kind: 'duplicate', plant: p })}
|
onDuplicate={() => setDialog({ kind: 'duplicate', plant: p })}
|
||||||
|
onAddLot={() => setDialog({ kind: 'addLot', plant: p })}
|
||||||
|
onEditLot={(lot) => setDialog({ kind: 'editLot', plant: p, lot })}
|
||||||
|
onDeleteLot={(lot) => setDialog({ kind: 'deleteLot', lot })}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -121,6 +133,9 @@ export function PlantsPage() {
|
|||||||
{dialog?.kind === 'edit' && <PlantFormModal plant={dialog.plant} unit={unit} onClose={close} />}
|
{dialog?.kind === 'edit' && <PlantFormModal plant={dialog.plant} unit={unit} onClose={close} />}
|
||||||
{dialog?.kind === 'duplicate' && <PlantFormModal template={dialog.plant} unit={unit} onClose={close} />}
|
{dialog?.kind === 'duplicate' && <PlantFormModal template={dialog.plant} unit={unit} onClose={close} />}
|
||||||
{dialog?.kind === 'delete' && <DeletePlantModal plant={dialog.plant} onClose={close} />}
|
{dialog?.kind === 'delete' && <DeletePlantModal plant={dialog.plant} onClose={close} />}
|
||||||
|
{dialog?.kind === 'addLot' && <SeedLotModal plant={dialog.plant} onClose={close} />}
|
||||||
|
{dialog?.kind === 'editLot' && <SeedLotModal plant={dialog.plant} lot={dialog.lot} onClose={close} />}
|
||||||
|
{dialog?.kind === 'deleteLot' && <DeleteSeedLotModal lot={dialog.lot} onClose={close} />}
|
||||||
{dialog?.kind === 'picker' && (
|
{dialog?.kind === 'picker' && (
|
||||||
<PlantPicker
|
<PlantPicker
|
||||||
unit={unit}
|
unit={unit}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export function PublicGardenPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3">
|
<div className="flex h-[calc(100dvh-8rem)] flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<h1 className="truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
<h1 className="truncate text-lg font-semibold tracking-tight" title={garden.name}>
|
||||||
{garden.name}
|
{garden.name}
|
||||||
|
|||||||
@@ -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,
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user