Author SHA1 Message Date
steveandClaude Opus 4.8 4c4abe23c6 Use objectPlantingsPath helper instead of inline URL (#88)
Build image / build-and-push (push) Successful in 6s
Gadfly: I was wrong that no plantings-path helper existed — objectPlantingsPath
is defined in plantings_test.go in the same package. Use it for the two
planting POSTs in the remaining test.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 22:06:57 -04:00
steveandClaude Opus 4.8 5bdaf21828 Address Gadfly findings on #88
Build image / build-and-push (push) Successful in 13s
- Cover the plantId filter's out-of-range branch (0, -1), not just the
  non-numeric one — the handler rejects id < 1.
- Add the documented negative-remaining case: over-planting a lot (57 against
  50 bought) drives remaining to -7 through the HTTP surface. The number is a
  derived truth about what's committed, not a floor at zero, and that's the
  signal a gardener wants.
- Rename objID → oid in the remaining test to match the package convention
  (shares_test etc.).

Not taken: relocating createPlantAPI to plants_test.go — it's shared with #89's
branch and the move is cleanest once both land (consolidating with that branch's
makeFillPlant), noted on both PRs. The 409 "current" assertion style is
deliberate and reads clearly; matching journal_test.go's exact phrasing isn't
worth a divergence churn.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 20:39:43 -04:00
steveandClaude Opus 4.8 8552f1d152 API-level tests for the /seed-lots route group (#83)
Build image / build-and-push (push) Successful in 7s
Gadfly review (reusable) / review (pull_request) Successful in 6m32s
Adversarial Review (Gadfly) / review (pull_request) Successful in 6m32s
Every other handler file had a sibling API test; this group had none, which is
the state PATCH/DELETE /journal/:id shipped in — implemented, unit-tested
through the service, and completely unreachable. Service tests cannot see a
route that was never registered or one registered with the wrong :param.

Four tests, covering what a broken route would silently take with it:

- Full CRUD over HTTP, including GET/PATCH/DELETE by id, the ?plantId= filter,
  a rejected non-numeric plantId, and a stale-version 409 carrying the current
  row so the client can rebase.
- `remaining` is derived through the HTTP surface, not just in the service.
  DESIGN.md makes derivation load-bearing, and the number is the whole reason
  anyone opens the seed shelf.
- Lots are private to the buyer: another user gets 404 (not 403 — existence is
  masked per the project convention) on get/patch/delete, sees none in their
  listing, and the owner still has access afterwards.
- The group is behind requireAuth: unauthenticated calls 401 rather than
  returning an empty list.

Verified the tests actually catch the failure they exist for by unregistering
GET /seed-lots/:id — all four fail with "404 page not found", the journal
signature.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 18:16:40 -04:00
steve 437c535cd1 Fill: honour the half-spacing edge rule when packing plops (#76)
Build image / build-and-push (push) Successful in 11s
Closes #75.

The outer row of a fill sat 1.5 spacings from the bed edge where the rule says
half a spacing, staggered rows started a full pitch in, and the far edge had
clumps hanging 13cm outside a bed nothing clips them to. Centre the lattice and
inset each edge by radius - spacing/2.

Also fixed here: a region that misses the object entirely planted plops metres
off the bed (clampTo inverts rather than empties, which the old loop handled
implicitly and the counted lattice did not), non-finite region bounds now give
ErrInvalidInput instead of a raw store error or a silent no-op, and the lattice
size is derived before it is built so an oversized fill is refused without
allocating what it is rejecting.

Five Gadfly rounds; the substantive findings were the finiteness guard and the
allocate-before-cap ordering.
2026-07-21 18:28:17 +00:00
steveandClaude Opus 4.8 07d598cffd Address fifth round of Gadfly findings on #76
Build image / build-and-push (push) Successful in 5s
- fillLoaded's doc listed what it does and omitted the non-finite-region
  rejection this PR added to it.
- Trim the half-spacing rule's restatement in DESIGN.md to the decision and a
  pointer. The rule, the square-foot arithmetic and the failure mode are
  written out once, in hexCenters, rather than near-verbatim in four places.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 14:27:58 -04:00
steveandClaude Opus 4.8 958b90ebc6 Address fourth round of Gadfly findings on #76
Build image / build-and-push (push) Successful in 6s
- Split hexCenters' doc: the count/limit contract had run straight on from
  the #75 anti-regression paragraph with no separator, so its opening "It"
  read as referring to the wrong thing.
- Write the stagger as pitch/2 rather than radius. Same value, but the intent
  is "half a pitch" and only incidentally "one radius".
- fitAxis's step<=0 guard is unreachable from its only caller. Kept, and now
  says so: a helper this small shouldn't need its caller read to be shown
  safe, and the failure mode without it is ±Inf into an int conversion.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 14:03:41 -04:00
steveandClaude Opus 4.8 28af101634 Address third round of Gadfly findings on #76
Build image / build-and-push (push) Successful in 7s
- hexCenters now derives its exact point count BEFORE building anything and
  returns it alongside the points, refusing over the cap without allocating.
  Previously it materialised the whole lattice and fillLoaded checked len()
  afterwards — so the "too large" path paid for the thing it was rejecting.
  This also makes the preallocation exact, which subsumes the earlier
  over-allocation finding I'd declined: staggered rows hold cols-1, so
  rows*cols over-reserved by ~12%.

- Region.empty() names the invariant that clampTo expresses "no overlap" by
  INVERTING the region rather than zeroing it. A bare `MaxX < MinX` at each
  call site was spreading a non-obvious convention across three functions.

The count is now load-bearing (it gates the cap), so the test asserts it
matches what actually gets built.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 13:25:58 -04:00
steveandClaude Opus 4.8 70ff970672 Address second round of Gadfly findings on #76
Build image / build-and-push (push) Successful in 5s
- FillRegion's doc still said "half-pitch inset", left over from the first
  draft of the fix; the inset is radius - spacing/2. Two docs on the same
  function disagreeing is worse than either being terse.
- Reject non-finite region bounds. They survive clamping and the inverted-
  region guard (NaN compares false both ways). Nothing corrupt reached the
  table — SQLite stores NaN as NULL and NOT NULL refuses it — but NaN
  surfaced as a raw store error and +Inf as a silent zero-plop success.
- TestHexCentersTinyRegion used a region symmetric about the origin, so it
  could not distinguish "the middle of the region" from "the origin" and
  would have passed for an implementation that just returned (0,0). Added an
  off-centre case.

Not taken: the finding that `make(..., rows*cols)` over-allocates ~12%
because staggered rows hold cols-1. True, but the slice is capped at
maxFillPlops (5000) and the exact count needs a ceil/floor split for no
measurable gain.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 12:13:50 -04:00
steveandClaude Opus 4.8 f8929a19a8 Address Gadfly findings on #76
Build image / build-and-push (push) Successful in 5s
- clampTo's doc justified itself by stopping hexCenters "looping forever",
  which stopped being true when hexCenters became count-bounded. Say what it
  actually does now, and note the inversion the new guard relies on.
- Trim the changelog prose from hexCenters' doc down to the one line that
  earns its keep: don't re-anchor at the min corner, and why.
- Rename a test local from `max` so it stops shadowing the builtin.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 10:23:22 -04:00
steveandClaude Opus 4.8 3af0d08779 Fill: plant nothing for a region that misses the object entirely
Build image / build-and-push (push) Successful in 8s
clampTo INVERTS a region lying wholly outside the object — Max clamps below
Min — rather than emptying it. The old loop-until-past-MaxX form handled that
for free by never entering the loop. Counting positions up front does not:
a region 500cm east of a bed with ±50cm local bounds produced 4 plops at
x=275, a couple of metres off the bed.

Caught by removing the guard and watching the new test fail, not by assuming
it would.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 10:13:33 -04:00
steveandClaude Opus 4.8 45da4b15e2 Fill: honour the half-spacing edge rule when packing plops (#75)
Build image / build-and-push (push) Successful in 7s
Gadfly review (reusable) / review (pull_request) Successful in 10m7s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m8s
Filling a bed left the outer row too far from the edge, and staggered rows
worse still. Two defects, both from anchoring the lattice at the region's min
corner:

- Odd rows offset by `radius` started at `MinX + 2·radius`, leaving a bare
  strip a whole plop wide down one side of every other row.
- All the leftover slack piled up on the far edge, where plops hung 13cm
  outside the bed on a 4×8ft garlic bed. Nothing clips them, so they drew
  over the bed outline.

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 owes
it half the spacing — the arithmetic inside every square-foot-gardening chart
(4/square = 6" apart, 3" from the square's edge).

The wrinkle: a plop is a CLUMP, not a plant. defaultPlopRadius is 1.5×spacing,
so keeping the whole circle inside the bed insets the outer row by 1.5
spacings, three times what the rule allows. So centre the lattice and set the
minimum centre-inset to `radius - spacing/2`: the clump may cross the edge by
up to half a spacing, putting its outermost plants exactly the half-spacing
from the edge the rule asks for. Capped there — a clump mostly outside the bed
would be a drawing of plants in the path.

Same bed, same 15 plops, now symmetric with a deliberate 6.5cm overhang inside
the 7.5cm budget instead of an accidental 13cm on one side only. The stagger
falls out of the centring for free: an offset row holds one fewer plop, and
centring that run puts it exactly half a pitch off its neighbours.

TestFillRegionDeterministicPacking expected 4 plops in a 60×60 bed; the fourth
was centred ON the east edge with half of it outside, well past the budget.
It is 3 now — the fix working, not a regression in it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 10:11:08 -04:00
steveandClaude Opus 4.8 c1c873d3f0 Record the two conventions the live v2 bugs produced
Build image / build-and-push (push) Successful in 9s
The history write's context.WithoutCancel reads like a mistake if you don't know
why it's there, and "tidying" it is exactly how the orphan-history bug came back
a second time (#73). Written down so the next person — or the next session —
doesn't remove it on the way past.

Also a short testing section. Two of the three bugs only real use found were
invisible to the tests I had: a route that was never registered (service tests
can't see that) and a fixture that populated a field the real response leaves
empty (a test asserting my mental model rather than the API).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 09:20:00 -04:00
steve 1d2f0eba56 Let the container align the undo outcome note (#74)
Build image / build-and-push (push) Successful in 10s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 13:02:27 +00:00
steve 62604523d7 Commit history detached from cancellation on every path, not just failure (#73)
Build image / build-and-push (push) Successful in 7s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 12:49:18 +00:00
steve 4ea0d0b262 Undo reported "nothing left to undo" after a successful undo (#72)
Build image / build-and-push (push) Successful in 19s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 12:25:34 +00:00
steve 8dbbc5439d Chat panel in the garden editor (#57) (#71)
Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:43:22 +00:00
steve 3a3ce16fce Agent runtime: majordomo in-process, Ollama Cloud config, chat endpoint (#56) (#70)
Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:22:10 +00:00
steve a1bb8ec463 Agent tools: plant lookup, plant creation, journal entries (#55) (#69)
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:05:05 +00:00
steve 5d9b10d7c7 Seed shelf UI: source links, lots, and what's left (#51) (#68)
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:03:12 +00:00
steve 7f8b5254d0 Journal UI: write and read season notes where you're already looking (#53) (#67)
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:55:45 +00:00
steve 8c5ddb21ec Season view: filter the editor to a year (#54) (#66)
Build image / build-and-push (push) Successful in 17s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:47:34 +00:00
66 changed files with 5672 additions and 177 deletions
+58 -4
View File
@@ -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,22 @@ 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.
**Gadfly reviews the PR as opened, not as merged.** The workflow triggers on
`opened`/`reopened`/`ready_for_review` — deliberately *not* `synchronize` — so
every commit you push afterwards, including the ones you push in response to
Gadfly itself, is unreviewed unless you ask. Once you've stopped pushing and
before you merge, comment **`@gadfly review`** on the PR to re-trigger it. The
phrase is required, and this is not hypothetical: on #76 the follow-up commit
was the one that contained a real bug.
**A skipped Gadfly run reports success.** A comment without the trigger phrase
still starts the workflow, which logs `comment does not contain trigger phrase`
and exits green in ~2 seconds. So "the pipeline is green" does NOT mean "this
was reviewed". Confirm a re-review actually ran by its **duration** — a real
pass takes ~10 minutes, a skip takes 2 seconds. Don't look for a new consensus
comment: Gadfly EDITS its existing status-board and consensus comments in place,
so their `created_at` stays at the first review and only `updated_at` moves.
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 +158,16 @@ 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.
`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.
+16 -4
View File
@@ -7,6 +7,7 @@ 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.
- **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.
@@ -23,6 +24,7 @@ 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. - **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.
@@ -55,6 +57,8 @@ 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)
@@ -62,6 +66,11 @@ POST /gardens/:id/objects PATCH,DELETE /objects/:id
POST /objects/:id/plantings PATCH,DELETE /plantings/:id POST /objects/:id/plantings PATCH,DELETE /plantings/:id
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, so the UI offers only what works
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)
``` ```
@@ -75,6 +84,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,
@@ -87,7 +97,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
``` ```
@@ -109,7 +120,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
@@ -121,13 +132,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.
+11 -5
View File
@@ -63,12 +63,17 @@ 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 disabled, not broken — the chat routes simply aren't registered. |
| `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` | Model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain, e.g. `ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud`. |
| `PANSY_AGENT_ENABLED` | on when a key is present | Turns the assistant off without removing the key. |
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 `garden assistant disabled` at startup with which of the three conditions failed, so the answer is in the first few lines of the log.
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 +113,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:
+20 -4
View File
@@ -3,14 +3,30 @@ 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/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/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
@@ -40,9 +56,9 @@ require (
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.31.0 // indirect
golang.org/x/text v0.21.0 // indirect golang.org/x/text v0.23.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
+112 -10
View File
@@ -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,6 +26,10 @@ 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/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/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -31,13 +48,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 +109,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=
@@ -85,6 +130,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
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 +139,77 @@ 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/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
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.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 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.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
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/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
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.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
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 +218,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
View File
@@ -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
+240
View File
@@ -0,0 +1,240 @@
package agent
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"gitea.stevedudenhoeffer.com/steve/majordomo"
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/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 the configured model 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.
func NewRunner(svc *service.Service, cfg *config.Config) (*Runner, error) {
if !cfg.Agent.Ready() {
return nil, errors.New("agent: not configured")
}
// A private registry, 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.
reg := majordomo.New()
reg.RegisterProvider(ollama.Cloud(ollama.WithToken(cfg.Agent.OllamaCloudAPIKey)))
// The spec goes to Parse VERBATIM. The grammar — including comma-separated
// failover chains — is majordomo's, and re-implementing any of it here would
// only mean two places to update when it grows.
model, err := reg.Parse(cfg.Agent.Model)
if err != nil {
return nil, fmt.Errorf("agent: resolve model %q: %w", cfg.Agent.Model, 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)
}
+359
View File
@@ -0,0 +1,359 @@
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/config"
"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 must not get a
// half-built runner; the caller treats the error as "no assistant" and carries on.
func TestNewRunnerNeedsConfiguration(t *testing.T) {
svc, _ := newAgentTestService(t)
for _, cfg := range []*config.Config{
{Agent: config.AgentConfig{Enabled: false, OllamaCloudAPIKey: "k", Model: "ollama-cloud/x"}},
{Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "", Model: "ollama-cloud/x"}},
{Agent: config.AgentConfig{Enabled: true, OllamaCloudAPIKey: "k", Model: ""}},
} {
if _, err := NewRunner(svc, cfg); err == nil {
t.Errorf("NewRunner accepted %+v", cfg.Agent)
}
}
// A model spec naming a provider that doesn't exist is a configuration
// error, not a panic at first use.
if _, err := NewRunner(svc, &config.Config{Agent: config.AgentConfig{
Enabled: true, OllamaCloudAPIKey: "k", Model: "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
View File
@@ -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
View File
@@ -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
}
+253
View File
@@ -0,0 +1,253 @@
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 3060s 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) {
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 := h.agent.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})
}
// 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
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.
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")
c.Writer.Flush()
return &eventStream{c: c}
}
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()
_, _ = 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."
}
}
+33
View File
@@ -0,0 +1,33 @@
package api
import (
"net/http"
"strconv"
"testing"
)
// TestAgentRoutesAbsentWithoutAKey — an instance with no API key must start,
// serve the app, and simply not offer the assistant. The routes aren't
// registered at all, so this is a 404 rather than a handler that apologizes:
// the same shape as OIDC when unconfigured.
func TestAgentRoutesAbsentWithoutAKey(t *testing.T) {
r := authEngine(t, localCfg()) // localCfg has no agent configuration
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
map[string]any{"gardenId": gid, "message": "plant garlic"}, cookie)
if w.Code != http.StatusNotFound {
t.Errorf("chat without a key: status %d, want 404", w.Code)
}
path := "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/agent/history"
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusNotFound {
t.Errorf("history without a key: status %d, want 404", 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)
}
}
+52
View File
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
sloggin "github.com/samber/slog-gin" sloggin "github.com/samber/slog-gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config" "gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
) )
@@ -22,6 +23,9 @@ type handlers struct {
cfg *config.Config cfg *config.Config
svc *service.Service svc *service.Service
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady) oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
// agent is nil unless the assistant is configured; the chat routes are only
// registered when it isn't, so a handler never has to check.
agent *agent.Runner
} }
// 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 +53,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,11 +92,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 // The grow journal hangs off a garden even for entries about one bed or one
// plop, so it inherits the ordinary garden-role check. // plop, so it inherits the ordinary garden-role check.
gardens.GET("/:id/journal", h.listJournal) gardens.GET("/:id/journal", h.listJournal)
gardens.POST("/:id/journal", h.createJournalEntry) 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)
@@ -116,6 +126,38 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
plantings.PATCH("/:id", h.updatePlanting) plantings.PATCH("/:id", h.updatePlanting)
plantings.DELETE("/:id", h.deletePlanting) plantings.DELETE("/:id", h.deletePlanting)
// The garden assistant, registered only when it can actually be offered —
// the same shape as OIDC. An instance with no API key serves the app
// normally and simply doesn't have these routes.
if !cfg.Agent.Ready() {
// Say WHY, at startup, in the logs an operator is already looking at.
// Someone who set the key and sees no assistant otherwise has nothing to
// check — and "is the variable reaching the container?" is exactly the
// question they need answered.
slog.Info("api: garden assistant disabled",
"enabled", cfg.Agent.Enabled,
"hasApiKey", cfg.Agent.OllamaCloudAPIKey != "",
"model", cfg.Agent.Model,
"hint", "needs OLLAMA_CLOUD_API_KEY set in the container's environment (not just the stack's)")
}
if cfg.Agent.Ready() {
runner, err := agent.NewRunner(svc, cfg)
if err != nil {
// Configured but unusable (an unresolvable model spec, say). Log it and
// carry on without the assistant rather than refusing to start: a
// garden planner that won't boot because of a chat feature is worse
// than one without chat.
slog.Error("api: garden assistant disabled", "error", err)
} else {
h.agent = runner
agentGroup := v1.Group("/agent", h.requireAuth())
agentGroup.POST("/chat", h.agentChat)
gardens.GET("/:id/agent/history", h.getAgentHistory)
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
slog.Info("api: garden assistant enabled", "model", cfg.Agent.Model)
}
}
// 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())
@@ -153,6 +195,16 @@ 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 runner BUILT, not whether it was configured: a
// configured-but-unresolvable model leaves the routes unregistered, and saying
// "yes" there would offer a chat tab whose first message 404s.
func (h *handlers) capabilities(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"agent": h.agent != 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})
+21
View File
@@ -71,6 +71,27 @@ func (h *handlers) listJournal(c *gin.Context) {
c.JSON(http.StatusOK, journalResponse{Entries: entries, HasMore: hasMore}) 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) { func (h *handlers) createJournalEntry(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id") gardenID, ok := parseIDParam(c, "id")
if !ok { if !ok {
+43
View File
@@ -155,3 +155,46 @@ func TestJournalRequiresAccessAPI(t *testing.T) {
t.Errorf("stranger delete: status %d, want 404", w.Code) 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
View File
@@ -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})
}
+264
View File
@@ -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)
}
}
}
+42
View File
@@ -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,33 @@ 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
}
// Ready reports whether the assistant can actually be offered. Both the route
// registration and whatever advertises capabilities gate on this, so what's
// advertised always matches what's live.
func (a AgentConfig) Ready() bool {
return a.Enabled && a.OllamaCloudAPIKey != "" && a.Model != ""
}
// Enabled reports whether enough OIDC config is present to attempt discovery. // Enabled reports whether enough OIDC config is present to attempt discovery.
func (o OIDCConfig) Enabled() bool { func (o OIDCConfig) Enabled() bool {
return o.Issuer != "" && o.ClientID != "" return o.Issuer != "" && o.ClientID != ""
@@ -87,6 +119,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
+22
View File
@@ -191,6 +191,28 @@ type JournalEntry struct {
AuthorName string `json:"authorName,omitempty"` 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.
+62
View File
@@ -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)
}
+3 -3
View File
@@ -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)
} }
+9
View File
@@ -84,6 +84,15 @@ func (s *Service) ListJournal(ctx context.Context, actorID, gardenID int64, q Jo
return entries, false, 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. // 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 // An entry may target the garden, one of its beds, or one plop in it — and the
+60 -7
View File
@@ -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
} }
+221 -3
View File
@@ -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
View File
@@ -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
} }
return pts
// 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
}
// 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
} }
+175 -6
View File
@@ -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))
} }
+3 -3
View File
@@ -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))
} }
+119
View File
@@ -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.
+135
View File
@@ -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)
}
}
+58 -4
View File
@@ -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
+189
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
} }
+2 -2
View File
@@ -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.
+95
View File
@@ -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
}
+32
View File
@@ -123,6 +123,38 @@ func (d *DB) ListJournalEntries(ctx context.Context, gardenID int64, f JournalFi
return entries, nil 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. // UpdateJournalEntry applies a version-guarded update of the mutable columns.
// Same contract as every other mutable resource. The target (garden/object/ // 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, // planting) and the author are immutable: an entry is a record of an observation,
@@ -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);
+62
View File
@@ -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.
+19 -7
View File
@@ -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)
+3 -1
View File
@@ -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
@@ -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>
)
}
+51 -1
View File
@@ -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>}
+161
View File
@@ -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>
)
}
+248
View File
@@ -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>
)
}
+27
View File
@@ -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>
)
}
+239
View File
@@ -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>
)
}
+3 -3
View File
@@ -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:
+9 -1
View File
@@ -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
+1 -1
View File
@@ -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'}
/> />
)} )}
+18
View File
@@ -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">
+309
View File
@@ -0,0 +1,309 @@
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'
/**
* 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
}) {
const filter = scopeObjectId != null ? { objectId: scopeObjectId } : {}
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>
{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>
)
}
+79 -2
View File
@@ -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>}
+69
View File
@@ -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>
)
}
+7 -3
View File
@@ -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
View File
@@ -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,
}), }),
})) }))
+29
View File
@@ -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')
})
})
+232
View File
@@ -0,0 +1,232 @@
// 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() })
/** Whether this instance has the assistant configured. Without it the panel
* isn't rendered at all — a dead button is worse than no button. */
export function useCapabilities() {
return useQuery({
queryKey: ['capabilities'] as const,
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
staleTime: Infinity, // server config; it doesn't change under a running page
})
}
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.ok || !res.body) {
handlers.onError(
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×(N1) 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) })
},
}
}
+35
View File
@@ -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
View File
@@ -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}.` }
} }
+146
View File
@@ -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' })
}
+34
View File
@@ -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) {
+6
View File
@@ -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
} }
+149
View File
@@ -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
})
})
+212
View File
@@ -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
}
}
+115 -9
View File
@@ -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,6 +388,16 @@ 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} />,
})
}
return ( return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row"> <div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
<div className="shrink-0 md:w-40"> <div className="shrink-0 md:w-40">
@@ -330,20 +409,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 +484,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 && (
+15
View File
@@ -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}