Commit Graph
100 Commits
Author SHA1 Message Date
steve 1b11b2bd62 Merge pull request 'Resume the last garden on this device (#97)' (#108) from feat/resume-last-garden into main
Build image / build-and-push (push) Successful in 7s
2026-07-22 04:54:33 +00:00
steveandClaude Opus 4.8 d8003b11fb Address review: key the "gone" message and the bounce off the same query
Build image / build-and-push (push) Successful in 12s
Gadfly (2 models, correctness): the redirect effect checked live.isError
but the rendered "taking you to your gardens…" message read full.error —
which is the SEASON query when viewing a past year. A season-view 404 with
a healthy live garden would then show a redirect promise the effect never
fires, stranding the user.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 09:20:00 -04:00
steve 1d2f0eba56 Let the container align the undo outcome note (#74)
Build image / build-and-push (push) Successful in 10s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 13:02:27 +00:00
steve 62604523d7 Commit history detached from cancellation on every path, not just failure (#73)
Build image / build-and-push (push) Successful in 7s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 12:49:18 +00:00
steve 4ea0d0b262 Undo reported "nothing left to undo" after a successful undo (#72)
Build image / build-and-push (push) Successful in 19s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 12:25:34 +00:00
steve 8dbbc5439d Chat panel in the garden editor (#57) (#71)
Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:43:22 +00:00
steve 3a3ce16fce Agent runtime: majordomo in-process, Ollama Cloud config, chat endpoint (#56) (#70)
Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:22:10 +00:00
steve a1bb8ec463 Agent tools: plant lookup, plant creation, journal entries (#55) (#69)
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:05:05 +00:00
steve 5d9b10d7c7 Seed shelf UI: source links, lots, and what's left (#51) (#68)
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 06:03:12 +00:00
steve 7f8b5254d0 Journal UI: write and read season notes where you're already looking (#53) (#67)
Build image / build-and-push (push) Successful in 11s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:55:45 +00:00
steve 8c5ddb21ec Season view: filter the editor to a year (#54) (#66)
Build image / build-and-push (push) Successful in 17s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:47:34 +00:00
steve b96ca1ec0a Grow journal: time-stamped notes on gardens, beds and plantings (#52) (#65)
Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:40:17 +00:00
steve 78a04672b5 Seed provenance + inventory: source links and seed lots (#50) (#64)
Build image / build-and-push (push) Successful in 8s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:39:24 +00:00
steve 2a8fe24766 History panel + undo in the editor (#49) (#63)
Build image / build-and-push (push) Successful in 9s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:23:25 +00:00
steve 3ec77a1099 Feet-and-inches entry: type 2' 7\" instead of 2.6 (#59) (#62)
Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:08:57 +00:00
steve f208da94d6 Revision history: change sets + revisions + revert — the undo substrate (#48) (#61)
Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 05:07:30 +00:00
steveandClaude Opus 4.8 653f381c4b Document the agent env vars; make keeping docs true a standing rule
Build image / build-and-push (push) Successful in 6s
README's config table gained OLLAMA_CLOUD_API_KEY and PANSY_AGENT_MODEL, marked
as read once the assistant lands (#56) so the table describes what the binary
does rather than what it will do. Both are already set in Komodo.

Also fixed the Compose example, which still said "# PANSY_OIDC_ISSUER: ... once
auth (#5) lands" — auth landed a long time ago. That is exactly the kind of rot
this commit is trying to prevent, so it seemed worth fixing in the same breath:
the snippet now shows the OIDC vars people actually need to set.

CLAUDE.md now carries the rule explicitly. README updates go in the SAME commit
as the change that needs them — the env var table and the compose snippet are
the two things people copy, so they hurt most when stale. DESIGN.md tracks real
architecture changes. Examples must run. And when you touch a file, check the
comments around your change are still true, because a stale comment is worse
than none: the next reader believes it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 00:55:06 -04:00
steve 8a12069118 Units correctness: exact imperial entry, garden grid at dimension scale (#47) (#60)
Build image / build-and-push (push) Successful in 6s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 04:50:49 +00:00
steveandClaude Opus 4.8 1716362981 Add CLAUDE.md and a prominent vibe-coded disclosure in the README
Build image / build-and-push (push) Canceled after 11s
pansy is written by an LLM with a human directing. That belongs near the top of
the README, not in a footnote — someone deciding whether to self-host this and
point it at their own data should know before they decide, not after. The
disclosure says what it actually means in practice (less scrutiny than a
hand-written project, read the code, back up the database) rather than a vague
"AI-assisted" hedge.

CLAUDE.md collects the operational details that otherwise get rediscovered every
session: GOWORK=off, the store/service/api seam and why rules go in the middle
layer, the two unit scales, version-guarded writes, ErrNotFound-as-existence-mask,
the branch/Gadfly/merge/deploy loop, and the env var names that aren't guessable.
It also makes the disclosure a standing rule: if the README is ever rewritten,
that section survives the rewrite, and the only acceptable edit is a clearer one.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
2026-07-21 00:50:37 -04:00
steve e22bdd6cab Copy a garden (deep duplicate) from the gardens list (#46)
Build image / build-and-push (push) Successful in 16s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-21 03:58:39 +00:00
steve e74fb308c1 Configurable grid + snap-to-grid in the layout system (#45)
Build image / build-and-push (push) Successful in 7s
Closes #44. Two independent grids (garden + per-bed), each with a size and a snap toggle; snapping defaults off. See PR #45 for details.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 07:07:14 +00:00
steve 9968c06243 Public read-only share link (no login / no OIDC) (#41) (#43)
Build image / build-and-push (push) Successful in 8s
Per-garden public read-only link: unauthenticated GET /api/v1/public/gardens/:token (no requireAuth, no OIDC), owner-only enable/rotate/disable, and a /g/$token page rendering GardenCanvas read-only. Review fixes: redact plant owner ids, Cache-Control: no-store, 400 on malformed body, shared resetTransient store action.

Closes #41.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 06:18:31 +00:00
steve 5cdc2779d7 Fix plant placement + add a Seed Tray (#39) (#42)
Build image / build-and-push (push) Successful in 21s
Use the full Plant the picker returns (fixes the silent placement failure) and add a per-garden Seed Tray for quick repeat placing. Review fixes: disarm on tray-remove, cap load, consolidate toolbar guards, rename interface, tidy.

Closes #39.

Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 06:09:51 +00:00
steveandClaude Opus 4.8 fef3f601bf Deploy to Komodo on main / manual dispatch (#40)
Build image / build-and-push (push) Successful in 12s
After a successful build on main (or a manual workflow_dispatch), point the
Komodo `pansy` stack's PANSY_TAG at the fresh :sha-<short> image and redeploy
it, mirroring mort's pipeline. Branch builds still only push the image.

Read-modify-write of the stack env via the Komodo /read + /write API, then
DeployStack via /execute. Uses the account-wide KOMODO_URL / KOMODO_API_KEY /
KOMODO_API_SECRET secrets; all secrets travel through env, never inline.

Closes #40.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-19 01:54:02 -04:00
steve 18e4a299c2 Agent seam: bulk ops (FillRegion/ClearObject/DescribeGarden) + DefineTool wrappers (#19)
Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 05:16:40 +00:00
steve 245e0cbe71 Polish: imperial, clear-bed, keyboard nudging, empty states (#18)
Build image / build-and-push (push) Successful in 4s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 04:44:26 +00:00
steve c2dd93a93d Sharing UI: invite by email, roles, read-only viewer mode (#17)
Build image / build-and-push (push) Successful in 8s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 04:14:30 +00:00
steve 2a86f87b50 Sharing backend: shares CRUD + ACL enforcement everywhere (#16)
Build image / build-and-push (push) Successful in 4s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 03:49:54 +00:00
steve 48ba08e8f2 Plops editor: focus mode, place/move/resize, semantic zoom (#15)
Build image / build-and-push (push) Successful in 14s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 03:22:51 +00:00
steve f4e5dab98c Plantings backend: plop CRUD, derived counts, removed_at (#14)
Build image / build-and-push (push) Successful in 7s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 02:41:22 +00:00
steve e4505ed9a7 Plant catalog UI: /plants page + PlantPicker (#13)
Build image / build-and-push (push) Successful in 15s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 02:18:12 +00:00
steve 293532f021 Plant catalog backend: CRUD + seeded built-ins (#12)
Build image / build-and-push (push) Successful in 5s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 01:48:22 +00:00
steve b79bfcf7a9 Field editor: palette, select/move/resize/rotate, inspector, optimistic sync (#11) (#30)
Build image / build-and-push (push) Successful in 13s
Co-authored-by: Steve Dudenhoeffer <[email protected]>
2026-07-19 01:01:38 +00:00
steve 2119f1a376 Merge pull request 'Canvas foundation: SVG viewport, pan/zoom/pinch, geometry lib (#9)' (#29) from phase-3-canvas into main
Build image / build-and-push (push) Successful in 12s
2026-07-19 00:18:28 +00:00
steveandClaude Opus 4.8 af34ced208 Address Gadfly review on #9: memoize shapes, fades, unique ids, robustness
Build image / build-and-push (push) Successful in 24s
Fixes from the PR #29 adversarial review (considered; not graded).

Performance / correctness
- ObjectShape is memo'd, so a pan/zoom (which only mutates the world <g>
  transform) no longer re-renders every object.
- 'circle' objects render as an <ellipse> (rx/ry), honoring both dims — a
  circle when width == height — instead of ignoring heightCm.
- The 1 m grid now actually fades in (opacity ramps as cells grow past the
  visibility threshold), matching its description.
- Unique SVG pattern id (useId) so multiple canvases can't collide.
- The canvas re-fits when the garden id changes (not just once), so #11
  switching gardens reframes.

Robustness
- geometry.zoomToFitRect takes rect size by magnitude; wheel/pinch ignore
  non-finite deltas; ObjectShape clamps dimensions to >= 0 (no invalid SVG).

Maintainability
- Renamed the Size params from `viewport` to `canvasSize` (4 models); moved
  easeInOutCubic/lerp into geometry.ts; renamed toLocal → clientToCanvas;
  named constants for the label/corner factors; DEFAULT_FILL const;
  EditorGarden.unitPref imports UnitPref; the Fit control uses the shared
  Button. focusedObjectId/plantable are intentionally reserved for #11/#15.

tsc + 17 vitest tests green; re-verified in a browser (ellipses render,
unique pattern id, wheel zoom + Fit).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 20:16:53 -04:00
steveandClaude Opus 4.8 30b36b7033 Add canvas foundation: SVG viewport, pan/zoom/pinch, geometry lib (#9)
Build image / build-and-push (push) Successful in 16s
Gadfly review (reusable) / review (pull_request) Successful in 9m31s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m31s
The editor's technically-riskiest slice, built against mock data so #11
only adds interactions on top.

- lib/geometry.ts (+ vitest): world<->screen and object-local<->world
  transforms, clampScale, zoomViewportAt (the wheel/pinch "keep the point
  under the cursor stationary" math), zoomToFitRect. 11 geometry tests
  (round-trips, rotation, cursor-anchored zoom, fit) + 6 units tests.
- editor/store.ts: Zustand store for ephemeral editor state — viewport,
  selection, focusedObjectId (selection interactions grow in #11).
- editor/useViewport.ts: @use-gesture wiring — wheel + pinch zoom toward
  the pointer, drag-pan, scale clamped to [0.05, 20] px/cm, and an animated
  fitToRect (eased rAF tween). touch-action:none so the browser doesn't
  fight gestures.
- editor/ObjectShape.tsx: rect/circle centered + rotated, kind default
  colors, name label; non-scaling strokes so outlines stay 1px at any zoom.
- editor/GardenCanvas.tsx: full-size svg, single world <g>, fading 1m grid,
  garden boundary, z-ordered objects; ResizeObserver-driven auto-fit, a
  zoom readout, and a Fit control. Deps: zustand, @use-gesture/react,
  vitest (+ test scripts).
- GardenEditorPage renders the canvas with a mock scene (#11 swaps in
  /full).

Verified in a real browser: wheel zoom keeps the world point under the
cursor stationary (sub-cm drift on a 12m garden), a real drag pans, and
Fit animates to frame the garden. tsc + vitest green.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 19:59:40 -04:00
steve 12e660e45b Merge pull request 'Garden objects backend: polymorphic CRUD + /full endpoint (#10)' (#28) from phase-3-objects-api into main
Build image / build-and-push (push) Successful in 11s
2026-07-18 23:48:39 +00:00
steveandClaude Opus 4.8 0793fef17c Address Gadfly review on #10: clearable color/props, column lists, dedup
Build image / build-and-push (push) Successful in 7s
Fixes from the PR #28 adversarial review (considered; not graded).

Correctness / API
- PATCH /objects/:id can now clear nullable color/props back to NULL: the
  request takes them as json.RawMessage, and ObjectPatch carries an explicit
  Set flag so an explicit `null` (clear) is distinguished from an absent
  field (unchanged) — the strongest cross-model finding (6 hits). New test.

Maintainability
- store/plantings.go + plants.go use explicit qualified column lists
  (qualifyColumns helper) instead of SELECT *, matching gardens/objects and
  surviving a future column add.
- Consolidated objectKinds + plantableByDefault into one kind→traits map.
- objectForRole factors the fetch-then-authorize shared by UpdateObject and
  DeleteObject; dropped the redundant kind check in CreateObject
  (finalizeObject is the single validation point).
- Request→service mapping via toInput()/toPatch() methods (matches gardens).
- Renamed handler gardenFull → getGardenFull (verbNoun); test helper
  decodeGarden → decodeMap; generic bind-error messages.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 19:47:10 -04:00
steveandClaude Opus 4.8 3dd935fb19 Add garden objects backend: polymorphic CRUD + /full (#10)
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 10m9s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m9s
Garden objects (beds, bags, containers, in-ground, trees, paths,
structures) in one polymorphic table, following the #7 service
conventions.

- store/objects.go: scanObject + Create (RETURNING), Get, ListForGarden
  (z_index order), version-guarded Update (RETURNING, returns current row
  on conflict), Delete (plantings cascade via FK).
- store/plantings.go + plants.go: the read side /full needs now —
  ListActivePlantingsForGarden (removed_at IS NULL) and
  ListReferencedPlants (distinct plants used by active plantings). Both
  return empty until #14 adds plantings; #12/#14 extend these files.
- service/objects.go: Create/Update(partial patch)/Delete/GardenFull, all
  (ctx, actorID, ...) through requireGardenRole(editor for mutations,
  viewer for /full). finalizeObject validates kind + shape (rect/circle;
  polygon reserved), finite dims in [1cm,100m], NaN/Inf rejection, loose
  bbox-overlaps-garden placement (partial overhang OK), hex color, valid
  JSON props, name/notes caps; normalizes rotation to [0,360). Plantable
  defaults by kind, overridable.
- api/objects.go: POST /gardens/:id/objects, PATCH/DELETE /objects/:id,
  GET /gardens/:id/full ({garden,objects,plantings,plants}). props is any
  JSON value stored as text; PATCH is partial + required version, 409
  returns the current row.

Tests: service (defaults, plantable-by-kind, rotation normalize,
validation rejects incl. off-field/polygon/bad-color/bad-props, partial
patch + version conflict, cross-user ErrNotFound, delete, /full shape) and
api (CRUD + /full, 409 envelope, cross-user 404, polygon/no-kind 400,
props round-trip).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 19:30:30 -04:00
steve a1294b9c69 Merge pull request 'Gardens UI: list page + create/edit/delete (#8)' (#27) from phase-2-gardens-ui into main
Build image / build-and-push (push) Successful in 16s
2026-07-18 23:05:27 +00:00
steveandClaude Opus 4.8 3f61f07034 Address Gadfly review on #8: modal focus/close, dim validation, dedup
Build image / build-and-push (push) Successful in 7s
Fixes from the PR #27 adversarial review (considered; not graded).

Correctness / error-handling
- Modal: focus once on mount and attach the keydown listener once (latest
  onClose/busy via refs), so a parent re-render (e.g. a background refetch)
  no longer re-fires focus() and steals it from the input being typed in.
- Modal takes a `busy` prop; while a save/delete is in flight, Escape and
  backdrop clicks don't dismiss it (form/delete modals pass busy=pending),
  so an action can't be interrupted mid-flight and lose its error.
- Form validates the *converted* centimeter values against the server's
  [1cm, 100m] bounds, so sub-cm / over-100m sizes fail client-side with a
  clear message instead of a generic server error.
- displayFromCm rounds imperial to 0.1 ft so an 8 ft garden (244 cm)
  prefills as "8", not "8.01".

Maintainability
- Extracted ui/field.ts (useFieldId + fieldControlClass) shared by
  TextField/TextArea/Select. Added a `danger` Button variant, used by the
  delete modal; card edit/delete buttons gained focus-visible rings.
- useUpdateGarden takes the id in its mutation variables (no dummy 0 during
  create). GardensPage shares a close handler; dropped a redundant zod
  annotation; 409 rebase reuses dimString; renamed `del` -> `deletion`.

Verified in a browser: 8 ft round-trips to a prefilled "8"; a sub-cm value
is rejected client-side with the modal staying open. tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 19:03:49 -04:00
steveandClaude Opus 4.8 e0a1522608 Add gardens UI: list page + create/edit/delete (#8)
Build image / build-and-push (push) Successful in 13s
Gadfly review (reusable) / review (pull_request) Successful in 7m21s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m21s
The /gardens page is now home base, backed by the #7 API.

- lib/units.ts: cm <-> meters/feet conversion + formatCm/formatDimensions
  (metric "m", imperial "ft/in"). Minimal for #8; #9's geometry lib
  consolidates.
- lib/gardens.ts: zod gardenSchema + useGardens/useCreateGarden/
  useUpdateGarden/useDeleteGarden (mutations invalidate the list) and
  conflictGarden() to pull the fresh row out of a 409.
- GardensPage: loading/empty/error states; empty state prompts a first
  garden; responsive card grid (single column on phones).
- GardenCard: name, dimensions formatted per the garden's unit, notes
  preview; body links into /gardens/:id, edit/delete kept out of the link.
- GardenFormModal: create/edit with a unit selector; dimensions are entered
  in the chosen unit and converted to cm (switching units converts the
  current values). A 409 rebases the form onto the server's fresh row.
- DeleteGardenModal: confirmation before removing.
- ui/: Modal (Escape/backdrop close), TextArea, Select.

Verified in a real browser against the embedded binary: create appears
without reload; edit persists (version 2), form prefilled from stored cm
converted back to the garden's unit; delete confirms then removes -> empty
state; an imperial 4 ft x 8 ft garden stores 122 x 244 cm and shows
"4' 0" x 8' 0"". tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 18:48:47 -04:00
steve 67c48162c3 Merge pull request 'Gardens CRUD + service-layer conventions (actor, version guard, 409) (#7)' (#26) from phase-2-gardens-api into main
Build image / build-and-push (push) Successful in 5s
2026-07-18 22:40:49 +00:00
steveandClaude Opus 4.8 3fbefa4e05 Address Gadfly review on #7: garden cap, dim validation, shared errors
Build image / build-and-push (push) Successful in 5s
Fixes from the PR #26 adversarial review (graded 18 real / 0 false positive).

Correctness / security
- maxGardenCM fixed to 10_000 (100 m), matching its comment — it was
  100_000 cm (1 km), 10x too lax (5 models flagged this).
- Dimension validation now rejects NaN/Inf (which slip past naive
  comparisons) and subnormal-tiny positives, via a finite [1cm, 100m]
  check. Name (200) and notes (10_000) are length-capped so untrusted
  input can't balloon storage.
- Update version binding is `required,min=1`, so a negative/zero version
  is a 400, not a 409.

Maintainability / performance
- One unified writeServiceError (new errors.go) maps every auth + resource
  sentinel; writeResourceError removed. writeVersionConflict and
  parseIDParam moved to errors.go (shared, not in the gardens feature file).
- Request structs share an embedded gardenFields (one toInput).
- CreateGarden uses INSERT ... RETURNING (one round-trip).
- ListGardensForOwner has a defensive LIMIT (pagination is post-v1).

Tests: name/notes length, NaN/Inf/subnormal dims, dimension-at-cap valid,
negative/zero version -> 400.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 18:39:27 -04:00
steveandClaude Opus 4.8 f39ed52868 Add gardens CRUD + service-layer conventions (#7)
Build image / build-and-push (push) Successful in 4s
Gadfly review (reusable) / review (pull_request) Successful in 9m56s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m56s
Establishes the patterns every later backend issue copies: the actor
parameter, centralized role checks, and the version-guard/409 sync
protocol. The service layer is the seam both REST handlers and future
agent tools call, so permissions live here, not in handlers.

- service/gardens.go: Service methods take (ctx, actorID, args).
  requireGardenRole(ctx, actor, gardenID, min) is THE authorization point
  — owner is implicit via owner_id now; #16 extends it to consult
  garden_shares. A user with no role gets ErrNotFound (existence masked),
  not ErrForbidden. Create/Get/List/Update/Delete with input validation
  (name required, 0 dims default to 10 m on create / rejected on update,
  negatives always rejected, unit metric|imperial, 100 m cap).
- store/gardens.go: version-guarded UPDATE ... WHERE id=? AND version=?
  RETURNING; a no-match re-reads to return (current row,
  ErrVersionConflict) vs ErrNotFound. ListGardensForOwner returns a
  non-nil slice.
- api/gardens.go: GET,POST /gardens and GET,PATCH,DELETE /gardens/:id
  behind requireAuth. writeVersionConflict documents the 409 envelope
  ({error:{code,message}, current:{...}}) — the contract for every
  mutable resource. writeResourceError maps ErrNotFound/Forbidden/
  InvalidInput/VersionConflict; parseIDParam guards path ids.

Tests: service (defaults, validation, owned-only list, version
conflict returns current + retry, cross-user ErrNotFound, delete) and
api (full CRUD flow, 409 envelope shape, cross-user 404, auth required,
create validation). Verified against the running binary: create stores
imperial 122x244 cm and list returns it.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 18:20:39 -04:00
steve 03f2eb2d5c Merge pull request 'Auth UI: login/register pages, OIDC button, route guard (#6)' (#25) from phase-1-auth-ui into main
Build image / build-and-push (push) Successful in 4s
2026-07-18 22:11:21 +00:00
steveandClaude Opus 4.8 ae1906e169 Address Gadfly review on #6: logout errors, register gating, guard errors
Build image / build-and-push (push) Successful in 7s
Fixes from the PR #25 adversarial review (graded 23 real / 5 false positive):

Error handling
- onLogout wraps mutateAsync in try/catch: a failed logout no longer
  becomes an unhandled rejection; the user stays put (session still valid)
  and the button offers "Retry sign out" (5 models flagged this).
- Root errorComponent (RouteError): a non-401 /auth/me failure in a
  beforeLoad guard now shows a recoverable "Try again" screen instead of
  blanking.
- Forms drop noValidate, restoring native required/type=email/minLength
  checks before hitting the server.

Correctness
- RegisterPage gates on providers.isPending/isError before rendering, so
  the form no longer briefly appears (submittable) on an SSO-only server.
- TextField id falls back to name then a useId() value, so the label/hint
  associations hold even if a caller omits both.

Maintainability
- Single safeRedirectPath (lib/redirect.ts) replaces the duplicated
  safeRedirect/safeInternalPath (4 models).
- Generic ApiError helpers (apiErrorCode, errorMessage) moved to lib/api.ts;
  LoginPage builds the OIDC URL from the exported API_BASE.
- Divider moved to components/ui/. errorMessage doc clarified.

Verified again in a real browser: register -> /gardens; Sign out -> /login.
tsc --noEmit and vite build clean.

Not changed (graded, with rationale): OIDC deep-link redirect isn't
preserved (needs backend state threading — follow-up); 60s me staleTime in
guards (server is authoritative); the login/register onSubmit catches are
the intended react-query pattern (errors render via mutation state).

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 18:10:00 -04:00
steveandClaude Opus 4.8 622010cd71 Add auth UI: login/register pages, OIDC button, route guard (#6)
Build image / build-and-push (push) Successful in 7s
Gadfly review (reusable) / review (pull_request) Successful in 9m4s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m4s
Frontend for pansy auth, rendering whatever /auth/providers reports.

- lib/auth.ts: zod-validated User/Providers, a shared meQueryOptions
  (a 401 resolves to null, not an error) reused by useMe() and the router
  guard, useProviders(), and useLogin/useRegister/useLogout mutations that
  prime/clear the me cache (logout also drops non-auth cached data).
- lib/queryClient.ts: one QueryClient shared by the React tree and the
  router context so guard and components hit the same cache.
- router.tsx: createRootRouteWithContext with the queryClient; requireAuth
  (redirects to /login?redirect=<dest>) on gardens/plants/editor, and
  requireGuest on login/register (bounces authed users away). Login search
  params (redirect, error) validated as optional; redirect targets are
  restricted to same-origin paths.
- pages/LoginPage: OIDC button (label from providers) linking to
  /auth/oidc/login, "or" divider, local email/password form, and friendly
  messages for the OIDC callback ?error= codes. RegisterPage: email +
  display name + password (min 8), registration-closed and SSO-only
  handling; auto-login lands on /gardens.
- AppShell: auth-aware nav — shows the user's display name + Sign out when
  logged in, Sign in otherwise; nav links only when authed.
- ui/: TextField (16px text to avoid iOS zoom, autocomplete + a11y),
  Button (+ shared buttonClasses for the OIDC anchor), Alert; AuthCard.

Verified in a real browser against the embedded binary: register →
auto-login → /gardens; refresh stays in; Sign out → /login; a logged-out
/gardens/1 redirects to /login and returns after login; OIDC button renders
with the Authentik label when configured. tsc --noEmit clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:49:57 -04:00
steve 92eb64a790 Merge pull request 'OIDC login via Authentik: PKCE, JIT provisioning, email linking (#5)' (#24) from phase-1-oidc into main
Build image / build-and-push (push) Successful in 8s
2026-07-18 21:34:36 +00:00
steveandClaude Opus 4.8 8ef092713f Address Gadfly review on #5: OIDC identity guard, verified-email, timeouts
Build image / build-and-push (push) Successful in 9s
Fixes from the PR #24 adversarial review (graded 23 real / 1 false positive):

Security / correctness
- LinkOIDC no longer overwrites a different stored identity: the UPDATE
  matches only when the row has no identity yet or already carries this
  exact one, so a second IdP asserting the same verified email can't
  hijack or lock out an account (returns ErrOIDCIdentityConflict). Uses
  a single UPDATE...RETURNING (also fixes the ignored-RowsAffected /
  misleading-ErrNotFound path and the round-trip).
- Provisioning now requires a verified email for BOTH linking and JIT
  creation (was: linking only), so an unverified-email identity can't
  create an account — nor become the first admin on a fresh instance,
  nor squat an email a real user later owns.
- OIDC-identity collisions surface as the dedicated ErrOIDCIdentityConflict
  instead of the email-specific ErrEmailTaken.

Robustness
- readOIDCTxCookie requires a non-empty nonce (an empty one would make the
  callback's nonce check pass vacuously).
- Callback token exchange + verify run under a 15s context timeout so a
  slow IdP can't outlast the server write timeout.
- ensure() performs discovery outside the mutex, so concurrent cold-start
  requests don't serialize behind one another's full timeout.
- setOIDCTxCookie returns its marshal error; oidcLogin aborts rather than
  redirecting to the IdP with no tx cookie.

Maintainability
- redirectAuthError helper dedups the ~dozen callback redirects (and the
  empty-code path now logs like the rest).
- Distinct login error codes (no_email / email_unverified / oidc_conflict)
  for the UI; writeServiceError maps the OIDC sentinels; shared test issuer
  const.

Tests: unverified email refused for both link and JIT; identity-overwrite
refused while the original identity keeps working.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:33:25 -04:00
steveandClaude Opus 4.8 84edf3e42a Add OIDC login via Authentik: PKCE, JIT provisioning, email linking (#5)
Build image / build-and-push (push) Successful in 5s
Gadfly review (reusable) / review (pull_request) Successful in 9m32s
Adversarial Review (Gadfly) / review (pull_request) Successful in 9m32s
OIDC is pansy's primary login path (Authentik the target IdP); local
auth (#4) remains the fallback and both issue the same session cookie.

- deps: github.com/coreos/go-oidc/v3 + golang.org/x/oauth2 (both pure Go;
  CGO stays off).
- api/oidc.go: lazy issuer discovery (retried per-request, never crashes
  a server that also serves local auth), GET /auth/oidc/login builds an
  authorization-code URL with PKCE S256 + random state + nonce stashed in
  a short-lived HttpOnly cookie, GET /auth/oidc/callback verifies state
  (constant-time), exchanges the code with the PKCE verifier, verifies the
  ID token + nonce, and starts a pansy session. Failures redirect to
  /login?error=... ; success to /gardens.
- service.LoginOIDC: (issuer,subject) match -> login; else *verified*
  email match -> link onto the existing account; else JIT-create (the IdP
  gates access, so PANSY_REGISTRATION doesn't apply). Unverified email
  colliding with an existing account is refused (takeover guard); no email
  is refused (email is the account key). Reuses the atomic CreateUser.
- store: GetUserByOIDC + LinkOIDC (unique-pair backstop).
- config: OIDCReady() (needs issuer+client+BaseURL for the redirect URI);
  /auth/providers now reports oidc from it and defaults the button label
  to "Sign in with Authentik". OIDC routes are only registered when ready,
  so an unconfigured instance 404s them.
- PANSY_LOCAL_AUTH=false rejects/hides local auth but not OIDC.

Tests: service provisioning (JIT, repeat login, link, unverified-collision
refusal, no-email, name fallback, works with local auth off); api
(routes-absent-when-unconfigured, providers reporting, login redirect with
PKCE params + tx cookie via a fake discovery server, callback state/error
paths). Smoke-tested: unreachable issuer degrades to error=oidc_unavailable
with the server still up and local auth working.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:13:47 -04:00
steve eb03d09d19 Merge pull request 'Local auth: users, sessions, register/login/logout/me (#4)' (#23) from phase-1-local-auth into main
Build image / build-and-push (push) Successful in 4s
2026-07-18 21:05:01 +00:00
steveandClaude Opus 4.8 02c928ac6d Address Gadfly review on #4: TOCTOU, sliding cookie, CSRF, argon2
Build image / build-and-push (push) Successful in 5s
Fixes from the PR #23 adversarial review (graded 35 real / 1 false positive):

Security / correctness
- Race-free registration: is_admin and the registration gate are now
  computed atomically inside a single INSERT...SELECT, so concurrent
  first registrations can't both become admin or bypass closed
  registration (fixed the whole TOCTOU cluster).
- Sliding session now reaches the browser: ResolveSession returns the
  current expiry and requireAuth re-sets the cookie, so active users
  aren't logged out 30 days after login regardless of activity.
- Login CSRF: csrfGuard rejects state-changing requests whose Origin
  doesn't match PANSY_BASE_URL (no-op when unset, so the dev proxy is
  unaffected). SameSite=Lax alone didn't cover this.
- argon2id tuned to RFC 9106's second recommended profile (t=3).
- Timing equalizer can't fail open: the dummy hash is derived
  deterministically (fixed salt, no RNG) so it's always present.
- Password length (<=1024) enforced in the service for both register
  and login, not just HTTP binding tags; login rejects over-long input
  before spending argon2 work.

Error handling / robustness
- Login logs a malformed stored hash instead of silently treating it as
  a wrong password.
- Best-effort session writes (Touch/Delete during renewal, expiry, and
  corrupt-expiry cleanup) now log on failure.
- index sessions.expires_at via new migration 0002 (0001 is immutable).

Maintainability
- Extract startSessionAndRespond and abortUnauthenticated; make
  writeServiceError a free function; consistent error handling in
  decodeHash; doc/comment fixes.

Tests: over-long password, CSRF guard (cross-origin/same-origin/dev
no-op), and cookie refresh on authenticated requests; migration-version
assertions bumped to 2.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 17:04:35 -04:00
steveandClaude Opus 4.8 0e41ccd95a Add local auth: users, sessions, register/login/logout/me (#4)
Build image / build-and-push (push) Successful in 8s
Gadfly review (reusable) / review (pull_request) Successful in 7m7s
Adversarial Review (Gadfly) / review (pull_request) Successful in 7m7s
Implements pansy's local (email + password) authentication and the
session layer that OIDC (#5) will also reuse.

- store: users.go (create/get-by-id/get-by-email/count) and sessions.go
  (create/get/touch/delete/delete-expired), scanning the existing 0001
  schema.
- service: the business-logic seam. auth.go (Register/Login/session
  lifecycle/Providers) + password.go (argon2id, 64 MiB/1/4, PHC-encoded,
  constant-time verify) + service.go (Service, clock injection, token
  hashing). First user is admin; closed registration still allows the
  bootstrap user; unknown-email and wrong-password are indistinguishable
  (same error, same argon2 work via a dummy hash).
- api: POST /auth/register|login|logout, GET /auth/me|providers, plus a
  requireAuth middleware that resolves the HttpOnly session cookie
  (SameSite=Lax, Secure under https) to the actor. Handlers stay thin.
- main: wires the service and a periodic expired-session sweep; sessions
  are also dropped lazily on access. Sliding 30-day expiry.
- tests: service (register/login/expiry/renewal/cleanup, password) and
  api (cookie flow, middleware, validation, providers).

Verified end-to-end via curl: register -> me -> restart -> session
persists -> logout -> 401.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 16:38:03 -04:00
steve 8305acf4b2 Merge pull request 'CI: build and push the container image for Komodo deploys' (#22) from ci-build-image into main
Build image / build-and-push (push) Successful in 6s
2026-07-18 19:56:06 +00:00
steveandClaude Opus 4.8 c59688c2a3 Address Gadfly review findings on build-image CI
Build image / build-and-push (push) Successful in 1m3s
Workflow:
- Pass untrusted github.ref_name/repository/sha/token via env instead of
  inline ${{ }} in run: blocks, closing a branch-name shell-injection vector.
- Check out the exact triggering commit (fetch by SHA, branch fallback) so
  the sha-<short> tag matches what was built.
- Guard the tag scheme: a non-main branch named/sanitized to "latest" can't
  clobber :latest, and an empty sanitized name falls back to branch-<sha>.
- Build --tag flags as a bash array (no word-splitting), drop the
  comma-string round-trip, and check-then-create the buildx builder so a
  real failure isn't swallowed.

Dockerfile:
- GOWORK=off in the build stage (matches the Makefile convention).
- npm ci uses a BuildKit cache mount.
- Drop the stale issue-number reference in a comment.

.dockerignore:
- Add .env/.env.*, go.work/go.work.sum, web/.vite; drop nonexistent .github.

Graded all findings in the gadfly store. Deferred/keep-as-is: token-in-clone-URL
(Gitea masks the secret in logs), sanitization collisions across branch names
(the sha-<short> tag is the collision-proof identifier), and registry build
cache (current builds are fast). False positives: alpine ships wget (busybox);
docker login is its own step.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 15:53:10 -04:00
steveandClaude Opus 4.8 c9c404aa01 Add Gitea Action to build and push the image for Komodo deploys
Build image / build-and-push (push) Successful in 22s
Gadfly review (reusable) / review (pull_request) Successful in 10m52s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m52s
Multi-stage Dockerfile builds the web bundle, embeds it into the static
Go binary (CGO_ENABLED=0), and ships it in a minimal alpine runtime:
non-root user, ca-certificates + tzdata, SQLite on a /data volume,
healthcheck on /api/v1/healthz, OCI provenance labels.

build-image.yml pushes to the Gitea registry on every branch push:
  * main             -> :latest
  * any other branch -> :<branch-name> (sanitized to valid tag chars)
  * every build      -> :sha-<short>   (immutable, for pinning)

A push to a PR branch covers the PR, so there is no separate
pull_request trigger. README documents the image, tags, and a
docker run / Komodo compose snippet. Modeled on mort's CI.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
2026-07-18 15:35:15 -04:00
steve e290adb2e0 Merge pull request 'Phase 0: backend + frontend scaffold, single-binary build' (#21) from phase-0-scaffold into main 2026-07-18 19:25:29 +00:00