Author SHA1 Message Date
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 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 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
47 changed files with 2886 additions and 166 deletions
+12 -14
View File
@@ -129,21 +129,19 @@ fix what's real → merge when the pipeline is green. Do not grade Gadfly findin
A push to `main` builds the image and deploys to Komodo; the live instance at A push to `main` builds the image and deploys to Komodo; the live instance at
`pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later. `pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later.
**Gadfly reviews the PR as opened, not as merged.** The workflow triggers on **One sweep, not a loop.** Take Gadfly's *initial* review, fix what's real, and
`opened`/`reopened`/`ready_for_review` — deliberately *not* `synchronize` — so merge once the build is green. Do NOT re-trigger Gadfly on the fix commits and
every commit you push afterwards, including the ones you push in response to wait for it again — a re-review-per-fix loop burns ~10 min a pass and drags a
Gadfly itself, is unreviewed unless you ask. Once you've stopped pushing and small PR out for an hour (Steve's explicit call, 2026-07-22). The initial review
before you merge, comment **`@gadfly review`** on the PR to re-trigger it. The is the check; your own build/test/judgment covers the fixes. Gadfly is advisory
phrase is required, and this is not hypothetical: on #76 the follow-up commit and never blocks merge, so green build + fixes applied is enough.
was the one that contained a real bug.
**A skipped Gadfly run reports success.** A comment without the trigger phrase (Mechanics, if you ever *do* need a manual re-run: the workflow triggers on
still starts the workflow, which logs `comment does not contain trigger phrase` `opened`/`reopened`/`ready_for_review`, not `synchronize`, so pushes don't
and exits green in ~2 seconds. So "the pipeline is green" does NOT mean "this re-review; a `@gadfly review` comment does. A comment without that exact phrase
was reviewed". Confirm a re-review actually ran by its **duration** — a real still runs and exits green in ~2s — a skip that looks like a pass, so judge a
pass takes ~10 minutes, a skip takes 2 seconds. Don't look for a new consensus real review by its ~10-min duration, not its status. Gadfly edits its consensus
comment: Gadfly EDITS its existing status-board and consensus comments in place, comment in place, so `updated_at` moves but `created_at` doesn't.)
so their `created_at` stays at the first review and only `updated_at` moves.
Workflow- and config-only changes (CI, this file, docs) go straight to `main` Workflow- and config-only changes (CI, this file, docs) go straight to `main`
without the PR dance. without the PR dance.
+10 -2
View File
@@ -7,11 +7,13 @@ Work is tracked in Gitea issues; the tracking epic links every piece in dependen
## Decisions ## Decisions
- **Placement model:** freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle. - **Placement model:** freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle.
- **Spacing is a plant-to-plant rule, so bed edges get half of it.** A bed edge is not a competitor for soil, light or water, so the outer row owes it half the spacing rather than a full one. `FillRegion` centres its lattice accordingly, and lets a plop — a *clump* three spacings across — cross the edge by up to half a spacing so its outermost plants land at that half-spacing. The rule, the square-foot-chart arithmetic behind it, and the failure mode it prevents are written out once in `hexCenters`; #75 is what getting it wrong looked like. - **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). Same `hexCenters` lattice and #75 edge rule for both; only the radius→spacing relationship differs. 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`). - **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. 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. - **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes. The first registered user is `is_admin` (set race-free inside the INSERT); admin gates instance-wide Settings (`requireAdmin`), the only thing that reads that flag.
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback. - **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
- **Instance settings (#79):** admin-editable, instance-wide config in a single-row `instance_settings` table — pansy's first DB-stored *instance* state (everything else hangs off a garden/object). Today it holds the agent model + on/off; **secrets never move here**`OLLAMA_CLOUD_API_KEY` stays in the env so it doesn't land in backups or the undo history. Precedence: Settings value → env → default. The live agent Runner sits behind an `atomic.Pointer` in the API layer (`agentHolder`) with its routes always registered, so a settings change swaps it with no restart and no race against in-flight requests; `/capabilities` reads that pointer, so it reports what's live rather than what was configured at boot. The model/registry knowledge lives in one leaf package (`internal/agentmodel`) that both the runner and the settings validator import — agent imports service, so it can live in neither. - **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). 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. - **Agentic future:** integration with majordomo/executus via typed Go tools (`llm.DefineTool[Args]`) wrapping the same service layer the REST API uses — not MCP/OpenAPI.
## Domain model ## Domain model
@@ -65,15 +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/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
POST /gardens/:id/objects PATCH,DELETE /objects/:id POST /gardens/:id/objects PATCH,DELETE /objects/:id
POST /objects/:id/plantings PATCH,DELETE /plantings/:id POST /objects/:id/plantings PATCH,DELETE /plantings/:id
POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
GET,POST /plants PATCH,DELETE /plants/:id (own plants only) GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private) GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
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,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator
POST /agent/chat ← SSE: step events, then the finished turn (editor only) POST /agent/chat ← SSE: step events, then the finished turn (editor only)
GET,DELETE /gardens/:id/agent/history (the actor's own thread) GET,DELETE /gardens/:id/agent/history (the actor's own thread)
GET /capabilities ← what this instance can do RIGHT NOW (tracks the live agent, not just config) GET /capabilities ← what this instance can do RIGHT NOW (tracks the live agent, not just config)
GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off GET,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 /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
GET,POST,DELETE /gardens/:id/share-link ← the public read-only token for this garden
GET /public/gardens/:token ← UNAUTHENTICATED read-only /full; the token is the capability
``` ```
**Sync:** plain REST + optimistic UI + last-write-wins with a version guard. Every PATCH/DELETE carries the row's `version`; the server increments on write and returns **409 + the current row** on mismatch; the client rolls back and refetches. No websockets/CRDT — the right cost for household-scale co-editing. Drags PATCH once on drop, not per frame. **Sync:** plain REST + optimistic UI + last-write-wins with a version guard. Every PATCH/DELETE carries the row's `version`; the server increments on write and returns **409 + the current row** on mismatch; the client rolls back and refetches. No websockets/CRDT — the right cost for household-scale co-editing. Drags PATCH once on drop, not per frame.
+2 -1
View File
@@ -70,8 +70,9 @@ The garden assistant reads three more. Setting none of them leaves the assistant
| `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. | | `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_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_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 model and enabled flag can be changed at runtime by an admin under **Settings** (the gear appears in the nav for admins) — the change swaps the live assistant with no restart. The env vars above are the defaults an untouched instance uses, and the API key is intentionally not among the runtime-editable settings: a secret in the database would land in every backup. Precedence for the model and enabled flag is **Settings value, if set → env var → built-in default**. The 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. 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.
+6 -2
View File
@@ -5,9 +5,11 @@ go 1.26.2
require ( require (
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f
github.com/coreos/go-oidc/v3 v3.20.0 github.com/coreos/go-oidc/v3 v3.20.0
github.com/gen2brain/heic v0.7.1
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.1
github.com/samber/slog-gin v1.15.0 github.com/samber/slog-gin v1.15.0
golang.org/x/crypto v0.36.0 golang.org/x/crypto v0.36.0
golang.org/x/image v0.44.0
golang.org/x/oauth2 v0.36.0 golang.org/x/oauth2 v0.36.0
modernc.org/sqlite v1.34.4 modernc.org/sqlite v1.34.4
) )
@@ -33,6 +35,7 @@ require (
github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/base64x v0.1.4 // indirect
github.com/cloudwego/iasm v0.2.0 // indirect github.com/cloudwego/iasm v0.2.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/ebitengine/purego v0.10.1 // indirect
github.com/gabriel-vasile/mimetype v1.4.4 // indirect github.com/gabriel-vasile/mimetype v1.4.4 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect
@@ -51,14 +54,15 @@ require (
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/tetratelabs/wazero v1.12.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.2.12 // indirect
go.opentelemetry.io/otel v1.29.0 // indirect go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect go.opentelemetry.io/otel/trace v1.29.0 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.38.0 // indirect golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect golang.org/x/sys v0.44.0 // indirect
golang.org/x/text v0.23.0 // indirect golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
+18 -10
View File
@@ -26,12 +26,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.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.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/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/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
github.com/gen2brain/heic v0.7.1 h1:Aha1sZdKEeZeWl5o0xkSg7NBRhhkrlokGVCRri+2Qcc=
github.com/gen2brain/heic v0.7.1/go.mod h1:ja42wMJc4fpnKsfdUJxeZa2YqqRnes1wS0xqs5+8o5w=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
@@ -126,6 +130,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@@ -144,11 +150,13 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 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/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/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-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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 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-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-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-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -163,27 +171,27 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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-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.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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-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-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-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 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.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 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-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-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-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-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 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= 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.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
+1 -1
View File
@@ -60,7 +60,7 @@ func TestTurnIsOneChangeSet(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("bed: %v", err) t.Fatalf("bed: %v", err)
} }
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil { if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil {
t.Fatalf("seed garlic: %v", err) t.Fatalf("seed garlic: %v", err)
} }
+2 -1
View File
@@ -127,8 +127,9 @@ 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"` 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"` 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"` 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) { }) (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 { func (a *adapter) findPlant(ctx context.Context, args struct {
+1 -1
View File
@@ -169,7 +169,7 @@ func TestGarlicBedToCucumbers(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("bed: %v", err) t.Fatalf("bed: %v", err)
} }
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil { if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil {
t.Fatalf("seed the garlic: %v", err) t.Fatalf("seed the garlic: %v", err)
} }
+44 -1
View File
@@ -118,6 +118,21 @@ func (h *handlers) agentChat(c *gin.Context) {
send(chatEvent{Done: turn}) send(chatEvent{Done: turn})
} }
// sseWriteTimeout bounds ONE write to the stream, not the stream itself.
//
// It is refreshed per frame, which is the only shape that satisfies both ends:
// the server's absolute WriteTimeout would cut a long turn (#78), while removing
// the deadline entirely would let a client that stops reading block a write
// forever once the socket buffer fills — pinning the run goroutine and this
// stream's mutex with it, and taking the keep-alive down too since it needs the
// same lock. Generous, because it is a backstop against a stuck peer and not a
// pacing mechanism.
//
// A var, not a const, ONLY so the test can shrink it to prove the deadline is
// refreshed per frame rather than set once — a set-once 30s deadline would pass
// a test whose whole run is under a second. Production never reassigns it.
var sseWriteTimeout = 30 * time.Second
// eventStream serializes writes to one SSE response. // eventStream serializes writes to one SSE response.
// //
// The mutex is load-bearing, not decoration: step events are sent from the // The mutex is load-bearing, not decoration: step events are sent from the
@@ -126,6 +141,7 @@ func (h *handlers) agentChat(c *gin.Context) {
// frames long before it crashes anything. // frames long before it crashes anything.
type eventStream struct { type eventStream struct {
c *gin.Context c *gin.Context
rc *http.ResponseController
mu sync.Mutex mu sync.Mutex
} }
@@ -134,12 +150,34 @@ type eventStream struct {
// Headers go out before the first write and the stream is flushed immediately, // 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 // so a proxy holding the response until it looks complete can't reintroduce
// exactly the silence streaming exists to remove. // 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 { func openEventStream(c *gin.Context) *eventStream {
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no") 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() c.Writer.Flush()
return &eventStream{c: c} return s
} }
func (s *eventStream) send(ev chatEvent) { func (s *eventStream) send(ev chatEvent) {
@@ -154,6 +192,11 @@ func (s *eventStream) send(ev chatEvent) {
func (s *eventStream) write(frame string) { func (s *eventStream) write(frame string) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() 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) _, _ = io.WriteString(s.c.Writer, frame)
s.c.Writer.Flush() s.c.Writer.Flush()
} }
+22 -1
View File
@@ -122,6 +122,11 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
objects.PATCH("/:id", h.updateObject) objects.PATCH("/:id", h.updateObject)
objects.DELETE("/:id", h.deleteObject) objects.DELETE("/:id", h.deleteObject)
objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object
// Bulk ops. These wrap the same service methods the agent tools call, so an
// instance with no model configured still gets the most valuable operation in
// the app — and so "clear bed" is ONE change set rather than one per plop.
objects.POST("/:id/fill", h.fillObject)
objects.POST("/:id/clear", h.clearObject)
// Plantings ("plops") are addressed by their own id; the service resolves the // Plantings ("plops") are addressed by their own id; the service resolves the
// owning object/garden for the permission check. // owning object/garden for the permission check.
@@ -180,6 +185,10 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
seedLots.GET("/:id", h.getSeedLot) seedLots.GET("/:id", h.getSeedLot)
seedLots.PATCH("/:id", h.updateSeedLot) seedLots.PATCH("/:id", h.updateSeedLot)
seedLots.DELETE("/:id", h.deleteSeedLot) 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 // Public, unauthenticated read of a garden by its share token. Deliberately
// NOT behind requireAuth: the token is the capability, so a logged-out visitor // NOT behind requireAuth: the token is the capability, so a logged-out visitor
@@ -199,7 +208,19 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
// so offering the tab must track the live Runner. Reading agent.get() (an atomic // 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. // load) means this reflects a settings-driven swap on the very next poll.
func (h *handlers) capabilities(c *gin.Context) { func (h *handlers) capabilities(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"agent": h.agent.get() != nil}) // 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. // healthz is a liveness probe: always returns {"ok": true} when the server is up.
+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 // 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. // scopes to the session actor with no garden in the picture.
// seedLotCreateRequest is the body for POST /seed-lots. // seedLotFields is the lot half of a create body — every field EXCEPT which plant
type seedLotCreateRequest struct { // it attaches to. seedLotCreateRequest adds a required plantId; the seed-packet
PlantID int64 `json:"plantId" binding:"required"` // 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"` Vendor string `json:"vendor"`
SourceURL string `json:"sourceUrl"` SourceURL string `json:"sourceUrl"`
SKU string `json:"sku"` SKU string `json:"sku"`
@@ -32,15 +35,28 @@ type seedLotCreateRequest struct {
Notes string `json:"notes"` 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{ return service.SeedLotInput{
PlantID: r.PlantID, Vendor: r.Vendor, SourceURL: r.SourceURL, SKU: r.SKU, Vendor: f.Vendor, SourceURL: f.SourceURL, SKU: f.SKU, LotCode: f.LotCode,
LotCode: r.LotCode, PurchasedAt: r.PurchasedAt, PackedForYear: r.PackedForYear, PurchasedAt: f.PurchasedAt, PackedForYear: f.PackedForYear, Quantity: f.Quantity,
Quantity: r.Quantity, Unit: r.Unit, CostCents: r.CostCents, Unit: f.Unit, CostCents: f.CostCents, GerminationPct: f.GerminationPct, Notes: f.Notes,
GerminationPct: r.GerminationPct, Notes: r.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 // seedLotUpdateRequest is the body for PATCH /seed-lots/:id: every field
// optional, plus the required current version. The nullable columns are // optional, plus the required current version. The nullable columns are
// json.RawMessage so an explicit null (clear it) is distinguishable from an // 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())
}
}
+17 -8
View File
@@ -51,26 +51,33 @@ type effectiveView struct {
// can be false even when Enabled+HasApiKey are true (an unresolvable model), // can be false even when Enabled+HasApiKey are true (an unresolvable model),
// which is exactly the case the UI needs to surface. // which is exactly the case the UI needs to surface.
AgentLive bool `json:"agentLive"` 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 // settingsPayload builds the response, or an error. It does NOT swallow an
// EffectiveAgent failure into a misleading empty "effective" view — an empty // EffectiveConfig failure into a misleading empty "effective" view — an empty
// view would report no model and no key, which reads as "nothing configured" // view would report no model and no key, which reads as "nothing configured"
// rather than "we couldn't read it". Since EffectiveAgent re-reads the same row // 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 // GetInstanceSettings just returned, a failure here is a genuine DB fault worth
// surfacing as a 500, not papering over. // 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) { func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings) (settingsResponse, error) {
eff, err := h.svc.EffectiveAgent(c.Request.Context()) eff, vis, err := h.svc.EffectiveConfig(c.Request.Context())
if err != nil { if err != nil {
return settingsResponse{}, err return settingsResponse{}, err
} }
return settingsResponse{ return settingsResponse{
Settings: st, Settings: st,
Effective: effectiveView{ Effective: effectiveView{
Model: eff.Model, Model: eff.Model,
Enabled: eff.Enabled, Enabled: eff.Enabled,
HasApiKey: eff.APIKey != "", HasApiKey: eff.APIKey != "",
AgentLive: h.agent.get() != nil, AgentLive: h.agent.get() != nil,
VisionModel: vis.Model,
VisionReady: vis.Ready(),
}, },
}, nil }, nil
} }
@@ -95,6 +102,7 @@ func (h *handlers) getSettings(c *gin.Context) {
type settingsUpdateRequest struct { type settingsUpdateRequest struct {
AgentModel string `json:"agentModel"` AgentModel string `json:"agentModel"`
AgentEnabled json.RawMessage `json:"agentEnabled"` AgentEnabled json.RawMessage `json:"agentEnabled"`
VisionModel string `json:"visionModel"`
Version int64 `json:"version" binding:"required"` Version int64 `json:"version" binding:"required"`
} }
@@ -117,6 +125,7 @@ func (h *handlers) updateSettings(c *gin.Context) {
st, err := h.svc.UpdateInstanceSettings(c.Request.Context(), mustActor(c).ID, service.InstanceSettingsPatch{ st, err := h.svc.UpdateInstanceSettings(c.Request.Context(), mustActor(c).ID, service.InstanceSettingsPatch{
AgentModel: req.AgentModel, AgentModel: req.AgentModel,
AgentEnabled: enabled, AgentEnabled: enabled,
VisionModel: req.VisionModel,
Version: req.Version, Version: req.Version,
}) })
if err != nil { if err != nil {
+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)
}
}
+8
View File
@@ -74,6 +74,11 @@ type AgentConfig struct {
// a key is present, so an instance with no key starts cleanly and simply // 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. // doesn't offer the agent — the same shape as OIDC 404ing when unconfigured.
Enabled bool 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. // Enabled reports whether enough OIDC config is present to attempt discovery.
@@ -120,6 +125,9 @@ func Load() *Config {
// opt-in, and making people set a second flag to use what they just // opt-in, and making people set a second flag to use what they just
// configured is a papercut with no upside. // configured is a papercut with no upside.
Enabled: envBool("PANSY_AGENT_ENABLED", agentKey != ""), 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 { if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
+7 -3
View File
@@ -253,9 +253,13 @@ type InstanceSettings struct {
// majordomo.Parse, exactly like the env var it shadows. // majordomo.Parse, exactly like the env var it shadows.
AgentModel string `json:"agentModel"` AgentModel string `json:"agentModel"`
// AgentEnabled overrides PANSY_AGENT_ENABLED when non-nil. nil = inherit. // AgentEnabled overrides PANSY_AGENT_ENABLED when non-nil. nil = inherit.
AgentEnabled *bool `json:"agentEnabled"` AgentEnabled *bool `json:"agentEnabled"`
Version int64 `json:"version"` // VisionModel overrides PANSY_VISION_MODEL when non-empty; the model that
UpdatedAt string `json:"updatedAt"` // 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. // User is a pansy account. It may have a local password, OIDC identity, or both.
+207
View File
@@ -0,0 +1,207 @@
// Package imagenorm normalizes an uploaded image to a JPEG the rest of pansy
// (and majordomo's vision path) can rely on, decoding the formats a phone
// actually produces.
//
// # Why this exists
//
// majordomo's own media pipeline is stdlib-based, so it cannot decode HEIC or
// WebP — and HEIC is the iPhone camera default. The seed-packet feature's very
// first input is "a photo from my phone", so without this the feature fails on
// the exact device that motivates it. Normalizing at the upload boundary means
// everything downstream only ever sees JPEG.
//
// # The CGO constraint
//
// pansy is CGO_ENABLED=0 (a single static binary — the reason modernc/sqlite was
// chosen over the C one), so a libheif *binding* is out. github.com/gen2brain/heic
// runs libheif as WebAssembly via wazero: pure Go, no cgo, and it registers with
// image.Decode like any other format. golang.org/x/image/webp is pure Go too.
//
// # Import-driven registry
//
// Go's image decoders register via blank imports, and the failure mode is
// backwards from intuition: forget "image/png" and PNG uploads fail with
// "unknown format" while the exotic HEIC still works. So the blank imports below
// are load-bearing, and TestNormalizeAllFormats exercises all four formats to
// keep them so.
package imagenorm
import (
"bytes"
"errors"
"fmt"
"image"
"image/jpeg"
"io"
"golang.org/x/image/draw"
// Decoders, registered with image.Decode by side effect. All four matter:
// jpeg/png are the common cases, heic is the iPhone default, webp is common
// on the web. Dropping any one silently breaks that format's uploads.
_ "image/jpeg"
_ "image/png"
_ "github.com/gen2brain/heic"
_ "golang.org/x/image/webp"
)
// Defaults chosen for the seed-packet path against ollama-cloud's limits (8
// images, 20 MiB, 2048px, jpeg+png). We re-encode to JPEG well under all of them.
const (
// DefaultMaxDim is the longest-edge ceiling. 2048 matches ollama-cloud's
// MaxDim; anything larger is downscaled. A packet photo has plenty of detail
// left at 2048.
DefaultMaxDim = 2048
// DefaultMaxBytes caps the *input* we will read. A phone photo is 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, and returns it re-encoded
// as JPEG, plus the decoded format name (e.g. "heic") — handy for logging what a
// phone actually sent. On any error the returned bytes are nil and format is "".
//
// Errors, by cause:
// - input over opts.MaxBytes, or a decoded canvas over maxDecodePixels /
// maxDimension → ErrTooLarge, refused before the bitmap is allocated;
// - bytes that aren't a decodable image, or a decoder that panics on them →
// ErrUnsupported;
// - a genuine read or JPEG-encode I/O failure → a wrapped error (not a
// sentinel), since those are the caller's stream/environment, not the image.
//
// It bounds work against a hostile upload three ways: the byte cap, the
// pre-decode pixel/dimension check, and a recover around the third-party decoders
// (a malformed HEIC/WebP shouldn't take the process down).
//
// Two known gaps, both deferred to the upload handler that wires this in (#81):
// - EXIF ORIENTATION is not applied, so a portrait phone photo tagged
// "rotate 90°" comes out sideways. That's best fixed and tested with a real
// oriented photo end-to-end, which the library has no consumer for yet.
// - There is no context: image.Decode is CPU-bound and not cancellable
// mid-decode, so a caller that needs a hard deadline should run Normalize
// under its own timeout. The size guards keep the work finite regardless.
func Normalize(r io.Reader, opts Options) (out []byte, format string, err error) {
// Cap the read at MaxBytes+1 so we can tell "exactly at the cap" from "over".
// maxBytes() is always a sane positive (default 25 MiB); guard the +1 anyway.
byteCap := opts.maxBytes()
limit := int64(byteCap) + 1
if limit < 1 {
limit = int64(DefaultMaxBytes) + 1
}
raw, err := io.ReadAll(io.LimitReader(r, limit))
if err != nil {
return nil, "", fmt.Errorf("imagenorm: read: %w", err)
}
if len(raw) > byteCap {
return nil, "", ErrTooLarge
}
// Check dimensions BEFORE a full decode, so a decompression bomb is refused
// before it allocates its bitmap. The per-side maxDimension check runs first
// so the pixel-count multiply below can't overflow.
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return nil, "", ErrUnsupported
}
if cfg.Width <= 0 || cfg.Height <= 0 ||
cfg.Width > maxDimension || cfg.Height > maxDimension ||
int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
return nil, "", ErrTooLarge
}
img, format, err := decodeSafely(raw)
if err != nil {
return nil, "", err
}
img = downscale(img, opts.maxDim())
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
return nil, "", fmt.Errorf("imagenorm: encode jpeg: %w", err)
}
return buf.Bytes(), format, nil
}
// decodeSafely decodes raw, converting both a decode error and a decoder PANIC
// into ErrUnsupported. The recover matters because the image comes from an
// untrusted upload and the HEIC/WebP decoders are third-party (libheif via WASM,
// x/image/webp): a malformed file that panics one of them must fail this one
// request, not crash the process.
func decodeSafely(raw []byte) (img image.Image, format string, err error) {
defer func() {
if r := recover(); r != nil {
img, format, err = nil, "", ErrUnsupported
}
}()
img, format, err = image.Decode(bytes.NewReader(raw))
if err != nil {
return nil, "", ErrUnsupported
}
return img, format, nil
}
// downscale returns img shrunk so its longest edge is at most maxDim, preserving
// aspect ratio. An image already within bounds is returned unchanged (no
// re-sampling, no quality loss beyond the JPEG round-trip). Uses Catmull-Rom for
// a sharp result on text, which is what a packet photo is mostly made of.
func downscale(img image.Image, maxDim int) image.Image {
b := img.Bounds()
w, h := b.Dx(), b.Dy()
longest := max(w, h)
if longest <= maxDim || longest == 0 {
return img
}
scale := float64(maxDim) / float64(longest)
nw, nh := max(int(float64(w)*scale), 1), max(int(float64(h)*scale), 1)
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
return dst
}
+202
View File
@@ -0,0 +1,202 @@
package imagenorm
import (
"bytes"
"encoding/binary"
"hash/crc32"
"image"
"image/jpeg"
"image/png"
"os"
"testing"
)
// pngBytes and jpegBytes generate in-memory fixtures for the two formats Go can
// encode; heic/webp come from testdata (Go has no encoder for them).
func pngBytes(t *testing.T, w, h int) []byte {
t.Helper()
m := image.NewRGBA(image.Rect(0, 0, w, h))
for y := range h {
for x := range w {
m.Pix[m.PixOffset(x, y)+0] = uint8(x)
m.Pix[m.PixOffset(x, y)+3] = 255
}
}
var b bytes.Buffer
if err := png.Encode(&b, m); err != nil {
t.Fatalf("encode png: %v", err)
}
return b.Bytes()
}
func jpegBytes(t *testing.T, w, h int) []byte {
t.Helper()
m := image.NewRGBA(image.Rect(0, 0, w, h))
var b bytes.Buffer
if err := jpeg.Encode(&b, m, nil); err != nil {
t.Fatalf("encode jpeg: %v", err)
}
return b.Bytes()
}
func readTestdata(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile("testdata/" + name)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return b
}
// TestNormalizeAllFormats is the load-bearing test: every format pansy claims to
// accept must round-trip to a valid JPEG. It exists specifically to catch a
// dropped blank import — the failure mode where the common format (PNG) breaks
// while the exotic one (HEIC) works, because someone deleted `_ "image/png"`.
func TestNormalizeAllFormats(t *testing.T) {
cases := []struct {
name string
input []byte
wantFormat string
}{
{"png", pngBytes(t, 120, 90), "png"},
{"jpeg", jpegBytes(t, 120, 90), "jpeg"},
{"heic", readTestdata(t, "sample.heic"), "heic"},
{"webp", readTestdata(t, "sample.webp"), "webp"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out, format, err := Normalize(bytes.NewReader(tc.input), Options{})
if err != nil {
t.Fatalf("Normalize(%s): %v", tc.name, err)
}
if format != tc.wantFormat {
t.Errorf("format = %q, want %q", format, tc.wantFormat)
}
// The output must itself be a decodable JPEG.
_, outFormat, err := image.Decode(bytes.NewReader(out))
if err != nil {
t.Fatalf("output isn't a valid image: %v", err)
}
if outFormat != "jpeg" {
t.Errorf("output format = %q, want jpeg", outFormat)
}
})
}
}
// TestNormalizeDownscales checks a large image is shrunk to fit MaxDim on its
// longest edge with aspect ratio preserved, and a small one is left alone.
func TestNormalizeDownscales(t *testing.T) {
// A 2:1 image twice as wide as DefaultMaxDim → clamped to DefaultMaxDim on the
// long edge with aspect preserved. Derived from the constant, not hard-coded,
// so the test tracks the default rather than silently asserting a magic number.
longEdge := DefaultMaxDim * 2
big := pngBytes(t, longEdge, longEdge/2)
out, _, err := Normalize(bytes.NewReader(big), Options{})
if err != nil {
t.Fatalf("Normalize: %v", err)
}
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode out: %v", err)
}
if cfg.Width != DefaultMaxDim {
t.Errorf("width = %d, want %d (longest edge clamped)", cfg.Width, DefaultMaxDim)
}
if cfg.Height != DefaultMaxDim/2 {
t.Errorf("height = %d, want %d (aspect preserved)", cfg.Height, DefaultMaxDim/2)
}
// A small image within bounds keeps its dimensions.
small := pngBytes(t, 100, 80)
out, _, err = Normalize(bytes.NewReader(small), Options{})
if err != nil {
t.Fatalf("Normalize small: %v", err)
}
cfg, _, err = image.DecodeConfig(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode small out: %v", err)
}
if cfg.Width != 100 || cfg.Height != 80 {
t.Errorf("small image resized to %dx%d, want 100x80", cfg.Width, cfg.Height)
}
}
// TestNormalizeRejectsOversizeInput: an input past the byte cap is ErrTooLarge,
// refused without a full decode.
func TestNormalizeRejectsOversizeInput(t *testing.T) {
big := pngBytes(t, 500, 500)
_, _, err := Normalize(bytes.NewReader(big), Options{MaxBytes: 100})
if err != ErrTooLarge {
t.Errorf("over-cap input err = %v, want ErrTooLarge", err)
}
}
// TestNormalizeRejectsGarbage: unreadable-as-image bytes and a truncated image
// both fail cleanly with ErrUnsupported, not a panic.
func TestNormalizeRejectsGarbage(t *testing.T) {
_, _, err := Normalize(bytes.NewReader([]byte("not an image at all")), Options{})
if err != ErrUnsupported {
t.Errorf("garbage err = %v, want ErrUnsupported", err)
}
// A truncated image (valid header, cut body) also fails cleanly, not a panic.
png := pngBytes(t, 100, 100)
_, _, err = Normalize(bytes.NewReader(png[:len(png)/2]), Options{})
if err != ErrUnsupported {
t.Errorf("truncated image err = %v, want ErrUnsupported", err)
}
}
// pngHeader builds a valid PNG signature + IHDR chunk (with a correct CRC, which
// DecodeConfig verifies) for the given dimensions, and nothing else. It's enough
// for image.DecodeConfig to report width/height without a real bitmap — exactly
// what's needed to exercise the pre-decode size guard with a tiny input.
func pngHeader(w, h uint32) []byte {
var buf bytes.Buffer
buf.Write([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})
ihdr := make([]byte, 13)
binary.BigEndian.PutUint32(ihdr[0:], w)
binary.BigEndian.PutUint32(ihdr[4:], h)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // colour type: RGBA
// compression/filter/interlace already 0.
binary.Write(&buf, binary.BigEndian, uint32(len(ihdr)))
chunk := append([]byte("IHDR"), ihdr...)
buf.Write(chunk)
binary.Write(&buf, binary.BigEndian, crc32.ChecksumIEEE(chunk))
return buf.Bytes()
}
// TestNormalizeRejectsPixelBomb is the guard the review found untested: a small
// input (a bare ~40-byte PNG header) claiming an enormous canvas is refused with
// ErrTooLarge from DecodeConfig alone, before image.Decode allocates anything.
// Covers both the per-side maxDimension trip and the pixel-count trip — and, via
// the near-2^16-per-side case, that the count math doesn't overflow.
func TestNormalizeRejectsPixelBomb(t *testing.T) {
cases := []struct {
name string
w, h uint32
}{
{"huge single side", 60000, 10}, // > maxDimension on width
{"huge area within side cap", 40000, 40000}, // sides < cap, area 1.6 GP > maxDecodePixels
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
hdr := pngHeader(tc.w, tc.h)
if len(hdr) > 100 {
t.Fatalf("header unexpectedly large (%d bytes) — not a bomb test", len(hdr))
}
// Sanity: the header really does decode to those dimensions.
cfg, _, err := image.DecodeConfig(bytes.NewReader(hdr))
if err != nil {
t.Fatalf("crafted PNG header didn't parse: %v", err)
}
if uint32(cfg.Width) != tc.w || uint32(cfg.Height) != tc.h {
t.Fatalf("header reports %dx%d, want %dx%d", cfg.Width, cfg.Height, tc.w, tc.h)
}
if _, _, err := Normalize(bytes.NewReader(hdr), Options{}); err != ErrTooLarge {
t.Errorf("pixel bomb %dx%d err = %v, want ErrTooLarge", tc.w, tc.h, err)
}
})
}
}
+14
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

+63 -5
View File
@@ -43,6 +43,7 @@ func (s *Service) GetInstanceSettings(ctx context.Context, actorID int64) (*doma
type InstanceSettingsPatch struct { type InstanceSettingsPatch struct {
AgentModel string AgentModel string
AgentEnabled *bool AgentEnabled *bool
VisionModel string
Version int64 Version int64
} }
@@ -58,16 +59,20 @@ func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, pat
return nil, err return nil, err
} }
model := strings.TrimSpace(patch.AgentModel) model := strings.TrimSpace(patch.AgentModel)
// Validate a non-empty spec up front. An empty one is the "inherit env" 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. // sentinel and needs no check — the env value was validated at boot.
if model != "" { for _, spec := range []string{model, vision} {
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, model); err != nil { if spec != "" {
return nil, domain.ErrInvalidInput if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, spec); err != nil {
return nil, domain.ErrInvalidInput
}
} }
} }
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{ return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
AgentModel: model, AgentModel: model,
AgentEnabled: patch.AgentEnabled, AgentEnabled: patch.AgentEnabled,
VisionModel: vision,
Version: patch.Version, Version: patch.Version,
}) })
} }
@@ -95,6 +100,13 @@ func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) {
if err != nil { if err != nil {
return EffectiveAgent{}, err 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{ eff := EffectiveAgent{
Model: s.cfg.Agent.Model, Model: s.cfg.Agent.Model,
Enabled: s.cfg.Agent.Enabled, Enabled: s.cfg.Agent.Enabled,
@@ -106,5 +118,51 @@ func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) {
if st.AgentEnabled != nil { if st.AgentEnabled != nil {
eff.Enabled = *st.AgentEnabled eff.Enabled = *st.AgentEnabled
} }
return eff, nil 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
} }
+109 -44
View File
@@ -97,30 +97,106 @@ func defaultPlopRadius(spacingCM float64) float64 {
return math.Max(1.5*spacingCM, 15) return math.Max(1.5*spacingCM, 15)
} }
// FillRegion lays a hex-packed field of plops of one plant across a region of a // FillLayout selects what a fill packs (#77).
// 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× // A plop is a CLUMP, not a plant, and that abstraction is the right primitive for
// radius pitch, centered in the region, and set in from each edge by the plop's // SKETCHING — "a few plops of garlic in one corner" — but it can't draw a real
// radius less half a spacing — see hexCenters for why that half-spacing is what // planting: a filled 4×8ft bed comes out as ~15 blobs, not 8 rows of garlic. So
// the edge is owed. A candidate is skipped when its plop would sit entirely // filling is now two operations. FillClump (the default, unchanged) drops fat
// inside an existing active plop (so re-filling doesn't stack duplicates). // clumps for quick coverage; FillGrid lays out individual plants at true spacing,
// Returns the plops it created. // producing a layout you could actually plant from.
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) { 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) o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil { if err != nil {
return nil, err 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 // fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
// already loaded and authorized (roleEditor). It rejects a non-finite region, // already loaded and authorized (roleEditor). It validates the layout, rejects a
// clamps the region to the object's bounds, refuses fills over maxFillPlops, and // non-finite region, clamps the region to the object's bounds, refuses fills over
// inserts the whole batch in one transaction rather than one round-trip per plop. // maxFillPlops, and inserts the whole batch in one transaction rather than one
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) { // 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 { if !o.Plantable {
return nil, domain.ErrInvalidInput return nil, domain.ErrInvalidInput
} }
layout, ok := validFillLayout(layout)
if !ok {
return nil, domain.ErrInvalidInput
}
plant, err := s.visiblePlant(ctx, actorID, plantID) plant, err := s.visiblePlant(ctx, actorID, plantID)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -132,7 +208,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
} }
spacing = *spacingOverride spacing = *spacingOverride
} }
radius := defaultPlopRadius(spacing) radius := plopRadiusFor(spacing, layout)
if !isFinite(radius) || radius <= 0 { if !isFinite(radius) || radius <= 0 {
return nil, domain.ErrInvalidInput return nil, domain.ErrInvalidInput
} }
@@ -150,7 +226,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
} }
region = region.clampTo(o.WidthCM/2, o.HeightCM/2) region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
centers, total := hexCenters(region, radius, spacing, maxFillPlops) centers, total := hexCenters(region, radius, edgeInset(radius, spacing, layout), maxFillPlops)
if total > maxFillPlops { if total > maxFillPlops {
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
} }
@@ -161,13 +237,17 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
} }
today := s.now().UTC().Format(dateLayout) today := s.now().UTC().Format(dateLayout)
batch := make([]*domain.Planting, 0, len(centers)) 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 { for _, c := range centers {
if coveredByExisting(c.x, c.y, radius, existing) { if coveredByExisting(c.x, c.y, radius, existing) {
continue continue
} }
p := &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today} batch = append(batch, &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
} }
created, err := s.store.CreatePlantings(ctx, batch) created, err := s.store.CreatePlantings(ctx, batch)
if err != nil { if err != nil {
@@ -196,24 +276,14 @@ type localPoint struct{ x, y float64 }
// //
// # How close to the edge the outer row goes // # How close to the edge the outer row goes
// //
// Spacing is a constraint BETWEEN NEIGHBOURING PLANTS competing for the same // The caller passes `inset`: the margin the outer row keeps from every edge. It
// soil, light and water. A bed edge is not a competitor, so the outer row only // encodes the half-spacing rule (spacing is owed between neighbouring plants, and
// owes it HALF the spacing — the half it would otherwise share with a neighbour. // a bed edge is nobody's neighbour) and differs by layout — see edgeInset, which
// That is the arithmetic inside every square-foot-gardening chart: 4 per square // derives it. hexCenters just honours it on all four sides.
// is 6" apart and 3" from the square's edge; 9 per square is 4" apart and 2"
// from the edge. Garlic at 9 per square goes in 2" from the frame, not 6".
// //
// A plop is a CLUMP, not a plant — defaultPlopRadius makes it 1.5×spacing, so // Do not "simplify" the centering back to anchoring at the region's min corner.
// three spacings across — and its plants sit out to its rim. So keeping the whole // That is what #75 was: staggered rows start a full pitch in, and the leftover all
// circle inside the bed would inset the outer row by a full 1.5 spacings, three // lands on the far edge, where clumps hang outside a bed that nothing clips them to.
// times what the rule allows. Instead the clump may hang over the edge by up to
// half a spacing, which puts its outermost plants exactly the half-spacing from
// the edge that the rule asks for. Overhang is capped there and nowhere near the
// full radius: a clump mostly outside the bed is a drawing of plants in the path.
//
// Do not "simplify" this back to anchoring at the region's min corner. That is
// what #75 was: staggered rows start a full pitch in, and the leftover all lands
// on the far edge, where clumps hang outside a bed that nothing clips them to.
// //
// # Counting before building // # Counting before building
// //
@@ -221,7 +291,7 @@ type localPoint struct{ x, y float64 }
// BEFORE building anything: a fill large enough to be refused shouldn't allocate // 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 // 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. // returns (nil, total), so the caller can still refuse with the real number.
func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int) { func hexCenters(r Region, radius, inset float64, limit int) ([]localPoint, int) {
if radius <= 0 { if radius <= 0 {
return nil, 0 return nil, 0
} }
@@ -234,11 +304,6 @@ func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int
pitch := 2 * radius pitch := 2 * radius
rowH := pitch * math.Sqrt(3) / 2 rowH := pitch * math.Sqrt(3) / 2
// How far a clump's centre must stay inside the edge: its own radius, less the
// half-spacing of overhang the rule allows. Never negative, and never past the
// centre of the clump.
inset := math.Max(0, radius-math.Max(0, spacing)/2)
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset) rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset) cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
@@ -311,7 +376,7 @@ func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half") // 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 // 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. // 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) o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -320,7 +385,7 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64,
if err != nil { if err != nil {
return nil, err 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 // ClearObject soft-removes every active plop in an object the actor can edit (one
+75 -11
View File
@@ -59,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 bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all") 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) t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err)
} }
} }
@@ -96,7 +96,7 @@ func TestHexCentersEdgeInset(t *testing.T) {
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
r := rect(-tc.w/2, -tc.h/2, tc.w/2, tc.h/2) r := rect(-tc.w/2, -tc.h/2, tc.w/2, tc.h/2)
pts, total := hexCenters(r, tc.radius, tc.spacing, maxFillPlops) pts, total := hexCenters(r, tc.radius, edgeInset(tc.radius, tc.spacing, FillClump), maxFillPlops)
if len(pts) == 0 { if len(pts) == 0 {
t.Fatal("no centers") t.Fatal("no centers")
} }
@@ -173,7 +173,7 @@ func TestHexCentersTinyRegion(t *testing.T) {
{"off in a corner", rect(20, -40, 30, -30), 25, -35}, {"off in a corner", rect(20, -40, 30, -30), 25, -35},
} { } {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
pts, _ := hexCenters(tc.r, 15, 10, maxFillPlops) pts, _ := hexCenters(tc.r, 15, edgeInset(15, 10, FillClump), maxFillPlops)
if len(pts) != 1 || pts[0].x != tc.wantX || pts[0].y != tc.wantY { 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) t.Errorf("got %+v, want one plop at (%v,%v)", pts, tc.wantX, tc.wantY)
} }
@@ -199,7 +199,7 @@ func TestFillRegionRejectsNonFiniteRegion(t *testing.T) {
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50}, {MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)}, {MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
} { } {
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil) created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil, FillClump)
if !errors.Is(err, domain.ErrInvalidInput) { if !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err) t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
} }
@@ -223,7 +223,7 @@ func TestFillRegionOutsideObjectPlantsNothing(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50. // 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) created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil, FillClump)
if err != nil { if err != nil {
t.Fatalf("FillRegion: %v", err) t.Fatalf("FillRegion: %v", err)
} }
@@ -256,7 +256,7 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15 plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15
region, _ := NamedRegion(bed, "all") 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 { if err != nil {
t.Fatalf("FillRegion: %v", err) t.Fatalf("FillRegion: %v", err)
} }
@@ -283,7 +283,7 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
// Re-filling the same region skips everything (each candidate sits exactly on // Re-filling the same region skips everything (each candidate sits exactly on
// an existing plop → entirely inside it). // 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 { if err != nil {
t.Fatalf("second FillRegion: %v", err) t.Fatalf("second FillRegion: %v", err)
} }
@@ -292,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) { func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
ctx := context.Background() ctx := context.Background()
s := newTestService(t, openConfig()) s := newTestService(t, openConfig())
@@ -304,7 +368,7 @@ func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 20) plant := seedOwnPlant(t, s, owner, 20)
region, _ := NamedRegion(bed, "ne") 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 { if err != nil {
t.Fatalf("FillRegion: %v", err) t.Fatalf("FillRegion: %v", err)
} }
@@ -327,7 +391,7 @@ func TestClearObject(t *testing.T) {
bed := seedBed(t, s, owner, g.ID) bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all") 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) t.Fatalf("fill: %v", err)
} }
@@ -361,7 +425,7 @@ func TestOpsForbiddenForViewer(t *testing.T) {
} }
region, _ := NamedRegion(bed, "all") 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) t.Errorf("viewer fill = %v, want ErrForbidden", err)
} }
if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) { if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) {
@@ -391,7 +455,7 @@ func TestFillScenario(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("region %q: %v", name, err) 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) t.Fatalf("fill %q: %v", name, err)
} }
} }
+5 -5
View File
@@ -73,7 +73,7 @@ func TestFillRegionIsOneChangeSet(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() 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 { if err != nil {
t.Fatalf("FillNamedRegion: %v", err) t.Fatalf("FillNamedRegion: %v", err)
} }
@@ -320,7 +320,7 @@ func TestRevertClearObject(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() 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) t.Fatalf("fill: %v", err)
} }
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID) before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
@@ -688,7 +688,7 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() 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) t.Fatalf("fill: %v", err)
} }
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID) before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
@@ -725,7 +725,7 @@ func TestRevertResultCarriesItsCounts(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() 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) t.Fatalf("fill: %v", err)
} }
// A second, different kind of change, so the breakdown has more than one row // A second, different kind of change, so the breakdown has more than one row
@@ -830,7 +830,7 @@ func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{ cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
Source: domain.SourceAgent, Summary: "plant beans in the second bed", Source: domain.SourceAgent, Summary: "plant beans in the second bed",
}, func(ctx context.Context) error { }, func(ctx context.Context) error {
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 {
return err return err
} }
cancel() // the client disconnects, mid-turn, after the work landed cancel() // the client disconnects, mid-turn, after the work landed
+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)
}
}
}
+27 -6
View File
@@ -9,6 +9,7 @@
package service package service
import ( import (
"context"
"crypto/rand" "crypto/rand"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
@@ -18,6 +19,7 @@ import (
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config" "gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/store" "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 // 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 // produced by timingHash (fixed salt, no RNG) so it is always present — an
// empty one would silently re-open account enumeration. // empty one would silently re-open account enumeration.
dummyHash string 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. // New constructs a Service.
func New(st *store.DB, cfg *config.Config) *Service { func New(st *store.DB, cfg *config.Config, opts ...Option) *Service {
return &Service{ s := &Service{
store: st, store: st,
cfg: cfg, cfg: cfg,
now: time.Now, now: time.Now,
dummyHash: timingHash(), dummyHash: timingHash(),
extractPacket: vision.Extract,
} }
for _, o := range opts {
o(s)
}
return s
} }
// formatTime renders a time as pansy's canonical UTC string. // formatTime renders a time as pansy's canonical UTC string.
+4 -4
View File
@@ -12,7 +12,7 @@ import (
// The instance_settings row is seeded by migration 0010 and there is exactly one // 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. // (CHECK id = 1), so reads never branch on existence and writes never insert.
const instanceSettingsColumns = `agent_model, agent_enabled, version, updated_at` const instanceSettingsColumns = `agent_model, agent_enabled, vision_model, version, updated_at`
// scanInstanceSettings reads the single settings row. agent_enabled is a nullable // scanInstanceSettings reads the single settings row. agent_enabled is a nullable
// INTEGER (NULL = inherit env), so it is scanned through sql.NullInt64. // INTEGER (NULL = inherit env), so it is scanned through sql.NullInt64.
@@ -21,7 +21,7 @@ func scanInstanceSettings(s scanner) (*domain.InstanceSettings, error) {
out domain.InstanceSettings out domain.InstanceSettings
enabled sql.NullInt64 enabled sql.NullInt64
) )
if err := s.Scan(&out.AgentModel, &enabled, &out.Version, &out.UpdatedAt); err != nil { if err := s.Scan(&out.AgentModel, &enabled, &out.VisionModel, &out.Version, &out.UpdatedAt); err != nil {
return nil, err return nil, err
} }
if enabled.Valid { if enabled.Valid {
@@ -60,12 +60,12 @@ func (d *DB) UpdateInstanceSettings(ctx context.Context, s *domain.InstanceSetti
} }
updated, err := scanInstanceSettings(d.sql.QueryRowContext(ctx, updated, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
`UPDATE instance_settings `UPDATE instance_settings
SET agent_model = ?, agent_enabled = ?, SET agent_model = ?, agent_enabled = ?, vision_model = ?,
version = version + 1, version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = 1 AND version = ? WHERE id = 1 AND version = ?
RETURNING `+instanceSettingsColumns, RETURNING `+instanceSettingsColumns,
s.AgentModel, enabled, s.Version, s.AgentModel, enabled, s.VisionModel, s.Version,
)) ))
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
current, gerr := d.GetInstanceSettings(ctx) current, gerr := d.GetInstanceSettings(ctx)
@@ -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 '';
+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")
}
}
+55 -3
View File
@@ -1,5 +1,12 @@
import { useEffect, useRef, type ReactNode } from 'react' import { useEffect, useRef, type ReactNode } from 'react'
// Tabbable controls inside the dialog, in DOM order. type="hidden" inputs are
// excluded — they'd match `input:not([disabled])` and, sitting at a boundary,
// break the wrap math. Hoisted out of the handler so it isn't rebuilt per Tab.
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
/** /**
* A centered modal dialog over a dimmed backdrop. Closes on Escape or a backdrop * A centered modal dialog over a dimmed backdrop. Closes on Escape or a backdrop
* click, unless `busy` (a mutation is in flight) — then it stays put so the * click, unless `busy` (a mutation is in flight) — then it stays put so the
@@ -27,12 +34,57 @@ export function Modal({
busyRef.current = busy busyRef.current = busy
useEffect(() => { useEffect(() => {
cardRef.current?.focus() const card = cardRef.current
// Remember who opened the dialog so focus can return there on close —
// otherwise it lands on <body> and a keyboard user loses their place.
const opener = document.activeElement as HTMLElement | null
card?.focus()
const focusable = () =>
Array.from(card?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])
function onKey(e: KeyboardEvent) { function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && !busyRef.current) onCloseRef.current() if (e.key === 'Escape' && !busyRef.current) {
onCloseRef.current()
return
}
if (e.key !== 'Tab') return
const items = focusable()
if (items.length === 0) {
e.preventDefault()
card?.focus()
return
}
const first = items[0]
const last = items[items.length - 1]
const active = document.activeElement
// If focus is NOT inside the dialog, pull it back in rather than let Tab
// escape. This is the robust case that covers focus having fallen to
// <body> — a control that was removed (ShareGardenModal's remove-share
// button) or disabled while busy — as well as any externally-stolen focus.
if (!card || !card.contains(active)) {
e.preventDefault()
;(e.shiftKey ? last : first).focus()
return
}
if (e.shiftKey && (active === first || active === card)) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && active === last) {
e.preventDefault()
first.focus()
}
} }
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey) return () => {
document.removeEventListener('keydown', onKey)
// Restore focus to the opener only if it's still in the document — the
// delete/clear flows this trap targets often remove the element that
// opened the dialog (a garden card, a plop row). A disconnected node's
// focus() silently no-ops and leaves focus on <body>, so fall through to
// that case explicitly rather than pretend it worked.
if (opener && opener.isConnected) opener.focus()
}
}, []) }, [])
return ( return (
+26 -6
View File
@@ -17,9 +17,16 @@ interface ToastState {
let nextId = 1 let nextId = 1
// Error toasts no longer auto-dismiss (#85), so a burst of failures could grow
// the stack without bound and push older ones off-screen. Cap it: keep the most
// recent MAX_TOASTS and drop the oldest, so the newest — the one that just
// happened — is always visible.
const MAX_TOASTS = 4
export const useToastStore = create<ToastState>((set) => ({ export const useToastStore = create<ToastState>((set) => ({
toasts: [], toasts: [],
push: (message, tone = 'info') => set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }] })), push: (message, tone = 'info') =>
set((s) => ({ toasts: [...s.toasts, { id: nextId++, message, tone }].slice(-MAX_TOASTS) })),
dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })), dismiss: (id) => set((s) => ({ toasts: s.toasts.filter((t) => t.id !== id) })),
})) }))
@@ -32,21 +39,34 @@ export const toast = {
// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export. // Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
function ToastItem({ item }: { item: Toast }) { function ToastItem({ item }: { item: Toast }) {
const dismiss = useToastStore((s) => s.dismiss) const dismiss = useToastStore((s) => s.dismiss)
const isError = item.tone === 'error'
useEffect(() => { useEffect(() => {
// Error toasts are the primary report that a mutation failed, so they do NOT
// auto-dismiss — a user who looked away at second 4 would otherwise lose the
// only notice, with nothing to retrieve (#85). Info toasts still time out.
if (isError) return
const t = setTimeout(() => dismiss(item.id), 4000) const t = setTimeout(() => dismiss(item.id), 4000)
return () => clearTimeout(t) return () => clearTimeout(t)
}, [item.id, dismiss]) }, [item.id, dismiss, isError])
return ( return (
<div <div
role={item.tone === 'error' ? 'alert' : 'status'} role={isError ? 'alert' : 'status'}
className={cn( className={cn(
'pointer-events-auto rounded-md border px-3 py-2 text-sm shadow-md', 'pointer-events-auto flex items-start gap-2 rounded-md border px-3 py-2 text-sm shadow-md',
item.tone === 'error' isError
? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300' ? 'border-red-500/40 bg-red-500/10 text-red-700 dark:text-red-300'
: 'border-border bg-surface text-fg', : 'border-border bg-surface text-fg',
)} )}
> >
{item.message} <span className="flex-1">{item.message}</span>
<button
type="button"
onClick={() => dismiss(item.id)}
aria-label="Dismiss"
className="-mr-1 shrink-0 rounded px-1 text-current opacity-60 outline-none hover:opacity-100 focus-visible:ring-2 focus-visible:ring-current/40"
>
</button>
</div> </div>
) )
} }
+8 -6
View File
@@ -5,23 +5,25 @@ import { useClearObject } from '@/lib/objects'
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows /** Confirm clearing every active plop from a focused bed (soft-remove — the rows
* are kept with removed_at, so history survives). */ * are kept with removed_at, so history survives). */
export function ClearBedModal({ export function ClearBedModal({
objectId,
objectName, objectName,
plops, plopCount,
gardenId, gardenId,
onClose, onClose,
}: { }: {
objectId: number
objectName: string objectName: string
plops: { id: number; version: number }[] plopCount: number
gardenId: number gardenId: number
onClose: () => void onClose: () => void
}) { }) {
const clear = useClearObject(gardenId) const clear = useClearObject(gardenId)
const n = plops.length
return ( return (
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}> <Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<p className="text-sm text-muted"> <p className="text-sm text-muted">
Remove all <span className="font-medium text-fg">{n}</span> {n === 1 ? 'plant' : 'plants'} from{' '} Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history. <span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
</p> </p>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
@@ -31,8 +33,8 @@ export function ClearBedModal({
<Button <Button
type="button" type="button"
variant="danger" variant="danger"
disabled={clear.isPending || n === 0} disabled={clear.isPending || plopCount === 0}
onClick={() => clear.mutate(plops, { onSuccess: onClose })} onClick={() => clear.mutate(objectId, { onSuccess: onClose })}
> >
{clear.isPending ? 'Clearing' : 'Clear bed'} {clear.isPending ? 'Clearing' : 'Clear bed'}
</Button> </Button>
+6
View File
@@ -235,7 +235,13 @@ export function GardenCanvas({
className="h-full w-full select-none" className="h-full w-full select-none"
style={{ touchAction: 'none' }} style={{ touchAction: 'none' }}
onPointerDown={onCanvasPointerDown} onPointerDown={onCanvasPointerDown}
// role="application" tells a screen reader this is an interactive canvas
// to operate, not a document to read linearly. The <title> names it, and
// objects inside are individually focusable buttons (see ObjectShape).
role="application"
aria-label={`${garden.name} — garden layout. Tab between objects; Enter selects; arrow keys nudge a selection.`}
> >
<title>{garden.name} garden layout</title>
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}> <g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
{drawnGridCm != null && ( {drawnGridCm != null && (
<> <>
+49 -1
View File
@@ -16,6 +16,11 @@ import {
import type { EditorObject } from './types' import type { EditorObject } from './types'
import { objectDisplayName } from './kinds' 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. * The garden's journal: write an entry, read the season back.
* *
@@ -42,7 +47,15 @@ export function JournalPanel({
scopeObjectId: number | null scopeObjectId: number | null
onScopeChange: (id: number | null) => void onScopeChange: (id: number | null) => void
}) { }) {
const filter = scopeObjectId != null ? { objectId: scopeObjectId } : {} // Date-range narrowing (#85): the backend and JournalFilter already supported
// from/to; they just had no UI. Empty inputs don't filter.
const [from, setFrom] = useState('')
const [to, setTo] = useState('')
const filter = {
...(scopeObjectId != null ? { objectId: scopeObjectId } : {}),
...(from ? { from } : {}),
...(to ? { to } : {}),
}
const journal = useJournal(gardenId, filter) const journal = useJournal(gardenId, filter)
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? [] const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
@@ -70,6 +83,41 @@ export function JournalPanel({
)} )}
</div> </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 && ( {canEdit && (
<Composer <Composer
gardenId={gardenId} gardenId={gardenId}
+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 { objectTransform } from './shared'
import { kindDef, objectDisplayName } from './kinds'
import type { EditorObject } from './types' import type { EditorObject } from './types'
const DEFAULT_FILL = '#8a8a8a' const DEFAULT_FILL = '#8a8a8a'
@@ -57,11 +58,50 @@ export const ObjectShape = memo(function ObjectShape({
onSelect(object.id) onSelect(object.id)
} }
// Keyboard path into selection (#84): the arrow-key nudge handler already
// exists but only ever acted on a pointer selection, so it was unreachable
// without a mouse. Enter/Space on a focused object selects it, which is the
// step that was missing.
function handleKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onSelect(object.id)
}
}
const stroke = selected ? '#2f7a3e' : '#00000033' const stroke = selected ? '#2f7a3e' : '#00000033'
const strokeWidth = selected ? 2 : 1 const strokeWidth = selected ? 2 : 1
// A concise accessible name: the object's label plus its kind's canonical
// label, e.g. "North Bed, In-ground" — reusing kindDef so it never diverges
// from what the UI shows (an ad-hoc kind.replace() gave "in ground"). The
// dimensions aren't included; they need the garden's unit context this
// component doesn't hold, so they're a follow-up.
const kindLabel = kindDef(object.kind)?.label ?? object.kind
const label = `${objectDisplayName(object)}, ${kindLabel}`
// Keyboard focus needs to be VISIBLE — that's the point of making the canvas
// keyboard-reachable. The `object-shape` class carries a :focus-visible rule
// (styles/index.css) that draws a dashed ring; :focus-visible means it shows
// for keyboard focus but NOT a mouse click, which is exactly what we want. CSS
// rather than React state because onFocus on an SVG <g> is unreliable and a
// presentation attribute is overridden by any CSS rule.
return ( return (
<g transform={objectTransform(object)} onPointerDown={handleDown} style={{ cursor: 'pointer' }}> <g
className="object-shape"
transform={objectTransform(object)}
onPointerDown={handleDown}
onKeyDown={handleKey}
role="button"
tabIndex={0}
aria-label={label}
// aria-current, not aria-pressed: selecting an object isn't a toggle (a
// toggle is what aria-pressed means). aria-current marks it as the active
// item among the objects. Omitted, not "false", when unselected.
aria-current={selected || undefined}
style={{ cursor: 'pointer' }}
>
{object.shape === 'circle' ? ( {object.shape === 'circle' ? (
<ellipse <ellipse
cx={0} cx={0}
+9
View File
@@ -160,6 +160,15 @@ export async function streamChat(
handlers.onError('Could not reach the server.') handlers.onError('Could not reach the server.')
return 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) { if (!res.ok || !res.body) {
// 503 is the assistant being turned off at runtime (#79) — the route exists, // 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 // there's just no model behind it. Distinct from a 404, which would mean the
+17 -17
View File
@@ -328,28 +328,28 @@ export function useUpdatePlanting(gardenId: number) {
}) })
} }
/** Clear a bed: soft-remove every active plop in an object (a loop of PATCHes; const clearResultSchema = z.object({ cleared: z.number() })
* a bulk ClearObject endpoint arrives with the agent seam, #19). Invalidates
* once at the end. Pass the object's active plops (id + current version). */ /** Clear a bed: soft-remove every active plop in an object (#82).
*
* ONE request, and so ONE change set. This used to be a loop of PATCHes, which
* meant clearing a 40-plop bed wrote 40 change sets and took 40 presses of Undo
* to put back — while the agent's clear_object, for the identical user-facing
* action, undid in a single click. The rule it violated is stated in CLAUDE.md:
* multi-row operations record all their changes together so they undo as one
* unit. Doing it server-side also removes the partial-failure case the old loop
* had to reconcile. */
export function useClearObject(gardenId: number) { export function useClearObject(gardenId: number) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async (plops: { id: number; version: number }[]) => { mutationFn: async (objectId: number): Promise<number> => {
const today = new Date().toISOString().slice(0, 10) // No body — clear takes none; passing undefined sends none rather than an
// allSettled, not all: a partial failure still soft-removed some rows // empty {}. The response is just a count; validate it rather than cast.
// server-side, so we must reconcile the cache rather than roll everything const res = clearResultSchema.parse(await api.post(`/objects/${objectId}/clear`))
// back. Report how many failed. return res.cleared
const results = await Promise.allSettled(
plops.map((p) => api.patch(`/plantings/${p.id}`, { removedAt: today, version: p.version })),
)
const failed = results.filter((r) => r.status === 'rejected').length
if (failed > 0) {
throw new Error(`${failed} of ${plops.length} plants couldn't be cleared — refresh and try again.`)
}
}, },
// Reconcile on success OR partial failure, so the cache matches the server.
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }), onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
onError: (err) => toast.error(err instanceof Error ? err.message : 'Could not clear the bed.'), onError: (err) => toast.error(objectErrorMessage(err, 'Could not clear the bed.')),
}) })
} }
+6 -2
View File
@@ -398,8 +398,11 @@ export function GardenEditorPage() {
}) })
} }
// 100dvh, not 100vh: on mobile Safari/Chrome 100vh is the *largest* viewport
// (URL bar hidden), so with the bar showing the editor overflowed and pushed
// the canvas bottom + Fit button under the browser chrome (#85).
return ( return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row"> <div className="flex h-[calc(100dvh-8rem)] flex-col gap-3 md:flex-row">
<div className="shrink-0 md:w-40"> <div className="shrink-0 md:w-40">
<h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}> <h1 className="mb-2 truncate text-lg font-semibold tracking-tight" title={garden.name}>
{garden.name} {garden.name}
@@ -526,8 +529,9 @@ export function GardenEditorPage() {
{clearing && focusedObject && ( {clearing && focusedObject && (
<ClearBedModal <ClearBedModal
objectId={focusedObject.id}
objectName={objectDisplayName(focusedObject)} objectName={objectDisplayName(focusedObject)}
plops={focusedPlops.map((p) => ({ id: p.id, version: p.version }))} plopCount={focusedPlops.length}
gardenId={gid} gardenId={gid}
onClose={() => setClearing(false)} onClose={() => setClearing(false)}
/> />
+1 -1
View File
@@ -53,7 +53,7 @@ export function PublicGardenPage() {
} }
return ( return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3"> <div className="flex h-[calc(100dvh-8rem)] flex-col gap-3">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<h1 className="truncate text-lg font-semibold tracking-tight" title={garden.name}> <h1 className="truncate text-lg font-semibold tracking-tight" title={garden.name}>
{garden.name} {garden.name}
+13
View File
@@ -28,6 +28,19 @@
font-family: var(--font-sans); font-family: var(--font-sans);
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
} }
/* Keyboard focus on a canvas object (#84). :focus-visible shows the ring for
keyboard focus but not a mouse click; the dashed accent ring distinguishes
"focused" from the solid ring that marks "selected". A CSS rule overrides
the shape's inline stroke presentation attributes. */
.object-shape {
outline: none;
}
.object-shape:focus-visible :is(rect, ellipse) {
stroke: var(--color-accent-strong);
stroke-width: 2;
stroke-dasharray: 5 4;
}
} }
/* Dark theme: override the same tokens so utilities recolor automatically. */ /* Dark theme: override the same tokens so utilities recolor automatically. */