Author SHA1 Message Date
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
42 changed files with 3785 additions and 130 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.
+13 -3
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.
@@ -64,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)
``` ```
@@ -77,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,
@@ -89,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
``` ```
@@ -111,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
@@ -123,7 +132,8 @@ 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
+12 -6
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)
}
}
+51
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
@@ -90,6 +98,7 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
// 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)
@@ -117,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())
@@ -154,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)
}
}
+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)
}
+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
+135 -35
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)
for x := xStart; x <= r.MaxX+eps; x += pitch {
if r.contains(x, y) { // Exact, not an upper bound: staggered rows hold one fewer, so rows*cols would
pts = append(pts, localPoint{x, y}) // 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
row++ if cols > 1 {
staggered = cols - 1
} }
return pts total := (rows+1)/2*cols + rows/2*staggered
if total > limit {
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})
}
}
return pts, total
}
// fitAxis returns how many lattice positions fit along a span at `step`, keeping
// at least `inset` from each end, and the offset from the span's start that
// centers them — so the leftover is split between the two edges rather than all
// landing on the far one.
//
// A span too small to hold even one position at that inset still gets one, in the
// middle: filling a bed narrower than a single plop with one plop is a better
// answer than refusing to plant it.
//
// The step<=0 half of that guard is currently unreachable — hexCenters, the only
// caller, returns early unless radius > 0, which makes both steps it passes
// positive. It stays because dividing by a non-positive step yields ±Inf and then
// a garbage int conversion, and a helper this small should not require reading
// its caller to know it is safe. Deliberate, not an oversight.
func fitAxis(length, step, inset float64) (n int, start float64) {
if step <= 0 || length < 2*inset {
return 1, length / 2
}
// 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
+174 -5
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)
} }
} }
+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)
}
}
+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);
+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
+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:
+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>
)
}
+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>
) )
+10
View File
@@ -38,6 +38,12 @@ interface EditorState {
seasonYear: number | null seasonYear: number | null
setSeasonYear: (year: number | null) => void 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
@@ -91,6 +97,9 @@ export const useEditorStore = create<EditorState>((set) => ({
seasonYear: null, seasonYear: null,
setSeasonYear: (year) => set({ seasonYear: year }), setSeasonYear: (year) => set({ seasonYear: year }),
journalObjectId: null,
setJournalObjectId: (id) => set({ journalObjectId: id }),
armedPlant: null, armedPlant: null,
armedLotId: null, armedLotId: null,
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }), setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
@@ -119,5 +128,6 @@ export const useEditorStore = create<EditorState>((set) => ({
livePlanting: null, livePlanting: null,
railTab: null, railTab: null,
seasonYear: 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' })
}
+66
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'
@@ -29,6 +31,8 @@ import {
} from '@/lib/objects' } 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 { 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'
@@ -59,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)
@@ -328,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
@@ -344,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',
@@ -351,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">
@@ -374,10 +421,29 @@ export function GardenEditorPage() {
<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 flex min-h-0 flex-1 flex-col gap-2"> <div className="relative flex min-h-0 flex-1 flex-col gap-2">