Author SHA1 Message Date
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 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 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
65 changed files with 5899 additions and 160 deletions
+75 -4
View File
@@ -85,6 +85,12 @@ Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
deliberately. `ErrForbidden` means "you can see it but may not do that".
- **Plops (plantings) live in their parent object's local frame**, origin at the
object's center, `-y` is north. Moving or rotating a bed moves its plants free.
- **A plop is a clump, not a plant.** `defaultPlopRadius` is `1.5 × spacing`, so a
plop is three spacings across and holds `π·r²/spacing²` plants. Reasoning about
fills as if one plop were one plant gets the geometry wrong every time — which
is how #75 happened: requiring the whole circle inside the bed inset the outer
row by 1.5 spacings when the horticultural rule is *half* a spacing. Spacing is
a constraint between neighbouring plants; a bed edge is nobody's neighbour.
- **Soft removal**: "clear bed" sets `removed_at`; the editor reads
`removed_at IS NULL`. Hard delete is a different operation.
- **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run
@@ -92,6 +98,29 @@ Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
- **Every service mutation lands in history** (#48). If you add one, record it —
see `internal/service/revisions.go`. Multi-row operations pass all their
changes to a single `record` call so they undo as one unit.
- **The history write is detached from cancellation on purpose.** `commitScope`
calls `context.WithoutCancel` — that is not a mistake to tidy up. By the time
a commit runs, the rows it describes are already written, so cancelling it
cannot undo anything; it can only leave real changes with no way to undo them.
This was a live bug twice (#73): a client disconnect mid-request orphaned 18
plantings. Fixing it per-call-site is how it came back, which is why the rule
lives in `commitScope` where no caller can forget it.
## Testing
Match the test to the failure it would catch:
- **Anything addressed by its own id needs an API-level test through the router.**
Service tests can't see a route that was never registered — PATCH/DELETE
`/journal/:id` once shipped fully implemented, fully unit-tested, and
completely unreachable.
- **Watch for fixtures that assert your assumptions instead of the API.** A test
for the undo message passed because the fixture I wrote populated a field the
real response leaves empty. If a test builds the thing it's testing against,
it is checking your mental model, not the system.
- Some things only real use finds. The agent's whole loop is covered by
majordomo's scriptable fake provider (`provider/fake`), which is worth using —
but the three worst v2 bugs all turned up in one live session afterwards.
## Workflow
@@ -100,6 +129,20 @@ fix what's real → merge when the pipeline is green. Do not grade Gadfly findin
A push to `main` builds the image and deploys to Komodo; the live instance at
`pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later.
**One sweep, not a loop.** Take Gadfly's *initial* review, fix what's real, and
merge once the build is green. Do NOT re-trigger Gadfly on the fix commits and
wait for it again — a re-review-per-fix loop burns ~10 min a pass and drags a
small PR out for an hour (Steve's explicit call, 2026-07-22). The initial review
is the check; your own build/test/judgment covers the fixes. Gadfly is advisory
and never blocks merge, so green build + fixes applied is enough.
(Mechanics, if you ever *do* need a manual re-run: the workflow triggers on
`opened`/`reopened`/`ready_for_review`, not `synchronize`, so pushes don't
re-review; a `@gadfly review` comment does. A comment without that exact phrase
still runs and exits green in ~2s — a skip that looks like a pass, so judge a
real review by its ~10-min duration, not its status. Gadfly edits its consensus
comment in place, so `updated_at` moves but `created_at` doesn't.)
Workflow- and config-only changes (CI, this file, docs) go straight to `main`
without the PR dance.
@@ -113,7 +156,35 @@ implemented in later per-issue sessions.
the `_PATH`/`_ADDR` names you might guess. Authentik is the primary IdP;
OIDC-first with local passwords as fallback.
Agent config (v2): `OLLAMA_CLOUD_API_KEY` and `PANSY_AGENT_MODEL` (default
`ollama-cloud/glm-5.2:cloud`), set in Komodo. Model strings pass verbatim to
`majordomo.Parse`, so a comma-separated spec gives failover for free.
`majordomo` and `executus` are sibling repos at `../`.
Agent config: `OLLAMA_CLOUD_API_KEY`, `PANSY_AGENT_MODEL` (default
`ollama-cloud/glm-5.2:cloud`) and `PANSY_AGENT_ENABLED`, set in Komodo. Model
strings pass verbatim to `majordomo.Parse`, so a comma-separated spec gives
failover for free — don't parse that grammar in pansy.
The model and enabled flag are also **admin-editable at runtime** in Settings
(#79); the env vars are just defaults (precedence: DB setting → env → default).
Two things this makes load-bearing:
- **The live Runner is hot-swapped, not built once.** It sits behind an
`atomic.Pointer` in `internal/api` (`agentHolder`), and its routes are
registered *unconditionally* with a nil-check on `agent.get()`. Do NOT go back
to registering the chat routes only when a key is present — a settings change
has to be able to turn the assistant on without a restart, which a missing
route can't. `/capabilities` reads the pointer, so it reflects the live state.
- **`OLLAMA_CLOUD_API_KEY` stays in the environment, never the DB.** Model
selection is a setting; the key is not. A secret in `instance_settings` lands
in every backup and in the undo history's blast radius. The Settings API
reports whether a key is *present*, never its value.
- The "how to turn a spec into a model" knowledge lives once in
`internal/agentmodel`, imported by both `agent` (to run) and `service` (to
validate a spec before storing it). It can't live in `agent``agent` imports
`service`, so a `service``agent` import would cycle.
`majordomo` is a **real dependency** now, resolved from the Gitea instance as a
pseudo-version. There is no `replace` directive and there must not be one: a
`replace` pointing at `../majordomo` builds on your laptop and breaks the Docker
build, which has no sibling checkout. `executus` is a sibling repo at `../` and
is not a dependency.
The `majordomo` build tag is **gone**. Don't reintroduce it — an untagged CI that
never compiles the agent is worse than a slightly larger binary.
+20 -4
View File
@@ -7,9 +7,11 @@ Work is tracked in Gitea issues; the tracking epic links every piece in dependen
## Decisions
- **Placement model:** freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle.
- **Spacing is a plant-to-plant rule, so bed edges get half of it.** A bed edge is not a competitor for soil, light or water, so the outer row owes it half the spacing rather than a full one. `FillRegion` centres its lattice accordingly, and lets a plop — a *clump* three spacings across — cross the edge by up to half a spacing so its outermost plants land at that half-spacing. The rule, the square-foot-chart arithmetic behind it, and the failure mode it prevents are written out once in `hexCenters`; #75 is what getting it wrong looked like.
- **Stack:** Go 1.26.x backend, module `gitea.stevedudenhoeffer.com/steve/pansy`; React + TypeScript + Vite + Tailwind frontend, production build embedded via `embed.FS` → one static binary (`CGO_ENABLED=0`).
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes.
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes. The first registered user is `is_admin` (set race-free inside the INSERT); admin gates instance-wide Settings (`requireAdmin`), the only thing that reads that flag.
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
- **Instance settings (#79):** admin-editable, instance-wide config in a single-row `instance_settings` table — pansy's first DB-stored *instance* state (everything else hangs off a garden/object). Today it holds the agent model + on/off; **secrets never move here**`OLLAMA_CLOUD_API_KEY` stays in the env so it doesn't land in backups or the undo history. Precedence: Settings value → env → default. The live agent Runner sits behind an `atomic.Pointer` in the API layer (`agentHolder`) with its routes always registered, so a settings change swaps it with no restart and no race against in-flight requests; `/capabilities` reads that pointer, so it reports what's live rather than what was configured at boot. The model/registry knowledge lives in one leaf package (`internal/agentmodel`) that both the runner and the settings validator import — agent imports service, so it can live in neither.
- **Agentic future:** integration with majordomo/executus via typed Go tools (`llm.DefineTool[Args]`) wrapping the same service layer the REST API uses — not MCP/OpenAPI.
## Domain model
@@ -23,6 +25,7 @@ SQLite, centimeters everywhere (display-side imperial conversion only), `version
- **garden_objects** — one polymorphic table: `kind` (`bed`|`grow_bag`|`container`|`in_ground`|`tree`|`path`|`structure`), name, `shape` (`rect`|`circle`; `polygon` + `points` JSON reserved in schema, not in the v1 editor), center `x_cm`,`y_cm` in garden space, `width_cm`,`height_cm` (circle: width=diameter), `rotation_deg`, `z_index`, `plantable` bool, nullable `color` override, `props` JSON for kind-specific extras (bed height, tree species…), notes.
- **seed_lots** — one purchase: vendor, source URL, sku, lot code, purchase date, packed-for year, quantity + unit, cost, germination %. `owner_id` is on the LOT, not inherited from the plant, so you can record buying generic built-in Garlic from Johnny's and keep it private. `plantings.seed_lot_id` (ON DELETE SET NULL) attributes a plop to a purchase; **`remaining` is derived** (quantity summed effective count of active plantings), never stored — a decremented column drifts the moment a planting is edited behind its back and can't be audited afterwards. Lots are never shared along with a garden.
- **plants** — catalog, plus `source_url`/`vendor` provenance for the variety itself. `owner_id NULL` = built-in (~30 seeded via migration, read-only, clone to customize). name, category (`vegetable`|`herb`|`flower`|`fruit`|`tree_shrub`|`cover`), `spacing_cm` (mature spacing), `color` hex, `icon` (emoji string — zero assets in v1), nullable `days_to_maturity`, notes. Users see built-ins + their own.
- **agent_conversations** / **agent_messages** — the assistant's chat, one thread per (user, garden). Only the user/assistant TEXT of each turn is stored, not the model's full transcript: continuity needs what was said and what came back, and replaying a stored tool call would replay a decision made against a garden that has since moved on. `agent_messages.change_set_id` is the handle the chat panel uses to offer Undo on the turn that caused it.
- **journal_entries** — the grow log: `garden_id` (NOT NULL), nullable `object_id`/`planting_id` to narrow the target, author, body, `observed_at`. `notes` on a garden/object/plant says what the thing IS and a new note overwrites the old; a journal entry says what HAPPENED and when, and entries accumulate. `garden_id` is set even for a bed- or plop-level entry so permission checks reuse `requireGardenRole` unchanged rather than inventing a second ACL path. `observed_at` is distinct from `created_at` — you write up Saturday's observations on Sunday. Deliberately **not** in the revision history: entries are already append-shaped and individually versioned.
- **change_sets** / **revisions** — the undo substrate. A change set groups the row-level revisions one logical operation produced (`source` ui/agent/api, `summary`, nullable `agent_run_id`, nullable `reverts_id`); a revision is `(entity_type, entity_id, op, before, after)` with full JSON row snapshots. The unit of undo is the **operation, not the row**. Revert is itself a change set, so an undo can be undone; it is version-guarded per entity and reports conflicts rather than clobbering a later edit. Garden *deletion* is deliberately not revertible (the cascade is invisible to the service, and would take the history with it).
- **plantings** ("plops") — object_id, plant_id, center `x_cm`,`y_cm` **in the parent object's local frame** (moving/rotating a bed moves its plants for free), `radius_cm` (a plop is a circular patch), nullable `count` (NULL = derived: `max(1, round(π·r² / spacing_cm²))`), nullable label, `planted_at`/`removed_at` dates. "Clear bed" sets `removed_at`; v1 shows rows where `removed_at IS NULL`. Year-over-year history and a future plan/scenario layer build on these dates without schema surgery.
@@ -62,9 +65,19 @@ POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
POST /gardens/:id/objects PATCH,DELETE /objects/:id
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator
POST /agent/chat ← SSE: step events, then the finished turn (editor only)
GET,DELETE /gardens/:id/agent/history (the actor's own thread)
GET /capabilities ← what this instance can do RIGHT NOW (tracks the live agent, not just config)
GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
GET,POST,DELETE /gardens/:id/share-link ← the public read-only token for this garden
GET /public/gardens/:token ← UNAUTHENTICATED read-only /full; the token is the capability
```
**Sync:** plain REST + optimistic UI + last-write-wins with a version guard. Every PATCH/DELETE carries the row's `version`; the server increments on write and returns **409 + the current row** on mismatch; the client rolls back and refetches. No websockets/CRDT — the right cost for household-scale co-editing. Drags PATCH once on drop, not per frame.
@@ -77,6 +90,7 @@ GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invit
cmd/pansy/main.go config load → store.Open → migrate → service → api → ListenAndServe
internal/config/ PANSY_PORT, PANSY_DB, PANSY_BASE_URL, PANSY_REGISTRATION,
PANSY_OIDC_* (issuer/client_id/secret/button label), PANSY_LOCAL_AUTH,
PANSY_AGENT_MODEL / OLLAMA_CLOUD_API_KEY / PANSY_AGENT_ENABLED,
trusted proxies
internal/store/ sqlite.go (open/pragmas), migrate.go, migrations/*.sql (embedded),
queries per entity (users.go, gardens.go, objects.go, plantings.go,
@@ -89,7 +103,8 @@ internal/service/ ★ THE SEAM. All permission checks + business ops. aut
internal/api/ thin gin handlers (decode → service → encode), session middleware,
ginserver setup, routes.go, oidc.go, spa.go (embed fallback)
internal/webdist/ dist.go: //go:embed all:dist (web build copied in by Makefile)
internal/agent/ later: llm.DefineTool[Args] wrappers over the SAME service methods
internal/agent/ tools.go (llm.DefineTool wrappers over the SAME service methods),
runtime.go (model resolution, run loop, one change set per turn)
web/ Vite app
Makefile (cd web && npm run build) → copy dist → CGO_ENABLED=0 go build
```
@@ -111,7 +126,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
- **Routes:** `/login`, `/register`, `/gardens` (list), `/gardens/:id` (editor, `?focus=objectId`), `/plants` (catalog). Auth guard on the router root via `/auth/me`.
- **State:** TanStack Query for all server state (editor keyed on `gardens/:id/full`; optimistic mutations with version-conflict rollback). One small Zustand store for ephemeral editor state only: viewport, selection, focused object, active tool, in-flight drag.
- **Editor components (`web/src/editor/`):** `GardenCanvas` (svg root + viewport g), `useViewport` (use-gesture pan/zoom/pinch), `ObjectShape`, `PlopMarker` (semantic-zoom branching), `Palette` (drag-to-place object kinds), `EditorRail` (the one side panel), `Inspector`, `HistoryPanel`, `PlantPicker`.
- **One rail, tabs inside it.** The inspector, history, and later the journal and chat panel all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
- **One rail, tabs inside it.** The inspector, history, journal and assistant all want the same strip of screen; rather than each bolting on its own chrome they are tabs in `EditorRail` — so the canvas is one width instead of a different width per panel, and adding a panel is adding a tab. Selecting an object switches to the Inspector tab automatically, so the rail is never something you operate before you can edit; on a phone the same tabs render in the bottom sheet the inspector already used. Pure geometry helpers (local↔world transforms, unit formatting) in `web/src/lib/geometry.ts`, unit-tested.
## Roadmap
@@ -123,7 +138,8 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
6. **Plops** — focus-zoom, place/move/resize, derived counts, semantic zoom. *The core vision lands here.*
7. **Sharing** — invite by email, roles, viewer read-only mode.
8. **Polish** — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging.
9. **Agent seam**`ops.go` bulk ops + `internal/agent` DefineTool wrappers; optional mort-style API-key agent endpoint. The agent harness itself lives in majordomo/executus, outside pansy.
9. **Agent seam**`ops.go` bulk ops + `internal/agent` DefineTool wrappers.
10. **Garden assistant** — majordomo in-process, Ollama Cloud, streaming chat. Each turn runs inside ONE change set (`source='agent'`), so a turn that clears a bed and replants it undoes as one action; that is what makes acting without a confirmation prompt defensible. Bounded by a step cap and a timeout — loop safety, not spend control. The `majordomo` build tag is gone: a tag that keeps the agent out of the binary only earns its keep if you'd ship a build without it, and the agent is the point.
## Deliberate v1 limits
+13 -5
View File
@@ -63,12 +63,19 @@ All configuration is via environment variables; every value has a default, so `.
| `PANSY_OIDC_BUTTON_LABEL` | `Sign in with Authentik` | Label for the OIDC button on the login page. |
| `PANSY_TRUSTED_PROXIES` | *(none)* | Comma-separated proxy CIDRs/IPs to trust for client-IP resolution. |
The garden assistant reads two more. Both are **read only once the assistant lands** ([#56](https://gitea.stevedudenhoeffer.com/steve/pansy/issues/56)); setting them earlier is harmless and setting neither leaves the assistant off.
The garden assistant reads three more. Setting none of them leaves the assistant off; the app runs exactly as it does without it.
| Variable | Default | Description |
| ----------------------- | ----------------------------- | --------------------------------------------------------------------------- |
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is disabled, not broken. |
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain. |
| ----------------------- | ------------------------------ | --------------------------------------------------------------------------- |
| `OLLAMA_CLOUD_API_KEY` | *(empty)* | Ollama Cloud API key. Without it the assistant is off, not broken. This is the one agent value that stays in the environment — it is **never** stored in the database or editable in Settings. |
| `PANSY_AGENT_MODEL` | `ollama-cloud/glm-5.2:cloud` | Default model spec, passed verbatim to `majordomo.Parse` — a comma-separated list is a failover chain, e.g. `ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud`. An admin can override this per-instance in **Settings** without a redeploy; a blank Settings value inherits this. |
| `PANSY_AGENT_ENABLED` | on when a key is present | Default on/off for the assistant. Also overridable in Settings (which can inherit this default). |
The model and enabled flag can be changed at runtime by an admin under **Settings** (the gear appears in the nav for admins) — the change swaps the live assistant with no restart. The env vars above are the defaults an untouched instance uses, and the API key is intentionally not among the runtime-editable settings: a secret in the database would land in every backup. Precedence for the model and enabled flag is **Settings value, if set → env var → built-in default**.
The assistant acts without asking first, which is only reasonable because every turn is one undoable change set — see the History panel in the editor.
**If you set the key and the assistant still doesn't appear**, check that the variable reaches the *container*, not just your orchestrator's stack config — Compose needs it listed under the service's `environment:`. pansy logs why the assistant is off at startup, and Settings shows the same status (a key present, the resolved model, and whether it's actually running).
Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`, `/auth/logout`, `GET /auth/me`, `GET /auth/providers`); the session is an HttpOnly cookie (`Secure` when `PANSY_BASE_URL` is https). The first account registered becomes admin, and it may register even when `PANSY_REGISTRATION=closed` to bootstrap the instance.
@@ -108,7 +115,8 @@ services:
# PANSY_OIDC_ISSUER: https://auth.example.com/application/o/pansy/
# PANSY_OIDC_CLIENT_ID: ...
# PANSY_OIDC_CLIENT_SECRET: ...
# OLLAMA_CLOUD_API_KEY: ... # enables the garden assistant
# OLLAMA_CLOUD_API_KEY: ${OLLAMA_CLOUD_API_KEY} # enables the garden assistant
# PANSY_AGENT_MODEL: ollama-cloud/glm-5.2:cloud
restart: unless-stopped
volumes:
pansy-data:
+20 -4
View File
@@ -3,14 +3,30 @@ module gitea.stevedudenhoeffer.com/steve/pansy
go 1.26.2
require (
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f
github.com/coreos/go-oidc/v3 v3.20.0
github.com/gin-gonic/gin v1.10.1
github.com/samber/slog-gin v1.15.0
golang.org/x/crypto v0.31.0
golang.org/x/crypto v0.36.0
golang.org/x/oauth2 v0.36.0
modernc.org/sqlite v1.34.4
)
require (
cloud.google.com/go v0.116.0 // indirect
cloud.google.com/go/auth v0.9.3 // indirect
cloud.google.com/go/compute/metadata v0.5.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/s2a-go v0.1.8 // indirect
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
go.opencensus.io v0.24.0 // indirect
google.golang.org/genai v1.59.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.66.2 // indirect
)
require (
github.com/bytedance/sonic v1.11.9 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect
@@ -40,9 +56,9 @@ require (
go.opentelemetry.io/otel v1.29.0 // indirect
go.opentelemetry.io/otel/trace v1.29.0 // indirect
golang.org/x/arch v0.8.0 // indirect
golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.21.0 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
google.golang.org/protobuf v1.34.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
+112 -10
View File
@@ -1,11 +1,24 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f h1:Zt91pngDr+TIU14sxv3fk1QS7bMQetvccFNg8kPaltA=
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f/go.mod h1:UZLveG17SmENt4sne2RSLIbioix30RZbRIQUzBAnOyY=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg=
github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-oidc/v3 v3.20.0 h1:EtE0WIBHk03N+DqGkY4+UONzzZHk7amKt6IyNd7OsZE=
github.com/coreos/go-oidc/v3 v3.20.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -13,6 +26,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -31,13 +48,40 @@ github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4
github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
@@ -65,6 +109,7 @@ github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/samber/slog-gin v1.15.0 h1:Kqs/ilXd9divtslWjbz5DVptmLlzyntbBiXUAta2SFg=
@@ -85,6 +130,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
@@ -92,24 +139,77 @@ go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+M
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genai v1.59.0 h1:xp+ydkJFW8hO0hTUaAkr8TrLM9HFP3NYAwFhPd0nDqA=
google.golang.org/genai v1.59.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -118,6 +218,8 @@ gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+32 -14
View File
@@ -1,18 +1,36 @@
// Package agent adapts pansy's service layer to majordomo tools so an agent can
// drive a garden in natural language ("fill the NE corner with garlic, the NW
// with basil, the south half with beans"). Each tool runs as a fixed actor and
// therefore inherits pansy's ACL checks for free — a viewer's fill_region returns
// ErrForbidden, exactly as the REST API would.
// Package agent is pansy's garden assistant: a majordomo toolbox over the
// service layer, plus the run loop that drives a model with it.
//
// Two deliberate separations keep the core server lean:
// Every tool runs as a fixed actor, so the agent inherits pansy's permission
// checks for free — a viewer's fill_region returns ErrForbidden, exactly as the
// REST API would. That is the whole reason the tools are thin adapters over
// internal/service and never reach past it; the moment one does, the ACL story
// stops being automatic and becomes something to remember.
//
// - cmd/pansy does NOT import this package, so the server binary carries no
// agent/LLM dependencies.
// - the tool wiring (tools.go) sits behind the `majordomo` build tag, so the
// default `go build ./...` / `go test ./...` compiles without the majordomo
// module. Build the agent tools with `-tags majordomo` once majordomo is a
// dependency (`go get gitea.stevedudenhoeffer.com/steve/majordomo`).
// # A turn is one change set
//
// The agent harness itself (model loop, chat surface) lives in the
// majordomo/executus stack, outside this repo; this package is only the toolbox.
// Runs wrap their whole turn in a single change set (source "agent"), so
// "empty the garlic bed and plant cucumbers" — one object edit and a dozen
// planting inserts — undoes as one action rather than thirteen. That is what
// makes acting without a confirmation prompt defensible: the answer to "it did
// the wrong thing" is one click, not an archaeology exercise.
//
// # No build tag
//
// This package used to sit behind a `majordomo` build tag, with cmd/pansy
// deliberately not importing it, so the default build carried no LLM
// dependency. Both separations are gone, on purpose: a tag that keeps the agent
// out of the binary only earns its keep if you would ever ship a build without
// the agent, and the agent is the point. Keeping it would have meant an
// untagged CI that never compiled the code that matters.
//
// majordomo is stdlib-first and pure Go, so CGO_ENABLED=0 and the single static
// binary survive it.
//
// # Unconfigured instances
//
// With no API key the assistant is simply not offered: the chat route isn't
// registered and the capability isn't advertised — the same shape as OIDC
// 404ing when unconfigured. An instance without a key starts and serves the app
// exactly as it did before.
package agent
+232
View File
@@ -0,0 +1,232 @@
package agent
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"gitea.stevedudenhoeffer.com/steve/majordomo/agent"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// Loop bounds. These exist so a stuck run terminates, NOT to control spend —
// pansy is a personal tool and multi-tenant cost control is explicitly not a
// concern here. A model that gets wedged calling describe_garden forever should
// stop on its own, and a hung upstream shouldn't hold a connection open all day.
const (
maxSteps = 24
// A turn that legitimately fills several beds does a lot of round trips, so
// this is generous; it is a backstop, not a budget.
runTimeout = 4 * time.Minute
// Successive all-error steps, and identical repeated calls, that end a run.
maxConsecutiveToolErrors = 4
maxSameCallRepeats = 3
)
// Runner drives a model over pansy's toolbox. One per process; Run is safe to
// call concurrently.
type Runner struct {
svc *service.Service
model llm.Model
}
// NewRunner resolves modelSpec against pansy's registry and returns a Runner, or
// an error if the assistant can't be offered. Callers should treat an error as
// "no assistant" rather than a startup failure — an instance with no key must
// still serve the app.
//
// It takes the key and spec explicitly rather than a *config.Config so the same
// constructor serves both boot (from env) and a runtime settings change (from
// the DB) — the Runner has no idea which one configured it.
func NewRunner(svc *service.Service, apiKey, modelSpec string) (*Runner, error) {
if apiKey == "" {
return nil, errors.New("agent: not configured")
}
// agentmodel.Resolve already rejects an empty/blank spec, so don't duplicate
// that guard here — one place decides what a valid spec is.
model, err := agentmodel.Resolve(apiKey, modelSpec)
if err != nil {
return nil, err
}
return &Runner{svc: svc, model: model}, nil
}
// Turn is the outcome of one exchange.
type Turn struct {
// Reply is what to show the user.
Reply string `json:"reply"`
// ChangeSetID is the change set this turn produced, if it changed anything —
// the handle the UI needs to offer Undo.
ChangeSetID *int64 `json:"changeSetId,omitempty"`
// Steps is how many model round trips it took.
Steps int `json:"steps"`
// Truncated is set when the run hit its step cap rather than finishing.
Truncated bool `json:"truncated,omitempty"`
}
// Run executes one turn against a garden, as actorID.
//
// The whole turn runs inside ONE change set, so everything the model did undoes
// together. That is what makes acting without a confirmation prompt defensible.
// The scope is opened even for a turn that turns out to be a question — a change
// set with no revisions is never written, so asking costs nothing.
func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message string, history []llm.Message, onStep func(agent.Step)) (*Turn, error) {
message = strings.TrimSpace(message)
if message == "" {
return nil, domain.ErrInvalidInput
}
ctx, cancel := context.WithTimeout(ctx, runTimeout)
defer cancel()
garden, err := r.svc.GetGarden(ctx, actorID, gardenID)
if err != nil {
return nil, err
}
// An id for this run, stamped on the change set so a row in the history list
// can be matched to the log lines that produced it. Without it, "the agent
// did something odd on Tuesday" has no thread back to what it was thinking.
runID := newRunID()
slog.Info("agent: run start", "run", runID, "garden", gardenID, "actor", actorID)
var (
result *agent.Result
runErr error
truncErr bool
)
changeSet, err := r.svc.WithChangeSet(ctx, actorID, gardenID, service.ChangeSetOptions{
Source: domain.SourceAgent,
Summary: turnSummary(message),
AgentRunID: &runID,
}, func(ctx context.Context) error {
box := NewToolbox(r.svc, actorID)
a := agent.New(r.model, systemPrompt(garden),
agent.WithMaxSteps(maxSteps),
agent.WithToolErrorLimits(maxConsecutiveToolErrors, maxSameCallRepeats),
)
a.AddToolbox(box)
opts := []agent.RunOption{agent.WithHistory(history)}
if onStep != nil {
opts = append(opts, agent.OnStep(onStep))
}
result, runErr = a.Run(ctx, message, opts...)
// A run that ran out of steps still DID things, and those things must be
// recorded and undoable. So the loop-guard errors don't fail the scope —
// they're reported to the user instead.
if runErr != nil && isLoopLimit(runErr) {
truncErr = true
return nil
}
return runErr
})
if err != nil {
// WithChangeSet records whatever committed before failing, so the partial
// work is undoable even though the turn errored.
return nil, err
}
turn := &Turn{Truncated: truncErr}
if changeSet != nil {
turn.ChangeSetID = &changeSet.ID
}
if result != nil {
turn.Reply = result.Output
turn.Steps = len(result.Steps)
}
if turn.Reply == "" {
turn.Reply = fallbackReply(turn)
}
return turn, nil
}
// isLoopLimit reports whether an error is one of majordomo's loop guards firing
// rather than a genuine failure. Those runs have a partial result worth keeping.
func isLoopLimit(err error) bool {
return errors.Is(err, agent.ErrMaxSteps) || errors.Is(err, agent.ErrToolLoop)
}
// fallbackReply covers a run that finished with no text — a model that made its
// last tool call and then stopped. Silence reads as a failure, so say what
// happened.
func fallbackReply(t *Turn) string {
switch {
case t.Truncated:
return "I stopped partway through — that turned into more steps than I should take in one go. " +
"Have a look at what changed, and tell me what to do next."
case t.ChangeSetID != nil:
return "Done — have a look at the canvas."
default:
return "I didn't change anything."
}
}
// newRunID returns a short random identifier for one run.
func newRunID() string {
var b [8]byte
if _, err := rand.Read(b[:]); err != nil {
// The id is for correlating logs, not for security. A clock-based
// fallback is worse than random and better than an empty string.
return fmt.Sprintf("t%d", time.Now().UnixNano())
}
return hex.EncodeToString(b[:])
}
// turnSummary is what the history list shows for this turn. The user's own words
// are the most useful label available, trimmed to fit a list row.
//
// Trimmed by RUNES, not bytes: slicing a byte offset would cut a multibyte
// character in half and store invalid UTF-8 in the summary — which is not a
// hypothetical for text people type.
func turnSummary(message string) string {
const max = 120
s := strings.Join(strings.Fields(message), " ")
runes := []rune(s)
if len(runes) > max {
s = strings.TrimSpace(string(runes[:max])) + "…"
}
return s
}
// systemPrompt gives the model the conventions it cannot infer.
//
// The compass convention in particular is not guessable: -y is north because
// screen y grows downward, and a model that assumes otherwise plants the south
// half when asked for the north one.
func systemPrompt(g *domain.Garden) string {
units := "metric — all measurements are centimeters"
if g.UnitPref == domain.UnitImperial {
units = "imperial for display, but every measurement you send or receive is in CENTIMETERS"
}
return fmt.Sprintf(`You are pansy's garden assistant. You help plan and edit a real garden by calling tools.
The garden you are working on is %q (id %d), %.0f x %.0f cm. The user's units are %s.
Conventions you cannot guess and must not assume:
- Positions in a garden are centimeters from its top-left corner: x grows east, y grows SOUTH.
- Inside an object (a bed), positions are relative to that object's CENTER, and -y is NORTH.
So the north half of a bed is negative y. Getting this backwards plants the wrong end.
- Objects and plantings are version-guarded. Use the version from describe_garden when editing.
How to work:
- Start from describe_garden to see what is actually there. Do not guess ids.
- Use find_plant to turn a plant name into an id. If it returns several candidates,
pick the one that matches what the user said, or ask them which they meant.
- To replant a bed with something else: clear_object, then fill_region with region "all".
- When a tool refuses (for example, the user only has view access to this garden),
explain what happened in plain words. Do not retry it.
When you are done, say briefly what you changed — the user is watching the canvas
and wants to know what to look at. If you changed nothing, say that too.`,
g.Name, g.ID, g.WidthCM, g.HeightCM, units)
}
+358
View File
@@ -0,0 +1,358 @@
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
"unicode/utf8"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/fake"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// scriptedRunner wires a Runner to a fake provider so the whole loop — tools,
// change-set scoping, loop guards — is exercised with no live model.
func scriptedRunner(t *testing.T, svc *service.Service, steps ...fake.Step) *Runner {
t.Helper()
p := fake.New("fake")
for _, s := range steps {
p.Enqueue("m", s)
}
model, err := p.Model("m")
if err != nil {
t.Fatalf("fake model: %v", err)
}
return &Runner{svc: svc, model: model}
}
// toolCall scripts one model turn that calls a tool.
func toolCall(name string, args any) fake.Step {
raw, _ := json.Marshal(args)
return fake.ReplyWith(llm.Response{
FinishReason: llm.FinishToolCalls,
ToolCalls: []llm.ToolCall{{ID: "c1", Name: name, Arguments: raw}},
})
}
// TestTurnIsOneChangeSet is the acceptance criterion the whole "act freely"
// posture rests on: a turn that clears a bed and replants it — one object edit
// and many planting inserts — has to undo as ONE action, not thirteen.
func TestTurnIsOneChangeSet(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄")
cucumber := mustPlant(t, svc, owner, "Cucumber", 45, "🥒")
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
Kind: domain.KindBed, Name: "Garlic bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil {
t.Fatalf("seed garlic: %v", err)
}
before, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if err != nil {
t.Fatalf("history: %v", err)
}
r := scriptedRunner(t, svc,
toolCall("clear_object", map[string]any{"objectId": bed.ID}),
toolCall("fill_region", map[string]any{"objectId": bed.ID, "region": "all", "plantId": cucumber.ID}),
fake.Reply("Cleared the garlic and replanted the bed with cucumbers."),
)
turn, err := r.Run(ctx, owner, g.ID, "change the garlic bed to cucumbers this year", nil, nil)
if err != nil {
t.Fatalf("Run: %v", err)
}
if turn.ChangeSetID == nil {
t.Fatal("a turn that changed things produced no change set")
}
if !strings.Contains(turn.Reply, "cucumbers") {
t.Errorf("reply = %q", turn.Reply)
}
// Exactly ONE new change set, whatever the model did inside the turn.
after, _, err := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if err != nil {
t.Fatalf("history: %v", err)
}
if len(after)-len(before) != 1 {
t.Fatalf("turn produced %d change sets, want exactly 1", len(after)-len(before))
}
cs := after[0]
if cs.Source != domain.SourceAgent {
t.Errorf("source = %q, want agent", cs.Source)
}
if cs.Summary != "change the garlic bed to cucumbers this year" {
t.Errorf("summary = %q, want the user's own words", cs.Summary)
}
// The bed really is cucumbers now.
full, _ := svc.GardenFull(ctx, owner, g.ID, nil)
if len(full.Plantings) == 0 {
t.Fatal("bed ended up empty")
}
for _, p := range full.Plantings {
if p.PlantID != cucumber.ID {
t.Errorf("unexpected plant %d still in the bed", p.PlantID)
}
}
// And one undo puts it back.
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, *turn.ChangeSetID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts)
}
full, _ = svc.GardenFull(ctx, owner, g.ID, nil)
for _, p := range full.Plantings {
if p.PlantID != garlic.ID {
t.Errorf("after undo the bed holds plant %d, want the garlic back", p.PlantID)
}
}
}
// TestViewerGetsAnExplainableRefusal — the ACL story only works if the model can
// narrate the refusal, so a tool denial has to reach it as a tool RESULT it can
// read, not as a 500 that ends the run.
func TestViewerGetsAnExplainableRefusal(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
viewer, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "V", Password: "password123"})
if err != nil {
t.Fatalf("register: %v", err)
}
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
Kind: domain.KindBed, XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
if _, err := svc.AddShare(ctx, owner, g.ID, "[email protected]", domain.RoleViewer); err != nil {
t.Fatalf("share: %v", err)
}
// A viewer can't open a change set at all, so the turn is refused up front —
// before any model call — and the API turns that into a plain explanation.
r := scriptedRunner(t, svc, fake.Reply("unused"))
_, err = r.Run(ctx, viewer.ID, g.ID, "plant garlic in that bed", nil, nil)
if !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("viewer turn err = %v, want ErrForbidden", err)
}
// And at the tool layer, a refusal comes back as a readable tool result
// rather than killing the run.
box := NewToolbox(svc, viewer.ID)
raw, _ := json.Marshal(map[string]any{"objectId": bed.ID})
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "clear_object", Arguments: raw})
if !res.IsError {
t.Fatal("a viewer's clear_object succeeded")
}
if res.Content == "" {
t.Error("the refusal carried no text for the model to explain")
}
}
// TestRunStopsAtTheStepCap — a model that gets wedged should stop on its own,
// and what it managed to do must still be recorded and undoable.
func TestRunStopsAtTheStepCap(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
// More describe_garden calls than the cap allows, forever.
steps := make([]fake.Step, 0, maxSteps+4)
for i := 0; i < maxSteps+4; i++ {
steps = append(steps, toolCall("describe_garden", map[string]any{"gardenId": g.ID}))
}
r := scriptedRunner(t, svc, steps...)
turn, err := r.Run(ctx, owner, g.ID, "look at the garden", nil, nil)
if err != nil {
t.Fatalf("a capped run should end cleanly, got %v", err)
}
if !turn.Truncated {
t.Error("turn.Truncated = false; the run hit the cap and should say so")
}
if turn.Reply == "" {
t.Error("a capped run said nothing; silence reads as a hang")
}
// It only read, so there is nothing to undo — and no empty change set either.
if turn.ChangeSetID != nil {
t.Errorf("a read-only turn produced change set %d", *turn.ChangeSetID)
}
}
// TestReadOnlyTurnWritesNoChangeSet — asking a question must not litter the
// history with empty entries.
func TestReadOnlyTurnWritesNoChangeSet(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
before, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
r := scriptedRunner(t, svc,
toolCall("describe_garden", map[string]any{"gardenId": g.ID}),
fake.Reply("It's empty — nothing planted yet."),
)
turn, err := r.Run(ctx, owner, g.ID, "what's in the garden?", nil, nil)
if err != nil {
t.Fatalf("Run: %v", err)
}
if turn.ChangeSetID != nil {
t.Errorf("a question produced change set %d", *turn.ChangeSetID)
}
after, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if len(after) != len(before) {
t.Errorf("history grew by %d for a read-only turn", len(after)-len(before))
}
}
// TestNewRunnerNeedsConfiguration — an instance with no key or no model must not
// get a half-built runner; the caller treats the error as "no assistant" and
// carries on. (Whether the assistant is ENABLED is resolved before NewRunner is
// reached, so it isn't NewRunner's concern any more.)
func TestNewRunnerNeedsConfiguration(t *testing.T) {
svc, _ := newAgentTestService(t)
for _, tc := range []struct{ key, model string }{
{"", "ollama-cloud/x"},
{"k", ""},
{"k", " "},
} {
if _, err := NewRunner(svc, tc.key, tc.model); err == nil {
t.Errorf("NewRunner accepted key=%q model=%q", tc.key, tc.model)
}
}
// A model spec naming a provider that doesn't exist is a configuration
// error, not a panic at first use.
if _, err := NewRunner(svc, "k", "nonesuch/model"); err == nil {
t.Error("NewRunner accepted an unresolvable model spec")
}
}
// TestTurnSummaryFitsAHistoryRow — the user's own words are the most useful
// label for a change set, but a paragraph would wreck the list.
func TestTurnSummaryFitsAHistoryRow(t *testing.T) {
if got := turnSummary(" change the garlic bed\n to cucumbers "); got != "change the garlic bed to cucumbers" {
t.Errorf("turnSummary = %q", got)
}
long := turnSummary(strings.Repeat("plant garlic ", 40))
if len(long) > 130 || !strings.HasSuffix(long, "…") {
t.Errorf("long summary = %q (%d chars)", long, len(long))
}
}
// TestSystemPromptStatesTheCompassConvention — -y being north is not guessable,
// and a model that assumes otherwise plants the wrong end of the bed.
func TestSystemPromptStatesTheCompassConvention(t *testing.T) {
p := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitImperial})
for _, want := range []string{"NORTH", "-y", "centimeters", "Plot", "version"} {
if !strings.Contains(p, want) {
t.Errorf("system prompt is missing %q:\n%s", want, p)
}
}
if !strings.Contains(p, fmt.Sprintf("%.0f", 500.0)) {
t.Error("system prompt doesn't state the garden's size")
}
}
// TestPartialWorkSurvivesATimeout is the finding that mattered most on this PR.
//
// The run context carries a timeout. When it fires, WithChangeSet's recovery
// path has to record what already committed — and doing that with the SAME
// dead context would fail, losing the history for changes that really happened.
// The user-facing message says "anything I'd already changed is in History", so
// this isn't just a gap, it's a promise the code has to keep.
func TestPartialWorkSurvivesATimeout(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
before, _, _ := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
// A turn that renames the bed, then dies with the context already cancelled.
cancelled, cancel := context.WithCancel(ctx)
r := scriptedRunner(t, svc,
toolCall("move_object", map[string]any{
"objectId": bed.ID, "xCm": 600.0, "yCm": 600.0, "version": bed.Version,
}),
fake.Step{Err: context.DeadlineExceeded},
)
// Cancel once the first tool call has landed, so the failure path runs with a
// dead context — exactly the timeout case.
go func() {
time.Sleep(50 * time.Millisecond)
cancel()
}()
_, err = r.Run(cancelled, owner, g.ID, "move the bed", nil, nil)
if err == nil {
t.Fatal("expected the turn to fail")
}
// The move committed, so it must be in history and undoable.
after, _, herr := svc.GardenHistory(ctx, owner, g.ID, 0, 0)
if herr != nil {
t.Fatalf("history: %v", herr)
}
if len(after) != len(before)+1 {
t.Fatalf("the failed turn recorded %d change sets, want 1 — its work is otherwise un-undoable",
len(after)-len(before))
}
if !strings.Contains(after[0].Summary, "failed partway") {
t.Errorf("summary = %q, want it marked as partial", after[0].Summary)
}
if _, conflicts, rerr := svc.RevertChangeSet(ctx, owner, after[0].ID, domain.SourceUI); rerr != nil || len(conflicts) != 0 {
t.Fatalf("the partial turn should be undoable: err=%v conflicts=%+v", rerr, conflicts)
}
o, _ := svc.DescribeGarden(ctx, owner, g.ID)
if len(o.Objects) > 0 && o.Objects[0].XCM != bed.XCM {
t.Errorf("undo left the bed at %v, want %v", o.Objects[0].XCM, bed.XCM)
}
}
// TestTurnSummaryTrimsByRunes — slicing a byte offset would cut a multibyte
// character in half and store invalid UTF-8 in the history summary.
func TestTurnSummaryTrimsByRunes(t *testing.T) {
// 200 multibyte runes: a byte slice at 120 would land mid-character.
got := turnSummary(strings.Repeat("🌱", 200))
if !utf8.ValidString(got) {
t.Errorf("turnSummary produced invalid UTF-8: %q", got)
}
if !strings.HasSuffix(got, "…") {
t.Errorf("long summary should be elided, got %q", got)
}
if n := utf8.RuneCountInString(got); n > 121 {
t.Errorf("summary is %d runes, want it trimmed", n)
}
}
+63 -4
View File
@@ -1,5 +1,3 @@
//go:build majordomo
package agent
import (
@@ -36,11 +34,36 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
"Place one plop of a plant inside a plantable object, positioned in the object's LOCAL frame (0,0 = object center, -y = north).",
a.placePlanting),
llm.DefineTool("fill_region",
"Fill a named region of a plantable object with a plant, hex-packed. Region is one of nw/ne/sw/se (corners), north/south/east/west or top/bottom/left/right (halves), or all.",
"Fill part of a plantable object with one plant, hex-packed at the plant's spacing. "+
"region is a compass name, not coordinates: nw|ne|sw|se for the quarter corners, "+
"north|south|east|west (or top|bottom|left|right) for halves, or all for the whole thing. "+
"North is the top of the garden. Example: to replant a whole bed, clear_object then "+
"fill_region with region=all. Filling skips spots already covered by an existing plant, "+
"so it is safe to run twice.",
a.fillRegion),
llm.DefineTool("clear_object",
"Remove all plants from an object (soft-remove; history is kept).",
"Remove all plants from an object. They are soft-removed, so the planting history for past "+
"seasons is kept and the change can be undone. Use this before replanting a bed with "+
"something else.",
a.clearObject),
llm.DefineTool("find_plant",
"Look up plants in the user's catalog by name or category, to get the plantId that "+
"place_planting and fill_region need. Returns SEVERAL candidates when the query is "+
"ambiguous — \"garlic\" may match both the built-in \"Garlic\" and a custom \"German Red "+
"Garlic\" — so pick the one that fits what the user asked for, or ask them which. Each "+
"result also reports how much seed the user has left of it, when they have recorded any.",
a.findPlant),
llm.DefineTool("create_plant",
"Add a new plant to the user's own catalog, for when they name a variety that isn't in it "+
"yet. Check find_plant first — creating a duplicate of something that already exists is "+
"worse than reusing it. The plant belongs to the user, not to any garden.",
a.createPlant),
llm.DefineTool("add_journal_entry",
"Write a dated observation into the garden's grow journal — what happened, and when. "+
"Attach it to one bed with objectId when it is about that bed. This is for events "+
"(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+
"thing is. observedAt defaults to today; set it to backdate.",
a.addJournalEntry),
)
}
@@ -108,6 +131,42 @@ func (a *adapter) fillRegion(ctx context.Context, args struct {
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride)
}
func (a *adapter) findPlant(ctx context.Context, args struct {
Query string `json:"query" description:"plant name or category to search for, e.g. \"cucumber\" or \"herb\"; empty lists the catalog"`
}) (any, error) {
return a.svc.FindPlants(ctx, a.actor, args.Query)
}
func (a *adapter) createPlant(ctx context.Context, args struct {
Name string `json:"name" description:"the variety's name, e.g. \"German Red Garlic\""`
Category string `json:"category" description:"vegetable | herb | flower | fruit | tree_shrub | cover"`
SpacingCM float64 `json:"spacingCm" description:"mature in-row spacing in cm; this is what fill_region packs to"`
Color string `json:"color" description:"hex color for the plant on the canvas, e.g. #8a5a8a"`
Icon string `json:"icon" description:"a single emoji to draw it with, e.g. 🧄"`
DaysToMaturity *int `json:"daysToMaturity" description:"optional days from planting to harvest"`
SourceURL string `json:"sourceUrl" description:"optional http(s) link to where the seed came from"`
Vendor string `json:"vendor" description:"optional vendor name, e.g. \"Johnny\u0027s Selected Seeds\""`
}) (any, error) {
return a.svc.CreatePlant(ctx, a.actor, service.PlantInput{
Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM,
Color: args.Color, Icon: args.Icon, DaysToMaturity: args.DaysToMaturity,
SourceURL: args.SourceURL, Vendor: args.Vendor,
})
}
func (a *adapter) addJournalEntry(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden the observation is about"`
ObjectID *int64 `json:"objectId" description:"optional bed the observation is about; omit for a garden-level note"`
Body string `json:"body" description:"what happened, in plain words"`
ObservedAt string `json:"observedAt" description:"optional date it happened, YYYY-MM-DD; defaults to today"`
}) (any, error) {
in := service.JournalInput{ObjectID: args.ObjectID, Body: args.Body}
if args.ObservedAt != "" {
in.ObservedAt = &args.ObservedAt
}
return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, in)
}
func (a *adapter) clearObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to remove all plants from"`
}) (any, error) {
+238 -22
View File
@@ -1,10 +1,9 @@
//go:build majordomo
package agent
import (
"context"
"encoding/json"
"strings"
"testing"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
@@ -24,21 +23,8 @@ import (
// Build/run with: go test -tags majordomo ./internal/agent/
func TestToolboxScenario(t *testing.T) {
ctx := context.Background()
db, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := db.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
svc := service.New(db, &config.Config{Registration: config.RegistrationOpen, LocalAuth: true})
owner, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "A", Password: "password123"})
if err != nil {
t.Fatalf("register: %v", err)
}
box := NewToolbox(svc, owner.ID)
svc, ownerID := newAgentTestService(t)
box := NewToolbox(svc, ownerID)
call := func(name string, args any) llm.ToolResult {
t.Helper()
@@ -48,13 +34,13 @@ func TestToolboxScenario(t *testing.T) {
// Garden + plants are set up directly (there are no create_garden/plant tools);
// the agent-facing bits — object + fills + describe — go through the toolbox.
g, err := svc.CreateGarden(ctx, owner.ID, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
g, err := svc.CreateGarden(ctx, ownerID, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
garlic := mustPlant(t, svc, owner.ID, "Garlic", 15, "🧄")
basil := mustPlant(t, svc, owner.ID, "Basil", 25, "🌿")
beans := mustPlant(t, svc, owner.ID, "Beans", 10, "🫘")
garlic := mustPlant(t, svc, ownerID, "Garlic", 15, "🧄")
basil := mustPlant(t, svc, ownerID, "Basil", 25, "🌿")
beans := mustPlant(t, svc, ownerID, "Beans", 10, "🫘")
// create_object → a 400×400 bed.
res := call("create_object", map[string]any{
@@ -119,7 +105,7 @@ func TestToolboxScenario(t *testing.T) {
if err != nil {
t.Fatalf("register viewer: %v", err)
}
if _, err := svc.AddShare(ctx, owner.ID, g.ID, "[email protected]", domain.RoleViewer); err != nil {
if _, err := svc.AddShare(ctx, ownerID, g.ID, "[email protected]", domain.RoleViewer); err != nil {
t.Fatalf("share: %v", err)
}
viewerBox := NewToolbox(svc, viewerUser.ID)
@@ -150,3 +136,233 @@ func mustPlant(t *testing.T, svc *service.Service, owner int64, name string, spa
}
return p
}
// TestGarlicBedToCucumbers is the flagship interaction from #58, driven end to
// end through the tool layer: "change the garlic garden bed to instead be
// cucumbers this year".
//
// The sequence is the one a model would actually run — describe to find the bed,
// find_plant to turn the word "cucumber" into an id, clear, refill — and until
// find_plant existed step three had no way to get its plantId, which blocked the
// whole thing on one missing tool.
func TestGarlicBedToCucumbers(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
box := NewToolbox(svc, owner)
call := func(name string, args any) llm.ToolResult {
t.Helper()
raw, _ := json.Marshal(args)
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: raw})
}
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄")
mustPlant(t, svc, owner, "Cucumber", 45, "🥒")
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
Kind: domain.KindBed, Name: "Garlic bed", XCM: 1000, YCM: 1000, WidthCM: 400, HeightCM: 400,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil); err != nil {
t.Fatalf("seed the garlic: %v", err)
}
// 1. describe_garden — "the garlic bed" resolves to an object id, because the
// description carries the NAMES of what's planted in each object.
res := call("describe_garden", map[string]any{"gardenId": g.ID})
if res.IsError {
t.Fatalf("describe_garden: %s", res.Content)
}
if !strings.Contains(res.Content, "Garlic") {
t.Fatalf("describe_garden didn't name what's planted: %s", res.Content)
}
// 2. find_plant — the step that used to be impossible.
res = call("find_plant", map[string]any{"query": "cucumber"})
if res.IsError {
t.Fatalf("find_plant: %s", res.Content)
}
var matches []struct {
ID int64 `json:"id"`
Name string `json:"name"`
}
if err := json.Unmarshal([]byte(res.Content), &matches); err != nil {
t.Fatalf("decode find_plant: %v (%s)", err, res.Content)
}
if len(matches) == 0 || matches[0].Name != "Cucumber" {
t.Fatalf("find_plant(cucumber) = %+v", matches)
}
// 3. clear_object, 4. fill_region.
if r := call("clear_object", map[string]any{"objectId": bed.ID}); r.IsError {
t.Fatalf("clear_object: %s", r.Content)
}
if r := call("fill_region", map[string]any{
"objectId": bed.ID, "region": "all", "plantId": matches[0].ID,
}); r.IsError {
t.Fatalf("fill_region: %s", r.Content)
}
// The bed is cucumbers, and no garlic is still growing in it.
full, err := svc.GardenFull(ctx, owner, g.ID, nil)
if err != nil {
t.Fatalf("GardenFull: %v", err)
}
if len(full.Plantings) == 0 {
t.Fatal("the bed ended up empty")
}
for _, p := range full.Plantings {
if p.PlantID == garlic.ID {
t.Errorf("garlic is still active in the bed")
}
if p.PlantID != matches[0].ID {
t.Errorf("unexpected plant %d in the bed", p.PlantID)
}
}
}
// TestFindPlantReturnsCandidatesNotAGuess — "garlic" against a catalog holding
// both "Garlic" and "German Red Garlic" is genuinely ambiguous, and silently
// picking one is how the agent plants the wrong thing.
func TestFindPlantReturnsCandidatesNotAGuess(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
box := NewToolbox(svc, owner)
mustPlant(t, svc, owner, "German Red Garlic", 15, "🧄")
raw, _ := json.Marshal(map[string]any{"query": "garlic"})
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "find_plant", Arguments: raw})
if res.IsError {
t.Fatalf("find_plant: %s", res.Content)
}
var matches []struct {
Name string `json:"name"`
}
if err := json.Unmarshal([]byte(res.Content), &matches); err != nil {
t.Fatalf("decode: %v", err)
}
names := map[string]bool{}
for _, m := range matches {
names[m.Name] = true
}
// The built-in catalog seeds a plain "Garlic"; the custom one is ours.
if !names["Garlic"] || !names["German Red Garlic"] {
t.Errorf("find_plant(garlic) = %+v, want both the built-in and the custom variety", matches)
}
// Exact match ranks first, so a caller taking [0] gets the least surprising one.
if len(matches) > 0 && matches[0].Name != "Garlic" {
t.Errorf("first match = %q, want the exact name", matches[0].Name)
}
}
// TestCreatePlantIsUserScoped — the catalog belongs to the user, not to any
// garden, so someone with no editable garden can still name a new variety. The
// issue asks for this to be asserted rather than assumed.
func TestCreatePlantIsUserScoped(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
other, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "B", Password: "password123"})
if err != nil {
t.Fatalf("register: %v", err)
}
box := NewToolbox(svc, other.ID)
raw, _ := json.Marshal(map[string]any{
"name": "Painted Mountain Corn", "category": "vegetable",
"spacingCm": 30.0, "color": "#c08a3f", "icon": "🌽",
})
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "create_plant", Arguments: raw})
if res.IsError {
t.Fatalf("create_plant: %s", res.Content)
}
var created struct {
ID int64 `json:"id"`
OwnerID *int64 `json:"ownerId"`
Name string `json:"name"`
}
if err := json.Unmarshal([]byte(res.Content), &created); err != nil {
t.Fatalf("decode: %v", err)
}
if created.OwnerID == nil || *created.OwnerID != other.ID {
t.Errorf("ownerId = %v, want the acting user %d", created.OwnerID, other.ID)
}
// And it stays theirs: the other user's catalog doesn't gain it.
ownerPlants, err := svc.ListPlants(ctx, owner)
if err != nil {
t.Fatalf("ListPlants: %v", err)
}
for _, p := range ownerPlants {
if p.Name == "Painted Mountain Corn" {
t.Error("a plant created by one user showed up in another's catalog")
}
}
}
// TestJournalToolWritesADatedObservation — "note that the west bed has mildew"
// is squarely the kind of thing you say out loud while walking around.
func TestJournalToolWritesADatedObservation(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
box := NewToolbox(svc, owner)
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
Kind: domain.KindBed, Name: "West bed", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
raw, _ := json.Marshal(map[string]any{
"gardenId": g.ID, "objectId": bed.ID,
"body": "Powdery mildew on the west bed", "observedAt": "2026-08-14",
})
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "add_journal_entry", Arguments: raw})
if res.IsError {
t.Fatalf("add_journal_entry: %s", res.Content)
}
entries, _, err := svc.ListJournal(ctx, owner, g.ID, service.JournalQuery{ObjectID: &bed.ID})
if err != nil {
t.Fatalf("ListJournal: %v", err)
}
if len(entries) != 1 {
t.Fatalf("got %d entries, want 1", len(entries))
}
if entries[0].Body != "Powdery mildew on the west bed" || entries[0].ObservedAt != "2026-08-14" {
t.Errorf("unexpected entry: %+v", entries[0])
}
if entries[0].AuthorID != owner {
t.Errorf("author = %d, want the acting user %d", entries[0].AuthorID, owner)
}
}
// newAgentTestService spins up an in-memory pansy with one registered user.
func newAgentTestService(t *testing.T) (*service.Service, int64) {
t.Helper()
ctx := context.Background()
db, err := store.Open(":memory:")
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := db.Migrate(ctx); err != nil {
t.Fatalf("migrate: %v", err)
}
svc := service.New(db, &config.Config{Registration: config.RegistrationOpen, LocalAuth: true})
owner, err := svc.Register(ctx, service.RegisterInput{Email: "[email protected]", DisplayName: "A", Password: "password123"})
if err != nil {
t.Fatalf("register: %v", err)
}
return svc, owner.ID
}
+57
View File
@@ -0,0 +1,57 @@
// Package agentmodel holds the ONE place that knows how pansy turns a model spec
// into a majordomo model: which provider to register and under which token.
//
// It exists as a leaf so both internal/agent (which builds the run loop) and
// internal/service (which validates a spec before storing it as a setting) can
// share that knowledge without an import cycle — agent imports service, so the
// shared bit can live in neither of them.
package agentmodel
import (
"errors"
"fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/majordomo/provider/ollama"
)
// registry builds the private majordomo registry pansy uses.
//
// Private, not the package-level default: pansy passes the key it was configured
// with rather than depending on ambient environment, and majordomo's own
// ollama-cloud preset reads OLLAMA_API_KEY while pansy (like gadfly) is
// configured with OLLAMA_CLOUD_API_KEY. Registering the provider explicitly
// makes that bridge visible instead of a mysterious empty token.
func registry(apiKey string) *majordomo.Registry {
reg := majordomo.New()
reg.RegisterProvider(ollama.Cloud(ollama.WithToken(apiKey)))
return reg
}
// Resolve parses a model spec against pansy's registry into a live model. The
// spec goes to Parse VERBATIM — the grammar, including comma-separated failover
// chains, is majordomo's, and re-implementing any of it here would only mean two
// places to update when it grows.
func Resolve(apiKey, spec string) (llm.Model, error) {
if strings.TrimSpace(spec) == "" {
return nil, errors.New("agentmodel: empty model spec")
}
m, err := registry(apiKey).Parse(spec)
if err != nil {
return nil, fmt.Errorf("agentmodel: resolve %q: %w", spec, err)
}
return m, nil
}
// Validate reports whether a spec resolves, without building anything the caller
// keeps — the cheap, deterministic check a settings save runs to reject a typo.
//
// It does NOT make a live call, so it needs no working key and won't catch a
// model that is merely absent upstream; that surfaces on first use. Parse
// resolving (known provider, well-formed spec) is the half worth doing eagerly.
func Validate(apiKey, spec string) error {
_, err := Resolve(apiKey, spec)
return err
}
+35
View File
@@ -0,0 +1,35 @@
package agentmodel
import "testing"
// TestValidate is what the settings PATCH relies on to reject a typo at save
// time rather than on the next chat turn.
func TestValidate(t *testing.T) {
if err := Validate("k", "ollama-cloud/glm-5.2:cloud"); err != nil {
t.Errorf("valid spec rejected: %v", err)
}
// No key needed to validate — Parse resolves the provider, it doesn't call it.
if err := Validate("", "ollama-cloud/glm-5.2:cloud"); err != nil {
t.Errorf("valid spec rejected without a key: %v", err)
}
// A comma-separated failover chain is majordomo grammar and must resolve.
if err := Validate("k", "ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud"); err != nil {
t.Errorf("failover chain rejected: %v", err)
}
for _, bad := range []string{"", " ", "nonesuch/model"} {
if err := Validate("k", bad); err == nil {
t.Errorf("Validate accepted %q", bad)
}
}
}
// TestResolveReturnsAModel confirms a good spec yields a usable model handle.
func TestResolveReturnsAModel(t *testing.T) {
m, err := Resolve("k", "ollama-cloud/glm-5.2:cloud")
if err != nil {
t.Fatalf("resolve: %v", err)
}
if m == nil {
t.Fatal("resolve returned a nil model with no error")
}
}
+306
View File
@@ -0,0 +1,306 @@
package api
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"sync"
"time"
mdagent "gitea.stevedudenhoeffer.com/steve/majordomo/agent"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// The garden assistant's chat surface (#56).
//
// Streaming, because a turn that clears a bed and replants it makes a dozen tool
// calls over tens of seconds. Without streaming that is a long silence followed
// by everything at once, which reads as a hang — and the whole design rests on
// watching the canvas change as it happens.
// keepAliveInterval is how often a quiet stream emits a comment frame. Well
// under the 3060s idle timeout typical of reverse proxies, which is the thing
// it exists to stay ahead of.
const keepAliveInterval = 20 * time.Second
// chatRequest is the body of POST /agent/chat.
type chatRequest struct {
GardenID int64 `json:"gardenId" binding:"required"`
Message string `json:"message" binding:"required"`
}
// chatEvent is one server-sent event. Exactly one field is set.
type chatEvent struct {
// Step reports a completed model round trip: which tools it called.
Step *stepEvent `json:"step,omitempty"`
// Done carries the finished turn.
Done *agent.Turn `json:"done,omitempty"`
// Error is a turn that failed, in words meant for a person.
Error string `json:"error,omitempty"`
// Warning rides alongside Done: the turn worked, but something adjacent to it
// didn't, and saying nothing would be the quieter lie.
Warning string `json:"warning,omitempty"`
}
type stepEvent struct {
Index int `json:"index"`
Tools []string `json:"tools"`
}
func (h *handlers) agentChat(c *gin.Context) {
// The route is always registered, so the assistant being off is a runtime
// state, not a missing route: answer it plainly rather than 404ing a path
// that exists. Loaded once here so a settings-driven swap mid-request can't
// make it flip between the guard and the Run call.
runner := h.agent.get()
if runner == nil {
writeAPIError(c, http.StatusServiceUnavailable, "AGENT_DISABLED", "the garden assistant isn't enabled on this instance")
return
}
var req chatRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required")
return
}
actor := mustActor(c)
history, err := h.svc.AgentHistory(c.Request.Context(), actor.ID, req.GardenID)
if err != nil {
writeServiceError(c, err)
return
}
stream := openEventStream(c)
send := stream.send
// A model thinking hard between tool calls sends nothing for a while, and an
// idle proxy will cut a quiet connection. Deferred so a panic in the run
// can't leak the ticker goroutine; stopping it twice is harmless.
stopBeat := stream.keepAlive(keepAliveInterval)
defer stopBeat()
turn, err := runner.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
replayHistory(history),
func(s mdagent.Step) {
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
})
stopBeat()
if err != nil {
// The stream is already open, so an error is an event rather than a
// status code — the client has committed to reading a stream by now.
send(chatEvent{Error: chatErrorMessage(err)})
return
}
// The turn itself succeeded — the garden really did change — so Done goes out
// regardless. But if the transcript couldn't be saved, say so: a clean "done"
// followed by a conversation that has forgotten the exchange after a reload is
// exactly the kind of quiet inconsistency that makes a tool feel unreliable.
//
// Detached from the request context, because the commonest reason this fails
// is the client having gone away — and the exchange is worth keeping either
// way, since the change set it produced certainly is.
if _, err := h.svc.RecordAgentExchange(context.WithoutCancel(c.Request.Context()), actor.ID, req.GardenID,
req.Message, turn.Reply, turn.ChangeSetID); err != nil {
slog.Error("api: record agent exchange", "error", err, "garden", req.GardenID)
send(chatEvent{Done: turn, Warning: "I couldn't save this exchange, so it won't be here after a reload. Anything I changed is still on the canvas, and in History."})
return
}
send(chatEvent{Done: turn})
}
// sseWriteTimeout bounds ONE write to the stream, not the stream itself.
//
// It is refreshed per frame, which is the only shape that satisfies both ends:
// the server's absolute WriteTimeout would cut a long turn (#78), while removing
// the deadline entirely would let a client that stops reading block a write
// forever once the socket buffer fills — pinning the run goroutine and this
// stream's mutex with it, and taking the keep-alive down too since it needs the
// same lock. Generous, because it is a backstop against a stuck peer and not a
// pacing mechanism.
//
// A var, not a const, ONLY so the test can shrink it to prove the deadline is
// refreshed per frame rather than set once — a set-once 30s deadline would pass
// a test whose whole run is under a second. Production never reassigns it.
var sseWriteTimeout = 30 * time.Second
// eventStream serializes writes to one SSE response.
//
// The mutex is load-bearing, not decoration: step events are sent from the
// agent's run goroutine while the keep-alive ticker writes from its own, and two
// goroutines writing a ResponseWriter concurrently is a data race that corrupts
// frames long before it crashes anything.
type eventStream struct {
c *gin.Context
rc *http.ResponseController
mu sync.Mutex
}
// openEventStream puts the response into SSE mode.
//
// Headers go out before the first write and the stream is flushed immediately,
// so a proxy holding the response until it looks complete can't reintroduce
// exactly the silence streaming exists to remove.
//
// Taking the write deadline off the server's absolute WriteTimeout and onto a
// per-write one is what makes a turn longer than 30s possible at all (#78).
// WriteTimeout is an ABSOLUTE deadline from when the request header was read,
// not an idle timeout, so a streaming response is cut mid-turn however recently
// it wrote. Without this the 4-minute runTimeout is unreachable and the
// keep-alive below tops out at one tick — pacing a connection that is destroyed
// underneath it.
//
// That failure is INVISIBLE from in here: writes past the deadline return
// err == nil and their bytes are dropped, so there is nothing to detect on the
// write path. Only the client sees it, as a truncated stream it reports as a
// dropped connection. Hence a deadline set up front and refreshed per frame,
// rather than anything checked after the fact.
func openEventStream(c *gin.Context) *eventStream {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no")
s := &eventStream{c: c, rc: http.NewResponseController(c.Writer)}
// Probe once here rather than reporting per frame: a writer that can't take
// deadlines will fail identically on every write, and the operator needs to
// hear it once. If this fails the stream still works — it is just back to
// being cut at WriteTimeout, which is worth saying out loud.
if err := s.rc.SetWriteDeadline(time.Now().Add(sseWriteTimeout)); err != nil {
slog.Error("api: SSE write deadlines unavailable; long turns will be truncated at the server WriteTimeout", "error", err)
}
c.Writer.Flush()
return s
}
func (s *eventStream) send(ev chatEvent) {
b, err := json.Marshal(ev)
if err != nil {
slog.Error("api: encode chat event", "error", err)
return
}
s.write(fmt.Sprintf("data: %s\n\n", b))
}
func (s *eventStream) write(frame string) {
s.mu.Lock()
defer s.mu.Unlock()
// Refresh for THIS write, so the stream as a whole is unbounded but no single
// write is. Error deliberately unchecked: openEventStream already reported
// whether deadlines work at all, and this call can only fail the same way, so
// checking here would log once per frame to say the same thing.
_ = s.rc.SetWriteDeadline(time.Now().Add(sseWriteTimeout))
_, _ = io.WriteString(s.c.Writer, frame)
s.c.Writer.Flush()
}
// keepAlive writes an SSE comment frame on an interval until the returned
// function is called, so a long silence while the model thinks doesn't look like
// a dead connection to whatever sits in between. SSE ignores comment frames, so
// this costs the client nothing.
func (s *eventStream) keepAlive(every time.Duration) func() {
done := make(chan struct{})
stopped := make(chan struct{})
go func() {
defer close(stopped)
t := time.NewTicker(every)
defer t.Stop()
for {
select {
case <-done:
return
case <-s.c.Request.Context().Done():
return
case <-t.C:
s.write(": keep-alive\n\n")
}
}
}()
// Idempotent: the handler stops it explicitly when the run returns and again
// via defer, so a panic can't leak the goroutine.
var once sync.Once
return func() {
once.Do(func() { close(done) })
<-stopped
}
}
// getAgentHistory returns the actor's thread for a garden.
func (h *handlers) getAgentHistory(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
msgs, err := h.svc.AgentHistory(c.Request.Context(), mustActor(c).ID, gardenID)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"messages": msgs})
}
// deleteAgentHistory is the "start over" escape hatch.
func (h *handlers) deleteAgentHistory(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
if err := h.svc.ClearAgentHistory(c.Request.Context(), mustActor(c).ID, gardenID); err != nil {
writeServiceError(c, err)
return
}
c.Status(http.StatusNoContent)
}
// replayHistory turns stored text into model messages.
//
// Only the text is replayed — no stored tool calls. Continuity needs what was
// said and what came back; replaying a tool call would be replaying a decision
// made against a garden that has since moved on.
func replayHistory(msgs []domain.AgentMessage) []llm.Message {
out := make([]llm.Message, 0, len(msgs))
for _, m := range msgs {
if m.Role == domain.AgentRoleUser {
out = append(out, llm.UserText(m.Body))
} else {
out = append(out, llm.AssistantText(m.Body))
}
}
return out
}
func toolNames(s mdagent.Step) []string {
names := make([]string, 0, len(s.Results))
for _, r := range s.Results {
names = append(names, r.Name)
}
return names
}
// chatErrorMessage turns a failure into something worth reading. Permission
// errors in particular get named: "the agent broke" and "you can only view this
// garden" want very different reactions.
func chatErrorMessage(err error) string {
switch {
case errors.Is(err, domain.ErrForbidden):
return "You can only view this garden, so I can't change anything in it."
case errors.Is(err, domain.ErrNotFound):
return "I can't find that garden."
case errors.Is(err, domain.ErrInvalidInput):
return "I didn't get a message to work from."
case errors.Is(err, io.EOF), errors.Is(err, context.Canceled):
return "The connection dropped partway through. Anything I'd already changed is on the canvas, and in History."
case errors.Is(err, context.DeadlineExceeded):
return "That took too long and I stopped. Anything I'd already changed is on the canvas, and in History."
default:
slog.Error("api: agent run failed", "error", err)
return "Something went wrong talking to the model. Anything I'd already changed is on the canvas, and in History."
}
}
+89
View File
@@ -0,0 +1,89 @@
package api
import (
"context"
"log/slog"
"sync"
"sync/atomic"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agent"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// agentHolder owns the live assistant Runner and lets an admin swap it at
// runtime when the model settings change (#79).
//
// The Runner used to be a plain handlers field set once at boot, with the chat
// routes registered only when it existed. That made the assistant permanently
// whatever the environment said at startup. Now the routes are always
// registered and the Runner lives behind an atomic pointer, so a settings change
// can turn the assistant on, off, or onto a different model without a restart —
// and without a data race against in-flight requests reading the pointer.
//
// A nil pointer means "no assistant right now"; handlers nil-check get() rather
// than assuming a Runner is present.
type agentHolder struct {
svc *service.Service
ptr atomic.Pointer[agent.Runner]
// rebuildMu serializes rebuilds so two concurrent settings saves can't
// interleave into a torn "resolve A, resolve B, store A, store B" swap. The
// read path (get) stays lock-free on the atomic pointer.
rebuildMu sync.Mutex
}
// newAgentHolder builds the holder and resolves the initial Runner from whatever
// the settings + environment currently say. A resolution failure is logged and
// left as "no assistant", never fatal: a garden planner must still boot.
func newAgentHolder(ctx context.Context, svc *service.Service) *agentHolder {
h := &agentHolder{svc: svc}
h.rebuild(ctx)
return h
}
// get returns the current Runner, or nil if the assistant is off.
func (h *agentHolder) get() *agent.Runner { return h.ptr.Load() }
// rebuild resolves the effective agent configuration and swaps the Runner to
// match: a new one when the assistant should be on, nil when it shouldn't. It is
// safe to call at boot and from a settings save; concurrent calls serialize.
//
// It logs what it did rather than returning an error, because every caller wants
// the same thing — best-effort apply, keep serving either way — and a settings
// save must not fail just because the new model won't resolve. The save already
// validated the spec; a rebuild failure here means the environment changed under
// it, and "assistant off, with a reason in the log" is the right outcome.
func (h *agentHolder) rebuild(ctx context.Context) {
h.rebuildMu.Lock()
defer h.rebuildMu.Unlock()
eff, err := h.svc.EffectiveAgent(ctx)
if err != nil {
// EffectiveAgent errors only if the settings row can't be read — a DB fault,
// and a very transient one when it happens right after a settings write. We
// keep the current Runner rather than tear down a working assistant on a
// blip: the state is already persisted, so the next rebuild (any later save,
// or a restart) reconciles it. Loud, because a persistent failure here means
// the live assistant no longer matches stored settings.
slog.Error("api: could not resolve agent settings; leaving the assistant as-is", "error", err)
return
}
if !eff.Ready() {
if h.ptr.Swap(nil) != nil {
slog.Info("api: garden assistant turned off",
"enabled", eff.Enabled, "hasKey", eff.APIKey != "", "hasModel", eff.Model != "")
}
return
}
runner, err := agent.NewRunner(h.svc, eff.APIKey, eff.Model)
if err != nil {
// Configured but unusable. Turn the assistant off rather than leaving a
// stale Runner on the old model — an admin who just pointed it at a broken
// spec should see it stop, not silently keep answering on the previous one.
slog.Error("api: garden assistant disabled (model won't resolve)", "error", err, "model", eff.Model)
h.ptr.Store(nil)
return
}
h.ptr.Store(runner)
slog.Info("api: garden assistant ready", "model", eff.Model)
}
+49
View File
@@ -0,0 +1,49 @@
package api
import (
"net/http"
"strconv"
"testing"
)
// TestAgentDisabledWithoutAKey — an instance with no API key must start, serve
// the app, and not offer the assistant.
//
// The contract CHANGED with #79: the chat route is now always registered (so a
// settings change can turn the assistant on without a restart), so "off" is a
// runtime 503 rather than a missing route. capabilities reports agent:false, and
// the frontend keys the chat tab off that — so a user never reaches the 503.
func TestAgentDisabledWithoutAKey(t *testing.T) {
r := authEngine(t, localCfg()) // localCfg has no agent configuration
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
// Chat is refused, plainly, because there is no Runner to run.
w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
map[string]any{"gardenId": gid, "message": "plant garlic"}, cookie)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("chat without a key: status %d, want 503", w.Code)
}
// Capabilities advertises the assistant as unavailable, which is what the UI
// actually consults.
w = doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("capabilities: status %d", w.Code)
}
if agent, _ := decodeMap(t, w.Body.Bytes())["agent"].(bool); agent {
t.Error("capabilities reported agent:true with no key")
}
// History is just stored data behind the ordinary garden-role check, so it
// reads fine (empty) whether or not a Runner exists — it isn't gated on one.
path := "/api/v1/gardens/" + strconv.FormatInt(gid, 10) + "/agent/history"
if w := doJSON(t, r, http.MethodGet, path, nil, cookie); w.Code != http.StatusOK {
t.Errorf("history without a key: status %d, want 200 (it's data, not the model)", w.Code)
}
// And the rest of the app is entirely unaffected.
if w := doJSON(t, r, http.MethodGet, fullPath(gid), nil, cookie); w.Code != http.StatusOK {
t.Errorf("editor load: status %d, want 200 — an unconfigured agent must not break the app", w.Code)
}
}
+53
View File
@@ -6,6 +6,7 @@
package api
import (
"context"
"log/slog"
"net/http"
@@ -22,6 +23,12 @@ type handlers struct {
cfg *config.Config
svc *service.Service
oidc *oidcClient // nil unless OIDC is configured (see config.OIDCReady)
// agent holds the live Runner behind an atomic pointer. Unlike oidc it is
// never nil — the holder is always present and its Runner may be nil when the
// assistant is off. The chat routes are registered unconditionally and
// nil-check agent.get(), so a settings change can turn the assistant on or off
// at runtime (#79) rather than only at boot.
agent *agentHolder
}
// New builds the gin engine with the standard middleware stack and registers the
@@ -49,6 +56,10 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
// is set; see csrfGuard).
v1.Use(h.csrfGuard())
v1.GET("/healthz", healthz)
// What this instance can actually do, so the UI offers only what works.
// Registered after the agent below, because it reports whether the runner
// actually built — not merely whether it was configured to.
v1.GET("/capabilities", h.capabilities)
// Auth endpoints are exempt from requireAuth (you can't be logged in yet);
// /me is the one that needs a session. Feature routers in later issues attach
@@ -90,6 +101,7 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
// plop, so it inherits the ordinary garden-role check.
gardens.GET("/:id/journal", h.listJournal)
gardens.POST("/:id/journal", h.createJournalEntry)
gardens.GET("/:id/journal/counts", h.getJournalCounts)
// Sharing (owner-managed; a recipient may remove their own share).
gardens.GET("/:id/shares", h.listShares)
@@ -110,6 +122,11 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
objects.PATCH("/:id", h.updateObject)
objects.DELETE("/:id", h.deleteObject)
objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object
// Bulk ops. These wrap the same service methods the agent tools call, so an
// instance with no model configured still gets the most valuable operation in
// the app — and so "clear bed" is ONE change set rather than one per plop.
objects.POST("/:id/fill", h.fillObject)
objects.POST("/:id/clear", h.clearObject)
// Plantings ("plops") are addressed by their own id; the service resolves the
// owning object/garden for the permission check.
@@ -117,6 +134,31 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
plantings.PATCH("/:id", h.updatePlanting)
plantings.DELETE("/:id", h.deletePlanting)
// The garden assistant. Its routes are registered UNCONDITIONALLY and the live
// Runner sits behind an atomic pointer in the holder, so a settings change can
// turn the assistant on or off at runtime (#79). Each handler nil-checks
// agent.get(); a chat request while the assistant is off gets a clean 503
// (AGENT_DISABLED), not a panic and not a missing route.
//
// The holder resolves its initial Runner from settings + environment at
// construction. If the key never reaches the container, the assistant is off
// and the reason is logged below — the same operability need #72 added.
h.agent = newAgentHolder(context.Background(), svc)
if cfg.Agent.OllamaCloudAPIKey == "" {
slog.Info("api: garden assistant has no API key",
"hint", "set OLLAMA_CLOUD_API_KEY in the container's environment (not just the stack's); the model can be chosen in Settings")
}
agentGroup := v1.Group("/agent", h.requireAuth())
agentGroup.POST("/chat", h.agentChat)
gardens.GET("/:id/agent/history", h.getAgentHistory)
gardens.DELETE("/:id/agent/history", h.deleteAgentHistory)
// Instance settings: admin-only, and the first thing to enforce is_admin.
// requireAdmin runs after requireAuth (it reads the actor requireAuth stored).
settings := v1.Group("/settings", h.requireAuth(), h.requireAdmin())
settings.GET("", h.getSettings)
settings.PATCH("", h.updateSettings)
// Undo. A change set is addressed by its own id; the service resolves the
// owning garden for the permission check, same as objects and plantings.
changeSets := v1.Group("/change-sets", h.requireAuth())
@@ -154,6 +196,17 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
return r
}
// capabilities reports what this instance can actually do, so the UI offers only
// what works.
//
// It reports whether the assistant is live RIGHT NOW, not merely configured:
// the chat routes always exist, but a request while the Runner is nil is refused,
// so offering the tab must track the live Runner. Reading agent.get() (an atomic
// load) means this reflects a settings-driven swap on the very next poll.
func (h *handlers) capabilities(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"agent": h.agent.get() != nil})
}
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
func healthz(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
+21
View File
@@ -71,6 +71,27 @@ func (h *handlers) listJournal(c *gin.Context) {
c.JSON(http.StatusOK, journalResponse{Entries: entries, HasMore: hasMore})
}
// getJournalCounts powers the "there are notes about this bed" indicator, which
// has to work while the journal panel is closed — exactly when nothing else has
// loaded the entries. Key "0" is the garden itself.
func (h *handlers) getJournalCounts(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
return
}
counts, err := h.svc.JournalCounts(c.Request.Context(), mustActor(c).ID, gardenID)
if err != nil {
writeServiceError(c, err)
return
}
// JSON object keys are strings; the client parses them back to ids.
out := make(map[string]int, len(counts))
for id, n := range counts {
out[strconv.FormatInt(id, 10)] = n
}
c.JSON(http.StatusOK, gin.H{"counts": out})
}
func (h *handlers) createJournalEntry(c *gin.Context) {
gardenID, ok := parseIDParam(c, "id")
if !ok {
+43
View File
@@ -155,3 +155,46 @@ func TestJournalRequiresAccessAPI(t *testing.T) {
t.Errorf("stranger delete: status %d, want 404", w.Code)
}
}
// TestJournalCountsAPI — the indicator that makes the log discoverable has to
// work while the journal panel is closed, so it's its own endpoint.
func TestJournalCountsAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
gid := createGardenAPI(t, r, cookie, "G")
w := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
"kind": "bed", "name": "North Bed", "xCm": 100, "yCm": 100, "widthCm": 100, "heightCm": 100,
}, cookie)
objectID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
counts := func() map[string]any {
t.Helper()
w := doJSON(t, r, http.MethodGet, journalPath(gid)+"/counts", nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("counts: status %d, body %s", w.Code, w.Body.String())
}
c, _ := decodeMap(t, w.Body.Bytes())["counts"].(map[string]any)
return c
}
if len(counts()) != 0 {
t.Errorf("a garden with no entries should report no counts")
}
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"body": "garden note"}, cookie)
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"objectId": objectID, "body": "bed note"}, cookie)
doJSON(t, r, http.MethodPost, journalPath(gid), map[string]any{"objectId": objectID, "body": "another"}, cookie)
c := counts()
// Key "0" is the garden itself; anything else is an object id.
if c["0"] != float64(1) {
t.Errorf("garden-level count = %v, want 1", c["0"])
}
if c[strconv.FormatInt(objectID, 10)] != float64(2) {
t.Errorf("bed count = %v, want 2", c[strconv.FormatInt(objectID, 10)])
}
if w := doJSON(t, r, http.MethodGet, journalPath(gid)+"/counts", nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous counts: status %d, want 401", w.Code)
}
}
+116
View File
@@ -0,0 +1,116 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// Bulk operations on a plantable object (#82): fill a region with one plant, and
// clear everything out of it.
//
// These were reachable only through the agent toolbox until now, which meant the
// most valuable bulk operation in a garden planner — and the one carrying the
// most carefully reasoned geometry in the codebase — did not exist at all on an
// instance with no model configured. They are thin adapters over the same
// service methods `internal/agent/tools.go` calls, so the permission checks and
// the one-change-set-per-operation guarantee come along unchanged.
// fillRect is an explicit rectangle in the object's local frame, the alternative
// to a compass name. A named type (not an inline anonymous struct) to match the
// rest of internal/api and so it can carry its own validity check.
type fillRect struct {
MinX float64 `json:"minXCm"`
MinY float64 `json:"minYCm"`
MaxX float64 `json:"maxXCm"`
MaxY float64 `json:"maxYCm"`
}
// degenerate reports whether the rect encloses no area. Such a rect (including
// the all-zeros an empty `"rect": {}` decodes to) would otherwise slip through
// and plant a single plop at the object's centre — a surprising result for what
// is really malformed input.
func (r fillRect) degenerate() bool {
return r.MaxX <= r.MinX || r.MaxY <= r.MinY
}
// objectFillRequest is the body for POST /objects/:id/fill.
//
// A region is given EITHER by compass name ("ne", "south half", "all") or as an
// explicit rect in the object's local frame. The named form is what a person
// means and what the agent uses; the rect is for a future drag-a-box affordance.
// Exactly one must be supplied — accepting both and silently preferring one
// would make a client bug look like a geometry bug.
type objectFillRequest struct {
PlantID int64 `json:"plantId" binding:"required"`
Region string `json:"region"`
Rect *fillRect `json:"rect"`
// SpacingOverrideCM plants tighter or looser than the plant's mature spacing
// without editing the catalog entry.
SpacingOverrideCM *float64 `json:"spacingOverrideCm"`
}
func (h *handlers) fillObject(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
var req objectFillRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a plantId and a region are required")
return
}
named, hasRect := req.Region != "", req.Rect != nil
if named == hasRect {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT",
`supply exactly one of "region" (e.g. "all", "ne", "south half") or "rect"`)
return
}
actor := mustActor(c).ID
var (
created []domain.Planting
err error
)
if rect := req.Rect; rect != nil {
// Reject a zero-area rect here rather than let it plant one stray plop.
// (Binding the pointer to `rect` also keeps the deref visibly guarded,
// instead of reading req.Rect.MinX under an invariant from a line above.)
if rect.degenerate() {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "rect must enclose a positive area")
return
}
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM)
} else {
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM)
}
if err != nil {
writeServiceError(c, err)
return
}
// 200, not 201: a fill can legitimately create nothing (the region is already
// planted), and there is no single resource to point a Location at.
c.JSON(http.StatusOK, gin.H{"plantings": created, "created": len(created)})
}
// clearObject soft-removes every active plop in an object.
//
// Distinct from deleting the object, and — unlike the client-side loop this
// replaces — it lands as ONE change set, so undoing a cleared bed is one click
// rather than one per plop.
func (h *handlers) clearObject(c *gin.Context) {
id, ok := parseIDParam(c, "id")
if !ok {
return
}
n, err := h.svc.ClearObject(c.Request.Context(), mustActor(c).ID, id)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"cleared": n})
}
+226
View File
@@ -0,0 +1,226 @@
package api
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
)
func fillPath(id int64) string { return objectPath(id) + "/fill" }
func clearPath(id int64) string { return objectPath(id) + "/clear" }
// makeFillPlant creates a custom plant and returns its id. (A near-identical
// createPlantAPI landed alongside the seed-lot tests; consolidating the two into
// one shared helper is a fine follow-up, kept separate here only to avoid a
// merge collision on the shared symbol.)
func makeFillPlant(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
t.Helper()
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
}
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
}
// seedFillableBed makes a garden with one plantable bed and a custom plant,
// returning (gardenID, objectID, plantID).
func seedFillableBed(t *testing.T, r *gin.Engine, cookie *http.Cookie, w, h, spacing float64) (int64, int64, int64) {
t.Helper()
gid := createGardenAPI(t, r, cookie, "G")
rec := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
"kind": "bed", "widthCm": w, "heightCm": h, "plantable": true,
}, cookie)
if rec.Code != http.StatusCreated {
t.Fatalf("create bed: status %d, body %s", rec.Code, rec.Body.String())
}
objID := int64(decodeMap(t, rec.Body.Bytes())["id"].(float64))
plantID := makeFillPlant(t, r, cookie, "Fillable", spacing)
return gid, objID, plantID
}
// countChangeSets reads the history page and reports how many change sets exist.
func countChangeSets(t *testing.T, r *gin.Engine, cookie *http.Cookie, gardenID int64) int {
t.Helper()
w := doJSON(t, r, http.MethodGet, historyPath(gardenID), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("history: status %d, body %s", w.Code, w.Body.String())
}
sets, _ := decodeMap(t, w.Body.Bytes())["changeSets"].([]any)
return len(sets)
}
// TestFillAndClearAPI covers the two routes end to end through the router.
//
// These exist because both operations were previously reachable ONLY through the
// agent toolbox, so on an instance with no model configured the most valuable
// bulk operation in the app did not exist at all.
func TestFillAndClearAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
_, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
// Fill by compass name.
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
"plantId": plantID, "region": "all",
}, cookie)
if w.Code != http.StatusOK {
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
}
body := decodeMap(t, w.Body.Bytes())
created := int(body["created"].(float64))
if created == 0 {
t.Fatalf("fill created nothing: %s", w.Body.String())
}
if plops, _ := body["plantings"].([]any); len(plops) != created {
t.Errorf("created=%d but returned %d plantings", created, len(plops))
}
// Clear it: one call, and it reports what it removed.
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("clear: status %d, body %s", w.Code, w.Body.String())
}
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != created {
t.Errorf("cleared %d, want %d (everything the fill made)", n, created)
}
// Clearing an already-empty bed is a no-op, not an error.
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("second clear: status %d", w.Code)
}
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != 0 {
t.Errorf("second clear removed %d, want 0", n)
}
}
// TestFillRegionSelectionAPI: exactly one of region/rect, and a rect fills only
// its own corner of the bed.
func TestFillRegionSelectionAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
_, objID, plantID := seedFillableBed(t, r, cookie, 400, 400, 20)
// Neither → 400. Both → 400. Accepting both and silently preferring one
// would make a client bug look like a geometry bug.
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{"plantId": plantID}, cookie); w.Code != http.StatusBadRequest {
t.Errorf("no region = %d, want 400", w.Code)
}
both := map[string]any{
"plantId": plantID, "region": "all",
"rect": map[string]any{"minXCm": -50, "minYCm": -50, "maxXCm": 50, "maxYCm": 50},
}
if w := doJSON(t, r, http.MethodPost, fillPath(objID), both, cookie); w.Code != http.StatusBadRequest {
t.Errorf("both region and rect = %d, want 400", w.Code)
}
// An unknown compass name is rejected rather than silently filling nothing.
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
"plantId": plantID, "region": "middle-ish",
}, cookie); w.Code != http.StatusBadRequest {
t.Errorf("bad region name = %d, want 400", w.Code)
}
// A zero-area rect is malformed input, not "plant one at the centre". An empty
// `"rect": {}` decodes to all-zeros and must be caught the same way.
for _, rect := range []map[string]any{
{}, // {} → 0,0,0,0
{"minXCm": 10, "minYCm": 10, "maxXCm": 10, "maxYCm": 50}, // zero width
{"minXCm": 10, "minYCm": 50, "maxXCm": 50, "maxYCm": 50}, // zero height
{"minXCm": 50, "minYCm": 50, "maxXCm": 10, "maxYCm": 10}, // inverted
} {
if w := doJSON(t, r, http.MethodPost, fillPath(objID),
map[string]any{"plantId": plantID, "rect": rect}, cookie); w.Code != http.StatusBadRequest {
t.Errorf("degenerate rect %v = %d, want 400", rect, w.Code)
}
}
// A rect confined to the NE corner produces plops only there. Local frame:
// +x east, -y north.
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
"plantId": plantID,
"rect": map[string]any{"minXCm": 0, "minYCm": -200, "maxXCm": 200, "maxYCm": 0},
}, cookie)
if w.Code != http.StatusOK {
t.Fatalf("rect fill: status %d, body %s", w.Code, w.Body.String())
}
plops, _ := decodeMap(t, w.Body.Bytes())["plantings"].([]any)
if len(plops) == 0 {
t.Fatal("rect fill created nothing")
}
for _, raw := range plops {
p := raw.(map[string]any)
if x, y := p["xCm"].(float64), p["yCm"].(float64); x < 0 || y > 0 {
t.Errorf("plop at (%v,%v) outside the NE rect", x, y)
}
}
}
// TestClearObjectIsOneChangeSetAPI is the regression test for the behaviour this
// endpoint exists to restore.
//
// The UI used to clear a bed with a loop of PATCHes, and since every service
// mutation auto-scopes its own change set, clearing a 40-plop bed wrote 40 of
// them — 40 presses of Undo to put the bed back. CLAUDE.md states the rule
// directly: multi-row operations record together so they undo as one unit.
func TestClearObjectIsOneChangeSetAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
gid, objID, plantID := seedFillableBed(t, r, cookie, 300, 300, 20)
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
"plantId": plantID, "region": "all",
}, cookie)
if w.Code != http.StatusOK {
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
}
created := int(decodeMap(t, w.Body.Bytes())["created"].(float64))
if created < 4 {
t.Fatalf("need several plops to make this meaningful, got %d", created)
}
before := countChangeSets(t, r, cookie, gid)
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
t.Fatalf("clear: status %d", w.Code)
}
if after := countChangeSets(t, r, cookie, gid); after != before+1 {
t.Errorf("clearing %d plops added %d change sets, want exactly 1", created, after-before)
}
}
// TestFillClearPermissionsAPI: a viewer may look but not fill or clear, and a
// stranger gets 404 because existence is masked.
func TestFillClearPermissionsAPI(t *testing.T) {
r := authEngine(t, localCfg())
owner := registerAndCookie(t, r, "[email protected]")
viewer := registerAndCookie(t, r, "[email protected]")
stranger := registerAndCookie(t, r, "[email protected]")
gid, objID, plantID := seedFillableBed(t, r, owner, 200, 200, 20)
if w := doJSON(t, r, http.MethodPost, sharesPath(gid),
map[string]any{"email": "[email protected]", "role": "viewer"}, owner); w.Code != http.StatusCreated {
t.Fatalf("share as viewer: status %d, body %s", w.Code, w.Body.String())
}
fillBody := map[string]any{"plantId": plantID, "region": "all"}
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, viewer); w.Code != http.StatusForbidden {
t.Errorf("viewer fill = %d, want 403 (they can see it but may not do that)", w.Code)
}
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, viewer); w.Code != http.StatusForbidden {
t.Errorf("viewer clear = %d, want 403", w.Code)
}
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, stranger); w.Code != http.StatusNotFound {
t.Errorf("stranger fill = %d, want 404 (existence masked)", w.Code)
}
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, stranger); w.Code != http.StatusNotFound {
t.Errorf("stranger clear = %d, want 404", w.Code)
}
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous fill = %d, want 401", w.Code)
}
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous clear = %d, want 401", w.Code)
}
}
+264
View File
@@ -0,0 +1,264 @@
package api
import (
"encoding/json"
"net/http"
"strconv"
"testing"
"github.com/gin-gonic/gin"
)
func seedLotPath(id int64) string {
return "/api/v1/seed-lots/" + strconv.FormatInt(id, 10)
}
// decodeList decodes a bare JSON array body. Seed lot listing returns the array
// directly rather than wrapping it (unlike /journal's {"entries": …}), so a
// helper that assumed an object would quietly read nothing.
func decodeList(t *testing.T, body []byte) []any {
t.Helper()
var out []any
if err := json.Unmarshal(body, &out); err != nil {
t.Fatalf("decode list: %v (%s)", err, body)
}
return out
}
// createPlantAPI makes a custom plant and returns its id.
func createPlantAPI(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
t.Helper()
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
}
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
}
// TestSeedLotCrudAPI walks the whole seed lot lifecycle over HTTP.
//
// It exists for the reason CLAUDE.md gives: service tests cannot see a route
// that was never registered, or one registered with the wrong :param name. Every
// other handler file had a sibling API test; this group did not, which is the
// state PATCH/DELETE /journal/:id shipped in — implemented, unit-tested, and
// completely unreachable.
func TestSeedLotCrudAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
plantID := createPlantAPI(t, r, cookie, "Music Garlic", 15)
// Create.
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
"plantId": plantID, "vendor": "Johnny's", "sku": "2761",
"quantity": 100, "unit": "seeds", "packedForYear": 2026, "costCents": 495,
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create: status %d, body %s", w.Code, w.Body.String())
}
lot := decodeMap(t, w.Body.Bytes())
id := int64(lot["id"].(float64))
if lot["vendor"] != "Johnny's" || lot["unit"] != "seeds" {
t.Errorf("unexpected lot: %+v", lot)
}
// GET by id — the route most likely to be missing or mis-registered.
w = doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
}
if got := decodeMap(t, w.Body.Bytes()); int64(got["id"].(float64)) != id {
t.Errorf("get returned id %v, want %d", got["id"], id)
}
// List, and the ?plantId= filter.
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("list: status %d, body %s", w.Code, w.Body.String())
}
if n := len(decodeList(t, w.Body.Bytes())); n != 1 {
t.Fatalf("list returned %d lots, want 1: %s", n, w.Body.String())
}
other := createPlantAPI(t, r, cookie, "Cherokee Purple", 45)
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+strconv.FormatInt(other, 10), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("filtered list: status %d, body %s", w.Code, w.Body.String())
}
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
t.Errorf("filter by a plant with no lots returned %d, want 0", n)
}
// A bad plantId filter is 400, whether non-numeric or out of range — the
// handler rejects id < 1, not just unparseable strings.
for _, bad := range []string{"nope", "0", "-1"} {
if w := doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+bad, nil, cookie); w.Code != http.StatusBadRequest {
t.Errorf("plantId=%q filter = %d, want 400", bad, w.Code)
}
}
// PATCH with the current version.
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
"vendor": "Fedco", "version": lot["version"],
}, cookie)
if w.Code != http.StatusOK {
t.Fatalf("patch: status %d, body %s", w.Code, w.Body.String())
}
updated := decodeMap(t, w.Body.Bytes())
if updated["vendor"] != "Fedco" {
t.Errorf("vendor = %v, want Fedco", updated["vendor"])
}
if updated["version"].(float64) != lot["version"].(float64)+1 {
t.Errorf("patch didn't bump version: %v -> %v", lot["version"], updated["version"])
}
// A stale version conflicts and carries the current row back, so the client
// can rebase without a second request.
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
"vendor": "stale", "version": lot["version"],
}, cookie)
if w.Code != http.StatusConflict {
t.Fatalf("stale patch: status %d, want 409", w.Code)
}
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["vendor"] != "Fedco" {
t.Errorf("409 body missing the current row: %s", w.Body.String())
}
// DELETE.
if w := doJSON(t, r, http.MethodDelete, seedLotPath(id), nil, cookie); w.Code != http.StatusNoContent {
t.Fatalf("delete: status %d, body %s", w.Code, w.Body.String())
}
if w := doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie); w.Code != http.StatusNotFound {
t.Errorf("get after delete = %d, want 404", w.Code)
}
}
// TestSeedLotRemainingIsDerivedAPI checks that `remaining` reflects plantings
// through the HTTP surface, not just in the service.
//
// DESIGN.md makes derivation load-bearing — "a decremented column drifts the
// moment a planting is edited behind its back" — so a route that returned a
// stored or stale figure would break the invariant silently, and the number is
// the whole reason anyone opens the seed shelf.
func TestSeedLotRemainingIsDerivedAPI(t *testing.T) {
r := authEngine(t, localCfg())
cookie := registerAndCookie(t, r, "[email protected]")
plantID := createPlantAPI(t, r, cookie, "Beans", 10)
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
"plantId": plantID, "quantity": 50, "unit": "seeds",
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create lot: status %d, body %s", w.Code, w.Body.String())
}
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
gid := createGardenAPI(t, r, cookie, "G")
w = doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
"kind": "bed", "widthCm": 100, "heightCm": 100, "plantable": true,
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create object: status %d, body %s", w.Code, w.Body.String())
}
oid := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
// Plant 12 of them against the lot.
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
"plantId": plantID, "xCm": 0, "yCm": 0, "radiusCm": 20, "count": 12, "seedLotId": lotID,
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("create planting: status %d, body %s", w.Code, w.Body.String())
}
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("get lot: status %d, body %s", w.Code, w.Body.String())
}
got := decodeMap(t, w.Body.Bytes())
if rem, ok := got["remaining"].(float64); !ok || rem != 38 {
t.Errorf("remaining = %v, want 38 (50 bought - 12 planted): %s", got["remaining"], w.Body.String())
}
// Over-plant it: 45 more (57 total against 50 bought) drives remaining
// NEGATIVE. That's deliberate — the number is a derived truth about what
// you've committed, not a floor clamped at zero, and "you've planted more than
// you bought" is exactly the signal a gardener wants rather than a hidden -7.
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
"plantId": plantID, "xCm": 40, "yCm": 40, "radiusCm": 20, "count": 45, "seedLotId": lotID,
}, cookie)
if w.Code != http.StatusCreated {
t.Fatalf("over-plant: status %d, body %s", w.Code, w.Body.String())
}
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
if rem, ok := decodeMap(t, w.Body.Bytes())["remaining"].(float64); !ok || rem != -7 {
t.Errorf("remaining after over-planting = %v, want -7 (50 - 57)", rem)
}
}
// TestSeedLotsArePrivateAPI checks the ACL through the router.
//
// Lots are private to the buyer and deliberately never travel with a shared
// garden, so another user must not be able to read or edit one. Per the
// project's convention, no-access is ErrNotFound rather than ErrForbidden —
// existence is masked — so every one of these is a 404, not a 403.
func TestSeedLotsArePrivateAPI(t *testing.T) {
r := authEngine(t, localCfg())
alice := registerAndCookie(t, r, "[email protected]")
bob := registerAndCookie(t, r, "[email protected]")
plantID := createPlantAPI(t, r, alice, "Alice's garlic", 15)
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
"plantId": plantID, "vendor": "Secret Vendor", "quantity": 10, "unit": "bulbs",
}, alice)
if w.Code != http.StatusCreated {
t.Fatalf("alice create: status %d, body %s", w.Code, w.Body.String())
}
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
for _, tc := range []struct {
name string
method string
body any
}{
{"get", http.MethodGet, nil},
{"patch", http.MethodPatch, map[string]any{"vendor": "hijacked", "version": 1}},
{"delete", http.MethodDelete, nil},
} {
if w := doJSON(t, r, tc.method, seedLotPath(lotID), tc.body, bob); w.Code != http.StatusNotFound {
t.Errorf("bob %s = %d, want 404 (existence is masked)", tc.name, w.Code)
}
}
// Bob's own listing must not include it either.
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, bob)
if w.Code != http.StatusOK {
t.Fatalf("bob list: status %d", w.Code)
}
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
t.Errorf("bob sees %d of alice's lots, want 0: %s", n, w.Body.String())
}
// And it's still intact for alice.
if w := doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, alice); w.Code != http.StatusOK {
t.Errorf("alice lost access to her own lot: %d", w.Code)
}
}
// TestSeedLotsRequireAuthAPI: the group is behind requireAuth, and an
// unauthenticated caller gets 401 rather than an empty list.
func TestSeedLotsRequireAuthAPI(t *testing.T) {
r := authEngine(t, localCfg())
for _, tc := range []struct {
method, path string
}{
{http.MethodGet, "/api/v1/seed-lots"},
{http.MethodPost, "/api/v1/seed-lots"},
{http.MethodGet, seedLotPath(1)},
{http.MethodPatch, seedLotPath(1)},
{http.MethodDelete, seedLotPath(1)},
} {
if w := doJSON(t, r, tc.method, tc.path, nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("%s %s = %d, want 401", tc.method, tc.path, w.Code)
}
}
}
+143
View File
@@ -0,0 +1,143 @@
package api
import (
"context"
"encoding/json"
"errors"
"net/http"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// Instance settings (#79): admin-only, instance-wide. The authoritative admin
// check is in the service; requireAdmin here is a cheap early 403 that also
// keeps the route group readable.
// requireAdmin rejects a non-admin actor. It runs after requireAuth, so the
// actor is already resolved and carries IsAdmin — no extra query. Returns 403
// (not 404): a logged-in user knows settings exist, they just may not touch them.
func (h *handlers) requireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
if !mustActor(c).IsAdmin {
writeAPIError(c, http.StatusForbidden, "FORBIDDEN", "admin access required")
c.Abort()
return
}
c.Next()
}
}
// settingsResponse is what GET/PATCH /settings return. It carries the stored
// settings plus a read-only view of what's resolved and live, so the UI can show
// "inheriting ollama-cloud/glm-5.2:cloud from the environment" and whether a key
// is present — without ever exposing the key itself.
type settingsResponse struct {
Settings *domain.InstanceSettings `json:"settings"`
// Effective is the configuration actually in force after layering settings
// over the environment.
Effective effectiveView `json:"effective"`
}
type effectiveView struct {
Model string `json:"model"`
Enabled bool `json:"enabled"`
// HasApiKey reports whether OLLAMA_CLOUD_API_KEY is set. The key itself is
// never serialized — an admin may know one exists, not what it is.
HasApiKey bool `json:"hasApiKey"`
// AgentLive is whether the assistant Runner is actually built right now. It
// can be false even when Enabled+HasApiKey are true (an unresolvable model),
// which is exactly the case the UI needs to surface.
AgentLive bool `json:"agentLive"`
}
// settingsPayload builds the response, or an error. It does NOT swallow an
// EffectiveAgent failure into a misleading empty "effective" view — an empty
// view would report no model and no key, which reads as "nothing configured"
// rather than "we couldn't read it". Since EffectiveAgent re-reads the same row
// GetInstanceSettings just returned, a failure here is a genuine DB fault worth
// surfacing as a 500, not papering over.
func (h *handlers) settingsPayload(c *gin.Context, st *domain.InstanceSettings) (settingsResponse, error) {
eff, err := h.svc.EffectiveAgent(c.Request.Context())
if err != nil {
return settingsResponse{}, err
}
return settingsResponse{
Settings: st,
Effective: effectiveView{
Model: eff.Model,
Enabled: eff.Enabled,
HasApiKey: eff.APIKey != "",
AgentLive: h.agent.get() != nil,
},
}, nil
}
func (h *handlers) getSettings(c *gin.Context) {
st, err := h.svc.GetInstanceSettings(c.Request.Context(), mustActor(c).ID)
if err != nil {
writeServiceError(c, err)
return
}
payload, err := h.settingsPayload(c, st)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, payload)
}
// settingsUpdateRequest is the PATCH body. agentModel "" means inherit the env
// var. agentEnabled is json.RawMessage so an explicit null (inherit) is
// distinguishable from an absent field and from true/false.
type settingsUpdateRequest struct {
AgentModel string `json:"agentModel"`
AgentEnabled json.RawMessage `json:"agentEnabled"`
Version int64 `json:"version" binding:"required"`
}
func (h *handlers) updateSettings(c *gin.Context) {
var req settingsUpdateRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a current version is required")
return
}
// agentEnabled: absent or null → inherit (nil); true/false → explicit override.
// The shared parseNullable does exactly this three-way decode; present is
// irrelevant here because absent and null both mean "inherit".
enabled, _, err := parseNullable[bool](req.AgentEnabled)
if err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "agentEnabled must be true, false, or null")
return
}
st, err := h.svc.UpdateInstanceSettings(c.Request.Context(), mustActor(c).ID, service.InstanceSettingsPatch{
AgentModel: req.AgentModel,
AgentEnabled: enabled,
Version: req.Version,
})
if err != nil {
if errors.Is(err, domain.ErrVersionConflict) {
writeVersionConflict(c, st)
return
}
writeServiceError(c, err)
return
}
// Apply the change to the LIVE assistant. Detached from the request context:
// the write is committed and the rebuild describes it, so a client that hangs
// up now must not leave the running Runner out of step with the stored
// settings. Mirrors the same reasoning as the history-write detachment.
h.agent.rebuild(context.WithoutCancel(c.Request.Context()))
payload, err := h.settingsPayload(c, st)
if err != nil {
writeServiceError(c, err)
return
}
c.JSON(http.StatusOK, payload)
}
+208
View File
@@ -0,0 +1,208 @@
package api
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
)
// agentCfg is a config with the assistant configured — a (fake) key, on, and a
// resolvable model. The key isn't real, but ValidateAgentModel/NewRunner only
// PARSE the spec (no live call), so a Runner still builds and capabilities
// reports it live. That's enough to exercise the runtime on/off swap.
func agentCfg() *config.Config {
c := localCfg()
c.Agent = config.AgentConfig{
Model: "ollama-cloud/glm-5.2:cloud",
OllamaCloudAPIKey: "test-key",
Enabled: true,
}
return c
}
func settingsVersion(t *testing.T, r *gin.Engine, cookie *http.Cookie) int64 {
t.Helper()
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, cookie)
if w.Code != http.StatusOK {
t.Fatalf("get settings: status %d, body %s", w.Code, w.Body.String())
}
st, _ := decodeMap(t, w.Body.Bytes())["settings"].(map[string]any)
return int64(st["version"].(float64))
}
// TestSettingsAdminOnly: the first registered user is admin and can read/write
// settings; a second user is not and gets 403 (not 404 — settings aren't a
// masked resource).
func TestSettingsAdminOnly(t *testing.T) {
r := authEngine(t, localCfg())
admin := registerAndCookie(t, r, "[email protected]") // first user → admin
member := registerAndCookie(t, r, "[email protected]")
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin); w.Code != http.StatusOK {
t.Fatalf("admin GET settings: status %d, body %s", w.Code, w.Body.String())
}
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, member); w.Code != http.StatusForbidden {
t.Errorf("member GET settings: status %d, want 403", w.Code)
}
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "x", "version": 1}, member); w.Code != http.StatusForbidden {
t.Errorf("member PATCH settings: status %d, want 403", w.Code)
}
// Unauthenticated is 401, before the admin check.
if w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, nil); w.Code != http.StatusUnauthorized {
t.Errorf("anonymous GET settings: status %d, want 401", w.Code)
}
}
// TestSettingsInheritFromEnv: an untouched instance reports the env model as
// effective, and an empty stored model keeps inheriting it.
func TestSettingsInheritFromEnv(t *testing.T) {
r := authEngine(t, agentCfg())
admin := registerAndCookie(t, r, "[email protected]")
w := doJSON(t, r, http.MethodGet, "/api/v1/settings", nil, admin)
if w.Code != http.StatusOK {
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
}
body := decodeMap(t, w.Body.Bytes())
st := body["settings"].(map[string]any)
eff := body["effective"].(map[string]any)
if st["agentModel"] != "" {
t.Errorf("stored model = %v, want empty (inherit)", st["agentModel"])
}
if eff["model"] != "ollama-cloud/glm-5.2:cloud" {
t.Errorf("effective model = %v, want the env value", eff["model"])
}
if eff["hasApiKey"] != true || eff["agentLive"] != true {
t.Errorf("effective = %+v, want a key present and the agent live", eff)
}
}
// TestSettingsUpdateSwapsTheRunner is the core of #79: changing settings takes
// effect on the LIVE assistant, with no restart. Driven entirely through HTTP.
func TestSettingsUpdateSwapsTheRunner(t *testing.T) {
r := authEngine(t, agentCfg())
admin := registerAndCookie(t, r, "[email protected]")
capsAgent := func() bool {
w := doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
return decodeMap(t, w.Body.Bytes())["agent"] == true
}
// Configured and on out of the box.
if !capsAgent() {
t.Fatal("assistant should be live at boot with a key + enabled")
}
// Turn it OFF via settings → capabilities flips immediately.
v := settingsVersion(t, r, admin)
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin)
if w.Code != http.StatusOK {
t.Fatalf("disable: status %d, body %s", w.Code, w.Body.String())
}
if capsAgent() {
t.Error("assistant still live after being disabled — the runner wasn't swapped")
}
// Chat now refuses, at runtime, on a route that still exists.
gid := createGardenAPI(t, r, admin, "G")
if w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
map[string]any{"gardenId": gid, "message": "hi"}, admin); w.Code != http.StatusServiceUnavailable {
t.Errorf("chat while disabled: status %d, want 503", w.Code)
}
// Turn it back ON, with an explicit model, and confirm it's live again and the
// effective model reflects the change.
v = settingsVersion(t, r, admin)
w = doJSON(t, r, http.MethodPatch, "/api/v1/settings", map[string]any{
"agentModel": "ollama-cloud/kimi-k2.6:cloud", "agentEnabled": true, "version": v,
}, admin)
if w.Code != http.StatusOK {
t.Fatalf("re-enable: status %d, body %s", w.Code, w.Body.String())
}
if eff := decodeMap(t, w.Body.Bytes())["effective"].(map[string]any); eff["model"] != "ollama-cloud/kimi-k2.6:cloud" {
t.Errorf("effective model = %v after change, want the new one", eff["model"])
}
if !capsAgent() {
t.Error("assistant not live after being re-enabled")
}
}
// TestSettingsRejectsBadModel: a spec that won't resolve is a 400 at save time,
// not a broken assistant on the next turn.
func TestSettingsRejectsBadModel(t *testing.T) {
r := authEngine(t, agentCfg())
admin := registerAndCookie(t, r, "[email protected]")
v := settingsVersion(t, r, admin)
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest {
t.Errorf("bad model: status %d, want 400", w.Code)
}
// agentEnabled must be a bool or null, not a string.
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
t.Errorf("string agentEnabled: status %d, want 400", w.Code)
}
}
// TestSettingsVersionConflict: a stale version 409s and carries the current row.
func TestSettingsVersionConflict(t *testing.T) {
r := authEngine(t, agentCfg())
admin := registerAndCookie(t, r, "[email protected]")
v := settingsVersion(t, r, admin)
// First write succeeds and bumps the version.
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "", "agentEnabled": false, "version": v}, admin); w.Code != http.StatusOK {
t.Fatalf("first update: status %d, body %s", w.Code, w.Body.String())
}
// Reusing the old version conflicts.
w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "", "agentEnabled": true, "version": v}, admin)
if w.Code != http.StatusConflict {
t.Fatalf("stale update: status %d, want 409", w.Code)
}
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["version"].(float64) != float64(v+1) {
t.Errorf("409 body missing the current row at the bumped version: %s", w.Body.String())
}
}
// TestSettingsSwapUnderRace runs settings saves concurrently with chat requests,
// so `go test -race` proves the atomic swap of the live Runner is safe against
// in-flight readers. The whole point of the atomic.Pointer is this: without it,
// toggling the assistant while a request reads it is a data race.
func TestSettingsSwapUnderRace(t *testing.T) {
r := authEngine(t, agentCfg())
admin := registerAndCookie(t, r, "[email protected]")
done := make(chan struct{})
// Readers hammer capabilities, whose h.agent.get() is the SAME atomic Load the
// chat handler does — so this races the pointer read against the writer's swap
// without ever invoking the model (a real Run would hit the network on a fake
// key). If get() is race-clean here it is race-clean in chat.
for i := 0; i < 4; i++ {
go func() {
for {
select {
case <-done:
return
default:
doJSON(t, r, http.MethodGet, "/api/v1/capabilities", nil, admin)
}
}
}()
}
// Writer: flip the assistant on and off, swapping the pointer each time.
for i := 0; i < 12; i++ {
v := settingsVersion(t, r, admin)
doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "", "agentEnabled": i%2 == 0, "version": v}, admin)
}
close(done)
}
+101
View File
@@ -0,0 +1,101 @@
package api
import (
"bufio"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
// streamFrames spins up a real http.Server with the given WriteTimeout and an
// SSE handler that emits `frames` data frames, one every `tick`, then returns.
// It reports how many frames the client actually received and any read error —
// the only vantage point from which the deadline failures in #78/#87 are
// visible, since the writes themselves return nil when the bytes are dropped.
func streamFrames(t *testing.T, serverWriteTimeout, tick time.Duration, frames int) (int, error) {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.GET("/stream", func(c *gin.Context) {
s := openEventStream(c)
for i := 0; i < frames; i++ {
time.Sleep(tick)
s.send(chatEvent{Error: "frame"})
}
})
srv := httptest.NewUnstartedServer(r)
srv.Config.WriteTimeout = serverWriteTimeout
srv.Start()
defer srv.Close()
resp, err := srv.Client().Get(srv.URL + "/stream")
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
got := 0
sc := bufio.NewScanner(resp.Body)
for sc.Scan() {
if strings.HasPrefix(sc.Text(), "data: ") {
got++
}
}
return got, sc.Err()
}
// TestEventStreamOutlivesServerWriteTimeout is the regression test for #78.
//
// http.Server.WriteTimeout is an ABSOLUTE deadline measured from when the
// request header was read — not an idle timeout — so a streaming response is cut
// once it passes, however recently the handler wrote. pansy sets it to 30s while
// an agent turn may run for minutes. openEventStream must override it.
//
// This has to be asserted from the CLIENT side because the failure cannot be
// observed from the handler: writes made after the deadline return err == nil
// and their bytes are silently discarded. A test that checked the return of
// io.WriteString would pass against the bug.
func TestEventStreamOutlivesServerWriteTimeout(t *testing.T) {
// sseWriteTimeout stays at its 30s default here, so the per-frame refresh
// keeps the stream alive with a huge margin — CI slowness only ever makes
// this pass more surely. The server's 300ms WriteTimeout is the thing being
// overridden; frames straddle it (300ms/600ms/900ms).
got, err := streamFrames(t, 300*time.Millisecond, 300*time.Millisecond, 3)
if err != nil {
t.Errorf("client read error after %d/3 frames: %v", got, err)
}
if got != 3 {
t.Errorf("client received %d frames, want 3 — the stream was cut at the server WriteTimeout", got)
}
}
// TestEventStreamRefreshesDeadlinePerFrame guards the #87 fix specifically: the
// write deadline is refreshed on EVERY frame, not set once.
//
// A set-once deadline is a plausible "simplification" and it reintroduces the
// unbounded-block risk the per-frame refresh exists to prevent — yet it would
// sail through the test above, whose whole run is far under sseWriteTimeout. So
// shrink sseWriteTimeout below the stream's total duration and send frames whose
// gap stays comfortably under it: per-frame refresh delivers them all, while a
// deadline set once at open would expire mid-stream and cut it short.
func TestEventStreamRefreshesDeadlinePerFrame(t *testing.T) {
orig := sseWriteTimeout
sseWriteTimeout = 400 * time.Millisecond
t.Cleanup(func() { sseWriteTimeout = orig })
// The server WriteTimeout is generous (5s), so it isn't the limiter — the
// per-frame sseWriteTimeout is. 8 frames at a 100ms tick span 800ms, well past
// the 400ms deadline, but each 100ms gap is a 4× margin under it.
got, err := streamFrames(t, 5*time.Second, 100*time.Millisecond, 8)
if err != nil {
t.Errorf("client read error after %d/8 frames: %v", got, err)
}
if got != 8 {
t.Errorf("client received %d frames, want 8 — a set-once deadline would cut the stream at ~%v; the refresh must be per-frame",
got, sseWriteTimeout)
}
}
+35
View File
@@ -19,6 +19,9 @@ const (
RegistrationClosed = "closed"
)
// DefaultAgentModel is the assistant's model when PANSY_AGENT_MODEL is unset.
const DefaultAgentModel = "ollama-cloud/glm-5.2:cloud"
// Config is the fully-resolved runtime configuration.
type Config struct {
// Port is the TCP port the HTTP server listens on (PANSY_PORT, default 8080).
@@ -37,6 +40,8 @@ type Config struct {
LocalAuth bool
// OIDC holds the optional OpenID Connect provider settings.
OIDC OIDCConfig
// Agent holds the garden-assistant settings.
Agent AgentConfig
// TrustedProxies is the set of proxy CIDRs/IPs gin trusts for client IP
// resolution (PANSY_TRUSTED_PROXIES, comma-separated). Empty trusts none.
TrustedProxies []string
@@ -51,6 +56,26 @@ type OIDCConfig struct {
ButtonLabel string // PANSY_OIDC_BUTTON_LABEL — login-page button text
}
// AgentConfig holds the garden assistant's settings.
type AgentConfig struct {
// Model is the majordomo model spec, passed VERBATIM to majordomo.Parse
// (PANSY_AGENT_MODEL). The grammar is majordomo's, not pansy's — parsing or
// validating it here would only mean two places to update when it grows. A
// comma-separated spec is a failover chain, so
// "ollama-cloud/glm-5.2:cloud,ollama-cloud/kimi-k2.6:cloud" gets a fallback
// for free.
Model string
// OllamaCloudAPIKey authenticates against Ollama Cloud
// (OLLAMA_CLOUD_API_KEY — the same secret name gadfly uses, which is why
// it isn't majordomo's own OLLAMA_API_KEY; pansy passes it explicitly rather
// than relying on ambient environment).
OllamaCloudAPIKey string
// Enabled turns the assistant on (PANSY_AGENT_ENABLED). Defaults to on when
// a key is present, so an instance with no key starts cleanly and simply
// doesn't offer the agent — the same shape as OIDC 404ing when unconfigured.
Enabled bool
}
// Enabled reports whether enough OIDC config is present to attempt discovery.
func (o OIDCConfig) Enabled() bool {
return o.Issuer != "" && o.ClientID != ""
@@ -87,6 +112,16 @@ func Load() *Config {
TrustedProxies: envList("PANSY_TRUSTED_PROXIES"),
}
agentKey := envStr("OLLAMA_CLOUD_API_KEY", "")
cfg.Agent = AgentConfig{
Model: envStr("PANSY_AGENT_MODEL", DefaultAgentModel),
OllamaCloudAPIKey: agentKey,
// Default on when a key is present: having configured the key IS the
// opt-in, and making people set a second flag to use what they just
// configured is a papercut with no upside.
Enabled: envBool("PANSY_AGENT_ENABLED", agentKey != ""),
}
if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
slog.Warn("config: invalid PANSY_REGISTRATION, defaulting to open", "value", cfg.Registration)
cfg.Registration = RegistrationOpen
+38
View File
@@ -191,6 +191,28 @@ type JournalEntry struct {
AuthorName string `json:"authorName,omitempty"`
}
// Roles a stored agent message can have.
const (
AgentRoleUser = "user"
AgentRoleAssistant = "assistant"
)
// AgentMessage is one side of a chat exchange with the garden assistant. Only
// the text is kept, not the model's full transcript with its tool calls:
// continuity needs what was said and what came back, and replaying a stored tool
// call would be replaying a decision made against a garden that has since moved
// on.
type AgentMessage struct {
ID int64 `json:"id"`
ConversationID int64 `json:"conversationId"`
Role string `json:"role"`
Body string `json:"body"`
// ChangeSetID is what this turn changed, if anything — the handle the chat
// panel needs to offer Undo on the message that caused it.
ChangeSetID *int64 `json:"changeSetId,omitempty"`
CreatedAt string `json:"createdAt"`
}
// RevertConflict reports one entity a revert deliberately left alone, because
// reverting it would have discarded a change made after the change set being
// reverted. The rest of the change set still reverts.
@@ -220,6 +242,22 @@ const (
ConflictUnsupported = "unsupported"
)
// InstanceSettings is the single row of instance-wide, admin-editable
// configuration (#79). Secrets are deliberately absent — see migration 0010.
//
// Both agent fields express "inherit from the environment unless set":
// AgentModel == "" falls back to PANSY_AGENT_MODEL; AgentEnabled == nil inherits
// the env default. EffectiveAgent resolves them against the environment.
type InstanceSettings struct {
// AgentModel overrides PANSY_AGENT_MODEL when non-empty. Passed verbatim to
// majordomo.Parse, exactly like the env var it shadows.
AgentModel string `json:"agentModel"`
// AgentEnabled overrides PANSY_AGENT_ENABLED when non-nil. nil = inherit.
AgentEnabled *bool `json:"agentEnabled"`
Version int64 `json:"version"`
UpdatedAt string `json:"updatedAt"`
}
// User is a pansy account. It may have a local password, OIDC identity, or both.
type User struct {
ID int64 `json:"id"`
+62
View File
@@ -0,0 +1,62 @@
package service
import (
"context"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// Chat persistence for the garden assistant (#56). The run loop itself lives in
// internal/agent; this is the part that needs pansy's permission checks.
// maxRetainedTurns caps how much of a thread is kept in context. Retained turns
// are a working memory, not an archive — a season of chat replayed into every
// prompt would cost more than it informs.
const maxRetainedTurns = 20
// AgentHistory returns the recent messages of the actor's own thread for a
// garden they can edit, oldest first.
//
// Requires EDITOR, not viewer: the assistant acts, so being able to talk to it
// about a garden is the same permission as being able to change it. A viewer
// asking it to plant something would get a refusal from every tool anyway, and
// offering the conversation would be offering something that cannot work.
func (s *Service) AgentHistory(ctx context.Context, actorID, gardenID int64) ([]domain.AgentMessage, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
return nil, err
}
conv, err := s.store.EnsureConversation(ctx, gardenID, actorID)
if err != nil {
return nil, err
}
return s.store.ListAgentMessages(ctx, conv, maxRetainedTurns*2)
}
// RecordAgentExchange stores one user message and the assistant's reply, and
// returns the reply row. Called by the runtime after a turn completes.
func (s *Service) RecordAgentExchange(ctx context.Context, actorID, gardenID int64, userMessage, reply string, changeSetID *int64) (*domain.AgentMessage, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
return nil, err
}
conv, err := s.store.EnsureConversation(ctx, gardenID, actorID)
if err != nil {
return nil, err
}
if _, err := s.store.AppendAgentMessage(ctx, &domain.AgentMessage{
ConversationID: conv, Role: domain.AgentRoleUser, Body: userMessage,
}); err != nil {
return nil, err
}
return s.store.AppendAgentMessage(ctx, &domain.AgentMessage{
ConversationID: conv, Role: domain.AgentRoleAssistant, Body: reply, ChangeSetID: changeSetID,
})
}
// ClearAgentHistory drops the actor's thread for a garden — the "start over"
// escape hatch when a conversation has gone somewhere unhelpful.
func (s *Service) ClearAgentHistory(ctx context.Context, actorID, gardenID int64) error {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleEditor); err != nil {
return err
}
return s.store.DeleteConversation(ctx, gardenID, actorID)
}
+110
View File
@@ -0,0 +1,110 @@
package service
import (
"context"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// Instance settings (#79): admin-editable, instance-wide configuration. The
// admin gate lives HERE, in the seam, not in the handler — the same rule every
// other permission follows. The API's requireAdmin middleware is a cheap early
// 403, not the authority.
// requireAdmin returns nil iff the actor is an admin, else ErrForbidden.
//
// ErrForbidden, not ErrNotFound: settings are not a resource whose existence is
// masked. A logged-in non-admin knows the instance has settings; they simply may
// not touch them. (Contrast objects/lots, where no-access masks existence.)
func (s *Service) requireAdmin(ctx context.Context, actorID int64) error {
u, err := s.store.GetUserByID(ctx, actorID)
if err != nil {
return err
}
if !u.IsAdmin {
return domain.ErrForbidden
}
return nil
}
// GetInstanceSettings returns the instance settings for an admin.
func (s *Service) GetInstanceSettings(ctx context.Context, actorID int64) (*domain.InstanceSettings, error) {
if err := s.requireAdmin(ctx, actorID); err != nil {
return nil, err
}
return s.store.GetInstanceSettings(ctx)
}
// InstanceSettingsPatch is a full replacement of the editable fields plus the
// current version. Both agent fields carry their "inherit" sentinel: an empty
// AgentModel means fall back to env, a nil AgentEnabled means inherit.
type InstanceSettingsPatch struct {
AgentModel string
AgentEnabled *bool
Version int64
}
// UpdateInstanceSettings applies an admin's change, version-guarded. It returns
// (current row, ErrVersionConflict) on a stale version, like every mutable
// resource. The model spec is validated before it is stored, so a typo is a 400
// now rather than a broken assistant on the next turn.
//
// It does NOT rebuild the running agent — that is the API layer's job, because
// the live Runner lives there. The caller rebuilds on success.
func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, patch InstanceSettingsPatch) (*domain.InstanceSettings, error) {
if err := s.requireAdmin(ctx, actorID); err != nil {
return nil, err
}
model := strings.TrimSpace(patch.AgentModel)
// Validate a non-empty spec up front. An empty one is the "inherit env"
// sentinel and needs no check — the env value was validated at boot.
if model != "" {
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, model); err != nil {
return nil, domain.ErrInvalidInput
}
}
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
AgentModel: model,
AgentEnabled: patch.AgentEnabled,
Version: patch.Version,
})
}
// EffectiveAgent resolves the agent configuration actually in force: DB settings
// override the environment, and the API key always comes from the environment.
//
// This is internal plumbing (the API layer calls it to build the Runner), NOT an
// admin-gated operation — resolving what's configured is not the same as editing
// it, and the agent bootstrap must work before any user is even authenticated.
type EffectiveAgent struct {
Model string
Enabled bool
APIKey string
}
// Ready mirrors config.AgentConfig.Ready: enabled, with a key and a model.
func (e EffectiveAgent) Ready() bool {
return e.Enabled && e.APIKey != "" && e.Model != ""
}
// EffectiveAgent reads the settings row and layers it over the environment.
func (s *Service) EffectiveAgent(ctx context.Context) (EffectiveAgent, error) {
st, err := s.store.GetInstanceSettings(ctx)
if err != nil {
return EffectiveAgent{}, err
}
eff := EffectiveAgent{
Model: s.cfg.Agent.Model,
Enabled: s.cfg.Agent.Enabled,
APIKey: s.cfg.Agent.OllamaCloudAPIKey,
}
if st.AgentModel != "" {
eff.Model = st.AgentModel
}
if st.AgentEnabled != nil {
eff.Enabled = *st.AgentEnabled
}
return eff, nil
}
+117
View File
@@ -0,0 +1,117 @@
package service
import (
"context"
"errors"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// settingsTestService builds a service whose env config carries the given agent
// model/enabled/key, so EffectiveAgent's env fallback can be exercised.
func settingsTestService(t *testing.T, envModel string, envEnabled bool, key string) (*Service, int64) {
t.Helper()
cfg := openConfig()
cfg.Agent = config.AgentConfig{Model: envModel, Enabled: envEnabled, OllamaCloudAPIKey: key}
s := newTestService(t, cfg)
admin := seedUser(t, s, "[email protected]") // first user is admin
return s, admin
}
// TestRequireAdmin: the first user is admin; a second is not and gets
// ErrForbidden (not ErrNotFound — settings existence isn't masked).
func TestRequireAdmin(t *testing.T) {
s, admin := settingsTestService(t, "ollama-cloud/x", true, "k")
member := seedUser(t, s, "[email protected]")
if err := s.requireAdmin(context.Background(), admin); err != nil {
t.Errorf("admin rejected: %v", err)
}
if err := s.requireAdmin(context.Background(), member); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("member requireAdmin = %v, want ErrForbidden", err)
}
}
// TestEffectiveAgentLayering: DB settings override env; the API key always comes
// from env; the "inherit" sentinels fall back.
func TestEffectiveAgentLayering(t *testing.T) {
ctx := context.Background()
s, admin := settingsTestService(t, "ollama-cloud/env-model", true, "envkey")
// Untouched: everything inherits env.
eff, err := s.EffectiveAgent(ctx)
if err != nil {
t.Fatalf("effective: %v", err)
}
if eff.Model != "ollama-cloud/env-model" || !eff.Enabled || eff.APIKey != "envkey" {
t.Errorf("inherited effective = %+v, want the env values", eff)
}
// Override the model only; enabled still inherits env (true).
cur, _ := s.GetInstanceSettings(ctx, admin)
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
AgentModel: "ollama-cloud/glm-5.2:cloud", Version: cur.Version,
}); err != nil {
t.Fatalf("update model: %v", err)
}
eff, _ = s.EffectiveAgent(ctx)
if eff.Model != "ollama-cloud/glm-5.2:cloud" {
t.Errorf("model = %q, want the DB override", eff.Model)
}
if !eff.Enabled {
t.Error("enabled should still inherit env (true) when unset")
}
// Now override enabled to false explicitly.
cur, _ = s.GetInstanceSettings(ctx, admin)
no := false
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
AgentModel: "ollama-cloud/glm-5.2:cloud", AgentEnabled: &no, Version: cur.Version,
}); err != nil {
t.Fatalf("update enabled: %v", err)
}
eff, _ = s.EffectiveAgent(ctx)
if eff.Enabled {
t.Error("enabled should be the explicit false override now")
}
if eff.Ready() {
t.Error("Ready() should be false when disabled")
}
}
// TestUpdateInstanceSettingsRejectsBadModel: a spec that won't resolve is
// ErrInvalidInput, before it is stored.
func TestUpdateInstanceSettingsRejectsBadModel(t *testing.T) {
ctx := context.Background()
s, admin := settingsTestService(t, "ollama-cloud/x", true, "k")
cur, _ := s.GetInstanceSettings(ctx, admin)
if _, err := s.UpdateInstanceSettings(ctx, admin, InstanceSettingsPatch{
AgentModel: "nonesuch/model", Version: cur.Version,
}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("bad model = %v, want ErrInvalidInput", err)
}
// The rejected write didn't touch the row.
after, _ := s.GetInstanceSettings(ctx, admin)
if after.Version != cur.Version || after.AgentModel != "" {
t.Errorf("a rejected update changed the row: %+v", after)
}
}
// TestInstanceSettingsAdminGate: the read/write operations are admin-gated at the
// service seam, not just in the handler.
func TestInstanceSettingsAdminGate(t *testing.T) {
ctx := context.Background()
s, _ := settingsTestService(t, "ollama-cloud/x", true, "k")
member := seedUser(t, s, "[email protected]")
if _, err := s.GetInstanceSettings(ctx, member); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("member GetInstanceSettings = %v, want ErrForbidden", err)
}
if _, err := s.UpdateInstanceSettings(ctx, member, InstanceSettingsPatch{Version: 1}); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("member UpdateInstanceSettings = %v, want ErrForbidden", err)
}
}
+9
View File
@@ -84,6 +84,15 @@ func (s *Service) ListJournal(ctx context.Context, actorID, gardenID int64, q Jo
return entries, false, nil
}
// JournalCounts returns how many entries a garden holds per object (key 0 for
// entries about the garden itself), for an actor who can at least view it.
func (s *Service) JournalCounts(ctx context.Context, actorID, gardenID int64) (map[int64]int, error) {
if _, err := s.requireGardenRole(ctx, actorID, gardenID, roleViewer); err != nil {
return nil, err
}
return s.store.JournalCounts(ctx, gardenID)
}
// CreateJournalEntry writes an entry against a garden the actor can edit.
//
// An entry may target the garden, one of its beds, or one plop in it — and the
+132 -32
View File
@@ -28,13 +28,9 @@ type Region struct {
MinX, MinY, MaxX, MaxY float64
}
// contains reports whether a local point lies in the region.
func (r Region) contains(x, y float64) bool {
return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY
}
// clampTo intersects the region with an object's local bounds (±halfW, ±halfH),
// so an oversized caller-supplied region can't make hexCenters loop forever.
// so a fill can't plant outside the object it was aimed at. A region that misses
// the object entirely comes back empty — see empty().
func (r Region) clampTo(halfW, halfH float64) Region {
return Region{
MinX: math.Max(r.MinX, -halfW), MinY: math.Max(r.MinY, -halfH),
@@ -42,6 +38,16 @@ func (r Region) clampTo(halfW, halfH float64) Region {
}
}
// empty reports whether the region encloses nothing.
//
// This exists because clampTo expresses "no overlap" by INVERTING the region —
// Max clamps below Min — rather than by zeroing it, which is not something a
// reader guesses. Naming it once here beats a bare `MaxX < MinX` at each place
// that has to care.
func (r Region) empty() bool {
return r.MaxX < r.MinX || r.MaxY < r.MinY
}
// rect builds a rectangular region.
func rect(minX, minY, maxX, maxY float64) Region {
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
@@ -94,9 +100,11 @@ func defaultPlopRadius(spacingCM float64) float64 {
// FillRegion lays a hex-packed field of plops of one plant across a region of a
// plantable object the actor can edit. Plop radius comes from the plant's spacing
// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2×
// radius pitch, kept where the center is inside the region. A candidate is
// skipped when its plop would sit entirely inside an existing active plop (so
// re-filling doesn't stack duplicates). Returns the plops it created.
// radius pitch, centered in the region, and set in from each edge by the plop's
// radius less half a spacing — see hexCenters for why that half-spacing is what
// the edge is owed. A candidate is skipped when its plop would sit entirely
// inside an existing active plop (so re-filling doesn't stack duplicates).
// Returns the plops it created.
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil {
@@ -106,9 +114,9 @@ func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, regio
}
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
// already loaded and authorized (roleEditor). It clamps the region to the
// object's bounds, refuses fills over maxFillPlops, and inserts the whole batch
// in one transaction rather than one round-trip per plop.
// already loaded and authorized (roleEditor). It rejects a non-finite region,
// clamps the region to the object's bounds, refuses fills over maxFillPlops, and
// inserts the whole batch in one transaction rather than one round-trip per plop.
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
if !o.Plantable {
return nil, domain.ErrInvalidInput
@@ -129,9 +137,21 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
return nil, domain.ErrInvalidInput
}
// A caller-supplied region is arbitrary floats, and non-finite ones survive
// everything downstream: clamping keeps them, the inverted-region guard can't
// see NaN (it compares false both ways), and fitAxis centres on them happily.
// Nothing corrupt reaches the table — SQLite stores NaN as NULL and the NOT
// NULL constraint refuses it — but the caller gets an opaque store error for
// NaN, and for +Inf a silent zero-plop success. Both are lies about what went
// wrong; say "bad input" here instead.
if !isFinite(region.MinX) || !isFinite(region.MinY) ||
!isFinite(region.MaxX) || !isFinite(region.MaxY) {
return nil, domain.ErrInvalidInput
}
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
centers := hexCenters(region, radius)
if len(centers) > maxFillPlops {
centers, total := hexCenters(region, radius, spacing, maxFillPlops)
if total > maxFillPlops {
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
}
@@ -169,32 +189,112 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
type localPoint struct{ x, y float64 }
// hexCenters returns hex-packed lattice centers whose center lies in the region.
// Rows are spaced radius·√3 apart and every other row is offset by radius, the
// standard hexagonal packing at a 2×radius pitch. The lattice is anchored one
// radius inside the region's min corner so the first plop sits inside it.
func hexCenters(r Region, radius float64) []localPoint {
// hexCenters returns hex-packed lattice centers filling a region: rows radius·√3
// apart, alternate rows offset by half a pitch, at a 2×radius pitch. The lattice
// is CENTERED, so the leftover is shared between opposite edges instead of piling
// up against the far one.
//
// # How close to the edge the outer row goes
//
// Spacing is a constraint BETWEEN NEIGHBOURING PLANTS competing for the same
// soil, light and water. A bed edge is not a competitor, so the outer row only
// owes it HALF the spacing — the half it would otherwise share with a neighbour.
// That is the arithmetic inside every square-foot-gardening chart: 4 per square
// is 6" apart and 3" from the square's edge; 9 per square is 4" apart and 2"
// from the edge. Garlic at 9 per square goes in 2" from the frame, not 6".
//
// A plop is a CLUMP, not a plant — defaultPlopRadius makes it 1.5×spacing, so
// three spacings across — and its plants sit out to its rim. So keeping the whole
// circle inside the bed would inset the outer row by a full 1.5 spacings, three
// times what the rule allows. Instead the clump may hang over the edge by up to
// half a spacing, which puts its outermost plants exactly the half-spacing from
// the edge that the rule asks for. Overhang is capped there and nowhere near the
// full radius: a clump mostly outside the bed is a drawing of plants in the path.
//
// Do not "simplify" this back to anchoring at the region's min corner. That is
// what #75 was: staggered rows start a full pitch in, and the leftover all lands
// on the far edge, where clumps hang outside a bed that nothing clips them to.
//
// # Counting before building
//
// hexCenters returns the total alongside the points, and works that total out
// BEFORE building anything: a fill large enough to be refused shouldn't allocate
// its whole lattice first just to be counted and thrown away. Over `limit` it
// returns (nil, total), so the caller can still refuse with the real number.
func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int) {
if radius <= 0 {
return nil
return nil, 0
}
// An empty region has no inside to plant. The old loop-until-past-MaxX form
// got this for free by never entering the loop; counting positions up front
// does not, and would site a plop off the bed.
if r.empty() {
return nil, 0
}
pitch := 2 * radius
rowH := pitch * math.Sqrt(3) / 2
const eps = 1e-6
var pts []localPoint
row := 0
for y := r.MinY + radius; y <= r.MaxY+eps; y += rowH {
xStart := r.MinX + radius
if row%2 == 1 {
xStart += radius
// How far a clump's centre must stay inside the edge: its own radius, less the
// half-spacing of overhang the rule allows. Never negative, and never past the
// centre of the clump.
inset := math.Max(0, radius-math.Max(0, spacing)/2)
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
// Exact, not an upper bound: staggered rows hold one fewer, so rows*cols would
// over-reserve by ~12% — and, more to the point, allocating it is the thing we
// are trying to avoid when the answer is "too many".
staggered := cols
if cols > 1 {
staggered = cols - 1
}
for x := xStart; x <= r.MaxX+eps; x += pitch {
if r.contains(x, y) {
pts = append(pts, localPoint{x, y})
total := (rows+1)/2*cols + rows/2*staggered
if total > limit {
return nil, total
}
pts := make([]localPoint, 0, total)
for row := 0; row < rows; row++ {
y := r.MinY + y0 + float64(row)*rowH
n, x := cols, r.MinX+x0
// The stagger falls out of centering: an offset row holds one fewer plop,
// and centering THAT run puts it exactly half a pitch off its neighbours.
// A single-column region has nothing to stagger against.
if row%2 == 1 && cols > 1 {
n, x = staggered, r.MinX+x0+pitch/2
}
for i := 0; i < n; i++ {
pts = append(pts, localPoint{x + float64(i)*pitch, y})
}
}
row++
return pts, total
}
return pts
// fitAxis returns how many lattice positions fit along a span at `step`, keeping
// at least `inset` from each end, and the offset from the span's start that
// centers them — so the leftover is split between the two edges rather than all
// landing on the far one.
//
// A span too small to hold even one position at that inset still gets one, in the
// middle: filling a bed narrower than a single plop with one plop is a better
// answer than refusing to plant it.
//
// The step<=0 half of that guard is currently unreachable — hexCenters, the only
// caller, returns early unless radius > 0, which makes both steps it passes
// positive. It stays because dividing by a non-positive step yields ±Inf and then
// a garbage int conversion, and a helper this small should not require reading
// its caller to know it is safe. Deliberate, not an oversight.
func fitAxis(length, step, inset float64) (n int, start float64) {
if step <= 0 || length < 2*inset {
return 1, length / 2
}
// The epsilon keeps an exact fit from being lost to floating point — a 60cm
// span at a 30cm step should give 2 positions, not 1 because the division
// landed on 0.9999999.
const eps = 1e-9
n = int(math.Floor((length-2*inset)/step+eps)) + 1
return n, (length - float64(n-1)*step) / 2
}
// coveredByExisting reports whether a new plop (center, radius) would sit
+174 -5
View File
@@ -3,6 +3,8 @@ package service
import (
"context"
"errors"
"math"
"sort"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
@@ -71,6 +73,165 @@ func TestDefaultPlopRadius(t *testing.T) {
}
}
// TestHexCentersEdgeInset pins the spacing rule the packing exists to honour:
// spacing is a constraint between neighbouring plants, so a bed edge — which is
// nobody's neighbour — is owed half a pitch, not a whole one.
//
// The bug this guards against was visible to anyone who filled a bed: staggered
// rows began a full pitch in, leaving a bare strip a whole plop wide down one
// side of every other row, while the far edge had plops hanging off it.
func TestHexCentersEdgeInset(t *testing.T) {
for _, tc := range []struct {
name string
w, h, radius, spacing float64
wantRowStarts []float64 // x of the first plop in rows 0 and 1
}{
// 4ft × 8ft bed, garlic at 15cm spacing → radius 22.5, pitch 45. Three
// columns, the outer ones overhanging by 6.5cm — under the 7.5cm the rule
// allows. Anchored at the corner this row started at -38.5 and its
// staggered neighbour a full 45 further in still.
{"4ft bed of garlic", 122, 244, 22.5, 15, []float64{-45, -22.5}},
// An exact fit: 90 wide at pitch 30 → 3 columns, no overhang needed.
{"exact fit", 90, 90, 15, 10, []float64{-30, -15}},
} {
t.Run(tc.name, func(t *testing.T) {
r := rect(-tc.w/2, -tc.h/2, tc.w/2, tc.h/2)
pts, total := hexCenters(r, tc.radius, tc.spacing, maxFillPlops)
if len(pts) == 0 {
t.Fatal("no centers")
}
// The count is derived up front so an oversized fill is refused without
// building its lattice — which only works if it matches what gets built.
if total != len(pts) {
t.Errorf("reported total %d, built %d", total, len(pts))
}
// A clump may cross the edge, but only by the half-spacing the rule
// allows — never enough to be mostly out in the path.
budget := tc.spacing / 2
for _, p := range pts {
over := math.Max(
math.Max(r.MinX-(p.x-tc.radius), (p.x+tc.radius)-r.MaxX),
math.Max(r.MinY-(p.y-tc.radius), (p.y+tc.radius)-r.MaxY),
)
if over > budget+1e-6 {
t.Errorf("plop at (%.1f,%.1f) overhangs by %.2f, budget %.2f", p.x, p.y, over, budget)
}
}
// The margins match on opposite edges: the leftover is shared, not piled
// against the far side.
minX, maxX, minY, maxY := pts[0].x, pts[0].x, pts[0].y, pts[0].y
for _, p := range pts {
minX, maxX = math.Min(minX, p.x), math.Max(maxX, p.x)
minY, maxY = math.Min(minY, p.y), math.Max(maxY, p.y)
}
if w, e := minX-r.MinX, r.MaxX-maxX; math.Abs(w-e) > 1e-6 {
t.Errorf("lopsided horizontally: west margin %.2f, east %.2f", w, e)
}
if n, s := minY-r.MinY, r.MaxY-maxY; math.Abs(n-s) > 1e-6 {
t.Errorf("lopsided vertically: north margin %.2f, south %.2f", n, s)
}
// The staggered row is offset by HALF a pitch, not a whole one.
starts := map[float64]float64{}
for _, p := range pts {
if x, ok := starts[p.y]; !ok || p.x < x {
starts[p.y] = p.x
}
}
ys := make([]float64, 0, len(starts))
for y := range starts {
ys = append(ys, y)
}
sort.Float64s(ys)
for i, want := range tc.wantRowStarts {
if i >= len(ys) {
t.Fatalf("only %d rows, want at least %d", len(ys), len(tc.wantRowStarts))
}
if got := starts[ys[i]]; math.Abs(got-want) > 1e-6 {
t.Errorf("row %d starts at x=%.2f, want %.2f", i, got, want)
}
}
})
}
}
// TestHexCentersTinyRegion covers a region too small to hold a plop at the
// half-pitch inset: planting one in the middle beats refusing to plant at all.
//
// The off-centre case earns its place — a region symmetric about the origin
// can't tell "the middle of the region" from "the origin", so on its own it
// would pass for an implementation that just returned (0,0).
func TestHexCentersTinyRegion(t *testing.T) {
for _, tc := range []struct {
name string
r Region
wantX, wantY float64
}{
{"centred on the origin", rect(-5, -5, 5, 5), 0, 0},
{"off in a corner", rect(20, -40, 30, -30), 25, -35},
} {
t.Run(tc.name, func(t *testing.T) {
pts, _ := hexCenters(tc.r, 15, 10, maxFillPlops)
if len(pts) != 1 || pts[0].x != tc.wantX || pts[0].y != tc.wantY {
t.Errorf("got %+v, want one plop at (%v,%v)", pts, tc.wantX, tc.wantY)
}
})
}
}
// TestFillRegionRejectsNonFiniteRegion: non-finite bounds survive clamping and
// the inverted-region guard (NaN compares false both ways). Without the explicit
// check, NaN surfaced as a raw store error ("NOT NULL constraint failed") and
// +Inf as a silent success that planted nothing — neither of which tells the
// caller what it actually did wrong.
func TestFillRegionRejectsNonFiniteRegion(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
bed := seedFillBed(t, s, owner, g.ID, 100, 100)
plant := seedOwnPlant(t, s, owner, 10)
nan := math.NaN()
for _, r := range []Region{
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
} {
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil)
if !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
}
for _, p := range created {
if !isFinite(p.XCM) || !isFinite(p.YCM) {
t.Errorf("persisted a plop with non-finite coordinates: %+v", p)
}
}
}
}
// TestFillRegionOutsideObjectPlantsNothing covers a region that misses the object
// entirely. clampTo inverts such a region rather than emptying it, and an
// inverted region must plant nothing — not one plop at some point off the bed.
func TestFillRegionOutsideObjectPlantsNothing(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
bed := seedFillBed(t, s, owner, g.ID, 100, 100) // local bounds ±50
plant := seedOwnPlant(t, s, owner, 10)
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50.
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil)
if err != nil {
t.Fatalf("FillRegion: %v", err)
}
if len(created) != 0 {
t.Errorf("filled %d plops for a region outside the bed, want 0: %+v", len(created), created)
}
}
// seedFillBed makes a plantable bed of the given size centered in a big garden.
func seedFillBed(t *testing.T, s *Service, owner, gardenID int64, w, h float64) *domain.GardenObject {
t.Helper()
@@ -99,16 +260,24 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
if err != nil {
t.Fatalf("FillRegion: %v", err)
}
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart → 4 plops (2 rows × 2).
if len(created) != 4 {
t.Fatalf("filled %d plops, want 4 (60×60 bed, radius 15)", len(created))
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart, centered: a row of 2
// (x=±15), then a staggered row of 1 (x=0) → 3 plops.
//
// This was 4 while the lattice was anchored at the min corner, and the fourth
// sat at x=30 — centred ON the east edge, so half of it lay outside the bed,
// well past the half-spacing (5cm here) the rule allows. Packing one fewer
// plop is the point of the fix, not a regression in it.
if len(created) != 3 {
t.Fatalf("filled %d plops, want 3 (60×60 bed, radius 15)", len(created))
}
for _, p := range created {
if p.RadiusCM != 15 || p.PlantedAt == nil || p.DerivedCount < 1 {
t.Errorf("unexpected created plop: %+v", p)
}
if p.XCM < -30 || p.XCM > 30 || p.YCM < -30 || p.YCM > 30 {
t.Errorf("plop center out of bed bounds: %+v", p)
// This bed fits its lattice exactly, so nothing should need to overhang.
if p.XCM-p.RadiusCM < -30 || p.XCM+p.RadiusCM > 30 ||
p.YCM-p.RadiusCM < -30 || p.YCM+p.RadiusCM > 30 {
t.Errorf("plop overhangs a bed it fits inside: %+v", p)
}
}
+119
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"sort"
"strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
@@ -69,6 +70,124 @@ func (s *Service) ListPlants(ctx context.Context, actorID int64) ([]domain.Plant
return s.store.ListPlantsForActor(ctx, actorID)
}
// PlantMatch is a candidate from FindPlants: the plant, plus what seed the actor
// has left of it, so an agent can say "you only have enough for half that bed"
// instead of confidently planting seed that doesn't exist.
type PlantMatch struct {
domain.Plant
// SeedRemaining is the total left across the actor's lots of this plant, and
// SeedUnit the unit they're counted in. Both are omitted when there are no
// lots — and also when the lots disagree about the unit, because adding
// grams to packets produces a number that means nothing. SeedLots still
// reports how many there are, so "several lots, no single total" is
// distinguishable from "no seed at all".
SeedRemaining *float64 `json:"seedRemaining,omitempty"`
SeedUnit string `json:"seedUnit,omitempty"`
SeedLots int `json:"seedLots,omitempty"`
}
// maxPlantMatches caps FindPlants. A model given fifty candidates is not being
// helped; if the query is that broad the answer is to ask a better one.
const maxPlantMatches = 10
// FindPlants returns the plants in the actor's visible catalog (built-ins plus
// their own) whose name or category matches the query, best match first.
//
// It deliberately returns SEVERAL candidates rather than one guess. "garlic"
// against a catalog holding both "Garlic" and "German Red Garlic" is genuinely
// ambiguous, and a caller with the surrounding conversation is far better placed
// to disambiguate than a fuzzy-match heuristic here. An empty query returns the
// catalog head rather than nothing, so a caller can browse.
func (s *Service) FindPlants(ctx context.Context, actorID int64, query string) ([]PlantMatch, error) {
plants, err := s.store.ListPlantsForActor(ctx, actorID)
if err != nil {
return nil, err
}
q := strings.ToLower(strings.TrimSpace(query))
type scored struct {
plant domain.Plant
rank int
}
ranked := make([]scored, 0, len(plants))
for _, p := range plants {
name := strings.ToLower(p.Name)
switch {
case q == "":
ranked = append(ranked, scored{p, 3})
case name == q:
ranked = append(ranked, scored{p, 0})
case strings.HasPrefix(name, q):
ranked = append(ranked, scored{p, 1})
case strings.Contains(name, q):
ranked = append(ranked, scored{p, 2})
case strings.Contains(strings.ToLower(p.Category), q):
ranked = append(ranked, scored{p, 3})
}
}
// Stable by rank then name, so the same query always answers the same way —
// a tool whose results reshuffle between calls is one a model can't reason
// about across turns.
sort.SliceStable(ranked, func(i, j int) bool {
if ranked[i].rank != ranked[j].rank {
return ranked[i].rank < ranked[j].rank
}
return ranked[i].plant.Name < ranked[j].plant.Name
})
if len(ranked) > maxPlantMatches {
ranked = ranked[:maxPlantMatches]
}
matches := make([]PlantMatch, 0, len(ranked))
for _, r := range ranked {
matches = append(matches, PlantMatch{Plant: r.plant})
}
if err := s.attachSeedRemaining(ctx, actorID, matches); err != nil {
return nil, err
}
return matches, nil
}
// attachSeedRemaining fills SeedRemaining/SeedUnit from the actor's lots.
func (s *Service) attachSeedRemaining(ctx context.Context, actorID int64, matches []PlantMatch) error {
if len(matches) == 0 {
return nil
}
lots, err := s.ListSeedLots(ctx, actorID, nil)
if err != nil {
return err
}
byPlant := map[int64][]domain.SeedLot{}
for _, l := range lots {
byPlant[l.PlantID] = append(byPlant[l.PlantID], l)
}
for i := range matches {
ls := byPlant[matches[i].ID]
if len(ls) == 0 {
continue
}
matches[i].SeedLots = len(ls)
unit := ls[0].Unit
mixed := false
total := 0.0
for _, l := range ls {
total += l.Remaining
if l.Unit != unit {
mixed = true
}
}
if mixed {
// Dropping only the LABEL would leave a number that reads as a
// quantity and isn't one. Drop the total with it; SeedLots still says
// there is seed here, just not one figure for it.
continue
}
matches[i].SeedRemaining = &total
matches[i].SeedUnit = unit
}
return nil
}
// CreatePlant adds a plant owned by the actor. To "clone" a built-in the client
// simply POSTs a copy — there is no dedicated endpoint, and the copy is owned by
// (and editable by) the actor.
+135
View File
@@ -237,3 +237,138 @@ func TestDeletePlantInUseIsBlocked(t *testing.T) {
t.Errorf("delete freed plant: %v", err)
}
}
// TestFindPlantsRanksAndDisambiguates — FindPlants deliberately returns several
// candidates rather than one guess, because "garlic" against a catalog holding
// both "Garlic" and "German Red Garlic" is genuinely ambiguous and a caller with
// the surrounding context is better placed to choose than a heuristic here.
func TestFindPlantsRanksAndDisambiguates(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
ctx := context.Background()
custom, err := s.CreatePlant(ctx, owner, PlantInput{
Name: "German Red Garlic", Category: domain.CategoryVegetable, SpacingCM: 15,
Color: "#8a5a8a", Icon: "🧄",
})
if err != nil {
t.Fatalf("CreatePlant: %v", err)
}
got, err := s.FindPlants(ctx, owner, "garlic")
if err != nil {
t.Fatalf("FindPlants: %v", err)
}
names := map[string]bool{}
for _, m := range got {
names[m.Name] = true
}
if !names["Garlic"] || !names[custom.Name] {
t.Errorf("got %v, want both the built-in and the custom variety", names)
}
// Exact match first, so a caller taking [0] gets the least surprising one.
if got[0].Name != "Garlic" {
t.Errorf("first match = %q, want the exact name", got[0].Name)
}
// Prefix beats substring: "german" should surface the custom one first.
pre, err := s.FindPlants(ctx, owner, "german")
if err != nil {
t.Fatalf("FindPlants: %v", err)
}
if len(pre) == 0 || pre[0].Name != custom.Name {
t.Errorf("FindPlants(german) = %+v, want the custom variety first", pre)
}
// Category search works, and a query matching nothing says so plainly.
if herbs, _ := s.FindPlants(ctx, owner, "herb"); len(herbs) == 0 {
t.Error("category search found nothing")
}
if none, _ := s.FindPlants(ctx, owner, "zzzzz"); len(none) != 0 {
t.Errorf("FindPlants(zzzzz) = %+v, want nothing", none)
}
}
// TestFindPlantsReportsSeedRemaining — so a caller can say "you only have enough
// for half that bed" instead of confidently planting seed that doesn't exist.
func TestFindPlantsReportsSeedRemaining(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
other := seedUser(t, s, "[email protected]")
ctx := context.Background()
plant := seedOwnPlant(t, s, owner, 15)
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{
PlantID: plant.ID, Quantity: 100, Unit: domain.UnitSeeds,
}); err != nil {
t.Fatalf("CreateSeedLot: %v", err)
}
got, err := s.FindPlants(ctx, owner, plant.Name)
if err != nil {
t.Fatalf("FindPlants: %v", err)
}
if len(got) == 0 || got[0].SeedRemaining == nil || *got[0].SeedRemaining != 100 {
t.Fatalf("seedRemaining = %+v, want 100", got[0].SeedRemaining)
}
if got[0].SeedUnit != domain.UnitSeeds {
t.Errorf("seedUnit = %q", got[0].SeedUnit)
}
// A plant with no lots reports nothing rather than a misleading zero.
builtin, err := s.FindPlants(ctx, owner, "Garlic")
if err != nil {
t.Fatalf("FindPlants: %v", err)
}
if len(builtin) > 0 && builtin[0].SeedRemaining != nil {
t.Errorf("a plant with no lots reported %v remaining", *builtin[0].SeedRemaining)
}
// And lots are private: another user searching the same plant sees no seed.
// (They can't see the custom plant at all, so search a built-in instead.)
if _, err := s.CreateSeedLot(ctx, other, SeedLotInput{
PlantID: builtinPlantID(t, s, other), Quantity: 50, Unit: domain.UnitSeeds,
}); err != nil {
t.Fatalf("CreateSeedLot(other): %v", err)
}
mine, _ := s.FindPlants(ctx, owner, "Garlic")
for _, m := range mine {
if m.SeedRemaining != nil {
t.Errorf("saw another user's seed count on %q", m.Name)
}
}
}
// TestFindPlantsOmitsATotalItCannotHonestlyGive — lots in different units can't
// be summed. Dropping only the unit LABEL would leave a number that reads as a
// quantity and isn't one; the count of lots still says there is seed here.
func TestFindPlantsOmitsATotalItCannotHonestlyGive(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
for _, u := range []string{domain.UnitSeeds, domain.UnitGrams} {
if _, err := s.CreateSeedLot(ctx, owner, SeedLotInput{PlantID: plant.ID, Quantity: 50, Unit: u}); err != nil {
t.Fatalf("CreateSeedLot(%s): %v", u, err)
}
}
got, err := s.FindPlants(ctx, owner, plant.Name)
if err != nil {
t.Fatalf("FindPlants: %v", err)
}
if len(got) == 0 {
t.Fatal("plant not found")
}
if got[0].SeedRemaining != nil {
t.Errorf("reported %v across mismatched units; that number means nothing", *got[0].SeedRemaining)
}
if got[0].SeedUnit != "" {
t.Errorf("seedUnit = %q, want empty", got[0].SeedUnit)
}
// But "several lots, no single total" must stay distinguishable from "none".
if got[0].SeedLots != 2 {
t.Errorf("seedLots = %d, want 2", got[0].SeedLots)
}
}
+58 -4
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"log/slog"
"sort"
"sync"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
@@ -99,7 +100,8 @@ type ChangeSetOptions struct {
Source string
// Summary is the one-line description shown in the history list.
Summary string
// AgentRunID joins this change set back to the executus run that produced it.
// AgentRunID joins this change set back to the agent run that produced it,
// so a change in the history list can be correlated with the run's log lines.
AgentRunID *string
}
@@ -148,11 +150,19 @@ func partialSummary(summary string) string {
// commitScope writes a scope's buffered revisions as one change set. revertsID is
// set only by RevertChangeSet. A scope with no revisions writes nothing — an
// operation that changed nothing doesn't belong in history.
//
// The write is DETACHED FROM CANCELLATION, always, and that belongs here rather
// than at each call site so no caller can be the one that forgets. By the time a
// commit runs, the data changes it describes have already been written — so
// cancelling it cannot undo anything. It can only lose the record of what
// happened and leave real changes with no way to undo them, which is the one
// thing the whole change-set design exists to prevent.
func (s *Service) commitScope(ctx context.Context, sc *changeScope, revertsID *int64) (*domain.ChangeSet, error) {
revs := sc.taken()
if len(revs) == 0 {
return nil, nil
}
ctx = context.WithoutCancel(ctx)
return s.store.WriteChangeSet(ctx, &domain.ChangeSet{
GardenID: sc.gardenID,
ActorID: sc.actorID,
@@ -193,9 +203,13 @@ func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary s
sc.append(revs)
return
}
if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
GardenID: gardenID, ActorID: actorID, Source: domain.SourceUI, Summary: summary,
}, revs); err != nil {
// Auto-scope: one operation, its own change set. Written through the same
// detached path as everything else — a REST client that hangs up right after
// its PATCH landed must not leave that change without history, and this is
// the path virtually every mutation takes.
auto := &changeScope{gardenID: gardenID, actorID: actorID, source: domain.SourceUI, summary: summary}
auto.append(revs)
if _, err := s.commitScope(ctx, auto, nil); err != nil {
slog.Error("service: record change set", "error", err, "garden", gardenID, "summary", summary)
}
}
@@ -307,6 +321,7 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
if err != nil {
// Record what actually landed before surfacing the failure, so the
// partial revert is visible and undoable rather than orphaned.
// (commitScope detaches from cancellation itself.)
if _, cerr := s.commitScope(ctx, sc, &target.ID); cerr != nil {
slog.Error("service: partial revert could not be recorded", "error", cerr, "changeSet", changeSetID)
}
@@ -327,9 +342,48 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
if err != nil {
return nil, nil, err
}
// Fill in the per-op counts. commitScope returns the freshly inserted row,
// which carries no tally — and a caller receiving a change set with an empty
// Counts cannot tell "nothing was reverted" from "the tally wasn't loaded".
// That ambiguity is not theoretical: it made the chat panel report a
// successful undo as "nothing left to undo".
if cs != nil {
cs.Counts = countRevisions(sc.taken())
}
return cs, conflicts, nil
}
// countRevisions tallies revisions by (entity type, op).
//
// This deliberately reproduces in Go what ListChangeSets does in SQL, because a
// change set that has just been written has no rows to GROUP BY yet — the
// alternative is a second round trip to count what we are already holding.
// The parity is a real cross-layer contract, and TestRevertResultCarriesItsCounts
// compares the two breakdowns row for row so it can't drift silently.
func countRevisions(revs []domain.Revision) []domain.ChangeCount {
type key struct{ entityType, op string }
seen := map[key]int{}
order := []key{}
for _, r := range revs {
k := key{r.EntityType, r.Op}
if _, ok := seen[k]; !ok {
order = append(order, k)
}
seen[k]++
}
sort.Slice(order, func(i, j int) bool {
if order[i].entityType != order[j].entityType {
return order[i].entityType < order[j].entityType
}
return order[i].op < order[j].op
})
counts := make([]domain.ChangeCount, 0, len(order))
for _, k := range order {
counts = append(counts, domain.ChangeCount{EntityType: k.entityType, Op: k.op, N: seen[k]})
}
return counts
}
// entityKey identifies a row across the entity types a revision can name.
type entityKey struct {
entityType string
+189
View File
@@ -704,3 +704,192 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
t.Errorf("cleared %d of %d with %d revisions — all three should match", n, len(before), len(revs))
}
}
// TestRevertResultCarriesItsCounts — found by using the thing.
//
// commitScope returns the freshly inserted row, which carries no tally. A caller
// receiving a change set with empty Counts cannot tell "nothing was reverted"
// from "the tally wasn't loaded", and the chat panel read that ambiguity the
// wrong way: a successful undo reported "nothing left to undo", which is the
// worst possible thing to say about an action that just worked.
//
// It also pins the cross-layer contract that fix created. countRevisions tallies
// in Go what ListChangeSets tallies in SQL, and nothing but this test stops the
// two drifting — so it compares the FULL per-(entity, op) breakdown, not just
// the totals, which would agree even if the groupings had diverged.
func TestRevertResultCarriesItsCounts(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
t.Fatalf("fill: %v", err)
}
// A second, different kind of change, so the breakdown has more than one row
// to get wrong.
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("North Bed")}, bed.Version); err != nil {
t.Fatalf("rename: %v", err)
}
sets := history(t, s, owner, g.ID)
rename, fill := sets[0], sets[1]
undoRename, conflicts, err := s.RevertChangeSet(ctx, owner, rename.ID, domain.SourceUI)
if err != nil || len(conflicts) != 0 {
t.Fatalf("revert rename: err=%v conflicts=%+v", err, conflicts)
}
undoFill, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI)
if err != nil || len(conflicts) != 0 {
t.Fatalf("revert fill: err=%v conflicts=%+v", err, conflicts)
}
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
if undo == nil {
t.Fatal("a revert that did work returned no change set")
}
if len(undo.Counts) == 0 {
t.Fatalf("change set %d came back with no tally — an empty tally reads as 'nothing happened'", undo.ID)
}
}
// Undoing a fill deletes every plop it created, so the tallies must match.
if got, want := countsTotal(undoFill.Counts), countsTotal(fill.Counts); got != want {
t.Errorf("undo of the fill reports %d changes, want %d", got, want)
}
// The SQL tally and the Go tally must agree, row for row.
listed := history(t, s, owner, g.ID)
byID := map[int64][]domain.ChangeCount{}
for _, cs := range listed {
byID[cs.ID] = cs.Counts
}
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
fromSQL, ok := byID[undo.ID]
if !ok {
t.Fatalf("revert %d is missing from the history list", undo.ID)
}
if !sameCounts(undo.Counts, fromSQL) {
t.Errorf("change set %d: revert returned %+v, the list read says %+v — countRevisions and the SQL grouping have drifted",
undo.ID, undo.Counts, fromSQL)
}
}
}
// sameCounts compares two tallies regardless of order.
func sameCounts(a, b []domain.ChangeCount) bool {
if len(a) != len(b) {
return false
}
index := func(cs []domain.ChangeCount) map[string]int {
m := map[string]int{}
for _, c := range cs {
m[c.EntityType+"/"+c.Op] = c.N
}
return m
}
ia, ib := index(a), index(b)
for k, v := range ia {
if ib[k] != v {
return false
}
}
return true
}
// countsTotal sums a tally — the server-side twin of the client's totalChanges.
func countsTotal(counts []domain.ChangeCount) int {
n := 0
for _, c := range counts {
n += c.N
}
return n
}
// TestSucceededTurnRecordsEvenIfTheCallerWentAway is the production bug.
//
// The failure path was detached from cancellation; the SUCCESS path was not. An
// agent turn whose client disconnects can still COMPLETE — and then the success
// path committed with a dead context, the write failed, and real changes were
// left with no change set and no way to undo them. Found live with 18 plantings
// behind no history at all.
//
// Nothing about a commit needs the caller to still be there: by the time it
// runs, the data it describes has already been written.
func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 15)
ctx, cancel := context.WithCancel(context.Background())
before := len(history(t, s, owner, g.ID))
// fn does its work and SUCCEEDS, but the caller goes away before it returns.
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
Source: domain.SourceAgent, Summary: "plant beans in the second bed",
}, func(ctx context.Context) error {
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
return err
}
cancel() // the client disconnects, mid-turn, after the work landed
return nil
})
if err != nil {
t.Fatalf("WithChangeSet: %v", err)
}
if cs == nil {
t.Fatal("the turn changed things but produced no change set")
}
after := history(t, s, owner, g.ID)
if len(after) != before+1 {
t.Fatalf("recorded %d change sets, want 1", len(after)-before)
}
if after[0].Summary != "plant beans in the second bed" {
t.Errorf("summary = %q; a completed turn shouldn't be marked partial", after[0].Summary)
}
// And it's undoable, which is the entire point.
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, cs.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("the recorded turn should be undoable: err=%v conflicts=%+v", err, conflicts)
}
active, _ := s.store.ListActivePlantingsForObject(context.Background(), bed.ID)
if len(active) != 0 {
t.Errorf("%d plantings survived the undo", len(active))
}
}
// TestAutoScopedMutationRecordsEvenIfTheCallerWentAway — the same rule for the
// path virtually every mutation takes.
//
// A plain REST PATCH auto-scopes into its own change set. If the client hangs up
// between the row landing and the change set being written, that change is
// orphaned exactly as an agent turn's was — and this path is used far more.
func TestAutoScopedMutationRecordsEvenIfTheCallerWentAway(t *testing.T) {
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
bed := seedBed(t, s, owner, g.ID)
before := len(history(t, s, owner, g.ID))
// The row write and the history write share this context; cancelling after
// the mutation returns is the client hanging up mid-request.
ctx, cancel := context.WithCancel(context.Background())
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
t.Fatalf("UpdateObject: %v", err)
}
cancel()
after := history(t, s, owner, g.ID)
if len(after) != before+1 {
t.Fatalf("recorded %d change sets, want 1 — the move is otherwise un-undoable", len(after)-before)
}
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, after[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("the recorded move should be undoable: err=%v conflicts=%+v", err, conflicts)
}
back, _ := s.store.GetObject(context.Background(), bed.ID)
if back.XCM != bed.XCM {
t.Errorf("undo left x at %v, want %v", back.XCM, bed.XCM)
}
}
+95
View File
@@ -0,0 +1,95 @@
package store
import (
"context"
"fmt"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// agentMessageColumns lists agent_messages columns in scan order.
const agentMessageColumns = `id, conversation_id, role, body, change_set_id, created_at`
// EnsureConversation returns the (garden, user) conversation id, creating it on
// first use. One thread per person per garden: two people editing a shared
// garden hold separate dialogues, and merging them would put words in someone's
// mouth.
func (d *DB) EnsureConversation(ctx context.Context, gardenID, userID int64) (int64, error) {
var id int64
err := d.sql.QueryRowContext(ctx,
`SELECT id FROM agent_conversations WHERE garden_id = ? AND user_id = ?`,
gardenID, userID).Scan(&id)
if err == nil {
return id, nil
}
if err := d.sql.QueryRowContext(ctx,
`INSERT INTO agent_conversations (garden_id, user_id) VALUES (?, ?)
ON CONFLICT (garden_id, user_id) DO UPDATE SET updated_at = updated_at
RETURNING id`,
gardenID, userID).Scan(&id); err != nil {
return 0, fmt.Errorf("store: ensure conversation: %w", err)
}
return id, nil
}
// AppendAgentMessage records one side of an exchange.
func (d *DB) AppendAgentMessage(ctx context.Context, m *domain.AgentMessage) (*domain.AgentMessage, error) {
var out domain.AgentMessage
if err := d.sql.QueryRowContext(ctx,
`INSERT INTO agent_messages (conversation_id, role, body, change_set_id)
VALUES (?, ?, ?, ?)
RETURNING `+agentMessageColumns,
m.ConversationID, m.Role, m.Body, m.ChangeSetID,
).Scan(&out.ID, &out.ConversationID, &out.Role, &out.Body, &out.ChangeSetID, &out.CreatedAt); err != nil {
return nil, fmt.Errorf("store: append agent message: %w", err)
}
// Touch the conversation so a "most recent thread" read is possible later.
if _, err := d.sql.ExecContext(ctx,
`UPDATE agent_conversations SET updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') WHERE id = ?`,
m.ConversationID); err != nil {
return nil, fmt.Errorf("store: touch conversation: %w", err)
}
return &out, nil
}
// ListAgentMessages returns the last `limit` messages of a conversation in
// chronological order.
//
// Reading the TAIL rather than the head is the point: an old opening exchange is
// the least useful context for "now do the same to the north bed", and an
// uncapped read would grow without bound over a season.
func (d *DB) ListAgentMessages(ctx context.Context, conversationID int64, limit int) ([]domain.AgentMessage, error) {
rows, err := d.sql.QueryContext(ctx,
`SELECT `+agentMessageColumns+` FROM (
SELECT `+agentMessageColumns+` FROM agent_messages
WHERE conversation_id = ? ORDER BY id DESC LIMIT ?
) ORDER BY id`,
conversationID, limit)
if err != nil {
return nil, fmt.Errorf("store: list agent messages: %w", err)
}
defer rows.Close()
msgs := []domain.AgentMessage{}
for rows.Next() {
var m domain.AgentMessage
if err := rows.Scan(&m.ID, &m.ConversationID, &m.Role, &m.Body, &m.ChangeSetID, &m.CreatedAt); err != nil {
return nil, fmt.Errorf("store: scan agent message: %w", err)
}
msgs = append(msgs, m)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate agent messages: %w", err)
}
return msgs, nil
}
// DeleteConversation clears a thread (the messages cascade).
func (d *DB) DeleteConversation(ctx context.Context, gardenID, userID int64) error {
if _, err := d.sql.ExecContext(ctx,
`DELETE FROM agent_conversations WHERE garden_id = ? AND user_id = ?`,
gardenID, userID); err != nil {
return fmt.Errorf("store: delete conversation: %w", err)
}
return nil
}
+81
View File
@@ -0,0 +1,81 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// The instance_settings row is seeded by migration 0010 and there is exactly one
// (CHECK id = 1), so reads never branch on existence and writes never insert.
const instanceSettingsColumns = `agent_model, agent_enabled, version, updated_at`
// scanInstanceSettings reads the single settings row. agent_enabled is a nullable
// INTEGER (NULL = inherit env), so it is scanned through sql.NullInt64.
func scanInstanceSettings(s scanner) (*domain.InstanceSettings, error) {
var (
out domain.InstanceSettings
enabled sql.NullInt64
)
if err := s.Scan(&out.AgentModel, &enabled, &out.Version, &out.UpdatedAt); err != nil {
return nil, err
}
if enabled.Valid {
b := enabled.Int64 != 0
out.AgentEnabled = &b
}
return &out, nil
}
// GetInstanceSettings returns the single settings row.
func (d *DB) GetInstanceSettings(ctx context.Context) (*domain.InstanceSettings, error) {
s, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
`SELECT `+instanceSettingsColumns+` FROM instance_settings WHERE id = 1`))
if errors.Is(err, sql.ErrNoRows) {
// The migration seeds this row, so its absence is a broken database, not a
// normal "not found" the caller should paper over.
return nil, fmt.Errorf("store: instance_settings row missing (migration 0010 not applied?)")
}
if err != nil {
return nil, fmt.Errorf("store: get instance settings: %w", err)
}
return s, nil
}
// UpdateInstanceSettings applies a version-guarded update to the single row,
// following the same optimistic-concurrency contract as every mutable resource:
// the updated row on success, (current row, ErrVersionConflict) on a version
// mismatch. There is no ErrNotFound path — the row always exists.
//
// agentEnabled is nil to store SQL NULL (inherit env), or a pointer to store an
// explicit 0/1.
func (d *DB) UpdateInstanceSettings(ctx context.Context, s *domain.InstanceSettings) (*domain.InstanceSettings, error) {
var enabled any
if s.AgentEnabled != nil {
enabled = boolToInt(*s.AgentEnabled)
}
updated, err := scanInstanceSettings(d.sql.QueryRowContext(ctx,
`UPDATE instance_settings
SET agent_model = ?, agent_enabled = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = 1 AND version = ?
RETURNING `+instanceSettingsColumns,
s.AgentModel, enabled, s.Version,
))
if errors.Is(err, sql.ErrNoRows) {
current, gerr := d.GetInstanceSettings(ctx)
if gerr != nil {
return nil, gerr
}
return current, domain.ErrVersionConflict
}
if err != nil {
return nil, fmt.Errorf("store: update instance settings: %w", err)
}
return updated, nil
}
+32
View File
@@ -123,6 +123,38 @@ func (d *DB) ListJournalEntries(ctx context.Context, gardenID int64, f JournalFi
return entries, nil
}
// JournalCounts returns how many entries a garden holds, keyed by the object
// they are about — key 0 for entries about the garden itself.
//
// A count per object rather than a flag, and one query rather than one per bed:
// the indicator has to be available when the journal panel is closed, which is
// exactly when nothing else has loaded the entries.
func (d *DB) JournalCounts(ctx context.Context, gardenID int64) (map[int64]int, error) {
rows, err := d.sql.QueryContext(ctx,
`SELECT COALESCE(object_id, 0), COUNT(*)
FROM journal_entries WHERE garden_id = ?
GROUP BY COALESCE(object_id, 0)`,
gardenID)
if err != nil {
return nil, fmt.Errorf("store: journal counts: %w", err)
}
defer rows.Close()
counts := map[int64]int{}
for rows.Next() {
var objectID int64
var n int
if err := rows.Scan(&objectID, &n); err != nil {
return nil, fmt.Errorf("store: scan journal count: %w", err)
}
counts[objectID] = n
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate journal counts: %w", err)
}
return counts, nil
}
// UpdateJournalEntry applies a version-guarded update of the mutable columns.
// Same contract as every other mutable resource. The target (garden/object/
// planting) and the author are immutable: an entry is a record of an observation,
@@ -0,0 +1,36 @@
-- Agent conversations (#56): multi-turn chat with the garden assistant.
--
-- "Now do the same to the north bed" needs the previous exchange, and history
-- held only by the client would be lost on a refresh — which is exactly when
-- someone reloads to see whether the agent's change actually landed.
--
-- One conversation per (user, garden). Two people editing a shared garden get
-- separate threads: a conversation is a dialogue with one person, and merging
-- them would put words in someone's mouth.
CREATE TABLE agent_conversations (
id INTEGER PRIMARY KEY,
garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
UNIQUE (garden_id, user_id)
);
-- Only the user/assistant TEXT of each turn is kept, not the full model
-- transcript with its tool calls. Continuity needs what was said and what came
-- back; replaying a stored tool call would be replaying a decision made against
-- a garden that has since moved on. It also keeps the schema free of majordomo's
-- message shape, which is a dependency's business, not the database's.
CREATE TABLE agent_messages (
conversation_id INTEGER NOT NULL REFERENCES agent_conversations (id) ON DELETE CASCADE,
id INTEGER PRIMARY KEY,
role TEXT NOT NULL CHECK (role IN ('user', 'assistant')),
body TEXT NOT NULL,
-- The change set this turn produced, so the panel can offer Undo on the
-- message that caused it. SET NULL rather than CASCADE: losing the history
-- entry must not delete what was said about it.
change_set_id INTEGER REFERENCES change_sets (id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
CREATE INDEX idx_agent_messages_conversation ON agent_messages (conversation_id, id);
@@ -0,0 +1,35 @@
-- Instance settings (#79): the first configuration that lives in the database
-- rather than the environment.
--
-- Until now every preference hung off a garden or object row; this is pansy's
-- first INSTANCE-level state. A single-row table (CHECK id = 1) is the least
-- surprising shape for "there is exactly one of these" — a key/value table would
-- invite typo'd keys and lose the column types.
--
-- Only NON-SECRET agent settings live here. OLLAMA_CLOUD_API_KEY stays in the
-- environment on purpose: a copy in SQLite would land in every backup and in the
-- blast radius of the undo history. An admin can change WHICH model runs, not
-- WHOSE account pays for it.
--
-- Both agent columns are "inherit from env unless set":
-- * agent_model = '' means fall back to PANSY_AGENT_MODEL, then the built-in
-- default. So an instance that never opens Settings behaves exactly as it
-- did before this migration, and the documented env var keeps working.
-- * agent_enabled is NULLABLE: NULL means inherit PANSY_AGENT_ENABLED's
-- behaviour (on when a key is present), 0/1 is an explicit override. A plain
-- boolean couldn't tell "admin hasn't touched this" from "admin turned it
-- off", and those must deploy differently.
--
-- version drives the same optimistic-concurrency 409 every other mutable row
-- uses, so two admins editing at once conflict rather than clobber.
CREATE TABLE instance_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
agent_model TEXT NOT NULL DEFAULT '',
agent_enabled INTEGER CHECK (agent_enabled IN (0, 1)),
version INTEGER NOT NULL DEFAULT 1,
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
);
-- Seed the single row so every read is a plain SELECT with no "does it exist
-- yet" branch. Inherits everything from the environment out of the box.
INSERT INTO instance_settings (id, agent_model, agent_enabled) VALUES (1, '', NULL);
+3 -1
View File
@@ -119,7 +119,9 @@ func (d *DB) ListChangeSets(ctx context.Context, gardenID int64, limit, offset i
return sets, nil
}
// One grouped query for the whole page rather than N per-row counts.
// One grouped query for the whole page rather than N per-row counts. The
// service's countRevisions mirrors this grouping for change sets it has just
// written; TestRevertResultCarriesItsCounts holds the two in step.
countRows, err := d.sql.QueryContext(ctx,
`SELECT change_set_id, entity_type, op, COUNT(*)
FROM revisions
+13
View File
@@ -53,6 +53,19 @@ export function AppShell() {
{l.label}
</Link>
))}
{/* Settings is admin-only, matching the server's requireAdmin gate.
A non-admin who typed /settings still gets a 403 from the API — the
hidden link is convenience, not the security boundary. */}
{user?.isAdmin && (
<Link
to="/settings"
className={navLinkBase}
activeProps={{ className: navLinkActive }}
inactiveProps={{ className: navLinkInactive }}
>
Settings
</Link>
)}
</div>
{user ? (
+55 -3
View File
@@ -1,5 +1,12 @@
import { useEffect, useRef, type ReactNode } from 'react'
// Tabbable controls inside the dialog, in DOM order. type="hidden" inputs are
// excluded — they'd match `input:not([disabled])` and, sitting at a boundary,
// break the wrap math. Hoisted out of the handler so it isn't rebuilt per Tab.
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), ' +
'input:not([disabled]):not([type="hidden"]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
/**
* A centered modal dialog over a dimmed backdrop. Closes on Escape or a backdrop
* click, unless `busy` (a mutation is in flight) — then it stays put so the
@@ -27,12 +34,57 @@ export function Modal({
busyRef.current = busy
useEffect(() => {
cardRef.current?.focus()
const card = cardRef.current
// Remember who opened the dialog so focus can return there on close —
// otherwise it lands on <body> and a keyboard user loses their place.
const opener = document.activeElement as HTMLElement | null
card?.focus()
const focusable = () =>
Array.from(card?.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR) ?? [])
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape' && !busyRef.current) onCloseRef.current()
if (e.key === 'Escape' && !busyRef.current) {
onCloseRef.current()
return
}
if (e.key !== 'Tab') return
const items = focusable()
if (items.length === 0) {
e.preventDefault()
card?.focus()
return
}
const first = items[0]
const last = items[items.length - 1]
const active = document.activeElement
// If focus is NOT inside the dialog, pull it back in rather than let Tab
// escape. This is the robust case that covers focus having fallen to
// <body> — a control that was removed (ShareGardenModal's remove-share
// button) or disabled while busy — as well as any externally-stolen focus.
if (!card || !card.contains(active)) {
e.preventDefault()
;(e.shiftKey ? last : first).focus()
return
}
if (e.shiftKey && (active === first || active === card)) {
e.preventDefault()
last.focus()
} else if (!e.shiftKey && active === last) {
e.preventDefault()
first.focus()
}
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
return () => {
document.removeEventListener('keydown', onKey)
// Restore focus to the opener only if it's still in the document — the
// delete/clear flows this trap targets often remove the element that
// opened the dialog (a garden card, a plop row). A disconnected node's
// focus() silently no-ops and leaves focus on <body>, so fall through to
// that case explicitly rather than pretend it worked.
if (opener && opener.isConnected) opener.focus()
}
}, [])
return (
+239
View File
@@ -0,0 +1,239 @@
import { useEffect, useRef, useState, type ReactNode } from 'react'
import { Alert } from '@/components/ui/Alert'
import { errorMessage } from '@/lib/api'
import { Button } from '@/components/ui/Button'
import { TextArea } from '@/components/ui/TextArea'
import { cn } from '@/lib/cn'
import {
describeStep,
streamChat,
useAgentHistory,
useAgentRefresh,
useClearAgentHistory,
type AgentStep,
type AgentTurn,
} from '@/lib/agent'
import { useUndo } from '@/lib/history'
import { UndoButton } from './UndoButton'
/**
* Talk to the garden assistant, in the editor beside the canvas.
*
* Here rather than on its own page because watching the garden change as the
* agent works IS the confirmation — which is what makes acting without asking
* first tolerable. It also means the agent never has to guess which garden you
* mean.
*/
export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: boolean }) {
const history = useAgentHistory(gardenId, true)
const clear = useClearAgentHistory(gardenId)
const refresh = useAgentRefresh(gardenId)
const undo = useUndo(gardenId)
const [input, setInput] = useState('')
// The turn in flight: what we sent, the steps so far, and how it ended.
const [pending, setPending] = useState<{ message: string; steps: AgentStep[] } | null>(null)
const [error, setError] = useState<string | null>(null)
const [warning, setWarning] = useState<string | null>(null)
const abort = useRef<AbortController | null>(null)
const bottom = useRef<HTMLDivElement>(null)
// Deliberately NOT aborted on unmount. Selecting an object auto-switches the
// rail to the inspector, so aborting here would mean clicking the canvas
// mid-turn silently killed the turn — and the canvas is exactly what you're
// meant to be watching. The request continues, the exchange is persisted
// server-side, and coming back to this tab shows it. Only Stop aborts, and
// even that only stops us READING: the turn keeps running server-side, which
// is why its work still lands in History either way.
// Follow the conversation as it grows, including mid-turn as steps arrive.
useEffect(() => {
bottom.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [history.data, pending])
const send = () => {
const message = input.trim()
if (!message || pending) return
setInput('')
setError(null)
setWarning(null)
setPending({ message, steps: [] })
const controller = new AbortController()
abort.current = controller
void streamChat(
gardenId,
message,
{
onStep: (step) => {
setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p))
// The canvas updating under the conversation is the whole point of
// putting the chat here, so refresh it as each step lands — but only
// it: nothing else can have changed until the turn commits.
refresh.canvas()
},
onDone: (turn: AgentTurn) => {
setPending(null)
refresh.everything()
if (turn.truncated) {
setError('That turned into more steps than I should take at once — check what changed before continuing.')
}
},
onWarning: setWarning,
onError: (message) => {
setPending(null)
setError(message)
// Something may still have landed before it failed.
refresh.everything()
},
},
controller.signal,
)
}
const messages = history.data ?? []
return (
<div className="flex h-full flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-fg">Assistant</h2>
{messages.length > 0 && (
<button
type="button"
onClick={() =>
clear.mutate(undefined, {
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
})
}
disabled={clear.isPending}
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
Start over
</button>
)}
</div>
{!canEdit && (
<p className="rounded-md bg-border/40 px-2 py-1 text-xs text-muted">
You can only view this garden, so the assistant can't change anything in it.
</p>
)}
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{history.isPending && <p className="text-sm text-muted">Loading the conversation…</p>}
{/* A failed load rendering as an empty thread would look like the
conversation had been lost, which is a much worse thing to believe. */}
{history.isError && (
<Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>
)}
{history.isSuccess && messages.length === 0 && !pending && (
<p className="text-sm text-muted">
Ask for what you want and it'll do it — “change the garlic bed to cucumbers this year”, “fill the
north half of the west bed with beans”, “what's in here?”. Everything it does lands as one change you
can undo.
</p>
)}
{messages.map((m) => (
<Bubble key={m.id} role={m.role} body={m.body}>
{/* Undo on the turn itself, so the common case never involves
opening the History panel. Same hook #49 uses, not a second
implementation. */}
{m.role === 'assistant' && m.changeSetId != null && canEdit && (
<UndoButton changeSet={{ id: m.changeSetId }} undo={undo} className="mt-1 items-start" />
)}
</Bubble>
))}
{pending && (
<>
<Bubble role="user" body={pending.message} />
<div className="rounded-lg border border-border px-2.5 py-2 text-sm">
<ol className="flex flex-col gap-0.5">
{pending.steps.map((s) => (
<li key={s.index} className="text-xs text-muted">
{describeStep(s)}…
</li>
))}
</ol>
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
{pending.steps.length === 0 ? 'Thinking…' : 'Working…'}
</p>
</div>
</>
)}
{warning && <Alert tone="info">{warning}</Alert>}
{error && <Alert>{error}</Alert>}
<div ref={bottom} />
</div>
<div className="flex flex-col gap-2 border-t border-border pt-2">
<TextArea
label="Message"
name="agentMessage"
rows={2}
placeholder="Change the garlic bed to cucumbers this year"
value={input}
disabled={!!pending}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
// Enter sends, Shift+Enter breaks the line — the convention every
// chat box uses, and typing a newline by accident mid-thought is a
// worse failure than the reverse.
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
send()
}
}}
/>
<div className="flex justify-end gap-2">
{pending && (
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
abort.current?.abort()
setPending(null)
refresh.everything()
}}
>
Stop
</Button>
)}
<Button className="px-3 py-1.5 text-sm" disabled={!!pending || input.trim() === ''} onClick={send}>
{pending ? 'Working…' : 'Send'}
</Button>
</div>
</div>
</div>
)
}
function Bubble({
role,
body,
children,
}: {
role: 'user' | 'assistant'
body: string
children?: ReactNode
}) {
const mine = role === 'user'
return (
<div className={cn('flex flex-col', mine ? 'items-end' : 'items-start')}>
<div
className={cn(
'max-w-[90%] whitespace-pre-wrap rounded-lg px-2.5 py-2 text-sm',
mine ? 'bg-accent/15 text-fg' : 'border border-border text-fg',
)}
>
{body}
</div>
{children}
</div>
)
}
+8 -6
View File
@@ -5,23 +5,25 @@ import { useClearObject } from '@/lib/objects'
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
* are kept with removed_at, so history survives). */
export function ClearBedModal({
objectId,
objectName,
plops,
plopCount,
gardenId,
onClose,
}: {
objectId: number
objectName: string
plops: { id: number; version: number }[]
plopCount: number
gardenId: number
onClose: () => void
}) {
const clear = useClearObject(gardenId)
const n = plops.length
return (
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Remove all <span className="font-medium text-fg">{n}</span> {n === 1 ? 'plant' : 'plants'} from{' '}
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
</p>
<div className="flex justify-end gap-2">
@@ -31,8 +33,8 @@ export function ClearBedModal({
<Button
type="button"
variant="danger"
disabled={clear.isPending || n === 0}
onClick={() => clear.mutate(plops, { onSuccess: onClose })}
disabled={clear.isPending || plopCount === 0}
onClick={() => clear.mutate(objectId, { onSuccess: onClose })}
>
{clear.isPending ? 'Clearing' : 'Clear bed'}
</Button>
+3 -3
View File
@@ -4,9 +4,9 @@ import { cn } from '@/lib/cn'
/**
* The editor's side rail, and the answer to "four things want one rail".
*
* The inspector, history, and (later) the journal and chat panel all want the
* same strip of screen. Rather than each bolting on its own chrome, they are
* tabs in one rail — which keeps the canvas one width instead of a different
* The inspector, history, journal, and (later) the chat panel all want the same
* strip of screen. Rather than each bolting on its own chrome, they are tabs in
* one rail — which keeps the canvas one width instead of a different
* width per panel, and means adding the journal or chat is adding a tab.
*
* Two constraints shaped it:
+6
View File
@@ -235,7 +235,13 @@ export function GardenCanvas({
className="h-full w-full select-none"
style={{ touchAction: 'none' }}
onPointerDown={onCanvasPointerDown}
// role="application" tells a screen reader this is an interactive canvas
// to operate, not a document to read linearly. The <title> names it, and
// objects inside are individually focusable buttons (see ObjectShape).
role="application"
aria-label={`${garden.name} — garden layout. Tab between objects; Enter selects; arrow keys nudge a selection.`}
>
<title>{garden.name} garden layout</title>
<g transform={`translate(${viewport.tx} ${viewport.ty}) scale(${viewport.scale})`}>
{drawnGridCm != null && (
<>
+1 -1
View File
@@ -116,7 +116,7 @@ function HistoryEntry({
<UndoButton
changeSet={changeSet}
undo={undo}
className="w-32 shrink-0"
className="w-32 shrink-0 text-right"
label={reverted ? 'Undo again' : 'Undo'}
/>
)}
+18
View File
@@ -31,12 +31,20 @@ export function Inspector({
gardenId,
unit,
onFocus,
onAddNote,
noteCount = 0,
readOnly = false,
}: {
object: EditorObject
gardenId: number
unit: UnitPref
onFocus?: () => void
/** Opens the journal scoped to this object. Two taps from a selected bed to
* typing is the bar; anything more and the log stays empty. */
onAddNote?: () => void
/** How many journal entries are about this object, so the log is discoverable
* from the thing it's about rather than being a panel you have to remember. */
noteCount?: number
readOnly?: boolean
}) {
const update = useUpdateObject(gardenId)
@@ -134,6 +142,16 @@ export function Inspector({
</Button>
)}
{onAddNote && (
<Button variant="ghost" onClick={onAddNote} className="w-full text-sm">
{noteCount > 0
? `📝 ${noteCount} ${noteCount === 1 ? 'note' : 'notes'}`
: readOnly
? '📝 No notes'
: '📝 Add note'}
</Button>
)}
{/* A disabled fieldset makes every control below read-only for viewers in
one shot (no per-input disabled). */}
<fieldset disabled={readOnly} className="flex min-w-0 flex-col gap-3 border-0 p-0">
+309
View File
@@ -0,0 +1,309 @@
import { useState } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { TextArea } from '@/components/ui/TextArea'
import { TextField } from '@/components/ui/TextField'
import { errorMessage } from '@/lib/api'
import {
formatObservedAt,
today,
useCreateJournalEntry,
useDeleteJournalEntry,
useJournal,
useUpdateJournalEntry,
type JournalEntry,
} from '@/lib/journal'
import type { EditorObject } from './types'
import { objectDisplayName } from './kinds'
/**
* The garden's journal: write an entry, read the season back.
*
* Notes get written standing in the garden holding a phone, usually one-handed.
* If it takes more than a couple of taps from looking at a bed to typing a
* sentence, the log stays empty — so the composer is open by default rather than
* behind an "add" button, and selecting a bed pre-scopes it to that bed.
*/
export function JournalPanel({
gardenId,
canEdit,
currentUserId,
isOwner,
objects,
scopeObjectId,
onScopeChange,
}: {
gardenId: number
canEdit: boolean
currentUserId?: number
isOwner: boolean
objects: EditorObject[]
/** Which bed the panel is filtered to, if any. */
scopeObjectId: number | null
onScopeChange: (id: number | null) => void
}) {
const filter = scopeObjectId != null ? { objectId: scopeObjectId } : {}
const journal = useJournal(gardenId, filter)
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
const scopedObject = objects.find((o) => o.id === scopeObjectId) ?? null
return (
<div className="flex flex-col gap-3">
<div className="flex items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-fg">Journal</h2>
{objects.length > 0 && (
<select
value={scopeObjectId ?? ''}
onChange={(e) => {
const id = Number(e.target.value)
onScopeChange(e.target.value === '' || !Number.isFinite(id) ? null : id)
}}
className="max-w-[9rem] truncate rounded-md border border-border bg-surface px-1.5 py-1 text-xs text-fg outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
>
<option value="">Whole garden</option>
{objects.map((o) => (
<option key={o.id} value={o.id}>
{objectDisplayName(o)}
</option>
))}
</select>
)}
</div>
{canEdit && (
<Composer
gardenId={gardenId}
objectId={scopeObjectId}
scopeLabel={scopedObject ? objectDisplayName(scopedObject) : null}
/>
)}
{journal.isPending && <p className="text-sm text-muted">Loading</p>}
{journal.isError && entries.length === 0 && (
<Alert>{errorMessage(journal.error, 'Could not load the journal.')}</Alert>
)}
{journal.isSuccess && entries.length === 0 && (
<p className="text-sm text-muted">
{scopedObject
? `Nothing written about ${objectDisplayName(scopedObject)} yet.`
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'}
</p>
)}
<ol className="flex flex-col gap-2">
{entries.map((e) => (
<Entry
key={e.id}
entry={e}
gardenId={gardenId}
objects={objects}
canDelete={canEdit && (e.authorId === currentUserId || isOwner)}
canRewrite={canEdit && e.authorId === currentUserId}
/>
))}
</ol>
{journal.hasNextPage && (
<Button
variant="ghost"
className="text-sm"
disabled={journal.isFetchingNextPage}
onClick={() => void journal.fetchNextPage()}
>
{journal.isFetchingNextPage ? 'Loading…' : 'Load older'}
</Button>
)}
{journal.isError && entries.length > 0 && (
<p className="text-xs text-red-700 dark:text-red-400">
{errorMessage(journal.error, "Couldn't load older entries.")}
</p>
)}
</div>
)
}
function Composer({
gardenId,
objectId,
scopeLabel,
}: {
gardenId: number
objectId: number | null
scopeLabel: string | null
}) {
const create = useCreateJournalEntry(gardenId)
const [body, setBody] = useState('')
// Editable so an observation can be backdated: you write up Saturday on Sunday.
const [observedAt, setObservedAt] = useState(today())
const [error, setError] = useState<string | null>(null)
const submit = () => {
const text = body.trim()
if (!text) return
setError(null)
create.mutate(
{ body: text, observedAt, objectId: objectId ?? undefined },
{
onSuccess: () => {
setBody('')
setObservedAt(today())
},
onError: (err) => setError(errorMessage(err, "Couldn't save that note.")),
},
)
}
return (
<div className="flex flex-col gap-2 rounded-lg border border-border p-2">
<TextArea
label={scopeLabel ? `Note about ${scopeLabel}` : 'Note'}
name="journalBody"
rows={3}
placeholder="What happened?"
value={body}
onChange={(e) => setBody(e.target.value)}
/>
<div className="flex items-end gap-2">
<div className="flex-1">
<TextField
label="Observed"
name="observedAt"
type="date"
value={observedAt}
onChange={(e) => setObservedAt(e.target.value)}
/>
</div>
<Button className="shrink-0" disabled={create.isPending || body.trim() === ''} onClick={submit}>
{create.isPending ? 'Saving…' : 'Save note'}
</Button>
</div>
{error && <p className="text-xs text-red-700 dark:text-red-400">{error}</p>}
</div>
)
}
function Entry({
entry,
gardenId,
objects,
canDelete,
canRewrite,
}: {
entry: JournalEntry
gardenId: number
objects: EditorObject[]
/** May remove it: the author, or the garden owner. */
canDelete: boolean
/** May rewrite the text: the author only — rewriting someone else's
* observation under their name is a different act from removing it. */
canRewrite: boolean
}) {
const update = useUpdateJournalEntry(gardenId)
const del = useDeleteJournalEntry(gardenId)
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(entry.body)
const [error, setError] = useState<string | null>(null)
const about = objects.find((o) => o.id === entry.objectId) ?? null
// Re-sync the draft when the entry changes underneath — a refetch after
// someone else's edit, or this entry's own successful save. Skipped while
// editing so a background refetch can't clobber what's being typed; the
// version guard is what catches a genuine collision.
const [syncedBody, setSyncedBody] = useState(entry.body)
if (!editing && entry.body !== syncedBody) {
setSyncedBody(entry.body)
setDraft(entry.body)
}
const save = () => {
const text = draft.trim()
if (!text) {
// Silence here reads as a broken button; say why nothing happened.
setError('An entry needs some text. Delete it instead if that\'s what you meant.')
return
}
setError(null)
update.mutate(
{ id: entry.id, version: entry.version, body: text },
{
onSuccess: () => {
setEditing(false)
setError(null)
},
onError: (err) => setError(errorMessage(err, "Couldn't save that edit.")),
},
)
}
return (
<li className="rounded-lg border border-border px-2.5 py-2 text-sm">
{editing ? (
<div className="flex flex-col gap-2">
<TextArea label="Note" name={`entry-${entry.id}`} rows={3} value={draft} onChange={(e) => setDraft(e.target.value)} />
<div className="flex justify-end gap-1">
<Button
variant="ghost"
className="px-2 py-1 text-xs"
onClick={() => {
setDraft(entry.body)
setEditing(false)
setError(null)
}}
>
Cancel
</Button>
<Button className="px-2 py-1 text-xs" disabled={update.isPending} onClick={save}>
{update.isPending ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
) : (
<p className="whitespace-pre-wrap text-fg">{entry.body}</p>
)}
<p className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-muted">
<time dateTime={entry.observedAt}>{formatObservedAt(entry.observedAt)}</time>
<span aria-hidden>·</span>
<span>{entry.authorName}</span>
{about && (
<>
<span aria-hidden>·</span>
<span className="rounded bg-border/60 px-1 py-px">{objectDisplayName(about)}</span>
</>
)}
{entry.plantingId != null && <span className="rounded bg-border/60 px-1 py-px">planting</span>}
</p>
{(canRewrite || canDelete) && !editing && (
<div className="mt-1 flex justify-end gap-1">
{canRewrite && (
<button
type="button"
onClick={() => setEditing(true)}
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
>
Edit
</button>
)}
{canDelete && (
<button
type="button"
disabled={del.isPending}
onClick={() => {
setError(null) // clear a previous failure so a retry isn't read as another
del.mutate(entry.id, {
onError: (err) => setError(errorMessage(err, "Couldn't delete that note.")),
})
}}
className="rounded px-1.5 py-0.5 text-xs text-red-700 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-red-400"
>
Delete
</button>
)}
</div>
)}
{error && <p className="mt-1 text-xs text-red-700 dark:text-red-400">{error}</p>}
</li>
)
}
+42 -2
View File
@@ -1,5 +1,6 @@
import { memo, type PointerEvent } from 'react'
import { memo, type KeyboardEvent, type PointerEvent } from 'react'
import { objectTransform } from './shared'
import { kindDef, objectDisplayName } from './kinds'
import type { EditorObject } from './types'
const DEFAULT_FILL = '#8a8a8a'
@@ -57,11 +58,50 @@ export const ObjectShape = memo(function ObjectShape({
onSelect(object.id)
}
// Keyboard path into selection (#84): the arrow-key nudge handler already
// exists but only ever acted on a pointer selection, so it was unreachable
// without a mouse. Enter/Space on a focused object selects it, which is the
// step that was missing.
function handleKey(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onSelect(object.id)
}
}
const stroke = selected ? '#2f7a3e' : '#00000033'
const strokeWidth = selected ? 2 : 1
// A concise accessible name: the object's label plus its kind's canonical
// label, e.g. "North Bed, In-ground" — reusing kindDef so it never diverges
// from what the UI shows (an ad-hoc kind.replace() gave "in ground"). The
// dimensions aren't included; they need the garden's unit context this
// component doesn't hold, so they're a follow-up.
const kindLabel = kindDef(object.kind)?.label ?? object.kind
const label = `${objectDisplayName(object)}, ${kindLabel}`
// Keyboard focus needs to be VISIBLE — that's the point of making the canvas
// keyboard-reachable. The `object-shape` class carries a :focus-visible rule
// (styles/index.css) that draws a dashed ring; :focus-visible means it shows
// for keyboard focus but NOT a mouse click, which is exactly what we want. CSS
// rather than React state because onFocus on an SVG <g> is unreliable and a
// presentation attribute is overridden by any CSS rule.
return (
<g transform={objectTransform(object)} onPointerDown={handleDown} style={{ cursor: 'pointer' }}>
<g
className="object-shape"
transform={objectTransform(object)}
onPointerDown={handleDown}
onKeyDown={handleKey}
role="button"
tabIndex={0}
aria-label={label}
// aria-current, not aria-pressed: selecting an object isn't a toggle (a
// toggle is what aria-pressed means). aria-current marks it as the active
// item among the objects. Omitted, not "false", when unselected.
aria-current={selected || undefined}
style={{ cursor: 'pointer' }}
>
{object.shape === 'circle' ? (
<ellipse
cx={0}
+7 -3
View File
@@ -1,6 +1,6 @@
import { Button } from '@/components/ui/Button'
import { cn } from '@/lib/cn'
import type { ChangeSet, UndoOutcome, useUndo } from '@/lib/history'
import type { UndoOutcome, UndoTarget, useUndo } from '@/lib/history'
/**
* Undo, plus what happened. Paired with useUndo so the history list and the
@@ -14,7 +14,7 @@ export function UndoButton({
className,
label = 'Undo',
}: {
changeSet: ChangeSet
changeSet: UndoTarget
undo: ReturnType<typeof useUndo>
className?: string
label?: string
@@ -43,8 +43,12 @@ const TONE_CLASS: Record<Exclude<UndoOutcome['tone'], 'pending'>, string> = {
function OutcomeNote({ outcome }: { outcome: UndoOutcome }) {
if (outcome.tone === 'pending') return null
// No text alignment of its own: the container decides. The history list stacks
// this to the right of an entry, the chat panel puts it under a left-aligned
// message, and a hard-coded text-right made the chat's copy read against its
// own column.
return (
<p role="status" className={cn('text-right text-xs', TONE_CLASS[outcome.tone])}>
<p role="status" className={cn('text-xs', TONE_CLASS[outcome.tone])}>
{outcome.message}
</p>
)
+10
View File
@@ -38,6 +38,12 @@ interface EditorState {
seasonYear: number | null
setSeasonYear: (year: number | null) => void
// Which bed the journal tab is filtered to, or null for the whole garden.
// Separate from selectedId deliberately: you can scope the journal to a bed
// and then select something else without the list moving under you.
journalObjectId: number | null
setJournalObjectId: (id: number | null) => void
// The plant armed for placing plops (set after the PlantPicker choice); stays
// armed for repeat-placement until cleared (Escape / done). null = not placing.
armedPlant: Plant | null
@@ -91,6 +97,9 @@ export const useEditorStore = create<EditorState>((set) => ({
seasonYear: null,
setSeasonYear: (year) => set({ seasonYear: year }),
journalObjectId: null,
setJournalObjectId: (id) => set({ journalObjectId: id }),
armedPlant: null,
armedLotId: null,
setArmedPlant: (p, lotId = null) => set({ armedPlant: p, armedLotId: p ? lotId : null }),
@@ -119,5 +128,6 @@ export const useEditorStore = create<EditorState>((set) => ({
livePlanting: null,
railTab: null,
seasonYear: null,
journalObjectId: null,
}),
}))
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { describeStep } from './agent'
describe('describeStep', () => {
// Raw tool names tell you the agent is busy; these tell you what it's busy
// DOING, which is the difference between the panel feeling alive and hung.
it("names tools in the app's own vocabulary", () => {
expect(describeStep({ index: 0, tools: ['clear_object'] })).toBe('Clearing a bed')
expect(describeStep({ index: 1, tools: ['find_plant'] })).toBe('Looking up a plant')
})
it('collapses repeats within one step', () => {
expect(describeStep({ index: 0, tools: ['fill_region', 'fill_region'] })).toBe('Filling a bed')
})
it('joins distinct tools', () => {
expect(describeStep({ index: 0, tools: ['clear_object', 'fill_region'] })).toBe('Clearing a bed, Filling a bed')
})
it('says something for a step with no tool calls', () => {
expect(describeStep({ index: 0, tools: [] })).toBe('Thinking')
})
// A tool added server-side before the client knows about it should degrade to
// something readable rather than showing snake_case at the user.
it('falls back readably for an unknown tool', () => {
expect(describeStep({ index: 0, tools: ['prune_orchard'] })).toBe('prune orchard')
})
})
+245
View File
@@ -0,0 +1,245 @@
// The garden assistant's client (#57).
//
// The chat lives in the editor, not on its own page, because watching the canvas
// change as the agent works IS the confirmation — which is what makes acting
// without asking first tolerable. So this module's job is as much about
// surfacing progress as it is about sending a message.
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { API_BASE, api } from './api'
import { gardenFullKey } from './objects'
import { historyKey } from './history'
const capabilitiesSchema = z.object({ agent: z.boolean() })
export const capabilitiesKey = ['capabilities'] as const
/** Whether the assistant is live RIGHT NOW. Without it the panel isn't rendered
* at all — a dead button is worse than no button.
*
* Not `staleTime: Infinity` any more: an admin can turn the assistant on or off
* in Settings (#79), so this must be able to change under a running page. The
* settings save invalidates this key directly; the finite staleTime just means
* another admin's change is picked up on the next focus/remount rather than
* never. */
export function useCapabilities() {
return useQuery({
queryKey: capabilitiesKey,
queryFn: async () => capabilitiesSchema.parse(await api.get('/capabilities')),
staleTime: 60_000,
})
}
export const agentMessageSchema = z.object({
id: z.number(),
conversationId: z.number(),
role: z.enum(['user', 'assistant']),
body: z.string(),
changeSetId: z.number().optional(),
createdAt: z.string(),
})
export type AgentMessage = z.infer<typeof agentMessageSchema>
const historySchema = z.object({ messages: z.array(agentMessageSchema) })
export function agentHistoryKey(gardenId: number) {
return ['gardens', gardenId, 'agent-history'] as const
}
/** The stored conversation, so a reload doesn't lose the thread — which is
* exactly when someone reloads, to check whether a change actually landed. */
export function useAgentHistory(gardenId: number, enabled: boolean) {
return useQuery({
queryKey: agentHistoryKey(gardenId),
enabled,
queryFn: async (): Promise<AgentMessage[]> =>
historySchema.parse(await api.get(`/gardens/${gardenId}/agent/history`)).messages,
})
}
export function useClearAgentHistory(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (): Promise<void> => {
await api.delete(`/gardens/${gardenId}/agent/history`)
},
onSuccess: () => qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) }),
})
}
/** One completed turn, as the server reports it. */
export interface AgentTurn {
reply: string
changeSetId?: number
steps: number
truncated?: boolean
}
/** A step the model just finished, named in the app's own vocabulary. */
export interface AgentStep {
index: number
tools: string[]
}
// What each tool is doing, in words. Raw tool names ("fill_region") tell you the
// agent is busy; these tell you what it's busy DOING, which is the difference
// between the panel feeling alive and feeling hung.
const TOOL_LABELS: Record<string, string> = {
list_gardens: 'Looking at your gardens',
describe_garden: 'Reading the garden',
create_object: 'Adding a bed',
move_object: 'Moving a bed',
place_planting: 'Planting',
fill_region: 'Filling a bed',
clear_object: 'Clearing a bed',
find_plant: 'Looking up a plant',
create_plant: 'Adding a plant to your catalog',
add_journal_entry: 'Writing a journal note',
}
export function describeStep(step: AgentStep): string {
if (step.tools.length === 0) return 'Thinking'
const labels = step.tools.map((t) => TOOL_LABELS[t] ?? t.replace(/_/g, ' '))
// Repeated tools in one step read as one action, not a list of identical ones.
return [...new Set(labels)].join(', ')
}
const chatEventSchema = z.object({
step: z.object({ index: z.number(), tools: z.array(z.string()) }).optional(),
done: z
.object({
reply: z.string(),
changeSetId: z.number().optional(),
steps: z.number(),
truncated: z.boolean().optional(),
})
.optional(),
error: z.string().optional(),
// The turn worked but something adjacent to it didn't — currently, the
// exchange couldn't be saved. Dropping this on the floor would recreate
// exactly the silent swallow the server added it to avoid.
warning: z.string().optional(),
})
export interface StreamHandlers {
onStep: (step: AgentStep) => void
onDone: (turn: AgentTurn) => void
onError: (message: string) => void
/** The turn succeeded, but something alongside it didn't. */
onWarning?: (message: string) => void
}
/**
* Send a message and stream the reply.
*
* Hand-rolled rather than EventSource, which can only issue GETs — this needs a
* POST body. The wire format is still SSE so a proxy that understands it doesn't
* buffer, and so switching to EventSource later wouldn't change the server.
*/
export async function streamChat(
gardenId: number,
message: string,
handlers: StreamHandlers,
signal?: AbortSignal,
): Promise<void> {
let res: Response
try {
res = await fetch(`${API_BASE}/agent/chat`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ gardenId, message }),
credentials: 'same-origin',
signal,
})
} catch {
// An abort here is the caller's own doing — Stop, or navigating away — not a
// failure to report back to them. The read loop below already knew this; the
// request path did not.
if (signal?.aborted) return
handlers.onError('Could not reach the server.')
return
}
if (!res.ok || !res.body) {
// 503 is the assistant being turned off at runtime (#79) — the route exists,
// there's just no model behind it. Distinct from a 404, which would mean the
// whole endpoint is absent.
handlers.onError(
res.status === 503
? "The assistant isn't enabled on this instance."
: res.status === 404
? "This instance doesn't have the assistant configured."
: 'The assistant is not available right now.',
)
return
}
const reader = res.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
for (;;) {
let chunk: ReadableStreamReadResult<Uint8Array>
try {
chunk = await reader.read()
} catch {
// An aborted read is the caller navigating away, not a failure worth
// reporting back to them.
if (signal?.aborted) return
handlers.onError('The connection dropped partway through.')
return
}
if (chunk.done) break
buffer += decoder.decode(chunk.value, { stream: true })
// SSE frames are separated by a blank line; anything after the last one is
// a partial frame to keep for the next chunk.
const frames = buffer.split('\n\n')
buffer = frames.pop() ?? ''
for (const frame of frames) {
const line = frame.split('\n').find((l) => l.startsWith('data:'))
if (!line) continue
// Parsed AND validated: a malformed or unexpected frame shouldn't kill a
// working stream, and shouldn't be trusted into the UI either.
const parsed = chatEventSchema.safeParse(safeJson(line.slice(5).trim()))
if (!parsed.success) continue
const e = parsed.data
if (e.warning) handlers.onWarning?.(e.warning)
if (e.error) handlers.onError(e.error)
else if (e.step) handlers.onStep(e.step)
else if (e.done) handlers.onDone(e.done)
}
}
}
function safeJson(raw: string): unknown {
try {
return JSON.parse(raw)
} catch {
return null
}
}
/**
* Refreshes for the two moments that need different amounts of work.
*
* Mid-turn, only the canvas can have changed: the change set isn't written until
* the turn commits, and the exchange isn't stored until it finishes. Refetching
* those on every step would be up to 2×(N1) requests per turn for data that
* cannot have moved.
*/
export function useAgentRefresh(gardenId: number) {
const qc = useQueryClient()
const canvas = () => {
void qc.invalidateQueries({ queryKey: gardenFullKey(gardenId) })
}
return {
/** After a step: the garden may have changed under the conversation. */
canvas,
/** After a turn: the change set and the stored exchange exist now too. */
everything: () => {
canvas()
void qc.invalidateQueries({ queryKey: historyKey(gardenId) })
void qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) })
},
}
}
+35
View File
@@ -49,6 +49,14 @@ describe('describeConflict', () => {
describe('describeUndo', () => {
const target = changeSet({ counts: [{ entityType: 'object', op: 'update', n: 3 }] })
// The bug this pair exists for: a real revert response carries a change set
// whose counts the server didn't populate. Reading that as "nothing happened"
// told the user a successful undo had done nothing.
it('trusts the change set, not the tally, for whether anything happened', () => {
const out = describeUndo({ id: 5 }, { changeSet: changeSet({ id: 6, counts: [] }), conflicts: [] })
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
})
it('is a plain success when nothing conflicted', () => {
const out = describeUndo(target, {
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 3 }] }),
@@ -61,6 +69,7 @@ describe('describeUndo', () => {
// already a no-op — reachable by undoing a creation whose object is gone.
// Claiming "Undone." there reports work that didn't happen.
it("doesn't claim to have undone a no-op", () => {
// A NULL change set — not an empty tally — is the server's no-op signal.
const out = describeUndo(target, { changeSet: null, conflicts: [] })
expect(out.tone).toBe('ok')
expect(out.message).toBe('Nothing left to undo — this was already reversed.')
@@ -113,3 +122,29 @@ describe('totalChanges', () => {
).toBe(13)
})
})
describe('describeUndo with an unknown total', () => {
// The chat panel offers Undo on a turn knowing only its change set id — the
// agent's reply carries the id, not the tally. Inventing a denominator to
// fill the sentence would be worse than not having one.
it("doesn't invent a denominator it was never given", () => {
const out = describeUndo(
{ id: 5 },
{
changeSet: changeSet({ id: 6, counts: [{ entityType: 'planting', op: 'delete', n: 2 }] }),
conflicts: [{ entityType: 'object', entityId: 7, reason: 'changed', name: 'North Bed' }],
},
)
expect(out.tone).toBe('partial')
expect(out.message).toBe('Partly undone — “North Bed” was edited since, so it was left alone.')
expect(out.message).not.toMatch(/\d+ of \d+/)
})
it('still reports a clean undo the same way', () => {
const out = describeUndo(
{ id: 5 },
{ changeSet: changeSet({ id: 6, counts: [{ entityType: 'object', op: 'update', n: 1 }] }), conflicts: [] },
)
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
})
})
+31 -11
View File
@@ -113,9 +113,10 @@ export function useRevertChangeSet(gardenId: number) {
})
}
/** How many rows a change set touched, for "3 changes" in the list. */
export function totalChanges(cs: ChangeSet): number {
return cs.counts.reduce((sum, c) => sum + c.n, 0)
/** How many rows a change set touched, for "3 changes" in the list. Undefined
* counts total zero, which callers read as "no denominator to quote". */
export function totalChanges(cs: { counts?: ChangeCount[] }): number {
return (cs.counts ?? []).reduce((sum, c) => sum + c.n, 0)
}
const ENTITY_NOUNS: Record<ChangeCount['entityType'], [string, string]> = {
@@ -171,7 +172,7 @@ export function useUndo(gardenId: number) {
const revert = useRevertChangeSet(gardenId)
const [outcomes, setOutcomes] = useState<Record<number, UndoOutcome>>({})
const undo = (cs: ChangeSet) => {
const undo = (cs: UndoTarget) => {
setOutcomes((prev) => ({ ...prev, [cs.id]: { tone: 'pending', message: 'Undoing…' } }))
revert.mutate(cs.id, {
onSuccess: (result) => {
@@ -197,6 +198,19 @@ export function useUndo(gardenId: number) {
}
}
/**
* What undo needs to know about a change set.
*
* `counts` is optional because the chat panel offers Undo on a turn knowing only
* its change set id — the agent's reply carries the id, not the tally. Fabricating
* counts to satisfy a type would produce a confidently wrong "1 of 1 changes
* undone"; leaving them out lets describeUndo say what it actually knows.
*/
export interface UndoTarget {
id: number
counts?: ChangeCount[]
}
export interface UndoOutcome {
tone: 'pending' | 'ok' | 'partial' | 'error'
message: string
@@ -208,19 +222,25 @@ export interface UndoOutcome {
* useful thing to report: a bare failure would be a lie about the two that did
* apply, and a bare success would hide the one that didn't.
*/
export function describeUndo(target: ChangeSet, result: RevertResult): UndoOutcome {
export function describeUndo(target: UndoTarget, result: RevertResult): UndoOutcome {
const skipped = result.conflicts.map(describeConflict).join('; ')
// A NULL change set is the server's signal that nothing needed doing. An empty
// `counts` is not the same thing and must not be read as one — that conflation
// made a successful undo report "nothing left to undo", which is the worst
// possible thing to tell someone about an action that just worked.
const didSomething = result.changeSet != null
const applied = result.changeSet ? totalChanges(result.changeSet) : 0
if (result.conflicts.length === 0) {
// The server answers 200 with a null change set when every revision was
// already a no-op — reachable by undoing a creation whose object is gone.
// Claiming "Undone." there would be reporting work that didn't happen.
if (applied === 0) return { tone: 'ok', message: 'Nothing left to undo — this was already reversed.' }
// Reachable by undoing a creation whose object is already gone.
if (!didSomething) return { tone: 'ok', message: 'Nothing left to undo — this was already reversed.' }
return { tone: 'ok', message: 'Undone.' }
}
if (applied === 0) {
if (!didSomething) {
return { tone: 'error', message: `Nothing was undone — ${skipped}.` }
}
// Only claim a denominator when we have one. "2 of 3" from a caller that
// never knew the total would be a number invented to fill a sentence.
const total = totalChanges(target)
return { tone: 'partial', message: `${applied} of ${total} changes undone${skipped}.` }
const scale = total > 0 && applied > 0 ? `${applied} of ${total} changes undone` : 'Partly undone'
return { tone: 'partial', message: `${scale}${skipped}.` }
}
+146
View File
@@ -0,0 +1,146 @@
// Grow journal data layer (#53): entries about a garden, a bed, or one plop.
//
// An entry is what HAPPENED and when, as opposed to the `notes` field on a
// garden/object/plant, which is what the thing IS. Entries accumulate; notes
// overwrite.
import { useInfiniteQuery, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
export const journalEntrySchema = z.object({
id: z.number(),
gardenId: z.number(),
objectId: z.number().optional(),
plantingId: z.number().optional(),
authorId: z.number(),
authorName: z.string().default(''),
body: z.string(),
// When it happened, which is not when it was written down.
observedAt: z.string(),
version: z.number(),
createdAt: z.string(),
updatedAt: z.string(),
})
export type JournalEntry = z.infer<typeof journalEntrySchema>
const journalPageSchema = z.object({
entries: z.array(journalEntrySchema),
hasMore: z.boolean(),
})
/** Narrows the journal. Undefined fields don't filter. */
export interface JournalFilter {
objectId?: number
plantingId?: number
from?: string
to?: string
}
const PAGE_SIZE = 30
export function journalKey(gardenId: number, filter: JournalFilter = {}) {
return ['gardens', gardenId, 'journal', filter] as const
}
export function useJournal(gardenId: number, filter: JournalFilter = {}, enabled = true) {
return useInfiniteQuery({
queryKey: journalKey(gardenId, filter),
enabled,
initialPageParam: 0,
queryFn: async ({ pageParam }) =>
journalPageSchema.parse(
await api.get(`/gardens/${gardenId}/journal`, {
params: { ...filter, limit: PAGE_SIZE, offset: pageParam },
}),
),
getNextPageParam: (last, pages) => (last.hasMore ? pages.length * PAGE_SIZE : undefined),
})
}
const countsSchema = z.object({ counts: z.record(z.string(), z.number().int().nonnegative()) })
/**
* How many entries each object has, keyed by object id — 0 for the garden
* itself. Its own query rather than derived from the list, because the
* indicator has to work while the journal panel is closed, which is exactly when
* the list hasn't loaded.
*/
export function useJournalCounts(gardenId: number) {
return useQuery({
queryKey: ['gardens', gardenId, 'journal-counts'] as const,
queryFn: async (): Promise<Map<number, number>> => {
const { counts } = countsSchema.parse(await api.get(`/gardens/${gardenId}/journal/counts`))
return new Map(Object.entries(counts).map(([id, n]) => [Number(id), n]))
},
})
}
export interface JournalInput {
objectId?: number
plantingId?: number
body: string
observedAt?: string
}
// Every journal query for this garden, whatever its filter — a new entry may
// belong to several of them at once (the garden's, its bed's, a date range's),
// and working out which is more effort than refetching a short list.
function invalidateJournal(qc: ReturnType<typeof useQueryClient>, gardenId: number) {
void qc.invalidateQueries({ queryKey: ['gardens', gardenId, 'journal'] })
void qc.invalidateQueries({ queryKey: ['gardens', gardenId, 'journal-counts'] })
}
export function useCreateJournalEntry(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: JournalInput): Promise<JournalEntry> =>
journalEntrySchema.parse(await api.post(`/gardens/${gardenId}/journal`, input)),
onSuccess: () => invalidateJournal(qc, gardenId),
})
}
export function useUpdateJournalEntry(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (vars: { id: number; version: number; body?: string; observedAt?: string }): Promise<JournalEntry> => {
const { id, ...rest } = vars
return journalEntrySchema.parse(await api.patch(`/journal/${id}`, rest))
},
onSuccess: () => invalidateJournal(qc, gardenId),
onError: (err) => {
// A 409 means the row moved on. Refetch so the component receives the
// current version — otherwise a retry resends the stale one and conflicts
// forever, which reads as a button that simply doesn't work.
if (err instanceof ApiError && err.isConflict) invalidateJournal(qc, gardenId)
},
})
}
export function useDeleteJournalEntry(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (id: number): Promise<void> => {
await api.delete(`/journal/${id}`)
},
onSuccess: () => invalidateJournal(qc, gardenId),
})
}
/** Today as YYYY-MM-DD in the viewer's own timezone — "today" means the day you
* are standing in the garden, not the day it is in UTC. */
export function today(): string {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
/** A date-only string as a short human label, without dragging the value
* through a Date (which would shift it by the timezone offset). */
export function formatObservedAt(iso: string): string {
const [y, m, d] = iso.split('-').map(Number)
// Out-of-range parts would silently roll over — 2026-13-45 becoming February
// 2027 — so show the raw string instead of a confidently wrong date.
if (!y || !m || !d || m < 1 || m > 12 || d < 1 || d > 31) return iso
return new Date(y, m - 1, d).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
+17 -17
View File
@@ -328,28 +328,28 @@ export function useUpdatePlanting(gardenId: number) {
})
}
/** Clear a bed: soft-remove every active plop in an object (a loop of PATCHes;
* a bulk ClearObject endpoint arrives with the agent seam, #19). Invalidates
* once at the end. Pass the object's active plops (id + current version). */
const clearResultSchema = z.object({ cleared: z.number() })
/** Clear a bed: soft-remove every active plop in an object (#82).
*
* ONE request, and so ONE change set. This used to be a loop of PATCHes, which
* meant clearing a 40-plop bed wrote 40 change sets and took 40 presses of Undo
* to put back — while the agent's clear_object, for the identical user-facing
* action, undid in a single click. The rule it violated is stated in CLAUDE.md:
* multi-row operations record all their changes together so they undo as one
* unit. Doing it server-side also removes the partial-failure case the old loop
* had to reconcile. */
export function useClearObject(gardenId: number) {
const qc = useQueryClient()
return useMutation({
mutationFn: async (plops: { id: number; version: number }[]) => {
const today = new Date().toISOString().slice(0, 10)
// allSettled, not all: a partial failure still soft-removed some rows
// server-side, so we must reconcile the cache rather than roll everything
// back. Report how many failed.
const results = await Promise.allSettled(
plops.map((p) => api.patch(`/plantings/${p.id}`, { removedAt: today, version: p.version })),
)
const failed = results.filter((r) => r.status === 'rejected').length
if (failed > 0) {
throw new Error(`${failed} of ${plops.length} plants couldn't be cleared — refresh and try again.`)
}
mutationFn: async (objectId: number): Promise<number> => {
// No body — clear takes none; passing undefined sends none rather than an
// empty {}. The response is just a count; validate it rather than cast.
const res = clearResultSchema.parse(await api.post(`/objects/${objectId}/clear`))
return res.cleared
},
// Reconcile on success OR partial failure, so the cache matches the server.
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
onError: (err) => toast.error(err instanceof Error ? err.message : 'Could not clear the bed.'),
onError: (err) => toast.error(objectErrorMessage(err, 'Could not clear the bed.')),
})
}
+79
View File
@@ -0,0 +1,79 @@
// Instance settings data layer (#79): admin-only, instance-wide configuration.
//
// The GET/PATCH return both the stored settings and a read-only "effective" view
// — what's actually in force after layering the DB over the environment — so the
// form can say "inheriting ollama-cloud/glm-5.2:cloud from the environment" and
// whether the API key is present, without the key ever crossing the wire.
import { queryOptions, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod'
import { ApiError, api } from './api'
import { capabilitiesKey } from './agent'
export const instanceSettingsSchema = z.object({
// '' means "inherit the PANSY_AGENT_MODEL env var".
agentModel: z.string(),
// null means "inherit PANSY_AGENT_ENABLED"; true/false is an explicit override.
agentEnabled: z.boolean().nullable(),
version: z.number(),
updatedAt: z.string(),
})
export type InstanceSettings = z.infer<typeof instanceSettingsSchema>
export const effectiveAgentSchema = z.object({
model: z.string(),
enabled: z.boolean(),
hasApiKey: z.boolean(),
agentLive: z.boolean(),
})
export type EffectiveAgent = z.infer<typeof effectiveAgentSchema>
export const settingsResponseSchema = z.object({
settings: instanceSettingsSchema,
effective: effectiveAgentSchema,
})
export type SettingsResponse = z.infer<typeof settingsResponseSchema>
export const settingsKey = ['settings'] as const
export const settingsQueryOptions = queryOptions({
queryKey: settingsKey,
queryFn: async (): Promise<SettingsResponse> =>
settingsResponseSchema.parse(await api.get('/settings')),
})
export function useSettings() {
return useQuery(settingsQueryOptions)
}
export interface SettingsUpdate {
agentModel: string
agentEnabled: boolean | null
version: number
}
export function useUpdateSettings() {
const qc = useQueryClient()
return useMutation({
mutationFn: async (input: SettingsUpdate): Promise<SettingsResponse> =>
settingsResponseSchema.parse(await api.patch('/settings', input)),
onSuccess: (res) => {
qc.setQueryData(settingsKey, res)
// The save may have turned the assistant on or off; the editor keys its
// chat tab off /capabilities, so make it re-read rather than trust its
// cached answer.
qc.invalidateQueries({ queryKey: capabilitiesKey })
},
})
}
/** If err is a 409 version conflict, return the fresh settings it carries so a
* form can rebase; otherwise null. */
export function conflictSettings(err: unknown): InstanceSettings | null {
if (err instanceof ApiError && err.isConflict && err.body && typeof err.body === 'object') {
const current = (err.body as { current?: unknown }).current
const parsed = instanceSettingsSchema.safeParse(current)
if (parsed.success) return parsed.data
}
return null
}
+68 -1
View File
@@ -5,6 +5,8 @@ import { Button } from '@/components/ui/Button'
import { GardenCanvas } from '@/editor/GardenCanvas'
import { EditorRail, type RailTab } from '@/editor/EditorRail'
import { HistoryPanel } from '@/editor/HistoryPanel'
import { ChatPanel } from '@/editor/ChatPanel'
import { JournalPanel } from '@/editor/JournalPanel'
import { Inspector } from '@/editor/Inspector'
import { PlopInspector } from '@/editor/PlopInspector'
import { PlantPicker } from '@/editor/PlantPicker'
@@ -29,6 +31,8 @@ import {
} from '@/lib/objects'
import { toEditorPlanting } from '@/lib/plantings'
import type { Plant } from '@/lib/plants'
import { useCapabilities } from '@/lib/agent'
import { useJournalCounts } from '@/lib/journal'
import { attributableLots, lotsByPlant, useSeedLots, type SeedLot } from '@/lib/seedLots'
import { useSeedTray } from '@/lib/seedTray'
import { usePageTitle } from '@/lib/usePageTitle'
@@ -59,6 +63,14 @@ export function GardenEditorPage() {
const setFocusedObject = useEditorStore((s) => s.setFocusedObject)
const railTab = useEditorStore((s) => s.railTab)
const setRailTab = useEditorStore((s) => s.setRailTab)
const journalObjectId = useEditorStore((s) => s.journalObjectId)
const setJournalObjectId = useEditorStore((s) => s.setJournalObjectId)
const journalCounts = useJournalCounts(gid)
const capabilities = useCapabilities()
const journalTotal = useMemo(
() => [...(journalCounts.data?.values() ?? [])].reduce((a, b) => a + b, 0),
[journalCounts.data],
)
const armedPlant = useEditorStore((s) => s.armedPlant)
const setArmedPlant = useEditorStore((s) => s.setArmedPlant)
@@ -328,6 +340,13 @@ export function GardenEditorPage() {
setFocusedObject(selectedObject.id)
select(null)
}}
noteCount={journalCounts.data?.get(selectedObject.id) ?? 0}
onAddNote={() => {
// Two taps from a selected bed to typing: scope the journal to it
// and switch tabs. The composer is already open in there.
setJournalObjectId(selectedObject.id)
setRailTab('journal')
}}
/>
) : selectedPlop ? (
<PlopInspector
@@ -344,6 +363,24 @@ export function GardenEditorPage() {
<p className="text-sm text-muted">Select a bed or a planting to edit it.</p>
),
},
{
id: 'journal',
label: 'Journal',
// The total on the tab is the discoverability half: a garden with a
// season's notes in it shouldn't look identical to an empty one.
badge: journalTotal,
render: () => (
<JournalPanel
gardenId={gid}
canEdit={canEdit}
currentUserId={me.data?.id}
isOwner={isOwner}
objects={objects}
scopeObjectId={journalObjectId}
onScopeChange={setJournalObjectId}
/>
),
},
{
id: 'history',
label: 'History',
@@ -351,6 +388,16 @@ export function GardenEditorPage() {
},
]
// Only when the instance actually has the assistant configured. A tab that
// opens onto an apology is worse than no tab.
if (capabilities.data?.agent) {
railTabs.push({
id: 'chat',
label: 'Assistant',
render: () => <ChatPanel gardenId={gid} canEdit={canEdit} />,
})
}
return (
<div className="flex h-[calc(100vh-8rem)] flex-col gap-3 md:flex-row">
<div className="shrink-0 md:w-40">
@@ -374,10 +421,29 @@ export function GardenEditorPage() {
<Button
variant="ghost"
className="mt-2 w-full text-sm"
onClick={() => {
setJournalObjectId(null)
setRailTab(railTab === 'journal' ? null : 'journal')
}}
>
Journal
</Button>
<Button
variant="ghost"
className="mt-1 w-full text-sm"
onClick={() => setRailTab(railTab === 'history' ? null : 'history')}
>
History
</Button>
{capabilities.data?.agent && (
<Button
variant="ghost"
className="mt-1 w-full text-sm"
onClick={() => setRailTab(railTab === 'chat' ? null : 'chat')}
>
💬 Assistant
</Button>
)}
</div>
<div className="relative flex min-h-0 flex-1 flex-col gap-2">
@@ -460,8 +526,9 @@ export function GardenEditorPage() {
{clearing && focusedObject && (
<ClearBedModal
objectId={focusedObject.id}
objectName={objectDisplayName(focusedObject)}
plops={focusedPlops.map((p) => ({ id: p.id, version: p.version }))}
plopCount={focusedPlops.length}
gardenId={gid}
onClose={() => setClearing(false)}
/>
+164
View File
@@ -0,0 +1,164 @@
import { useEffect, useState } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Select } from '@/components/ui/Select'
import { TextField } from '@/components/ui/TextField'
import { toast } from '@/components/ui/toast'
import { errorMessage } from '@/lib/api'
import {
conflictSettings,
useSettings,
useUpdateSettings,
type EffectiveAgent,
type InstanceSettings,
} from '@/lib/settings'
import { usePageTitle } from '@/lib/usePageTitle'
// agentEnabled is a tri-state on the wire (null = inherit env, true, false); the
// form models it as three named choices so "inherit" is a deliberate pick, not
// an empty control.
type EnabledChoice = 'inherit' | 'on' | 'off'
const toChoice = (v: boolean | null): EnabledChoice => (v === null ? 'inherit' : v ? 'on' : 'off')
const fromChoice = (c: EnabledChoice): boolean | null => (c === 'inherit' ? null : c === 'on')
/** Admin-only instance settings (#79). The one that matters today is the agent
* model; the API key stays in the environment and is only ever reported as
* present/absent, never shown or edited. */
export function SettingsPage() {
usePageTitle('Settings')
const settings = useSettings()
const update = useUpdateSettings()
if (settings.isPending) {
return <p className="text-sm text-muted">Loading settings</p>
}
if (settings.isError) {
return <Alert>{errorMessage(settings.error, "Couldn't load settings.")}</Alert>
}
return <SettingsForm data={settings.data} update={update} />
}
function SettingsForm({
data,
update,
}: {
data: { settings: InstanceSettings; effective: EffectiveAgent }
update: ReturnType<typeof useUpdateSettings>
}) {
const [model, setModel] = useState(data.settings.agentModel)
const [enabled, setEnabled] = useState<EnabledChoice>(toChoice(data.settings.agentEnabled))
const [version, setVersion] = useState(data.settings.version)
const [error, setError] = useState<string | null>(null)
// Rebase the form when the query cache updates (e.g. a successful save writes
// the new row back), so the version we submit is never stale.
useEffect(() => {
setVersion(data.settings.version)
}, [data.settings.version])
const eff = data.effective
const save = () => {
setError(null)
update.mutate(
{ agentModel: model.trim(), agentEnabled: fromChoice(enabled), version },
{
onSuccess: () => toast.info('Settings saved.'),
onError: (err) => {
const current = conflictSettings(err)
if (current) {
// Someone else saved first. Adopt their row so the next attempt is
// clean, and say so rather than silently discarding this edit.
setModel(current.agentModel)
setEnabled(toChoice(current.agentEnabled))
setVersion(current.version)
setError('Someone else changed these settings just now — reloaded their version. Re-apply your change if you still want it.')
return
}
setError(errorMessage(err, "Couldn't save settings."))
},
},
)
}
return (
<div className="flex max-w-2xl flex-col gap-6">
<div>
<h1 className="text-xl font-semibold text-fg">Settings</h1>
<p className="mt-1 text-sm text-muted">
Instance-wide, admin-only. Changes take effect immediately no restart.
</p>
</div>
<section className="flex flex-col gap-4 rounded-lg border border-border p-4">
<div>
<h2 className="text-sm font-semibold text-fg">Garden assistant</h2>
<p className="mt-1 text-xs text-muted">
The model runs against your Ollama Cloud key, which is set in the environment and never
shown here.
</p>
</div>
<AgentStatus eff={eff} />
<TextField
label="Model"
name="agentModel"
placeholder={eff.model || 'ollama-cloud/glm-5.2:cloud'}
value={model}
onChange={(e) => setModel(e.target.value)}
hint={
model.trim() === ''
? `Empty — inheriting ${eff.model || 'the built-in default'} from the environment.`
: 'A majordomo model spec. A comma-separated list is a failover chain.'
}
/>
<Select
label="Enabled"
name="agentEnabled"
value={enabled}
onChange={(e) => setEnabled(e.target.value as EnabledChoice)}
options={[
{ value: 'inherit', label: 'Inherit from environment' },
{ value: 'on', label: 'On' },
{ value: 'off', label: 'Off' },
]}
/>
</section>
{error && <Alert>{error}</Alert>}
<div className="flex justify-end">
<Button onClick={save} disabled={update.isPending}>
{update.isPending ? 'Saving…' : 'Save changes'}
</Button>
</div>
</div>
)
}
// AgentStatus surfaces the gap the capabilities endpoint exists for: enabled +
// a key does not guarantee the assistant is actually running (an unresolvable
// model leaves it down), and that's precisely what an admin needs to see.
function AgentStatus({ eff }: { eff: EffectiveAgent }) {
const [tone, text] = eff.agentLive
? (['ok', `Live on ${eff.model}`] as const)
: !eff.hasApiKey
? (['warn', 'No API key set in the environment — the assistant is off.'] as const)
: !eff.enabled
? (['warn', 'Turned off.'] as const)
: (['warn', `Configured but not running — check the model (${eff.model}).`] as const)
return (
<div className="flex items-center gap-2 text-sm">
<span
className={
'inline-block h-2 w-2 rounded-full ' + (tone === 'ok' ? 'bg-emerald-500' : 'bg-amber-500')
}
aria-hidden
/>
<span className={tone === 'ok' ? 'text-fg' : 'text-muted'}>{text}</span>
</div>
)
}
+23
View File
@@ -14,6 +14,7 @@ import { GardensPage } from '@/pages/GardensPage'
import { GardenEditorPage } from '@/pages/GardenEditorPage'
import { PublicGardenPage } from '@/pages/PublicGardenPage'
import { PlantsPage } from '@/pages/PlantsPage'
import { SettingsPage } from '@/pages/SettingsPage'
import { meQueryOptions } from '@/lib/auth'
import { queryClient } from '@/lib/queryClient'
import { safeRedirectPath } from '@/lib/redirect'
@@ -48,6 +49,20 @@ async function requireGuest(context: RouterContext, redirectTo: string) {
}
}
// requireAdmin: authenticated AND admin. A non-admin who navigates to /settings
// is sent to /gardens rather than shown a page whose API calls would 403. This
// is convenience routing, not the security boundary — the server's requireAdmin
// is authoritative.
async function requireAdmin(context: RouterContext, path: string) {
const me = await context.queryClient.ensureQueryData(meQueryOptions)
if (!me) {
throw redirect({ to: '/login', search: { redirect: path } })
}
if (!me.isAdmin) {
throw redirect({ to: '/gardens' })
}
}
const indexRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/',
@@ -109,6 +124,13 @@ const plantsRoute = createRoute({
component: PlantsPage,
})
const settingsRoute = createRoute({
getParentRoute: () => rootRoute,
path: 'settings',
beforeLoad: ({ context, location }) => requireAdmin(context, location.href),
component: SettingsPage,
})
// Public read-only garden by share token. Deliberately has NO beforeLoad auth
// guard, so a logged-out visitor viewing a shared link is never redirected to
// /login or OIDC.
@@ -125,6 +147,7 @@ const routeTree = rootRoute.addChildren([
gardensRoute,
gardenEditorRoute,
plantsRoute,
settingsRoute,
publicGardenRoute,
])
+13
View File
@@ -28,6 +28,19 @@
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
/* Keyboard focus on a canvas object (#84). :focus-visible shows the ring for
keyboard focus but not a mouse click; the dashed accent ring distinguishes
"focused" from the solid ring that marks "selected". A CSS rule overrides
the shape's inline stroke presentation attributes. */
.object-shape {
outline: none;
}
.object-shape:focus-visible :is(rect, ellipse) {
stroke: var(--color-accent-strong);
stroke-width: 2;
stroke-dasharray: 5 4;
}
}
/* Dark theme: override the same tokens so utilities recolor automatically. */