13 Commits
Author SHA1 Message Date
steve d884f62762 Merge pull request 'Agent: what a day of live use asked for' (#127) from feat/agent-live-test-fixes into main
Build image / build-and-push (push) Successful in 8s
2026-08-23 04:29:22 +00:00
steveandClaude Fable 5 ac9f6e8c63 A fill aimed entirely outside its object is an error, and the test says so
Build image / build-and-push (push) Successful in 5s
TestFillRegionOutsideObjectPlantsNothing pinned the old silent success;
the #127 review asked for the error, and the agent is the caller it helps.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-23 00:28:44 -04:00
steveandClaude Fable 5 d3d7238259 Address #127 review: quote the garden name, validate rectangles, require plantId
Build image / build-and-push (push) Successful in 17s
- The plan-name line of the system prompt interpolates the garden's name
  with %q like the rest of the prompt: any editor can rename a garden, and a
  name with a newline in it must not read as an instruction.
- fill_region refuses an inverted rectangle with its corners named, and a
  rectangle that misses the bed (or only touches its edge) is an error from
  the service rather than a successful fill of nothing.
- remove_plantings requires plantId; omitted it would remove plant 0 and
  report success.
- historyEntry.Undo → UndoOf (it holds the reverted change set's id).
- remove_planting's description names list_plantings as an id source.
- RemovePlanting takes the removal date itself; the dateless wrapper had no
  callers left.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-23 00:27:48 -04:00
steveandClaude Fable 5 a1baf4b871 Fill: refuse an empty rectangle; list plops whose plant is gone unnamed
Build image / build-and-push (push) Successful in 12s
A blank region name with a zero-area Region reached hexCenters, whose
tiny-region rule plants one plop in the middle — a caller that said nothing
about where got a plop at the centre. ListObjectPlantings also failed the whole
listing if one plop's plant no longer existed; it now lists that plop unnamed.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-23 00:16:54 -04:00
steveandClaude Fable 5 bc14bbed0d Agent: what a day of live use asked for
Build image / build-and-push (push) Successful in 19s
Gadfly review (reusable) / review (pull_request) Successful in 10m2s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m2s
Twenty-one prompts against the live assistant found one fabricated success,
a model that believed it was 2025, and a describe_garden that was ~450 plop
entries per turn. This is the set of fixes, each traceable to a finding:

- The gardener's LOCAL day travels with the turn (`today` on POST /agent/chat,
  sent by the UI like plantedAt) into the system prompt and every dated tool
  default. Left to guess, the model dated journal entries a year back; left to
  the server, a 9 pm fill landed on UTC's tomorrow.
- describe_garden groups plops by plant — count, where, planted date, days to
  maturity — and lists ids only for groups of ≤ 8; list_plantings spells a big
  group out on demand and remove_plantings acts on one plant in a bed ("take
  the beets out, leave the garlic"), which used to mean 116 single removals.
- New tools: move_planting (keeps the planting date; across beds via the new
  MovePlanting, which is why the store's UPDATE now writes object_id),
  update_plant, read_history, copy_garden (the "<garden> — <year>" plan
  convention). fill_region takes an explicit local rectangle and a seedLotId;
  place_planting's radius defaults to one plant (spacing/2) instead of a guess.
- The system prompt states the date and the gardener's units, forbids claiming
  a change no tool made, says it cannot undo and points at the Undo button,
  asks before clearing beds on an ambiguous sentence, and stops narrating its
  own plantings into the journal.
- A mutation aimed at ANOTHER garden inside a turn is recorded under that
  garden as its own change set, not filed into the open scope.
- UI: the thread scrolls inside the Assistant panel so the composer stays
  put; every tool has a step label; wide tables stay inside the bubble.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-23 00:14:41 -04:00
steve f0aefb5378 Merge pull request 'Make request deadline extensions reach the socket behind the logging middleware' (#126) from fix/sse-deadlines-behind-middleware into main
Build image / build-and-push (push) Successful in 9s
2026-08-23 03:10:37 +00:00
steveandClaude Fable 5 68cb686d60 Address #126 review: one home for the middleware rationale
Build image / build-and-push (push) Successful in 20s
The why-a-controller-can't-reach-the-socket story was told in full in
deadlines.go, agent.go, the test, and CLAUDE.md. It lives in deadlines.go
now; the others say what they need to and point there.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 23:09:36 -04:00
steveandClaude Fable 5 2a903f6428 Make request deadline extensions reach the socket behind the logging middleware
Build image / build-and-push (push) Successful in 6s
Gadfly review (reusable) / review (pull_request) Successful in 10m9s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m10s
Long agent turns were cut at exactly 30s on the live instance with "The
connection dropped partway through." — the #78 failure, which its tests
said was fixed. The tests host openEventStream on a bare gin.New(); in
production, slog-gin replaces c.Writer with a wrapper that embeds the
gin.ResponseWriter interface, which has no Unwrap, so the ResponseController
built from the handler's writer can't reach the connection and every
SetWriteDeadline returns ErrNotSupported. The stream fell back to the
server's absolute WriteTimeout; the first write past it failed, cancelled
the request context, and closed the socket under the client mid-frame.
The scan upload's read/write extensions failed the same way, with the
errors discarded.

captureController now runs first on the engine and stashes a controller
built before anything wraps the writer; openEventStream and scanSeedPacket
take it from responseController(c). The regression tests run the stream
through New() — the real stack, in the real order — and check from the
client side; the scan path logs once instead of swallowing the error.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:56:21 -04:00
steve 5622b1accd Merge pull request 'Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers' (#125) from fix/smoke-sweep into main
Build image / build-and-push (push) Successful in 7s
2026-08-23 02:26:06 +00:00
steveandClaude Fable 5 0d95578c6a Address #125 review: memoized ink, one fallback color, reactive copy name
Build image / build-and-push (push) Successful in 10s
- monogramInk is memoized by color string; the canvas asks for every
  visible plop on every frame of a pan (Gadfly, 2/4 models).
- FALLBACK_PLANT_COLOR lives in lib/plants and is used by the canvas, the
  inspector and the garden thumbnail instead of three raw '#97a97c's.
- CopyDialog keeps its proposed "<base> — <year>" in step with the gardens
  list until the person edits the name, so a list that loads after the
  dialog opens can't leave a taken year in the field.
- GardenCard: reflowed the summary comment; no dead fallback on a plan
  name that's already known to parse.
- today() has one import path (lib/dates); the journal re-export is gone.
- CLAUDE.md says what the inspector actually does (a text-compare guard)
  rather than claiming it uses LengthField.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:25:02 -04:00
steveandClaude Fable 5 157e04ed24 Skip no-op saves in the edit dialogs; clear a stale model-spec error
Build image / build-and-push (push) Successful in 26s
A Save that changed nothing still sent a PATCH, which bumped the row's
version and landed an "Edited garden settings" step in History that undid
nothing — the drift is gone since the last commit, but the write was still
there. Both dialogs now close without a request when every field matches
the loaded row.

In Settings, a rejected model spec's reason stayed under the field after
the field was blanked back to the saved value; committing an unchanged
value now clears it.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:22:22 -04:00
steveandClaude Fable 5 27f658c1f7 Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers
Build image / build-and-push (push) Successful in 2m55s
Gadfly review (reusable) / review (pull_request) Successful in 8m59s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m59s
- Garden and plant dialogs keep centimeters as the source of truth
  (LengthField in lib/units.ts): a no-change Save no longer rewrites
  900 cm as 899.922 or a 45 cm spacing as 44.958, bumping versions and
  writing bogus history entries on the way.
- The UI stamps every date with the browser's local day (lib/dates.ts).
  Journal notes already did; plop placement, fill and removal now do too,
  so a 9 pm placement isn't "planted tomorrow". The fill endpoint gained an
  optional plantedAt; API and agent callers still default to UTC today.
- Removing an object that holds plants asks first and says how many go
  with it. An empty one still goes straight away (one Undo restores it).
- The expanded plant card's action row wraps instead of clipping "Delete".
- Monogram lettering switches to a dark ink on pale marker colors (garlic,
  cabbage, marigold) instead of near-white on near-white.
- Copy-as-plan proposes the next free year and warns when the typed name
  already exists, so two gardens can't both read as "the 2027 plan".
- Plan cards show the base name with a "2027 plan" tag, so the year — the
  point of the name — survives truncation.
- A rejected model spec now says which model and why: a wrapped
  ErrInvalidInput's reason reaches the client as the 400's message, and the
  Settings field shows it inline instead of toasting "invalid input".

Also defuses a clock bomb in TestRemainingReturnsWhenAPlantingIsRemoved,
which only passed while the real date was before 2026-08-01.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:11:12 -04:00
steve 05392ee0db Merge pull request 'Replace the UI with the Organic design handoff' (#124) from feat/organic-ui into main
Build image / build-and-push (push) Successful in 6s
2026-08-22 23:40:23 +00:00
53 changed files with 2455 additions and 333 deletions
+45
View File
@@ -129,6 +129,24 @@ Conventions that follow from it:
a constraint between neighbouring plants; a bed edge is nobody's neighbour. a constraint between neighbouring plants; a bed edge is nobody's neighbour.
- **Soft removal**: "clear bed" sets `removed_at`; the editor reads - **Soft removal**: "clear bed" sets `removed_at`; the editor reads
`removed_at IS NULL`. Hard delete is a different operation. `removed_at IS NULL`. Hard delete is a different operation.
- **Length fields keep centimeters as the source of truth.** A dialog field
that takes a length is a `LengthField` (`web/src/lib/units.ts`): the text is
a view, `cm` changes only when the person types. Never re-parse the display
string on save — "29 6.3″" is the nearest tenth of an inch, and parsing it
back is how a no-change Save turned 900 cm into 899.922 (and bumped the
version, and wrote a bogus history entry). The inspector still keeps display
strings but gets the same result by refusing to commit text that still equals
the formatted original (`commitDim`); either way, a no-op save sends exactly
what was loaded — or nothing.
- **"Today" is the browser's local day**, from `today()` in
`web/src/lib/dates.ts`, and the UI always sends it: journal `observedAt`,
plop/fill `plantedAt`, `removedAt`. The server's UTC default is only for
API callers and the agent. A gardener placing at 9 pm in Ohio planted today,
not tomorrow — don't add a UI path that leaves the date to the server.
- **A wrapped `ErrInvalidInput` is shown to the person verbatim.**
`fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)`
reaches the client as the 400's message (minus the sentinel prefix); the bare
sentinel reads "invalid input". Write the reason for the keyboard, not the log.
- **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run - **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run
at startup, embedded. Never edit one that has shipped. at startup, embedded. Never edit one that has shipped.
- **Every service mutation lands in history** (#48). If you add one, record it — - **Every service mutation lands in history** (#48). If you add one, record it —
@@ -142,6 +160,33 @@ Conventions that follow from it:
plantings. Fixing it per-call-site is how it came back, which is why the rule 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. lives in `commitScope` where no caller can forget it.
- **The assistant's date comes from the client, never from the model.**
`POST /agent/chat` carries `today` (the browser's local day, same reason the
UI sends `plantedAt`); `Runner.Run` puts it in the system prompt and
`NewToolbox` stamps it on every dated tool default through `adapter.day`. A
new tool that takes a date defaults through `day()`, not `time.Now()`. Left to
guess, the live model dated journal entries a year back (2025) — the year it
remembered from training.
- **`describe_garden` is a summary, not a dump.** Plops are grouped per plant
(`service.DescribeGroup`: count, where, planted date, days to maturity) and
ids are listed only for groups of ≤ `maxListedPlops`; the first grid-filled
garden made the old per-plop describe ~450 entries on every turn. A tool that
needs individual ids uses `list_plantings`; bulk work takes (object, plant)
`remove_plantings`, `ClearPlantings`. Don't add a tool that lists plops.
- **The assistant can't undo and must not pretend to.** Asked to "undo the
beets", the live model replied "Done!" and changed nothing. The prompt now
forbids claiming a change no tool made and points at the Undo button; keep
both rules when editing `systemPrompt`.
- **Request deadlines are extended through `responseController(c)`, never
`http.NewResponseController(c.Writer)`.** A controller built in a handler
can't reach the socket — the logging middleware wraps the writer — so every
deadline call silently returns `ErrNotSupported`, in production only;
`internal/api/deadlines.go` has the mechanism and why `captureController`
must stay the first middleware. Corollary for tests: a deadline test must run
through `New()`, not `gin.New()` — the #78 fix shipped fully tested on a bare
engine and never worked on the live instance.
## Testing ## Testing
Match the test to the failure it would catch: Match the test to the failure it would catch:
+6 -3
View File
@@ -64,7 +64,7 @@ POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link) POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
POST /gardens/:id/objects PATCH,DELETE /objects/:id POST /gardens/:id/objects PATCH,DELETE /objects/:id
POST /objects/:id/plantings PATCH,DELETE /plantings/:id POST /objects/:id/plantings PATCH,DELETE /plantings/:id
POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect; optional plantedAt (default UTC today)
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
GET,POST /plants PATCH,DELETE /plants/:id (own plants only) GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private) GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
@@ -72,7 +72,10 @@ POST /seed-lots/scan ← multipart image → a seed-packet proposal (re
POST /seed-lots/from-packet ← confirmed proposal → a plant (new or existing) + a lot POST /seed-lots/from-packet ← confirmed proposal → a plant (new or existing) + a lot
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own) GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator GET /gardens/:id/journal/counts ← entries per object, for the "has notes" indicator
POST /agent/chat ← SSE: step events, then the finished turn (editor only) POST /agent/chat ← SSE: step events, then the finished turn (editor only);
body {gardenId, message, today?} — today is the sender's LOCAL
date, told to the model and stamped on everything the turn
plants, removes or journals (server UTC day when omitted)
GET,DELETE /gardens/:id/agent/history (the actor's own thread) GET,DELETE /gardens/:id/agent/history (the actor's own thread)
GET /capabilities ← what this instance can do RIGHT NOW (tracks the live agent, not just config) GET /capabilities ← what this instance can do RIGHT NOW (tracks the live agent, not just config)
GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off, vision model; GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off, vision model;
@@ -146,7 +149,7 @@ React 19 + TypeScript + Vite + Tailwind 4 (`@tailwindcss/vite`), `@tanstack/reac
7. **Sharing** — invite by email, roles, viewer read-only mode. 7. **Sharing** — invite by email, roles, viewer read-only mode.
8. **Polish** — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging. 8. **Polish** — imperial toggle, mobile ergonomics, clear-bed, keyboard nudging.
9. **Agent seam**`ops.go` bulk ops + `internal/agent` DefineTool wrappers. 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. 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. What a day of live use added: the turn carries the gardener's **local day** (`today` in the chat body) into the prompt and every dated tool default, because the model's own idea of the date was a year stale and the server's is UTC; `describe_garden` **groups plops by plant** (count, where, planted date, days to maturity — `DescribeGroup`) and lists ids only for small groups, with `list_plantings` for the rest and `remove_plantings` to act on a whole group; `move_planting` relocates a plop (`MovePlanting`, within or across beds) keeping its planting date; `fill_region` takes an explicit local rectangle and a `seedLotId`; `update_plant`, `read_history` and `copy_garden` (the "<garden> — <year>" plan convention) round out what the model kept reaching for. A mutation on another garden inside a turn is recorded under THAT garden (`record` refuses to file revisions into a scope for a different garden), so undo always finds them where the person is looking.
## Deliberate v1 limits ## Deliberate v1 limits
+11 -4
View File
@@ -29,8 +29,15 @@
// //
// # Unconfigured instances // # Unconfigured instances
// //
// With no API key the assistant is simply not offered: the chat route isn't // With no API key the assistant is simply not offered: the chat route answers
// registered and the capability isn't advertised — the same shape as OIDC // 503 and /capabilities says agent:false, so the UI never shows the tab. (The
// 404ing when unconfigured. An instance without a key starts and serves the app // route is always registered — a Settings change can turn the assistant on
// exactly as it did before. // without a restart, which a missing route couldn't do.) An instance without a
// key starts and serves the app exactly as it did before.
//
// # The gardener's day
//
// A turn carries the person's local date (from the client) into the prompt and
// every dated tool default. The model is never the source of a date: left to
// guess, it wrote the year it remembered from training.
package agent package agent
+74 -19
View File
@@ -32,6 +32,9 @@ const (
maxSameCallRepeats = 3 maxSameCallRepeats = 3
) )
// dateLayout is the YYYY-MM-DD form every date crosses the tool boundary in.
const dateLayout = "2006-01-02"
// Runner drives a model over pansy's toolbox. One per process; Run is safe to // Runner drives a model over pansy's toolbox. One per process; Run is safe to
// call concurrently. // call concurrently.
type Runner struct { type Runner struct {
@@ -73,17 +76,31 @@ type Turn struct {
Truncated bool `json:"truncated,omitempty"` Truncated bool `json:"truncated,omitempty"`
} }
// Run executes one turn against a garden, as actorID. // Run executes one turn against a garden, as actorID, on the day it is where
// they are.
//
// today is the gardener's local date (YYYY-MM-DD) as the client reports it; it
// goes into the prompt, so the model knows what day it is, and to every tool, so
// what the turn plants, removes or journals is dated the day the person did it.
// Empty means "the service's UTC today" — the best a caller with no local clock
// (a bare API client) can do. The model itself must never be the source of the
// date: left to guess, the live one stamped a year it remembered from training.
// //
// The whole turn runs inside ONE change set, so everything the model did undoes // 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. // 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 // 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. // 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) { func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message, today string, history []llm.Message, onStep func(agent.Step)) (*Turn, error) {
message = strings.TrimSpace(message) message = strings.TrimSpace(message)
if message == "" { if message == "" {
return nil, domain.ErrInvalidInput return nil, domain.ErrInvalidInput
} }
today = strings.TrimSpace(today)
if today == "" {
today = time.Now().UTC().Format(dateLayout)
} else if _, err := time.Parse(dateLayout, today); err != nil {
return nil, fmt.Errorf("%w: today must be a YYYY-MM-DD date", domain.ErrInvalidInput)
}
ctx, cancel := context.WithTimeout(ctx, runTimeout) ctx, cancel := context.WithTimeout(ctx, runTimeout)
defer cancel() defer cancel()
@@ -109,8 +126,8 @@ func (r *Runner) Run(ctx context.Context, actorID, gardenID int64, message strin
Summary: turnSummary(message), Summary: turnSummary(message),
AgentRunID: &runID, AgentRunID: &runID,
}, func(ctx context.Context) error { }, func(ctx context.Context) error {
box := NewToolbox(r.svc, actorID) box := NewToolbox(r.svc, actorID, today)
a := agent.New(r.model, systemPrompt(garden), a := agent.New(r.model, systemPrompt(garden, today),
agent.WithMaxSteps(maxSteps), agent.WithMaxSteps(maxSteps),
agent.WithToolErrorLimits(maxConsecutiveToolErrors, maxSameCallRepeats), agent.WithToolErrorLimits(maxConsecutiveToolErrors, maxSameCallRepeats),
) )
@@ -198,35 +215,73 @@ func turnSummary(message string) string {
return s return s
} }
// systemPrompt gives the model the conventions it cannot infer. // systemPrompt gives the model the conventions it cannot infer, the day it is,
// and the rules of conduct the live instance showed it needs.
// //
// The compass convention in particular is not guessable: -y is north because // 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 // screen y grows downward, and a model that assumes otherwise plants the south
// half when asked for the north one. // half when asked for the north one. The date is not guessable either — a model
func systemPrompt(g *domain.Garden) string { // asked to backdate nothing still wrote the year it remembered from training —
units := "metric — all measurements are centimeters" // and the conduct rules each answer a thing the assistant actually did in live
// testing: reported a change it never made, narrated every planting into the
// journal, swapped four beds on an ambiguous sentence, and answered an imperial
// gardener in centimeters.
func systemPrompt(g *domain.Garden, today string) string {
units := "The gardener works in meters and centimeters; answer in those."
size := fmt.Sprintf("%.0f x %.0f cm", g.WidthCM, g.HeightCM)
if g.UnitPref == domain.UnitImperial { if g.UnitPref == domain.UnitImperial {
units = "imperial for display, but every measurement you send or receive is in CENTIMETERS" units = "The gardener thinks in feet and inches. Convert what they say before calling a tool " +
"(1 ft = 30.48 cm, 1 in = 2.54 cm) and answer in feet and inches, never in centimeters."
size = fmt.Sprintf("%.1f x %.1f ft (%.0f x %.0f cm)", g.WidthCM/30.48, g.HeightCM/30.48, g.WidthCM, g.HeightCM)
} }
return fmt.Sprintf(`You are pansy's garden assistant. You help plan and edit a real garden by calling tools. 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. The garden you are working on is %q (id %d), %s. Today is %s — the gardener's local date.
%s
Conventions you cannot guess and must not assume: Conventions you cannot guess and must not assume:
- Every measurement a tool takes or returns is in CENTIMETERS.
- Positions in a garden are centimeters from its top-left corner: x grows east, y grows SOUTH. - 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. - 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. 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. - Objects and plantings are version-guarded. Use the version from describe_garden when editing.
- Dates are YYYY-MM-DD. Tools date what they plant, remove or journal as today unless you pass
a date; pass one only when the gardener says it happened on another day.
How to work: How to work:
- Start from describe_garden to see what is actually there. Do not guess ids. - Start from describe_garden to see what is actually there. Do not guess ids. It groups each
- Use find_plant to turn a plant name into an id. If it returns several candidates, bed's plantings by plant, with a count, a rough location and the planting date; a group lists
pick the one that matches what the user said, or ask them which they meant. its plops one by one only when it is small. For the ids of a large group use list_plantings,
- To replant a bed with something else: clear_object, then fill_region with region "all". or act on the whole group at once with remove_plantings.
- When a tool refuses (for example, the user only has view access to this garden), - Use find_plant to turn a plant name into an id. If it returns several candidates, pick the one
explain what happened in plain words. Do not retry it. 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". To take
one plant out of a mixed bed: remove_plantings. To relocate plants: move_planting, which keeps
their planting date — do not remove and replant them.
- fill_region in grid mode lays out individual plants at true spacing, which is what "so I can
plant from it" means; clump mode is a quick sketch. For an area no compass name describes (a
middle third, a strip along one edge) give fill_region a rectangle instead of placing plops by hand.
- A garden named %s is this garden's plan for that year; copy_garden with that name
makes one. Never use a different real garden as a scratch space.
- 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 How to behave:
and wants to know what to look at. If you changed nothing, say that too.`, - Only claim what a tool actually did. If a tool failed, or there is no tool for what was asked,
g.Name, g.ID, g.WidthCM, g.HeightCM, units) say so plainly — never describe a change you did not make.
- You cannot undo. Every reply of yours that changed the garden has an "Undo this" button under
it, and the History panel can revert any change; point the gardener there, or offer to reverse
the change by hand with tools.
- When a request could mean materially different things — "swap the cucumbers and the melons"
with two beds of each — say what you would do and ask, rather than clearing beds on a guess.
When it is clear, just do it.
- The plan already records what was planted where and when. Write a journal entry only when the
gardener asks for one or tells you something that happened — weather, pests, a harvest, an
observation — not to narrate your own planting.
- The gardener is watching the canvas. When you are done, say briefly what you changed and where
to look; if you changed nothing, say that too.`,
// %q throughout for the garden's name: any editor can rename a garden, and
// a name is data, not prompt — quoting keeps a newline or a stray quote
// in it from reading as a new instruction.
g.Name, g.ID, size, today, units, fmt.Sprintf("%q", g.Name+" — <year>"))
} }
+153 -9
View File
@@ -60,7 +60,7 @@ func TestTurnIsOneChangeSet(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("bed: %v", err) t.Fatalf("bed: %v", err)
} }
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil { if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump, nil); err != nil {
t.Fatalf("seed garlic: %v", err) t.Fatalf("seed garlic: %v", err)
} }
@@ -75,7 +75,7 @@ func TestTurnIsOneChangeSet(t *testing.T) {
fake.Reply("Cleared the garlic and replanted the bed with cucumbers."), 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) turn, err := r.Run(ctx, owner, g.ID, "change the garlic bed to cucumbers this year", "", nil, nil)
if err != nil { if err != nil {
t.Fatalf("Run: %v", err) t.Fatalf("Run: %v", err)
} }
@@ -152,14 +152,14 @@ func TestViewerGetsAnExplainableRefusal(t *testing.T) {
// A viewer can't open a change set at all, so the turn is refused up front — // 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. // before any model call — and the API turns that into a plain explanation.
r := scriptedRunner(t, svc, fake.Reply("unused")) r := scriptedRunner(t, svc, fake.Reply("unused"))
_, err = r.Run(ctx, viewer.ID, g.ID, "plant garlic in that bed", nil, nil) _, err = r.Run(ctx, viewer.ID, g.ID, "plant garlic in that bed", "", nil, nil)
if !errors.Is(err, domain.ErrForbidden) { if !errors.Is(err, domain.ErrForbidden) {
t.Fatalf("viewer turn err = %v, want ErrForbidden", err) t.Fatalf("viewer turn err = %v, want ErrForbidden", err)
} }
// And at the tool layer, a refusal comes back as a readable tool result // And at the tool layer, a refusal comes back as a readable tool result
// rather than killing the run. // rather than killing the run.
box := NewToolbox(svc, viewer.ID) box := NewToolbox(svc, viewer.ID, "")
raw, _ := json.Marshal(map[string]any{"objectId": bed.ID}) raw, _ := json.Marshal(map[string]any{"objectId": bed.ID})
res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "clear_object", Arguments: raw}) res := box.Execute(ctx, llm.ToolCall{ID: "1", Name: "clear_object", Arguments: raw})
if !res.IsError { if !res.IsError {
@@ -187,7 +187,7 @@ func TestRunStopsAtTheStepCap(t *testing.T) {
} }
r := scriptedRunner(t, svc, steps...) r := scriptedRunner(t, svc, steps...)
turn, err := r.Run(ctx, owner, g.ID, "look at the garden", nil, nil) turn, err := r.Run(ctx, owner, g.ID, "look at the garden", "", nil, nil)
if err != nil { if err != nil {
t.Fatalf("a capped run should end cleanly, got %v", err) t.Fatalf("a capped run should end cleanly, got %v", err)
} }
@@ -218,7 +218,7 @@ func TestReadOnlyTurnWritesNoChangeSet(t *testing.T) {
toolCall("describe_garden", map[string]any{"gardenId": g.ID}), toolCall("describe_garden", map[string]any{"gardenId": g.ID}),
fake.Reply("It's empty — nothing planted yet."), fake.Reply("It's empty — nothing planted yet."),
) )
turn, err := r.Run(ctx, owner, g.ID, "what's in the garden?", nil, nil) turn, err := r.Run(ctx, owner, g.ID, "what's in the garden?", "", nil, nil)
if err != nil { if err != nil {
t.Fatalf("Run: %v", err) t.Fatalf("Run: %v", err)
} }
@@ -268,8 +268,8 @@ func TestTurnSummaryFitsAHistoryRow(t *testing.T) {
// TestSystemPromptStatesTheCompassConvention — -y being north is not guessable, // TestSystemPromptStatesTheCompassConvention — -y being north is not guessable,
// and a model that assumes otherwise plants the wrong end of the bed. // and a model that assumes otherwise plants the wrong end of the bed.
func TestSystemPromptStatesTheCompassConvention(t *testing.T) { func TestSystemPromptStatesTheCompassConvention(t *testing.T) {
p := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitImperial}) p := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitImperial}, "2026-08-22")
for _, want := range []string{"NORTH", "-y", "centimeters", "Plot", "version"} { for _, want := range []string{"NORTH", "-y", "CENTIMETERS", "Plot", "version"} {
if !strings.Contains(p, want) { if !strings.Contains(p, want) {
t.Errorf("system prompt is missing %q:\n%s", want, p) t.Errorf("system prompt is missing %q:\n%s", want, p)
} }
@@ -315,7 +315,7 @@ func TestPartialWorkSurvivesATimeout(t *testing.T) {
time.Sleep(50 * time.Millisecond) time.Sleep(50 * time.Millisecond)
cancel() cancel()
}() }()
_, err = r.Run(cancelled, owner, g.ID, "move the bed", nil, nil) _, err = r.Run(cancelled, owner, g.ID, "move the bed", "", nil, nil)
if err == nil { if err == nil {
t.Fatal("expected the turn to fail") t.Fatal("expected the turn to fail")
} }
@@ -356,3 +356,147 @@ func TestTurnSummaryTrimsByRunes(t *testing.T) {
t.Errorf("summary is %d runes, want it trimmed", n) t.Errorf("summary is %d runes, want it trimmed", n)
} }
} }
// TestSystemPromptKnowsTheDayAndTheGardenersUnits — two things the live model
// got wrong for want of being told: it dated journal entries with the year it
// remembered from training, and answered a feet-and-inches gardener in
// centimeters. The conduct rules are checked by their load-bearing phrases.
func TestSystemPromptKnowsTheDayAndTheGardenersUnits(t *testing.T) {
imperial := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 731.52, HeightCM: 731.52, UnitPref: domain.UnitImperial}, "2026-08-22")
for _, want := range []string{
"Today is 2026-08-22",
"feet and inches",
"24.0 x 24.0 ft",
"never describe a change you did not make",
"You cannot undo",
"Undo this",
"rather than clearing beds on a guess",
"not to narrate your own planting",
`"Plot — <year>"`,
"remove_plantings",
"move_planting",
"list_plantings",
} {
if !strings.Contains(imperial, want) {
t.Errorf("imperial prompt is missing %q", want)
}
}
metric := systemPrompt(&domain.Garden{ID: 1, Name: "Plot", WidthCM: 500, HeightCM: 400, UnitPref: domain.UnitMetric}, "2026-08-22")
if strings.Contains(metric, "feet and inches") {
t.Error("metric prompt tells the model to answer in feet and inches")
}
if !strings.Contains(metric, "500 x 400 cm") {
t.Error("metric prompt doesn't state the garden's size in cm")
}
}
// TestRunRejectsAMalformedToday — the date reaches every tool as a default, so a
// bad one must stop the turn before the model runs, not fail its first fill.
func TestRunRejectsAMalformedToday(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)
}
r := scriptedRunner(t, svc, fake.Reply("unused"))
if _, err := r.Run(ctx, owner, g.ID, "hello", "yesterday", nil, nil); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("Run with today=%q: err = %v, want ErrInvalidInput", "yesterday", err)
}
}
// TestTurnDatesItsWorkTheGardenersDay — what a turn plants is dated the day the
// gardener sent it, not the server's UTC day (which is tomorrow by nine in the
// evening in Ohio) and not a day the model chose.
func TestTurnDatesItsWorkTheGardenersDay(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, "🧄")
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 200, HeightCM: 200,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
r := scriptedRunner(t, svc,
toolCall("fill_region", map[string]any{"objectId": bed.ID, "region": "all", "plantId": garlic.ID}),
fake.Reply("Filled the bed with garlic."),
)
if _, err := r.Run(ctx, owner, g.ID, "fill the bed with garlic", "2026-08-22", nil, nil); err != nil {
t.Fatalf("Run: %v", err)
}
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 turn planted nothing")
}
for _, p := range full.Plantings {
if p.PlantedAt == nil || *p.PlantedAt != "2026-08-22" {
t.Errorf("plop %d plantedAt = %v, want the gardener's day 2026-08-22", p.ID, p.PlantedAt)
}
}
}
// TestTurnOnAnotherGardenFilesHistoryThere — a turn is scoped to one garden, but
// nothing stops the model from pointing a tool at an object in another garden
// the person can edit ("do the same in my other garden"). Those revisions must
// land in THAT garden's history, as the agent's work, where its undo can see
// them — not in the open scope, where undoing this turn would quietly revert
// rows in a garden the person isn't looking at.
func TestTurnOnAnotherGardenFilesHistoryThere(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
a, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "A", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden A: %v", err)
}
b, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "B", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden B: %v", err)
}
bedB, err := svc.CreateObject(ctx, owner, b.ID, service.ObjectInput{
Kind: domain.KindBed, Name: "Bed", XCM: 1000, YCM: 1000, WidthCM: 200, HeightCM: 200,
})
if err != nil {
t.Fatalf("bed: %v", err)
}
beforeA, _, _ := svc.GardenHistory(ctx, owner, a.ID, 0, 0)
beforeB, _, _ := svc.GardenHistory(ctx, owner, b.ID, 0, 0)
r := scriptedRunner(t, svc,
toolCall("update_object", map[string]any{"objectId": bedB.ID, "version": bedB.Version, "name": "Renamed from A"}),
fake.Reply("Renamed the bed in B."),
)
turn, err := r.Run(ctx, owner, a.ID, "rename the bed in my other garden", "", nil, nil)
if err != nil {
t.Fatalf("Run: %v", err)
}
if turn.ChangeSetID != nil {
t.Errorf("the turn on A produced change set %d, but it changed nothing in A", *turn.ChangeSetID)
}
afterA, _, _ := svc.GardenHistory(ctx, owner, a.ID, 0, 0)
if len(afterA) != len(beforeA) {
t.Errorf("A's history grew by %d for a change made in B", len(afterA)-len(beforeA))
}
afterB, _, _ := svc.GardenHistory(ctx, owner, b.ID, 0, 0)
if len(afterB) != len(beforeB)+1 {
t.Fatalf("B's history grew by %d, want 1", len(afterB)-len(beforeB))
}
if cs := afterB[0]; cs.Source != domain.SourceAgent || cs.AgentRunID == nil {
t.Errorf("B's entry = source %q, run %v; want the agent's, with its run id", cs.Source, cs.AgentRunID)
}
// And it undoes from B, where the person would look for it.
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, afterB[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts)
}
d, _ := svc.DescribeGarden(ctx, owner, b.ID)
if len(d.Objects) != 1 || d.Objects[0].Name != "Bed" {
t.Errorf("after undo B's bed is %+v, want its original name back", d.Objects)
}
}
+257 -40
View File
@@ -2,28 +2,45 @@ package agent
import ( import (
"context" "context"
"fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm" "gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service" "gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
) )
// NewToolbox builds a majordomo toolbox over pansy's service layer, bound to a // NewToolbox builds a majordomo toolbox over pansy's service layer, bound to a
// single acting user. Every tool call runs as actorID, so pansy's permission // single acting user and to the day it is where they are. Every tool call runs
// checks (requireGardenRole / objectForRole) apply unchanged. Construct one per // as actorID, so pansy's permission checks (requireGardenRole / objectForRole)
// authenticated agent session: // apply unchanged. Construct one per authenticated agent session:
// //
// box := agent.NewToolbox(svc, session.UserID) // box := agent.NewToolbox(svc, session.UserID, "2026-08-22")
// agent.Run(ctx, model, box, "fill the NE corner with garlic") // agent.Run(ctx, model, box, "fill the NE corner with garlic")
func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox { //
a := &adapter{svc: svc, actor: actorID} // today (YYYY-MM-DD) is the date every tool stamps on what it plants, removes or
// journals unless the model passes one — the gardener's local day, from the
// client, because the server's UTC day is tomorrow by nine in the evening in
// Ohio. Empty falls back to the service's UTC today.
func NewToolbox(svc *service.Service, actorID int64, today string) *llm.Toolbox {
a := &adapter{svc: svc, actor: actorID, today: strings.TrimSpace(today)}
return llm.NewToolbox("pansy", return llm.NewToolbox("pansy",
llm.DefineTool("list_gardens", llm.DefineTool("list_gardens",
"List the gardens the user can see (owned and shared), with the user's role on each.", "List the gardens the user can see (owned and shared), with the user's role on each.",
a.listGardens), a.listGardens),
llm.DefineTool("describe_garden", llm.DefineTool("describe_garden",
"Summarize a garden: its dimensions, objects (with sizes/positions/version), and each object's active plantings with a rough compass location.", "Summarize a garden: its dimensions, objects (with sizes/positions/version), and each "+
"object's active plantings grouped by plant — how many, roughly where, when they went in, "+
"and days to maturity when known. A small group lists its plops individually (id + "+
"version, for move_planting/remove_planting); a large one (a grid-filled bed) does not — "+
"use list_plantings for those ids, or act on the whole group with remove_plantings.",
a.describeGarden), a.describeGarden),
llm.DefineTool("list_plantings",
"List one object's active plops one by one, each with its id, version, location, count and "+
"planting date — the detail describe_garden leaves out for a large group. Narrow to one "+
"plant with plantId. Use it only when you need to address individual plops.",
a.listPlantings),
llm.DefineTool("create_object", llm.DefineTool("create_object",
"Add an object (bed, grow_bag, container, in_ground, tree, path, structure) to a garden, positioned by its center in garden cm.", "Add an object (bed, grow_bag, container, in_ground, tree, path, structure) to a garden, positioned by its center in garden cm.",
a.createObject), a.createObject),
@@ -31,16 +48,38 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
"Move an object to a new center position (garden cm). Needs the object's current version from describe_garden.", "Move an object to a new center position (garden cm). Needs the object's current version from describe_garden.",
a.moveObject), a.moveObject),
llm.DefineTool("place_planting", llm.DefineTool("place_planting",
"Place one plop of a plant inside a plantable object, positioned in the object's LOCAL frame (0,0 = object center, -y = north).", "Place one plop of a plant inside a plantable object, positioned in the object's LOCAL frame "+
"(0,0 = object center, -y = north). Omit radiusCm for a single plant (it defaults to half "+
"the plant's spacing); a larger radius is a clump, whose plant count is derived from its "+
"area unless you pass count. Dated today unless plantedAt says otherwise.",
a.placePlanting), a.placePlanting),
llm.DefineTool("fill_region", llm.DefineTool("fill_region",
"Fill part of a plantable object with one plant, hex-packed at the plant's spacing. "+ "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, "+ "Say where EITHER by region a compass name, not coordinates: nw|ne|sw|se for the quarter "+
"north|south|east|west (or top|bottom|left|right) for halves, or all for the whole thing. "+ "corners, north|south|east|west (or top|bottom|left|right) for halves, or all for the whole "+
"North is the top of the garden. Example: to replant a whole bed, clear_object then "+ "thing; north is the top of the garden — OR by an explicit rectangle in the object's local "+
"fill_region with region=all. Filling skips spots already covered by an existing plant, "+ "frame (x0Cm,y0Cm,x1Cm,y1Cm; 0,0 = center, -y = north), for a middle third, a strip along "+
"so it is safe to run twice.", "one edge, or any area a compass name can't say. 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. Dated today unless plantedAt says otherwise.",
a.fillRegion), a.fillRegion),
llm.DefineTool("move_planting",
"Move ONE plop to a new position — within its object, or into another plantable object of "+
"the same garden with toObjectId — keeping its plant, size, count and planting date. This "+
"is how to relocate plants; removing and re-placing them would lose when they were planted. "+
"Needs the plop's id and version (describe_garden or list_plantings).",
a.movePlanting),
llm.DefineTool("remove_planting",
"Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+
"all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+
"bed does. Needs the plop's id and version (describe_garden or list_plantings). Use "+
"for \"pull the basil out of the corner\".",
a.removePlanting),
llm.DefineTool("remove_plantings",
"Remove every plop of ONE plant from an object, leaving the other plants in it — \"take the "+
"beets out of the south bed\". Soft-removes them (kept for planting history, undoable as "+
"one change). Use this rather than many remove_planting calls.",
a.removePlantings),
llm.DefineTool("clear_object", llm.DefineTool("clear_object",
"Remove all plants from an object. They are soft-removed, so the planting history for past "+ "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 "+ "seasons is kept and the change can be undone. Use this before replanting a bed with "+
@@ -58,11 +97,18 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
"yet. Check find_plant first — creating a duplicate of something that already exists is "+ "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.", "worse than reusing it. The plant belongs to the user, not to any garden.",
a.createPlant), a.createPlant),
llm.DefineTool("update_plant",
"Change a plant in the user's own catalog: its name, category, spacing, color, days to "+
"maturity, vendor, source link or notes. Only the fields you pass change. Needs the "+
"plant's current version from find_plant. Built-in plants can't be edited — create_plant "+
"the user's own variety instead.",
a.updatePlant),
llm.DefineTool("add_journal_entry", llm.DefineTool("add_journal_entry",
"Write a dated observation into the garden's grow journal — what happened, and when. "+ "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 "+ "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 "+ "(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+
"thing is. observedAt defaults to today; set it to backdate.", "thing is and not for narrating your own plantings. observedAt defaults to today; set "+
"it to backdate.",
a.addJournalEntry), a.addJournalEntry),
llm.DefineTool("read_journal", llm.DefineTool("read_journal",
"Read back the garden's grow journal — the observations add_journal_entry wrote. "+ "Read back the garden's grow journal — the observations add_journal_entry wrote. "+
@@ -70,6 +116,13 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
"recently observed first. Use this to answer \"what did I note about the west bed?\" "+ "recently observed first. Use this to answer \"what did I note about the west bed?\" "+
"or \"what happened last spring?\".", "or \"what happened last spring?\".",
a.readJournal), a.readJournal),
llm.DefineTool("read_history",
"Read the garden's change history: every change anyone made — by hand in the editor, or "+
"in an earlier conversation with you — newest first, with what it changed and whether it "+
"was undone. Use it to answer \"what changed this week?\" or \"what did you do last time?\" "+
"rather than reciting from memory. You cannot undo from here; the person has an Undo "+
"button on each change.",
a.readHistory),
llm.DefineTool("update_object", llm.DefineTool("update_object",
"Change an existing object: resize it (widthCm/heightCm), rotate it (rotationDeg), "+ "Change an existing object: resize it (widthCm/heightCm), rotate it (rotationDeg), "+
"rename it (name), or toggle whether it can hold plants (plantable). Only the fields "+ "rename it (name), or toggle whether it can hold plants (plantable). Only the fields "+
@@ -82,12 +135,6 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
"counterpart to create_object — use it for \"remove the old grow bag\". Permanent (not "+ "counterpart to create_object — use it for \"remove the old grow bag\". Permanent (not "+
"the same as clearing a bed's plants); prefer clear_object when the bed itself stays.", "the same as clearing a bed's plants); prefer clear_object when the bed itself stays.",
a.deleteObject), a.deleteObject),
llm.DefineTool("remove_planting",
"Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+
"all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+
"bed does. Needs the plop's id and version from describe_garden. Use for \"pull the "+
"basil out of the corner\".",
a.removePlanting),
llm.DefineTool("list_seed_lots", llm.DefineTool("list_seed_lots",
"List the seed lots (purchases) the user has recorded — vendor, quantity, and what's "+ "List the seed lots (purchases) the user has recorded — vendor, quantity, and what's "+
"left — optionally for one plant via plantId. This is the detail behind the \"seed "+ "left — optionally for one plant via plantId. This is the detail behind the \"seed "+
@@ -96,15 +143,37 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
llm.DefineTool("record_seed_lot", llm.DefineTool("record_seed_lot",
"Record a seed purchase for a plant the user owns, so pansy can track how much is left. "+ "Record a seed purchase for a plant the user owns, so pansy can track how much is left. "+
"Get the plantId from find_plant first. quantity + unit is what was bought (e.g. 2 "+ "Get the plantId from find_plant first. quantity + unit is what was bought (e.g. 2 "+
"\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\".", "\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\". To "+
"count seed as used, plant with a seedLotId on place_planting or fill_region.",
a.recordSeedLot), a.recordSeedLot),
llm.DefineTool("copy_garden",
"Duplicate a garden the user owns — beds, objects and plantings — as a new garden with the "+
"given name. This is how a season plan is made: a copy named \"<garden name> — <year>\" "+
"(with an em dash) is that garden's plan for the year, and the editor offers it as such. "+
"Use it for \"set up next year's plan\"; never use another real garden as a scratch space.",
a.copyGarden),
) )
} }
// adapter carries the service and the acting user for the tool handlers. // adapter carries the service, the acting user and their local day for the
// tool handlers.
type adapter struct { type adapter struct {
svc *service.Service svc *service.Service
actor int64 actor int64
today string
}
// day is the date a tool stamps: the one the model passed, else the gardener's
// local today, else nil for the service's UTC default.
func (a *adapter) day(explicit string) *string {
if d := strings.TrimSpace(explicit); d != "" {
return &d
}
if a.today != "" {
d := a.today
return &d
}
return nil
} }
func (a *adapter) listGardens(ctx context.Context, _ struct{}) (any, error) { func (a *adapter) listGardens(ctx context.Context, _ struct{}) (any, error) {
@@ -117,6 +186,13 @@ func (a *adapter) describeGarden(ctx context.Context, args struct {
return a.svc.DescribeGarden(ctx, a.actor, args.GardenID) return a.svc.DescribeGarden(ctx, a.actor, args.GardenID)
} }
func (a *adapter) listPlantings(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object whose plops to list"`
PlantID *int64 `json:"plantId" description:"optional: only plops of this plant"`
}) (any, error) {
return a.svc.ListObjectPlantings(ctx, a.actor, args.ObjectID, args.PlantID)
}
func (a *adapter) createObject(ctx context.Context, args struct { func (a *adapter) createObject(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden to add the object to"` GardenID int64 `json:"gardenId" description:"garden to add the object to"`
Kind string `json:"kind" description:"bed | grow_bag | container | in_ground | tree | path | structure"` Kind string `json:"kind" description:"bed | grow_bag | container | in_ground | tree | path | structure"`
@@ -144,26 +220,72 @@ func (a *adapter) moveObject(ctx context.Context, args struct {
} }
func (a *adapter) placePlanting(ctx context.Context, args struct { func (a *adapter) placePlanting(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"plantable object to plant in"` ObjectID int64 `json:"objectId" description:"plantable object to plant in"`
PlantID int64 `json:"plantId" description:"plant to place"` PlantID int64 `json:"plantId" description:"plant to place"`
XCM float64 `json:"xCm" description:"center x in the object's local frame (cm; 0,0 = center, -y = north)"` XCM float64 `json:"xCm" description:"center x in the object's local frame (cm; 0,0 = center, -y = north)"`
YCM float64 `json:"yCm" description:"center y in the object's local frame (cm)"` YCM float64 `json:"yCm" description:"center y in the object's local frame (cm)"`
RadiusCM float64 `json:"radiusCm" description:"plop radius in cm"` RadiusCM float64 `json:"radiusCm" description:"optional plop radius in cm; omit (0) for one plant at half the plant's spacing"`
Count *int `json:"count" description:"optional explicit plant count; omit to derive from area ÷ spacing²"` Count *int `json:"count" description:"optional explicit plant count; omit to derive from area ÷ spacing²"`
PlantedAt string `json:"plantedAt" description:"optional planting date, YYYY-MM-DD; defaults to today"`
SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) this planting uses, so the lot counts it as used"`
}) (any, error) { }) (any, error) {
return a.svc.CreatePlanting(ctx, a.actor, args.ObjectID, service.PlantingInput{ return a.svc.CreatePlanting(ctx, a.actor, args.ObjectID, service.PlantingInput{
PlantID: args.PlantID, XCM: args.XCM, YCM: args.YCM, RadiusCM: args.RadiusCM, Count: args.Count, PlantID: args.PlantID, XCM: args.XCM, YCM: args.YCM, RadiusCM: args.RadiusCM, Count: args.Count,
PlantedAt: a.day(args.PlantedAt), SeedLotID: args.SeedLotID,
}) })
} }
func (a *adapter) fillRegion(ctx context.Context, args struct { func (a *adapter) fillRegion(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"plantable object to fill"` ObjectID int64 `json:"objectId" description:"plantable object to fill"`
Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all"` Region string `json:"region" description:"nw|ne|sw|se corner, north|south|east|west (or top|bottom|left|right) half, or all; leave empty when giving a rectangle"`
X0CM *float64 `json:"x0Cm" description:"rectangle instead of region: west edge, local cm (0 = center)"`
Y0CM *float64 `json:"y0Cm" description:"rectangle: north edge, local cm (negative is north of center)"`
X1CM *float64 `json:"x1Cm" description:"rectangle: east edge, local cm"`
Y1CM *float64 `json:"y1Cm" description:"rectangle: south edge, local cm"`
PlantID int64 `json:"plantId" description:"plant to fill with"` PlantID int64 `json:"plantId" description:"plant to fill with"`
SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"` SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"`
Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"` Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"`
PlantedAt string `json:"plantedAt" description:"optional planting date for every plop, YYYY-MM-DD; defaults to today"`
SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) this fill uses, so the lot counts it as used"`
}) (any, error) { }) (any, error) {
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode)) spec := service.FillSpec{
RegionName: args.Region, PlantID: args.PlantID, SpacingOverride: args.SpacingOverride,
Layout: service.FillLayout(args.Mode), PlantedAt: a.day(args.PlantedAt), SeedLotID: args.SeedLotID,
}
rect := []*float64{args.X0CM, args.Y0CM, args.X1CM, args.Y1CM}
given := 0
for _, v := range rect {
if v != nil {
given++
}
}
switch {
case given == 4 && strings.TrimSpace(args.Region) == "":
if !(*args.X0CM < *args.X1CM && *args.Y0CM < *args.Y1CM) {
return nil, fmt.Errorf("%w: x0Cm must be west of x1Cm and y0Cm north of y1Cm (-y is north)", domain.ErrInvalidInput)
}
spec.Region = service.Region{MinX: *args.X0CM, MinY: *args.Y0CM, MaxX: *args.X1CM, MaxY: *args.Y1CM}
case given == 0 && strings.TrimSpace(args.Region) != "":
// the named region
case given == 4:
return nil, fmt.Errorf("%w: give either a region name or a rectangle, not both", domain.ErrInvalidInput)
case given > 0:
return nil, fmt.Errorf("%w: a rectangle needs all four of x0Cm, y0Cm, x1Cm, y1Cm", domain.ErrInvalidInput)
default:
return nil, fmt.Errorf("%w: say where to fill — a region name, or a rectangle", domain.ErrInvalidInput)
}
return a.svc.Fill(ctx, a.actor, args.ObjectID, spec)
}
func (a *adapter) movePlanting(ctx context.Context, args struct {
PlantingID int64 `json:"plantingId" description:"plop to move (its id from describe_garden or list_plantings)"`
Version int64 `json:"version" description:"the plop's current version"`
XCM float64 `json:"xCm" description:"new center x in the destination object's local frame (cm; 0,0 = center, -y = north)"`
YCM float64 `json:"yCm" description:"new center y in the destination object's local frame (cm)"`
ToObjectID *int64 `json:"toObjectId" description:"optional: another plantable object in the same garden to move it into; omit to move within its current object"`
}) (any, error) {
return a.svc.MovePlanting(ctx, a.actor, args.PlantingID,
service.MoveInput{ToObjectID: args.ToObjectID, XCM: args.XCM, YCM: args.YCM}, args.Version)
} }
func (a *adapter) findPlant(ctx context.Context, args struct { func (a *adapter) findPlant(ctx context.Context, args struct {
@@ -180,7 +302,7 @@ func (a *adapter) createPlant(ctx context.Context, args struct {
Icon string `json:"icon" description:"a single emoji to draw it with, e.g. 🧄"` 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"` 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"` 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\""` Vendor string `json:"vendor" description:"optional vendor name, e.g. \"Johnny's Selected Seeds\""`
}) (any, error) { }) (any, error) {
return a.svc.CreatePlant(ctx, a.actor, service.PlantInput{ return a.svc.CreatePlant(ctx, a.actor, service.PlantInput{
Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM, Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM,
@@ -189,29 +311,62 @@ func (a *adapter) createPlant(ctx context.Context, args struct {
}) })
} }
func (a *adapter) updatePlant(ctx context.Context, args struct {
PlantID int64 `json:"plantId" description:"plant to change (the user's own, from find_plant)"`
Version int64 `json:"version" description:"the plant's current version (from find_plant)"`
Name *string `json:"name" description:"optional new name"`
Category *string `json:"category" description:"optional: vegetable | herb | flower | fruit | tree_shrub | cover"`
SpacingCM *float64 `json:"spacingCm" description:"optional new mature in-row spacing in cm"`
Color *string `json:"color" description:"optional new hex color"`
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"`
Notes *string `json:"notes" description:"optional free-text notes"`
}) (any, error) {
return a.svc.UpdatePlant(ctx, a.actor, args.PlantID, service.PlantPatch{
Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM, Color: args.Color,
SetDays: args.DaysToMaturity != nil, DaysToMaturity: args.DaysToMaturity,
SourceURL: args.SourceURL, Vendor: args.Vendor, Notes: args.Notes,
}, args.Version)
}
func (a *adapter) addJournalEntry(ctx context.Context, args struct { func (a *adapter) addJournalEntry(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden the observation is about"` 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"` 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"` 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"` ObservedAt string `json:"observedAt" description:"optional date it happened, YYYY-MM-DD; defaults to today"`
}) (any, error) { }) (any, error) {
in := service.JournalInput{ObjectID: args.ObjectID, Body: args.Body} return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, service.JournalInput{
if args.ObservedAt != "" { ObjectID: args.ObjectID, Body: args.Body, ObservedAt: a.day(args.ObservedAt),
in.ObservedAt = &args.ObservedAt })
}
return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, in)
} }
func (a *adapter) clearObject(ctx context.Context, args struct { func (a *adapter) clearObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to remove all plants from"` ObjectID int64 `json:"objectId" description:"object to remove all plants from"`
}) (any, error) { }) (any, error) {
n, err := a.svc.ClearObject(ctx, a.actor, args.ObjectID) n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, service.ClearOptions{RemovedAt: a.day("")})
if err != nil { if err != nil {
return nil, err return nil, err
} }
return map[string]int{"cleared": n}, nil return map[string]int{"cleared": n}, nil
} }
func (a *adapter) removePlantings(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to remove the plant from"`
PlantID int64 `json:"plantId" description:"the plant to remove every plop of (from describe_garden)"`
}) (any, error) {
if args.PlantID == 0 {
// Left out, it would "remove" plant 0 — nothing — and report success.
return nil, fmt.Errorf("%w: plantId is required — say which plant to remove, or use clear_object for all of them", domain.ErrInvalidInput)
}
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID,
service.ClearOptions{PlantID: &args.PlantID, RemovedAt: a.day("")})
if err != nil {
return nil, err
}
return map[string]int{"removed": n}, nil
}
func (a *adapter) readJournal(ctx context.Context, args struct { func (a *adapter) readJournal(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden whose journal to read"` GardenID int64 `json:"gardenId" description:"garden whose journal to read"`
ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"` ObjectID *int64 `json:"objectId" description:"optional bed to narrow to; omit for the whole garden"`
@@ -234,6 +389,61 @@ func (a *adapter) readJournal(ctx context.Context, args struct {
return map[string]any{"entries": entries, "hasMore": hasMore}, nil return map[string]any{"entries": entries, "hasMore": hasMore}, nil
} }
// historyEntry is one change set as read_history reports it: the row a person
// would read in the History panel, not the revision snapshots behind it.
type historyEntry struct {
ID int64 `json:"id"`
When string `json:"when"`
Source string `json:"source"`
Who string `json:"who,omitempty"`
Summary string `json:"summary"`
Changes string `json:"changes"`
Undone bool `json:"undone,omitempty"`
// UndoOf is the earlier entry this one reverted, when it is itself an undo.
UndoOf *int64 `json:"undoOf,omitempty"`
}
func (a *adapter) readHistory(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden whose history to read"`
Limit int `json:"limit" description:"how many of the newest entries to return (default 20, max 100)"`
Offset int `json:"offset" description:"how many entries to skip; pass the count you've already seen to page when hasMore is true"`
}) (any, error) {
limit := args.Limit
if limit <= 0 {
limit = 20
}
sets, hasMore, err := a.svc.GardenHistory(ctx, a.actor, args.GardenID, limit, args.Offset)
if err != nil {
return nil, err
}
entries := make([]historyEntry, 0, len(sets))
for _, cs := range sets {
entries = append(entries, historyEntry{
ID: cs.ID, When: cs.CreatedAt, Source: cs.Source, Who: cs.ActorName,
Summary: cs.Summary, Changes: describeCounts(cs.Counts),
Undone: cs.RevertedByID != nil, UndoOf: cs.RevertsID,
})
}
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
}
// describeCounts turns a change set's tallies into words: "12 plantings created,
// 1 object updated".
func describeCounts(counts []domain.ChangeCount) string {
parts := make([]string, 0, len(counts))
for _, c := range counts {
noun := c.EntityType
if c.N != 1 {
noun += "s"
}
parts = append(parts, fmt.Sprintf("%d %s %sd", c.N, noun, c.Op))
}
if len(parts) == 0 {
return "nothing"
}
return strings.Join(parts, ", ")
}
func (a *adapter) updateObject(ctx context.Context, args struct { func (a *adapter) updateObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to change"` ObjectID int64 `json:"objectId" description:"object to change"`
Version int64 `json:"version" description:"the object's current version (from describe_garden)"` Version int64 `json:"version" description:"the object's current version (from describe_garden)"`
@@ -262,9 +472,9 @@ func (a *adapter) removePlanting(ctx context.Context, args struct {
PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"` PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"`
Version int64 `json:"version" description:"the plop's current version (from describe_garden)"` Version int64 `json:"version" description:"the plop's current version (from describe_garden)"`
}) (any, error) { }) (any, error) {
// Soft-remove via the service, so removed_at is stamped from the same // Soft-remove via the service, dated the gardener's local day like every
// (injectable) clock clear_object uses rather than the adapter's wall clock. // other tool here (the service clock's UTC day when that isn't known).
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version) return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version, a.day(""))
} }
func (a *adapter) listSeedLots(ctx context.Context, args struct { func (a *adapter) listSeedLots(ctx context.Context, args struct {
@@ -288,3 +498,10 @@ func (a *adapter) recordSeedLot(ctx context.Context, args struct {
PackedForYear: args.PackedForYear, Notes: args.Notes, PackedForYear: args.PackedForYear, Notes: args.Notes,
}) })
} }
func (a *adapter) copyGarden(ctx context.Context, args struct {
GardenID int64 `json:"gardenId" description:"garden to duplicate (the user must own it)"`
Name string `json:"name" description:"name for the copy; \"<garden name> — <year>\" makes it that garden's plan for the year"`
}) (any, error) {
return a.svc.CopyGarden(ctx, a.actor, args.GardenID, args.Name)
}
+263 -15
View File
@@ -24,7 +24,7 @@ import (
func TestToolboxScenario(t *testing.T) { func TestToolboxScenario(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc, ownerID := newAgentTestService(t) svc, ownerID := newAgentTestService(t)
box := NewToolbox(svc, ownerID) box := NewToolbox(svc, ownerID, "")
call := func(name string, args any) llm.ToolResult { call := func(name string, args any) llm.ToolResult {
t.Helper() t.Helper()
@@ -78,12 +78,17 @@ func TestToolboxScenario(t *testing.T) {
if len(desc.Objects) != 1 { if len(desc.Objects) != 1 {
t.Fatalf("objects = %d, want 1", len(desc.Objects)) t.Fatalf("objects = %d, want 1", len(desc.Objects))
} }
// Plantings come grouped by plant: a group's Where names the region when the
// whole group sits in one, and a small group also lists its plops.
seen := map[string]map[string]bool{} seen := map[string]map[string]bool{}
for _, p := range desc.Objects[0].Plantings { for _, g := range desc.Objects[0].Plantings {
if seen[p.Plant] == nil { if seen[g.Plant] == nil {
seen[p.Plant] = map[string]bool{} seen[g.Plant] = map[string]bool{}
}
seen[g.Plant][g.Where] = true
for _, p := range g.Each {
seen[g.Plant][p.Location] = true
} }
seen[p.Plant][p.Location] = true
} }
if !seen["Garlic"]["NE corner"] { if !seen["Garlic"]["NE corner"] {
t.Errorf("garlic at %v, want NE corner", seen["Garlic"]) t.Errorf("garlic at %v, want NE corner", seen["Garlic"])
@@ -108,7 +113,7 @@ func TestToolboxScenario(t *testing.T) {
if _, err := svc.AddShare(ctx, ownerID, g.ID, "[email protected]", domain.RoleViewer); err != nil { if _, err := svc.AddShare(ctx, ownerID, g.ID, "[email protected]", domain.RoleViewer); err != nil {
t.Fatalf("share: %v", err) t.Fatalf("share: %v", err)
} }
viewerBox := NewToolbox(svc, viewerUser.ID) viewerBox := NewToolbox(svc, viewerUser.ID, "")
vr := viewerBox.Execute(ctx, llm.ToolCall{ID: "2", Name: "fill_region", Arguments: mustJSON(t, map[string]any{ vr := viewerBox.Execute(ctx, llm.ToolCall{ID: "2", Name: "fill_region", Arguments: mustJSON(t, map[string]any{
"objectId": bed.ID, "region": "all", "plantId": garlic.ID, "objectId": bed.ID, "region": "all", "plantId": garlic.ID,
})}) })})
@@ -148,7 +153,7 @@ func mustPlant(t *testing.T, svc *service.Service, owner int64, name string, spa
func TestGarlicBedToCucumbers(t *testing.T) { func TestGarlicBedToCucumbers(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc, owner := newAgentTestService(t) svc, owner := newAgentTestService(t)
box := NewToolbox(svc, owner) box := NewToolbox(svc, owner, "")
call := func(name string, args any) llm.ToolResult { call := func(name string, args any) llm.ToolResult {
t.Helper() t.Helper()
@@ -169,7 +174,7 @@ func TestGarlicBedToCucumbers(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("bed: %v", err) t.Fatalf("bed: %v", err)
} }
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil { if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump, nil); err != nil {
t.Fatalf("seed the garlic: %v", err) t.Fatalf("seed the garlic: %v", err)
} }
@@ -233,7 +238,7 @@ func TestGarlicBedToCucumbers(t *testing.T) {
func TestFindPlantReturnsCandidatesNotAGuess(t *testing.T) { func TestFindPlantReturnsCandidatesNotAGuess(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc, owner := newAgentTestService(t) svc, owner := newAgentTestService(t)
box := NewToolbox(svc, owner) box := NewToolbox(svc, owner, "")
mustPlant(t, svc, owner, "German Red Garlic", 15, "🧄") mustPlant(t, svc, owner, "German Red Garlic", 15, "🧄")
@@ -272,7 +277,7 @@ func TestCreatePlantIsUserScoped(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("register: %v", err) t.Fatalf("register: %v", err)
} }
box := NewToolbox(svc, other.ID) box := NewToolbox(svc, other.ID, "")
raw, _ := json.Marshal(map[string]any{ raw, _ := json.Marshal(map[string]any{
"name": "Painted Mountain Corn", "category": "vegetable", "name": "Painted Mountain Corn", "category": "vegetable",
@@ -310,7 +315,7 @@ func TestCreatePlantIsUserScoped(t *testing.T) {
func TestJournalToolWritesADatedObservation(t *testing.T) { func TestJournalToolWritesADatedObservation(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc, owner := newAgentTestService(t) svc, owner := newAgentTestService(t)
box := NewToolbox(svc, owner) box := NewToolbox(svc, owner, "")
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000}) g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000})
if err != nil { if err != nil {
@@ -354,7 +359,7 @@ func TestJournalToolWritesADatedObservation(t *testing.T) {
func TestCorrectiveTools(t *testing.T) { func TestCorrectiveTools(t *testing.T) {
ctx := context.Background() ctx := context.Background()
svc, owner := newAgentTestService(t) svc, owner := newAgentTestService(t)
box := NewToolbox(svc, owner) box := NewToolbox(svc, owner, "")
var gid int64 // set once the garden exists; the describe closure reads it. var gid int64 // set once the garden exists; the describe closure reads it.
call := func(name string, args any) llm.ToolResult { call := func(name string, args any) llm.ToolResult {
@@ -405,10 +410,10 @@ func TestCorrectiveTools(t *testing.T) {
t.Fatalf("place_planting: %s", r.Content) t.Fatalf("place_planting: %s", r.Content)
} }
d = describe() d = describe()
if len(d.Objects[0].Plantings) != 1 { if len(d.Objects[0].Plantings) != 1 || len(d.Objects[0].Plantings[0].Each) != 1 {
t.Fatalf("want 1 plop before removal, got %d", len(d.Objects[0].Plantings)) t.Fatalf("want 1 plop before removal, got %+v", d.Objects[0].Plantings)
} }
plop := d.Objects[0].Plantings[0] plop := d.Objects[0].Plantings[0].Each[0]
if r := call("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}); r.IsError { if r := call("remove_planting", map[string]any{"plantingId": plop.ID, "version": plop.Version}); r.IsError {
t.Fatalf("remove_planting: %s", r.Content) t.Fatalf("remove_planting: %s", r.Content)
} }
@@ -487,3 +492,246 @@ func newAgentTestService(t *testing.T) (*service.Service, int64) {
} }
return svc, owner.ID return svc, owner.ID
} }
// TestToolsFromTheLiveSweep covers what a day of driving the live assistant
// asked for: grouped describes, whole-group removal, moves that keep the
// planting date, fills by rectangle, seed attribution, catalog edits, history
// reads, plan copies — and every date stamped the gardener's local day rather
// than the server's (UTC) or the model's (a year from its training data).
func TestToolsFromTheLiveSweep(t *testing.T) {
ctx := context.Background()
svc, owner := newAgentTestService(t)
const today = "2026-08-22"
box := NewToolbox(svc, owner, today)
call := func(name string, args any) llm.ToolResult {
t.Helper()
return box.Execute(ctx, llm.ToolCall{ID: "1", Name: name, Arguments: mustJSON(t, args)})
}
ok := func(name string, args any) string {
t.Helper()
r := call(name, args)
if r.IsError {
t.Fatalf("%s: %s", name, r.Content)
}
return r.Content
}
decode := func(raw string, into any) {
t.Helper()
if err := json.Unmarshal([]byte(raw), into); err != nil {
t.Fatalf("decode %v: %s", err, raw)
}
}
g, err := svc.CreateGarden(ctx, owner, service.GardenInput{Name: "Plot", WidthCM: 2000, HeightCM: 2000, UnitPref: domain.UnitImperial})
if err != nil {
t.Fatalf("garden: %v", err)
}
garlic := mustPlant(t, svc, owner, "Garlic", 15, "🧄")
beet := mustPlant(t, svc, owner, "Beet", 10, "🌱")
tomato := mustPlant(t, svc, owner, "Cherokee Purple", 60, "🍅")
bed, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "South bed", XCM: 1000, YCM: 1000, WidthCM: 240, HeightCM: 120})
if err != nil {
t.Fatalf("bed: %v", err)
}
other, err := svc.CreateObject(ctx, owner, g.ID, service.ObjectInput{Kind: domain.KindBed, Name: "North bed", XCM: 1000, YCM: 300, WidthCM: 240, HeightCM: 120})
if err != nil {
t.Fatalf("other bed: %v", err)
}
lot, err := svc.CreateSeedLot(ctx, owner, service.SeedLotInput{PlantID: beet.ID, Quantity: 500, Unit: "seeds"})
if err != nil {
t.Fatalf("lot: %v", err)
}
// fill_region by rectangle (the middle third of the bed's width), in grid mode,
// charged to the lot, dated today by default.
ok("fill_region", map[string]any{
"objectId": bed.ID, "plantId": beet.ID, "mode": "grid", "seedLotId": lot.ID,
"x0Cm": -40.0, "y0Cm": -60.0, "x1Cm": 40.0, "y1Cm": 60.0,
})
// Neither a region nor a full rectangle is a mistake the model can read.
if r := call("fill_region", map[string]any{"objectId": bed.ID, "plantId": beet.ID, "x0Cm": -40.0}); !r.IsError || !strings.Contains(r.Content, "x0Cm, y0Cm, x1Cm, y1Cm") {
t.Errorf("half a rectangle: %+v, want a readable refusal", r)
}
if r := call("fill_region", map[string]any{"objectId": bed.ID, "plantId": beet.ID}); !r.IsError {
t.Error("fill_region with nowhere to fill succeeded")
}
// An inverted rectangle is refused with its corners named, before the service
// sees it — the mistake a model makes is swapping which way is north.
if r := call("fill_region", map[string]any{"objectId": bed.ID, "plantId": beet.ID, "x0Cm": 40.0, "y0Cm": -60.0, "x1Cm": -40.0, "y1Cm": 60.0}); !r.IsError || !strings.Contains(r.Content, "west of") {
t.Errorf("inverted rectangle: %+v, want a refusal naming the corners", r)
}
// remove_plantings without a plant would "remove" plant 0 — nothing.
if r := call("remove_plantings", map[string]any{"objectId": bed.ID}); !r.IsError || !strings.Contains(r.Content, "plantId") {
t.Errorf("remove_plantings with no plant: %+v, want a refusal asking which plant", r)
}
// place_planting without a radius → one plant at half the spacing; two garlic
// cloves along the north edge, dated today.
ok("place_planting", map[string]any{"objectId": bed.ID, "plantId": garlic.ID, "xCm": -100, "yCm": -50})
ok("place_planting", map[string]any{"objectId": bed.ID, "plantId": garlic.ID, "xCm": 100, "yCm": -50})
// And a tomato planted back in May, with an explicit date.
ok("place_planting", map[string]any{"objectId": bed.ID, "plantId": tomato.ID, "xCm": 0, "yCm": 0, "plantedAt": "2026-05-20"})
// describe_garden: one group per plant, dated; only the small ones listed.
var d service.DescribeResult
groups := func() map[string]service.DescribeGroup {
t.Helper()
decode(ok("describe_garden", map[string]any{"gardenId": g.ID}), &d)
out := map[string]service.DescribeGroup{}
for _, o := range d.Objects {
if o.ID == bed.ID {
for _, gr := range o.Plantings {
out[gr.Plant] = gr
}
}
}
return out
}
gs := groups()
beets := gs["Beet"]
if beets.Plops <= 8 || beets.Each != nil {
t.Errorf("beets: %d plops, each=%v; want a large group with no per-plop listing", beets.Plops, beets.Each)
}
if beets.PlantedAt != today || beets.Plants != beets.Plops {
t.Errorf("beets plantedAt %q plants %d; want today and one plant per grid plop", beets.PlantedAt, beets.Plants)
}
if !strings.Contains(beets.Where, "cm from the centre") {
t.Errorf("beets where = %q, want the bounding box of a middle-third fill", beets.Where)
}
cloves := gs["Garlic"]
if cloves.Plops != 2 || len(cloves.Each) != 2 || cloves.Where != "north half" || cloves.PlantedAt != today {
t.Errorf("garlic group = %+v, want 2 listed plops in the north half, dated today", cloves)
}
if r := cloves.Each[0].RadiusCM; r != 7.5 {
t.Errorf("a clove placed without a radius got %v, want spacing/2 = 7.5", r)
}
tom := gs["Cherokee Purple"]
if tom.Plops != 1 || tom.Where != "center" || tom.PlantedAt != "2026-05-20" {
t.Errorf("tomato group = %+v, want one plop at the center dated 2026-05-20", tom)
}
// The lot counts the beets as used.
var lots []struct {
Used float64 `json:"used"`
Remaining float64 `json:"remaining"`
}
decode(ok("list_seed_lots", map[string]any{"plantId": beet.ID}), &lots)
if len(lots) != 1 || lots[0].Used != float64(beets.Plants) || lots[0].Remaining != 500-float64(beets.Plants) {
t.Errorf("lots = %+v, want %d used of 500", lots, beets.Plants)
}
// list_plantings spells the big group out, narrowed to one plant.
var listed []service.DescribePlanting
decode(ok("list_plantings", map[string]any{"objectId": bed.ID, "plantId": beet.ID}), &listed)
if len(listed) != beets.Plops {
t.Errorf("list_plantings: %d beets, want %d", len(listed), beets.Plops)
}
// move_planting: the tomato to the north bed, date kept; a within-bed move too.
var moved domain.Planting
decode(ok("move_planting", map[string]any{
"plantingId": tom.Each[0].ID, "version": tom.Each[0].Version, "toObjectId": other.ID, "xCm": 10.0, "yCm": -20.0,
}), &moved)
if moved.ObjectID != other.ID || moved.PlantedAt == nil || *moved.PlantedAt != "2026-05-20" {
t.Errorf("moved tomato = %+v, want it in the north bed with its May date", moved)
}
decode(ok("move_planting", map[string]any{
"plantingId": cloves.Each[0].ID, "version": cloves.Each[0].Version, "xCm": -110.0, "yCm": -55.0,
}), &moved)
if moved.ObjectID != bed.ID || moved.XCM != -110 {
t.Errorf("within-bed move = %+v, want the same bed at x=-110", moved)
}
// remove_plantings: the beets out, the garlic stays — dated today.
var removed struct {
Removed int `json:"removed"`
}
decode(ok("remove_plantings", map[string]any{"objectId": bed.ID, "plantId": beet.ID}), &removed)
if removed.Removed != beets.Plops {
t.Errorf("remove_plantings removed %d, want the %d beets", removed.Removed, beets.Plops)
}
gs = groups()
if _, still := gs["Beet"]; still || gs["Garlic"].Plops != 2 {
t.Errorf("after remove_plantings the bed has %+v, want the garlic only", gs)
}
var pulled domain.Planting
decode(ok("remove_planting", map[string]any{"plantingId": gs["Garlic"].Each[0].ID, "version": gs["Garlic"].Each[0].Version}), &pulled)
if pulled.RemovedAt == nil || *pulled.RemovedAt != today {
t.Errorf("remove_planting dated the removal %v, want today %s", pulled.RemovedAt, today)
}
// read_history sees all of that, newest first, and marks what was undone.
var hist struct {
Entries []historyEntry `json:"entries"`
HasMore bool `json:"hasMore"`
}
decode(ok("read_history", map[string]any{"gardenId": g.ID, "limit": 3}), &hist)
if len(hist.Entries) != 3 || !hist.HasMore {
t.Fatalf("read_history = %d entries, hasMore=%v; want 3 and more", len(hist.Entries), hist.HasMore)
}
if e := hist.Entries[1]; !strings.HasPrefix(e.Summary, "Removed Beet from South bed") || !strings.Contains(e.Changes, "planting") || e.Undone {
t.Errorf("entry = %+v, want the beet removal, not undone", e)
}
if _, conflicts, err := svc.RevertChangeSet(ctx, owner, hist.Entries[1].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts)
}
decode(ok("read_history", map[string]any{"gardenId": g.ID, "limit": 3}), &hist)
if hist.Entries[0].UndoOf == nil || !hist.Entries[2].Undone {
t.Errorf("after an undo: newest = %+v, undone = %+v; want the revert to point at the removal, and the removal marked undone", hist.Entries[0], hist.Entries[2])
}
// update_plant on the user's own plant; a built-in is refused.
var matches []struct {
ID int64 `json:"id"`
Version int64 `json:"version"`
}
decode(ok("find_plant", map[string]any{"query": "cherokee"}), &matches)
var updated domain.Plant
decode(ok("update_plant", map[string]any{"plantId": matches[0].ID, "version": matches[0].Version, "daysToMaturity": 75}), &updated)
if updated.DaysToMaturity == nil || *updated.DaysToMaturity != 75 || updated.Name != "Cherokee Purple" {
t.Errorf("update_plant = %+v, want days 75 and the name untouched", updated)
}
decode(ok("find_plant", map[string]any{"query": "basil"}), &matches)
if r := call("update_plant", map[string]any{"plantId": matches[0].ID, "version": matches[0].Version, "daysToMaturity": 60}); !r.IsError {
t.Error("update_plant changed a built-in")
}
// add_journal_entry is dated today unless told otherwise.
ok("add_journal_entry", map[string]any{"gardenId": g.ID, "body": "aphids on the beets"})
entries, _, err := svc.ListJournal(ctx, owner, g.ID, service.JournalQuery{})
if err != nil {
t.Fatalf("ListJournal: %v", err)
}
if len(entries) != 1 || entries[0].ObservedAt != today {
t.Errorf("journal = %+v, want one entry observed %s", entries, today)
}
// copy_garden makes next year's plan: a whole copy under the plan name.
var plan domain.Garden
decode(ok("copy_garden", map[string]any{"gardenId": g.ID, "name": "Plot — 2027"}), &plan)
if plan.Name != "Plot — 2027" || plan.ID == g.ID {
t.Errorf("copy_garden = %+v, want a new garden named for the plan", plan)
}
decode(ok("describe_garden", map[string]any{"gardenId": plan.ID}), &d)
if len(d.Objects) != 2 {
t.Errorf("the plan copy has %d objects, want the source's 2", len(d.Objects))
}
}
// TestToolsDefaultToTheServiceDayWithoutOne — a toolbox built with no local day
// (a bare API caller) still dates everything: the service's UTC today.
func TestToolsDefaultToTheServiceDayWithoutOne(t *testing.T) {
a := &adapter{today: ""}
if d := a.day(""); d != nil {
t.Errorf("no day at all → %q, want nil (the service default)", *d)
}
if d := a.day(" 2026-01-02 "); d == nil || *d != "2026-01-02" {
t.Errorf("an explicit day → %v, want it trimmed", d)
}
a.today = "2026-08-22"
if d := a.day(""); d == nil || *d != "2026-08-22" {
t.Errorf("the gardener's day → %v, want 2026-08-22", d)
}
if d := a.day("2026-05-20"); d == nil || *d != "2026-05-20" {
t.Errorf("an explicit day beats the default: %v", d)
}
}
+32 -8
View File
@@ -35,6 +35,21 @@ const keepAliveInterval = 20 * time.Second
type chatRequest struct { type chatRequest struct {
GardenID int64 `json:"gardenId" binding:"required"` GardenID int64 `json:"gardenId" binding:"required"`
Message string `json:"message" binding:"required"` Message string `json:"message" binding:"required"`
// Today is the sender's local date (YYYY-MM-DD): what the assistant tells the
// model the date is, and what the turn's plantings, removals and journal
// entries are dated. The UI always sends it, for the same reason it sends
// plantedAt on a fill — a gardener placing at 9 pm in Ohio planted today, not
// UTC's tomorrow. Optional for bare API callers, who get the server's UTC day.
Today string `json:"today"`
}
// validToday accepts an empty date or one in YYYY-MM-DD form.
func validToday(s string) bool {
if s == "" {
return true
}
_, err := time.Parse("2006-01-02", s)
return err == nil
} }
// chatEvent is one server-sent event. Exactly one field is set. // chatEvent is one server-sent event. Exactly one field is set.
@@ -60,17 +75,23 @@ func (h *handlers) agentChat(c *gin.Context) {
// state, not a missing route: answer it plainly rather than 404ing a path // 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 // that exists. Loaded once here so a settings-driven swap mid-request can't
// make it flip between the guard and the Run call. // make it flip between the guard and the Run call.
runner := h.agent.get() // The body is checked before the runner: a malformed request is a 400
if runner == nil { // whether or not there is a model behind the route, so a client can't
writeAPIError(c, http.StatusServiceUnavailable, "AGENT_DISABLED", "the garden assistant isn't enabled on this instance") // mistake its own bad date for the assistant being off.
return
}
var req chatRequest var req chatRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required") writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a gardenId and a message are required")
return return
} }
if !validToday(req.Today) {
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "today must be a YYYY-MM-DD date")
return
}
runner := h.agent.get()
if runner == nil {
writeAPIError(c, http.StatusServiceUnavailable, "AGENT_DISABLED", "the garden assistant isn't enabled on this instance")
return
}
actor := mustActor(c) actor := mustActor(c)
history, err := h.svc.AgentHistory(c.Request.Context(), actor.ID, req.GardenID) history, err := h.svc.AgentHistory(c.Request.Context(), actor.ID, req.GardenID)
@@ -88,7 +109,7 @@ func (h *handlers) agentChat(c *gin.Context) {
stopBeat := stream.keepAlive(keepAliveInterval) stopBeat := stream.keepAlive(keepAliveInterval)
defer stopBeat() defer stopBeat()
turn, err := runner.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message, turn, err := runner.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message, req.Today,
replayHistory(history), replayHistory(history),
func(s mdagent.Step) { func(s mdagent.Step) {
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}}) send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
@@ -164,11 +185,14 @@ type eventStream struct {
// write path. Only the client sees it, as a truncated stream it reports as a // 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, // dropped connection. Hence a deadline set up front and refreshed per frame,
// rather than anything checked after the fact. // rather than anything checked after the fact.
//
// The controller comes from responseController, not from c.Writer — a
// controller built here can't reach the socket; deadlines.go says why.
func openEventStream(c *gin.Context) *eventStream { func openEventStream(c *gin.Context) *eventStream {
c.Header("Content-Type", "text/event-stream") c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache") c.Header("Cache-Control", "no-cache")
c.Header("X-Accel-Buffering", "no") c.Header("X-Accel-Buffering", "no")
s := &eventStream{c: c, rc: http.NewResponseController(c.Writer)} s := &eventStream{c: c, rc: responseController(c)}
// Probe once here rather than reporting per frame: a writer that can't take // 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 // 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 // hear it once. If this fails the stream still works — it is just back to
+24
View File
@@ -47,3 +47,27 @@ func TestAgentDisabledWithoutAKey(t *testing.T) {
t.Errorf("editor load: status %d, want 200 — an unconfigured agent must not break the app", w.Code) t.Errorf("editor load: status %d, want 200 — an unconfigured agent must not break the app", w.Code)
} }
} }
// TestChatRejectsAMalformedToday — the sender's local date is validated before
// anything else about the request, assistant or no assistant: a bad body is a
// 400 either way, so a client can't mistake its own bad date for the assistant
// being off.
func TestChatRejectsAMalformedToday(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, "/api/v1/agent/chat",
map[string]any{"gardenId": gid, "message": "plant garlic", "today": "Aug 22"}, cookie)
if w.Code != http.StatusBadRequest {
t.Errorf("chat with today=%q: status %d, want 400", "Aug 22", w.Code)
}
// A well-formed date (or none) gets past validation to the runner check.
for _, today := range []string{"2026-08-22", ""} {
w := doJSON(t, r, http.MethodPost, "/api/v1/agent/chat",
map[string]any{"gardenId": gid, "message": "plant garlic", "today": today}, cookie)
if w.Code != http.StatusServiceUnavailable {
t.Errorf("chat with today=%q: status %d, want 503 (no runner configured)", today, w.Code)
}
}
}
+5 -1
View File
@@ -39,7 +39,11 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
gin.SetMode(gin.ReleaseMode) gin.SetMode(gin.ReleaseMode)
r := gin.New() r := gin.New()
r.Use(sloggin.New(slog.Default()), gin.Recovery()) // captureController goes first, on purpose: the logging middleware wraps
// c.Writer in a type a ResponseController can't see through, and anything
// that extends a request deadline (the SSE chat stream, the scan upload)
// needs a controller built before that happens. See deadlines.go.
r.Use(captureController(), sloggin.New(slog.Default()), gin.Recovery())
if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil { if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {
// Do not leave gin's trust-everyone default active on a parse failure — // Do not leave gin's trust-everyone default active on a parse failure —
+48
View File
@@ -0,0 +1,48 @@
package api
import (
"net/http"
"github.com/gin-gonic/gin"
)
// responseControllerKey is where captureController stashes the controller in
// the gin context for responseController to find.
const responseControllerKey = "pansy.responseController"
// captureController hands every handler an http.ResponseController that can
// actually reach the connection. It MUST be the first middleware on the engine.
//
// A ResponseController finds the connection's deadline setters by unwrapping
// the ResponseWriter it was built from, one layer at a time, until it reaches
// one that has them. gin's own writer unwraps cleanly. The logging middleware's
// does not: it replaces c.Writer with a type that embeds the gin.ResponseWriter
// INTERFACE, which has no Unwrap, so a controller built from c.Writer inside a
// handler stops there and every SetReadDeadline/SetWriteDeadline returns
// ErrNotSupported. That left the per-frame SSE deadline (#78) and the scan
// upload's extensions dead in production while their tests — on a bare engine
// with no logging — passed: long agent turns were cut at the server's absolute
// 30s WriteTimeout, and the client saw "The connection dropped partway through."
//
// Building the controller here, ahead of every wrapper, sidesteps the question
// of what any later middleware does to the writer. Handlers that extend a
// deadline take it from responseController; sse_deadline_test.go runs the
// scenario through New so a reorder or a new wrapper fails a test.
func captureController() gin.HandlerFunc {
return func(c *gin.Context) {
c.Set(responseControllerKey, http.NewResponseController(c.Writer))
c.Next()
}
}
// responseController returns the controller captureController stored, or — on
// an engine without that middleware, which only tests build — one made from
// c.Writer as it stands.
func responseController(c *gin.Context) *http.ResponseController {
if v, ok := c.Get(responseControllerKey); ok {
if rc, ok := v.(*http.ResponseController); ok {
return rc
}
}
return http.NewResponseController(c.Writer)
}
+17 -1
View File
@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -52,7 +53,7 @@ func writeServiceError(c *gin.Context, err error) {
case errors.Is(err, domain.ErrOIDCIdentityConflict): case errors.Is(err, domain.ErrOIDCIdentityConflict):
writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account") writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account")
case errors.Is(err, domain.ErrInvalidInput): case errors.Is(err, domain.ErrInvalidInput):
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input") writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", inputMessage(err))
default: default:
slog.Error("api: unhandled service error", "error", err) slog.Error("api: unhandled service error", "error", err)
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error") writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error")
@@ -114,3 +115,18 @@ func parseIDParam(c *gin.Context, name string) (int64, bool) {
} }
return id, true return id, true
} }
// inputMessage is the text a 400 carries for an ErrInvalidInput. The bare
// sentinel reads "invalid input"; a service that wraps it with a reason —
// fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)
// — has that reason shown to the person verbatim, minus the sentinel prefix.
// So anything wrapped this way is written for the keyboard, not the log (see
// the note on domain.ErrInvalidInput).
func inputMessage(err error) string {
msg := err.Error()
base := domain.ErrInvalidInput.Error()
if msg == base {
return msg
}
return strings.TrimPrefix(msg, base+": ")
}
+30
View File
@@ -0,0 +1,30 @@
package api
import (
"errors"
"fmt"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// TestInputMessage: the bare sentinel stays generic; a wrapped reason reaches
// the person without the "invalid input: " prefix in front of it.
func TestInputMessage(t *testing.T) {
cases := []struct {
err error
want string
}{
{domain.ErrInvalidInput, "invalid input"},
{fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, "nonesuch/model"), `chat model "nonesuch/model": unknown provider`},
{fmt.Errorf("loading: %w", domain.ErrInvalidInput), "loading: invalid input"},
}
for _, c := range cases {
if !errors.Is(c.err, domain.ErrInvalidInput) {
t.Fatalf("%v should still be an ErrInvalidInput", c.err)
}
if got := inputMessage(c.err); got != c.want {
t.Errorf("inputMessage(%v) = %q, want %q", c.err, got, c.want)
}
}
}
+6 -2
View File
@@ -55,6 +55,10 @@ type objectFillRequest struct {
// (individual plants in rows at true spacing). Empty = clump. An unknown value // (individual plants in rows at true spacing). Empty = clump. An unknown value
// is refused by the service (#77). // is refused by the service (#77).
Layout string `json:"layout"` Layout string `json:"layout"`
// PlantedAt dates every plop the fill makes (YYYY-MM-DD). The UI sends its
// local day; omitted, the server uses UTC today — which is tomorrow for an
// evening gardener west of Greenwich, so clients that know better say so.
PlantedAt *string `json:"plantedAt"`
} }
func (h *handlers) fillObject(c *gin.Context) { func (h *handlers) fillObject(c *gin.Context) {
@@ -88,9 +92,9 @@ func (h *handlers) fillObject(c *gin.Context) {
return return
} }
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY} region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout)) created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
} else { } else {
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout)) created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
} }
if err != nil { if err != nil {
writeServiceError(c, err) writeServiceError(c, err)
+9 -3
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"errors" "errors"
"log/slog"
"net/http" "net/http"
"time" "time"
@@ -40,9 +41,14 @@ const scanWriteTimeout = 120 * time.Second
func (h *handlers) scanSeedPacket(c *gin.Context) { func (h *handlers) scanSeedPacket(c *gin.Context) {
// Extend both deadlines for the (potentially large, potentially slow) upload // Extend both deadlines for the (potentially large, potentially slow) upload
// and the live vision call that follows. Best-effort: if the writer doesn't // and the live vision call that follows. Best-effort: if the writer doesn't
// support it, the server defaults apply. // support it, the server defaults apply — but say so, once, because this
rc := http.NewResponseController(c.Writer) // failed silently behind the logging middleware for as long as the errors
_ = rc.SetReadDeadline(time.Now().Add(scanReadTimeout)) // were discarded (see deadlines.go). The second call can only fail the same
// way as the first, so it isn't reported twice.
rc := responseController(c)
if err := rc.SetReadDeadline(time.Now().Add(scanReadTimeout)); err != nil {
slog.Error("api: scan deadlines unavailable; slow uploads will be cut at the server ReadTimeout", "error", err)
}
_ = rc.SetWriteDeadline(time.Now().Add(scanWriteTimeout)) _ = rc.SetWriteDeadline(time.Now().Add(scanWriteTimeout))
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit) c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, scanUploadLimit)
+16 -2
View File
@@ -1,7 +1,9 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -159,10 +161,22 @@ func TestSettingsRejectsBadModel(t *testing.T) {
admin := registerAndCookie(t, r, "[email protected]") admin := registerAndCookie(t, r, "[email protected]")
v := settingsVersion(t, r, admin) v := settingsVersion(t, r, admin)
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest { map[string]any{"agentModel": "nonesuch/model", "version": v}, admin)
if w.Code != http.StatusBadRequest {
t.Errorf("bad model: status %d, want 400", w.Code) t.Errorf("bad model: status %d, want 400", w.Code)
} }
// The message says which field and which spec, so the page can show a reason
// rather than a bare "invalid input".
var body struct {
Error struct{ Code, Message string } `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode bad-model body: %v", err)
}
if body.Error.Code != "INVALID_INPUT" || !strings.Contains(body.Error.Message, "chat model") || !strings.Contains(body.Error.Message, "nonesuch/model") {
t.Errorf("bad model error = %+v, want INVALID_INPUT naming the chat model and spec", body.Error)
}
// agentEnabled must be a bool or null, not a string. // agentEnabled must be a bool or null, not a string.
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest { map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
+67 -10
View File
@@ -2,6 +2,7 @@ package api
import ( import (
"bufio" "bufio"
"net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
@@ -10,15 +11,22 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
// streamFrames spins up a real http.Server with the given WriteTimeout and an // bareEngine is a gin engine with NO middleware: the narrowest possible host for
// SSE handler that emits `frames` data frames, one every `tick`, then returns. // openEventStream, and what the #78/#87 tests were originally written against.
// It reports how many frames the client actually received and any read error — // It is not what production runs — the middleware stack in New wraps the
// the only vantage point from which the deadline failures in #78/#87 are // ResponseWriter, and that difference is the whole subject of the third test.
// visible, since the writes themselves return nil when the bytes are dropped. func bareEngine() *gin.Engine {
func streamFrames(t *testing.T, serverWriteTimeout, tick time.Duration, frames int) (int, error) {
t.Helper()
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
r := gin.New() return gin.New()
}
// streamFrames spins up a real http.Server around r with the given WriteTimeout
// and an SSE route 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, r *gin.Engine, serverWriteTimeout, tick time.Duration, frames int) (int, error) {
t.Helper()
r.GET("/stream", func(c *gin.Context) { r.GET("/stream", func(c *gin.Context) {
s := openEventStream(c) s := openEventStream(c)
for i := 0; i < frames; i++ { for i := 0; i < frames; i++ {
@@ -64,7 +72,7 @@ func TestEventStreamOutlivesServerWriteTimeout(t *testing.T) {
// keeps the stream alive with a huge margin — CI slowness only ever makes // 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 // this pass more surely. The server's 300ms WriteTimeout is the thing being
// overridden; frames straddle it (300ms/600ms/900ms). // overridden; frames straddle it (300ms/600ms/900ms).
got, err := streamFrames(t, 300*time.Millisecond, 300*time.Millisecond, 3) got, err := streamFrames(t, bareEngine(), 300*time.Millisecond, 300*time.Millisecond, 3)
if err != nil { if err != nil {
t.Errorf("client read error after %d/3 frames: %v", got, err) t.Errorf("client read error after %d/3 frames: %v", got, err)
} }
@@ -90,7 +98,7 @@ func TestEventStreamRefreshesDeadlinePerFrame(t *testing.T) {
// The server WriteTimeout is generous (5s), so it isn't the limiter — the // 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 // 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. // the 400ms deadline, but each 100ms gap is a 4× margin under it.
got, err := streamFrames(t, 5*time.Second, 100*time.Millisecond, 8) got, err := streamFrames(t, bareEngine(), 5*time.Second, 100*time.Millisecond, 8)
if err != nil { if err != nil {
t.Errorf("client read error after %d/8 frames: %v", got, err) t.Errorf("client read error after %d/8 frames: %v", got, err)
} }
@@ -99,3 +107,52 @@ func TestEventStreamRefreshesDeadlinePerFrame(t *testing.T) {
got, sseWriteTimeout) got, sseWriteTimeout)
} }
} }
// TestEventStreamOutlivesWriteTimeoutBehindMiddleware is #78 again, through the
// production middleware stack — which is where it was still broken.
//
// The two tests above passed while the deployed instance cut every agent turn
// at exactly 30s: they host openEventStream on a bare engine, and it is the
// logging middleware in New that hides the socket from a ResponseController
// built in a handler (deadlines.go has the mechanism). So: the same scenario as
// the first test, hosted on the engine New builds, in the order cmd/pansy runs
// it. Any future middleware that wraps the writer, or a reorder that puts one
// ahead of the controller capture, fails here.
func TestEventStreamOutlivesWriteTimeoutBehindMiddleware(t *testing.T) {
got, err := streamFrames(t, authEngine(t, localCfg()), 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; the deadline override is not reaching the socket through the middleware stack", got)
}
}
// TestResponseControllerReachesTheSocketBehindMiddleware pins the mechanism the
// test above depends on, for every handler that extends a deadline — the scan
// upload extends both (seed_packet.go), and its calls were failing just as
// silently, with the errors discarded.
func TestResponseControllerReachesTheSocketBehindMiddleware(t *testing.T) {
r := authEngine(t, localCfg())
var readErr, writeErr error
r.GET("/deadlines", func(c *gin.Context) {
rc := responseController(c)
readErr = rc.SetReadDeadline(time.Now().Add(time.Minute))
writeErr = rc.SetWriteDeadline(time.Now().Add(time.Minute))
c.Status(http.StatusNoContent)
})
srv := httptest.NewServer(r)
defer srv.Close()
resp, err := srv.Client().Get(srv.URL + "/deadlines")
if err != nil {
t.Fatalf("get: %v", err)
}
resp.Body.Close()
if readErr != nil {
t.Errorf("SetReadDeadline through the production middleware: %v", readErr)
}
if writeErr != nil {
t.Errorf("SetWriteDeadline through the production middleware: %v", writeErr)
}
}
+4 -1
View File
@@ -33,7 +33,10 @@ var (
ErrShareExists = errors.New("garden already shared with that user") ErrShareExists = errors.New("garden already shared with that user")
// ErrInvalidInput means the caller supplied structurally invalid data (empty // ErrInvalidInput means the caller supplied structurally invalid data (empty
// required field, malformed value). Mapped to 400. // required field, malformed value). Mapped to 400. Wrap it with the reason —
// fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", ErrInvalidInput) —
// and the API shows that reason to the person verbatim, so write it for
// them, not for a log; the bare sentinel reads as just "invalid input".
ErrInvalidInput = errors.New("invalid input") ErrInvalidInput = errors.New("invalid input")
// ErrInvalidCredentials means a login attempt failed. It is deliberately // ErrInvalidCredentials means a login attempt failed. It is deliberately
// identical for an unknown email and a wrong password so neither can be // identical for an unknown email and a wrong password so neither can be
+20 -6
View File
@@ -2,6 +2,8 @@ package service
import ( import (
"context" "context"
"errors"
"fmt"
"strings" "strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel" "gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
@@ -61,12 +63,15 @@ func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, pat
model := strings.TrimSpace(patch.AgentModel) model := strings.TrimSpace(patch.AgentModel)
vision := strings.TrimSpace(patch.VisionModel) vision := strings.TrimSpace(patch.VisionModel)
// Validate non-empty specs up front. An empty one is the "inherit env" // Validate non-empty specs up front. An empty one is the "inherit env"
// sentinel and needs no check — the env value was validated at boot. // sentinel and needs no check — the env value was validated at boot. The
for _, spec := range []string{model, vision} { // reason rides on the sentinel so the 400 can show it: "unknown provider"
if spec != "" { // is something a person can act on, "invalid input" is not.
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, spec); err != nil { for _, f := range []struct{ label, spec string }{{"chat model", model}, {"vision model", vision}} {
return nil, domain.ErrInvalidInput if f.spec == "" {
} continue
}
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, f.spec); err != nil {
return nil, fmt.Errorf("%w: %s %q: %v", domain.ErrInvalidInput, f.label, f.spec, specReason(err))
} }
} }
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{ return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
@@ -166,3 +171,12 @@ func (s *Service) EffectiveConfig(ctx context.Context) (EffectiveAgent, Effectiv
} }
return s.agentOver(st), s.visionOver(st), nil return s.agentOver(st), s.visionOver(st), nil
} }
// specReason strips agentmodel's own "resolve %q:" wrapping so the message
// reads "unknown provider …" rather than repeating the spec twice.
func specReason(err error) string {
if u := errors.Unwrap(err); u != nil {
return u.Error()
}
return err.Error()
}
+369 -71
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math" "math"
@@ -175,38 +176,87 @@ func validFillLayout(l FillLayout) (FillLayout, bool) {
// in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half- // in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half-
// spacing for a clump (see edgeInset for the why). A candidate is skipped when its // spacing for a clump (see edgeInset for the why). A candidate is skipped when its
// plop would sit entirely inside an existing active plop (so re-filling doesn't // plop would sit entirely inside an existing active plop (so re-filling doesn't
// stack duplicates). Returns the plops it created. // stack duplicates). Every plop is dated plantedAt (YYYY-MM-DD), or UTC today
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) { // when nil — the UI always sends its local day, so the default is for API and
// agent callers. Returns the plops it created.
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
return s.Fill(ctx, actorID, objectID, FillSpec{
Region: region, PlantID: plantID, SpacingOverride: spacingOverride, Layout: layout, PlantedAt: plantedAt,
})
}
// FillSpec is everything a fill needs besides the object it fills: where (a
// compass RegionName, or an explicit Region in the object's local frame when the
// name is empty), what, and how.
type FillSpec struct {
// RegionName is a compass name for NamedRegion ("ne", "south half", "all").
// When it is empty, Region is used as given.
RegionName string
Region Region
PlantID int64
// SpacingOverride replaces the plant's own spacing for this fill, in cm.
SpacingOverride *float64
// Layout is clump (the default) or grid; see FillLayout.
Layout FillLayout
// PlantedAt dates every plop the fill makes (YYYY-MM-DD). nil means the
// service's UTC today; a caller that knows the person's local day sends it.
PlantedAt *string
// SeedLotID attributes every plop to one of the actor's seed lots, so the lot
// can report what it has left. Optional.
SeedLotID *int64
}
// Fill plants one plant across part of an object the actor can edit, per spec.
// FillRegion and FillNamedRegion are the two older spellings of it.
func (s *Service) Fill(ctx context.Context, actorID, objectID int64, spec FillSpec) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout) region := spec.Region
if strings.TrimSpace(spec.RegionName) != "" {
if region, err = NamedRegion(o, spec.RegionName); err != nil {
return nil, err
}
} else if !(region.MinX < region.MaxX && region.MinY < region.MaxY) {
// A zero or inverted rectangle is a caller that said nothing about where
// — not a request for the one plop hexCenters would put at its middle.
return nil, fmt.Errorf("%w: the fill rectangle is empty", domain.ErrInvalidInput)
}
return s.fillLoaded(ctx, actorID, o, region, spec)
} }
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object // fillLoaded is the body of Fill given an object already loaded and authorized
// already loaded and authorized (roleEditor). It validates the layout, rejects a // (roleEditor) and its region resolved. It validates the layout, rejects a
// non-finite region, clamps the region to the object's bounds, refuses fills over // 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 // maxFillPlops, and inserts the whole batch in one transaction rather than one
// round-trip per plop. // round-trip per plop.
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) { func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, spec FillSpec) ([]domain.Planting, error) {
if !o.Plantable { if !o.Plantable {
return nil, domain.ErrInvalidInput return nil, domain.ErrInvalidInput
} }
layout, ok := validFillLayout(layout) if !validDatePtr(spec.PlantedAt) {
return nil, fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
}
layout, ok := validFillLayout(spec.Layout)
if !ok { if !ok {
return nil, domain.ErrInvalidInput return nil, domain.ErrInvalidInput
} }
plant, err := s.visiblePlant(ctx, actorID, plantID) plant, err := s.visiblePlant(ctx, actorID, spec.PlantID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Checked before anything is planted, as CreatePlanting does: a lot of the
// wrong variety, or someone else's, refuses the whole fill.
if err := s.checkSeedLotForPlanting(ctx, actorID, spec.SeedLotID, spec.PlantID); err != nil {
return nil, err
}
spacing := plant.SpacingCM spacing := plant.SpacingCM
if spacingOverride != nil { if spec.SpacingOverride != nil {
if !isFinite(*spacingOverride) || *spacingOverride < minPlantSpacingCM || *spacingOverride > maxPlantSpacingCM { if !isFinite(*spec.SpacingOverride) || *spec.SpacingOverride < minPlantSpacingCM || *spec.SpacingOverride > maxPlantSpacingCM {
return nil, domain.ErrInvalidInput return nil, domain.ErrInvalidInput
} }
spacing = *spacingOverride spacing = *spec.SpacingOverride
} }
radius := plopRadiusFor(spacing, layout) radius := plopRadiusFor(spacing, layout)
if !isFinite(radius) || radius <= 0 { if !isFinite(radius) || radius <= 0 {
@@ -226,6 +276,14 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
} }
region = region.clampTo(o.WidthCM/2, o.HeightCM/2) region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
if region.MaxX <= region.MinX || region.MaxY <= region.MinY {
// An explicit rectangle that misses the object, or only touches its edge.
// Planting nothing and reporting success would read as "done" to a caller
// that aimed at the wrong coordinates (typically the agent mixing up the
// garden frame and the object's local one) — and a rectangle clamped to a
// line would get hexCenters' one-plop-in-the-middle rule, on the edge.
return nil, fmt.Errorf("%w: the region lies outside the object", domain.ErrInvalidInput)
}
centers, total := hexCenters(region, radius, edgeInset(radius, spacing, layout), maxFillPlops) centers, total := hexCenters(region, radius, edgeInset(radius, spacing, layout), maxFillPlops)
if total > maxFillPlops { if total > maxFillPlops {
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
@@ -235,7 +293,10 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
if err != nil { if err != nil {
return nil, err return nil, err
} }
today := s.now().UTC().Format(dateLayout) plantedOn := s.now().UTC().Format(dateLayout)
if spec.PlantedAt != nil {
plantedOn = *spec.PlantedAt
}
batch := make([]*domain.Planting, 0, len(centers)) batch := make([]*domain.Planting, 0, len(centers))
// Only the plops that were ALREADY here can cover a candidate: every plop this // Only the plops that were ALREADY here can cover a candidate: every plop this
// fill makes shares one radius and sits on a distinct lattice point, and a plop // fill makes shares one radius and sits on a distinct lattice point, and a plop
@@ -247,7 +308,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
if coveredByExisting(c.x, c.y, radius, existing) { if coveredByExisting(c.x, c.y, radius, existing) {
continue continue
} }
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today}) batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: spec.PlantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &plantedOn, SeedLotID: spec.SeedLotID})
} }
created, err := s.store.CreatePlantings(ctx, batch) created, err := s.store.CreatePlantings(ctx, batch)
if err != nil { if err != nil {
@@ -376,16 +437,15 @@ func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half") // FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
// instead of a resolved Region — the ergonomic form for agent tools, which don't // instead of a resolved Region — the ergonomic form for agent tools, which don't
// hold the object's geometry. It resolves the name against the object, then fills. // hold the object's geometry. It resolves the name against the object, then fills.
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) { func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) if strings.TrimSpace(regionName) == "" {
if err != nil { // Fill would read a blank name as "use the (zero) Region" and plant
return nil, err // nothing; here a blank name is the caller's mistake, as it always was.
return nil, domain.ErrInvalidInput
} }
region, err := NamedRegion(o, regionName) return s.Fill(ctx, actorID, objectID, FillSpec{
if err != nil { RegionName: regionName, PlantID: plantID, SpacingOverride: spacingOverride, Layout: layout, PlantedAt: plantedAt,
return nil, err })
}
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout)
} }
// ClearObject soft-removes every active plop in an object the actor can edit (one // ClearObject soft-removes every active plop in an object the actor can edit (one
@@ -394,10 +454,29 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64,
// non-plantable after it was planted must still be clearable (you can always // non-plantable after it was planted must still be clearable (you can always
// remove existing plops, only not add new ones). // remove existing plops, only not add new ones).
func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int, error) { func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int, error) {
return s.ClearPlantings(ctx, actorID, objectID, ClearOptions{})
}
// ClearOptions narrows ClearPlantings.
type ClearOptions struct {
// PlantID limits the clear to one plant — "pull the beets out, leave the
// garlic" — nil clears every plant.
PlantID *int64
// RemovedAt is the removal date (YYYY-MM-DD). nil means the service's UTC
// today; a caller that knows the person's local day sends it.
RemovedAt *string
}
// ClearPlantings is ClearObject with options: all of an object's active plops, or
// only one plant's. The whole clear is one change set either way.
func (s *Service) ClearPlantings(ctx context.Context, actorID, objectID int64, opts ClearOptions) (int, error) {
o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor) o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil { if err != nil {
return 0, err return 0, err
} }
if !validDatePtr(opts.RemovedAt) {
return 0, fmt.Errorf("%w: removedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
}
// Snapshot the rows the bulk UPDATE is about to touch, since it reports only a // Snapshot the rows the bulk UPDATE is about to touch, since it reports only a
// count — then clear exactly those ids. Clearing "every active plop" instead // count — then clear exactly those ids. Clearing "every active plop" instead
// would let a plop created between this read and the UPDATE be removed with no // would let a plop created between this read and the UPDATE be removed with no
@@ -406,12 +485,32 @@ func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int
if err != nil { if err != nil {
return 0, err return 0, err
} }
what := "" // names the plant in the summary when the clear is for one plant
if opts.PlantID != nil {
only := make([]domain.Planting, 0, len(before))
for i := range before {
if before[i].PlantID == *opts.PlantID {
only = append(only, before[i])
}
}
before = only
// The summary is read by a person, so name the plant, not its id. A plant
// that no longer exists just goes unnamed.
if plant, err := s.store.GetPlant(ctx, *opts.PlantID); err == nil {
what = plant.Name
} else if !errors.Is(err, domain.ErrNotFound) {
return 0, err
}
}
ids := make([]int64, 0, len(before)) ids := make([]int64, 0, len(before))
for i := range before { for i := range before {
ids = append(ids, before[i].ID) ids = append(ids, before[i].ID)
} }
today := s.now().UTC().Format(dateLayout) removedOn := s.now().UTC().Format(dateLayout)
n, err := s.store.ClearObjectPlantings(ctx, objectID, today, ids) if opts.RemovedAt != nil {
removedOn = *opts.RemovedAt
}
n, err := s.store.ClearObjectPlantings(ctx, objectID, removedOn, ids)
if err != nil || n == 0 { if err != nil || n == 0 {
return n, err return n, err
} }
@@ -439,7 +538,14 @@ func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int
} }
changes = append(changes, changeUpdate(domain.EntityPlanting, b.ID, &b, a)) changes = append(changes, changeUpdate(domain.EntityPlanting, b.ID, &b, a))
} }
s.record(ctx, g.ID, actorID, fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n), changes...) summary := fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n)
if opts.PlantID != nil {
if what == "" {
what = "plantings"
}
summary = fmt.Sprintf("Removed %s from %s (%d plantings)", what, objectLabel(o), n)
}
s.record(ctx, g.ID, actorID, summary, changes...)
return n, nil return n, nil
} }
@@ -453,39 +559,75 @@ type DescribeResult struct {
Objects []DescribeObject `json:"objects"` Objects []DescribeObject `json:"objects"`
} }
// DescribeObject is one object plus its active plantings, for DescribeResult. // DescribeObject is one object plus its active plantings grouped by plant, for
// Version is included so an agent can move/edit the object (the mutation guard). // DescribeResult. Version is included so an agent can move/edit the object (the
// mutation guard).
type DescribeObject struct { type DescribeObject struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Kind string `json:"kind"` Kind string `json:"kind"`
Name string `json:"name"` Name string `json:"name"`
Shape string `json:"shape"` Shape string `json:"shape"`
WidthCM float64 `json:"widthCm"` WidthCM float64 `json:"widthCm"`
HeightCM float64 `json:"heightCm"` HeightCM float64 `json:"heightCm"`
XCM float64 `json:"xCm"` XCM float64 `json:"xCm"`
YCM float64 `json:"yCm"` YCM float64 `json:"yCm"`
RotationDeg float64 `json:"rotationDeg"` RotationDeg float64 `json:"rotationDeg"`
Plantable bool `json:"plantable"` Plantable bool `json:"plantable"`
Version int64 `json:"version"` Version int64 `json:"version"`
Plantings []DescribePlanting `json:"plantings"` Plantings []DescribeGroup `json:"plantings"`
} }
// DescribePlanting is one plop with a rough compass location, for DescribeResult. // maxListedPlops is the largest group DescribeGroup.Each spells out plop by plop.
// ID + Version are included so an agent can address a single plop — remove it or // Up to it, a group is a handful of placements someone may address one at a time
// move it — the same way DescribeObject.Version lets it edit an object. // ("pull the basil out of the corner"). Past it — a grid-filled bed is hundreds —
// the ids are noise that costs a model more than it informs, and the group is
// addressed as a whole (ClearPlantings) or listed on demand (ListObjectPlantings).
// The live instance's first describe of a grid-filled garden was ~450 plop
// entries, on every turn.
const maxListedPlops = 8
// DescribeGroup summarizes every active plop of one plant in an object — the
// unit a person talks about ("the cucumbers in the west bed") — with the count,
// a rough location, and when it went in.
type DescribeGroup struct {
PlantID int64 `json:"plantId"`
Plant string `json:"plant"`
// Plops is how many placements make up the group; Plants the effective plant
// count across them (explicit counts, else derived from area and spacing).
Plops int `json:"plops"`
Plants int `json:"plants"`
// Where is a rough location: a compass region when the group sits in one
// ("north half", "NE corner"), "throughout" when it spans the object, a short
// list of locations, or — for anything else — its bounding box in local cm.
Where string `json:"where"`
// PlantedAt is the planting date, or "first…last" when the plops differ.
PlantedAt string `json:"plantedAt,omitempty"`
// DaysToMaturity is the plant's, when the catalog knows it — with PlantedAt,
// enough to say when the harvest is due.
DaysToMaturity *int `json:"daysToMaturity,omitempty"`
// Each lists the plops individually (id, version, location) only when the
// group has at most maxListedPlops of them.
Each []DescribePlanting `json:"each,omitempty"`
}
// DescribePlanting is one plop with a rough compass location. ID + Version let
// an agent address a single plop — remove it or move it — the same way
// DescribeObject.Version lets it edit an object.
type DescribePlanting struct { type DescribePlanting struct {
ID int64 `json:"id"` ID int64 `json:"id"`
Version int64 `json:"version"` Version int64 `json:"version"`
PlantID int64 `json:"plantId"` PlantID int64 `json:"plantId"`
Plant string `json:"plant"` Plant string `json:"plant"`
Count int `json:"count"` Count int `json:"count"`
Location string `json:"location"` Location string `json:"location"`
RadiusCM float64 `json:"radiusCm"` RadiusCM float64 `json:"radiusCm"`
PlantedAt string `json:"plantedAt,omitempty"`
} }
// DescribeGarden returns a structured summary — dimensions, objects, and each // DescribeGarden returns a structured summary — dimensions, objects, and each
// object's active plantings (plant, effective count, rough location) — for a // object's active plantings grouped by plant (count, rough location, planting
// garden the actor can view. Built on GardenFull so it inherits the ACL check. // date) — for a garden the actor can view. Built on GardenFull so it inherits
// the ACL check.
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) { func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) {
full, err := s.GardenFull(ctx, actorID, gardenID, nil) full, err := s.GardenFull(ctx, actorID, gardenID, nil)
if err != nil { if err != nil {
@@ -509,33 +651,189 @@ func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (
UnitPref: full.Garden.UnitPref, UnitPref: full.Garden.UnitPref,
Objects: make([]DescribeObject, 0, len(full.Objects)), Objects: make([]DescribeObject, 0, len(full.Objects)),
} }
for _, o := range full.Objects { for i := range full.Objects {
do := DescribeObject{ o := &full.Objects[i]
res.Objects = append(res.Objects, DescribeObject{
ID: o.ID, Kind: o.Kind, Name: o.Name, Shape: o.Shape, ID: o.ID, Kind: o.Kind, Name: o.Name, Shape: o.Shape,
WidthCM: o.WidthCM, HeightCM: o.HeightCM, XCM: o.XCM, YCM: o.YCM, WidthCM: o.WidthCM, HeightCM: o.HeightCM, XCM: o.XCM, YCM: o.YCM,
RotationDeg: o.RotationDeg, Plantable: o.Plantable, Version: o.Version, RotationDeg: o.RotationDeg, Plantable: o.Plantable, Version: o.Version,
Plantings: []DescribePlanting{}, Plantings: describeGroups(o, plopsByObject[o.ID], plantByID),
} })
for _, pl := range plopsByObject[o.ID] {
count := pl.DerivedCount
if pl.Count != nil {
count = *pl.Count
}
do.Plantings = append(do.Plantings, DescribePlanting{
ID: pl.ID,
Version: pl.Version,
PlantID: pl.PlantID,
Plant: plantByID[pl.PlantID].Name,
Count: count,
Location: describeLocation(pl.XCM, pl.YCM),
RadiusCM: pl.RadiusCM,
})
}
res.Objects = append(res.Objects, do)
} }
return res, nil return res, nil
} }
// ListObjectPlantings lists an object's active plops one by one — the ids that
// DescribeGarden summarizes away for a large group. plantID narrows it to one
// plant. Viewer role, like DescribeGarden.
func (s *Service) ListObjectPlantings(ctx context.Context, actorID, objectID int64, plantID *int64) ([]DescribePlanting, error) {
if _, _, err := s.objectForRole(ctx, actorID, objectID, roleViewer); err != nil {
return nil, err
}
plops, err := s.store.ListActivePlantingsForObject(ctx, objectID)
if err != nil {
return nil, err
}
// Plants looked up by id, not through the actor's catalog: a plop in a shared
// garden may be of the owner's private variety, and it still has a name.
plants := map[int64]domain.Plant{}
out := make([]DescribePlanting, 0, len(plops))
for _, pl := range plops {
if plantID != nil && pl.PlantID != *plantID {
continue
}
plant, ok := plants[pl.PlantID]
if !ok {
p, err := s.store.GetPlant(ctx, pl.PlantID)
if err != nil && !errors.Is(err, domain.ErrNotFound) {
return nil, err
}
if p != nil {
plant = *p
}
plants[pl.PlantID] = plant // a plant that no longer exists lists unnamed, not as an error
}
pl.DerivedCount = derivedCount(pl.RadiusCM, plant.SpacingCM)
out = append(out, describePlanting(pl, plant.Name))
}
return out, nil
}
// describeGroups groups an object's active plops by plant, in the order the
// plants first appear, so the same garden always describes the same way.
func describeGroups(o *domain.GardenObject, plops []domain.Planting, plantByID map[int64]domain.Plant) []DescribeGroup {
byPlant := map[int64][]domain.Planting{}
var order []int64
for _, pl := range plops {
if _, seen := byPlant[pl.PlantID]; !seen {
order = append(order, pl.PlantID)
}
byPlant[pl.PlantID] = append(byPlant[pl.PlantID], pl)
}
groups := make([]DescribeGroup, 0, len(order))
for _, pid := range order {
members := byPlant[pid]
plant := plantByID[pid]
g := DescribeGroup{
PlantID: pid, Plant: plant.Name, Plops: len(members),
Where: summarizeWhere(o, members), PlantedAt: dateRange(members),
DaysToMaturity: plant.DaysToMaturity,
}
for _, pl := range members {
g.Plants += effectiveCount(pl)
}
if len(members) <= maxListedPlops {
g.Each = make([]DescribePlanting, 0, len(members))
for _, pl := range members {
g.Each = append(g.Each, describePlanting(pl, plant.Name))
}
}
groups = append(groups, g)
}
return groups
}
func describePlanting(pl domain.Planting, plantName string) DescribePlanting {
d := DescribePlanting{
ID: pl.ID, Version: pl.Version, PlantID: pl.PlantID, Plant: plantName,
Count: effectiveCount(pl), Location: describeLocation(pl.XCM, pl.YCM), RadiusCM: pl.RadiusCM,
}
if pl.PlantedAt != nil {
d.PlantedAt = *pl.PlantedAt
}
return d
}
// effectiveCount is the plant count a plop stands for: its explicit count, else
// the one derived from its area and the plant's spacing.
func effectiveCount(pl domain.Planting) int {
if pl.Count != nil {
return *pl.Count
}
return pl.DerivedCount
}
// dateRange is the planting date shared by a group's plops, "first…last" when
// they were planted on different days, or "" when none is dated. ISO dates
// order as strings, so min/max need no parsing.
func dateRange(plops []domain.Planting) string {
first, last := "", ""
for _, pl := range plops {
if pl.PlantedAt == nil || *pl.PlantedAt == "" {
continue
}
if first == "" || *pl.PlantedAt < first {
first = *pl.PlantedAt
}
if *pl.PlantedAt > last {
last = *pl.PlantedAt
}
}
if first == last {
return first
}
return first + "…" + last
}
// summarizeWhere names where a group of plops sits in its object, in the words
// NamedRegion understands when that is exact ("north half", "NE corner"), and
// otherwise as honestly as it can: "throughout" for a group spanning most of the
// object, a short list of rough locations, or the bounding box of the plop
// centres in local cm — which is what a fill needs to put something back there.
func summarizeWhere(o *domain.GardenObject, plops []domain.Planting) string {
if len(plops) == 1 {
return describeLocation(plops[0].XCM, plops[0].YCM)
}
minX, maxX := plops[0].XCM, plops[0].XCM
minY, maxY := plops[0].YCM, plops[0].YCM
for _, pl := range plops[1:] {
minX, maxX = math.Min(minX, pl.XCM), math.Max(maxX, pl.XCM)
minY, maxY = math.Min(minY, pl.YCM), math.Max(maxY, pl.YCM)
}
const eps = 1e-6
// A half is "everything on one side of the centre line, and not just ON it":
// a column of plops down the middle is neither the west half nor the east.
north := maxY <= eps && minY < -eps
south := minY >= -eps && maxY > eps
west := maxX <= eps && minX < -eps
east := minX >= -eps && maxX > eps
switch {
case north && west:
return "NW corner"
case north && east:
return "NE corner"
case south && west:
return "SW corner"
case south && east:
return "SE corner"
case north:
return "north half"
case south:
return "south half"
case west:
return "west half"
case east:
return "east half"
}
// Centres spanning at least 60% of both dimensions is a whole-object fill
// (the outer row sits half a spacing in from each edge).
if hw, hh := o.WidthCM/2, o.HeightCM/2; hw > 0 && hh > 0 && maxX-minX >= 1.2*hw && maxY-minY >= 1.2*hh {
return "throughout"
}
var locs []string
seen := map[string]bool{}
for _, pl := range plops {
if l := describeLocation(pl.XCM, pl.YCM); !seen[l] {
seen[l] = true
locs = append(locs, l)
}
}
if len(locs) <= 3 {
return strings.Join(locs, ", ")
}
return fmt.Sprintf("x %.0f…%.0f, y %.0f…%.0f cm from the centre", minX, maxX, minY, maxY)
}
// describeLocation reverse-maps a local point to a rough compass location — the // describeLocation reverse-maps a local point to a rough compass location — the
// inverse of NamedRegion's quarters/halves ("NE corner", "south", "center"). // inverse of NamedRegion's quarters/halves ("NE corner", "south", "center").
func describeLocation(x, y float64) string { func describeLocation(x, y float64) string {
+330 -22
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"math" "math"
"sort" "sort"
"testing" "testing"
@@ -59,7 +60,7 @@ func TestFillRegionCappedForHugeArea(t *testing.T) {
bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); !errors.Is(err, domain.ErrInvalidInput) { if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err) t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err)
} }
} }
@@ -199,7 +200,7 @@ func TestFillRegionRejectsNonFiniteRegion(t *testing.T) {
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50}, {MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)}, {MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
} { } {
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil, FillClump) created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil, FillClump, nil)
if !errors.Is(err, domain.ErrInvalidInput) { if !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err) t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
} }
@@ -211,10 +212,13 @@ func TestFillRegionRejectsNonFiniteRegion(t *testing.T) {
} }
} }
// TestFillRegionOutsideObjectPlantsNothing covers a region that misses the object // TestFillRegionOutsideObjectIsRefused covers a region that misses the object
// entirely. clampTo inverts such a region rather than emptying it, and an // entirely. clampTo inverts such a region rather than emptying it; it used to
// inverted region must plant nothing — not one plop at some point off the bed. // plant nothing and report success, which read as "done" to a caller that had
func TestFillRegionOutsideObjectPlantsNothing(t *testing.T) { // aimed at the wrong coordinates — the agent, mixing up the garden frame and
// the bed's local one. Now it is an error, and still never one plop at some
// point off the bed.
func TestFillRegionOutsideObjectIsRefused(t *testing.T) {
ctx := context.Background() ctx := context.Background()
s := newTestService(t, openConfig()) s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]") owner := seedUser(t, s, "[email protected]")
@@ -223,13 +227,16 @@ func TestFillRegionOutsideObjectPlantsNothing(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50. // Wholly east of the bed: clampTo gives MinX=500, MaxX=50.
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil, FillClump) created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil, FillClump, nil)
if err != nil { if !errors.Is(err, domain.ErrInvalidInput) {
t.Fatalf("FillRegion: %v", err) t.Errorf("FillRegion outside the bed: err = %v, want ErrInvalidInput", err)
} }
if len(created) != 0 { if len(created) != 0 {
t.Errorf("filled %d plops for a region outside the bed, want 0: %+v", len(created), created) t.Errorf("filled %d plops for a region outside the bed, want 0: %+v", len(created), created)
} }
if full, _ := s.GardenFull(ctx, owner, g.ID, nil); len(full.Plantings) != 0 {
t.Errorf("the bed holds %d plops after a refused fill", len(full.Plantings))
}
} }
// seedFillBed makes a plantable bed of the given size centered in a big garden. // seedFillBed makes a plantable bed of the given size centered in a big garden.
@@ -256,7 +263,7 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15 plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump) created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("FillRegion: %v", err) t.Fatalf("FillRegion: %v", err)
} }
@@ -283,7 +290,7 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
// Re-filling the same region skips everything (each candidate sits exactly on // Re-filling the same region skips everything (each candidate sits exactly on
// an existing plop → entirely inside it). // an existing plop → entirely inside it).
again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump) again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("second FillRegion: %v", err) t.Fatalf("second FillRegion: %v", err)
} }
@@ -305,14 +312,14 @@ func TestFillGridLaysOutIndividualPlants(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 10) // spacing 10 plant := seedOwnPlant(t, s, owner, 10) // spacing 10
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
clump, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump) clump, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("clump: %v", err) t.Fatalf("clump: %v", err)
} }
if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil { if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil {
t.Fatalf("clear: %v", err) t.Fatalf("clear: %v", err)
} }
grid, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillGrid) grid, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillGrid, nil)
if err != nil { if err != nil {
t.Fatalf("grid: %v", err) t.Fatalf("grid: %v", err)
} }
@@ -351,7 +358,7 @@ func TestFillRejectsUnknownLayout(t *testing.T) {
bed := seedFillBed(t, s, owner, g.ID, 60, 60) bed := seedFillBed(t, s, owner, g.ID, 60, 60)
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillLayout("spiral")); !errors.Is(err, domain.ErrInvalidInput) { if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillLayout("spiral"), nil); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("unknown layout err = %v, want ErrInvalidInput", err) t.Errorf("unknown layout err = %v, want ErrInvalidInput", err)
} }
} }
@@ -368,7 +375,7 @@ func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 20) plant := seedOwnPlant(t, s, owner, 20)
region, _ := NamedRegion(bed, "ne") region, _ := NamedRegion(bed, "ne")
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump) created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("FillRegion: %v", err) t.Fatalf("FillRegion: %v", err)
} }
@@ -391,7 +398,7 @@ func TestClearObject(t *testing.T) {
bed := seedBed(t, s, owner, g.ID) bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); err != nil { if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err) t.Fatalf("fill: %v", err)
} }
@@ -425,7 +432,7 @@ func TestOpsForbiddenForViewer(t *testing.T) {
} }
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil, FillClump); !errors.Is(err, domain.ErrForbidden) { if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil, FillClump, nil); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("viewer fill = %v, want ErrForbidden", err) t.Errorf("viewer fill = %v, want ErrForbidden", err)
} }
if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) { if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) {
@@ -455,7 +462,7 @@ func TestFillScenario(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("region %q: %v", name, err) t.Fatalf("region %q: %v", name, err)
} }
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil, FillClump); err != nil { if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil, FillClump, nil); err != nil {
t.Fatalf("fill %q: %v", name, err) t.Fatalf("fill %q: %v", name, err)
} }
} }
@@ -471,12 +478,17 @@ func TestFillScenario(t *testing.T) {
t.Fatalf("objects = %d, want 1", len(desc.Objects)) t.Fatalf("objects = %d, want 1", len(desc.Objects))
} }
// Tally plant → the set of rough locations it appears in. // Tally plant → the set of rough locations it appears in.
// Plantings come grouped by plant: a group's Where names the region when the
// whole group sits in one, and a small group also lists its plops.
locs := map[string]map[string]bool{} locs := map[string]map[string]bool{}
for _, p := range desc.Objects[0].Plantings { for _, g := range desc.Objects[0].Plantings {
if locs[p.Plant] == nil { if locs[g.Plant] == nil {
locs[p.Plant] = map[string]bool{} locs[g.Plant] = map[string]bool{}
}
locs[g.Plant][g.Where] = true
for _, p := range g.Each {
locs[g.Plant][p.Location] = true
} }
locs[p.Plant][p.Location] = true
} }
if len(locs["Garlic"]) == 0 || !locs["Garlic"]["NE corner"] { if len(locs["Garlic"]) == 0 || !locs["Garlic"]["NE corner"] {
t.Errorf("garlic locations = %v, want NE corner", locs["Garlic"]) t.Errorf("garlic locations = %v, want NE corner", locs["Garlic"])
@@ -507,3 +519,299 @@ func seedNamedPlant(t *testing.T, s *Service, owner int64, name string, spacingC
} }
return p return p
} }
// TestFillRegionPlantedAt: a fill dates its plops as told and refuses a date
// that isn't one. The UI sends its local day, so an evening fill isn't stamped
// with UTC's tomorrow; API and agent callers that omit it still get UTC today.
func TestFillRegionPlantedAt(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Dated", WidthCM: 2000, HeightCM: 2000})
bed := seedFillBed(t, s, owner, g.ID, 200, 100)
plant := seedOwnPlant(t, s, owner, 30)
day := "2026-04-01"
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, &day)
if err != nil {
t.Fatalf("fill: %v", err)
}
if len(created) == 0 {
t.Fatal("fill created nothing")
}
for _, p := range created {
if p.PlantedAt == nil || *p.PlantedAt != day {
t.Errorf("planting %d plantedAt = %v, want %s", p.ID, p.PlantedAt, day)
}
}
bad := "April 1st"
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, &bad); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("bad date err = %v, want ErrInvalidInput", err)
}
}
// TestDescribeGardenGroupsByPlant — describe_garden is what the assistant reads
// at the start of every turn, and the live one's first describe of a grid-filled
// garden was ~450 plop entries. A group per plant says what a person would say
// ("beans across the north half, sown in May"), spells out its plops only when
// there are few, and carries the dates the model had no way to know before.
func TestDescribeGardenGroupsByPlant(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Grouped", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed := seedFillBed(t, s, owner, g.ID, 400, 400)
beans := seedNamedPlant(t, s, owner, "Beans", 10)
basil := seedNamedPlant(t, s, owner, "Basil", 25)
may, june := "2026-05-01", "2026-06-01"
// A grid fill of the north half: far more plops than get listed, all May.
if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "north", PlantID: beans.ID, Layout: FillGrid, PlantedAt: &may}); err != nil {
t.Fatalf("fill beans: %v", err)
}
// Three basil plops in the south half, on two dates, one with an explicit count.
three := 3
for _, in := range []PlantingInput{
{PlantID: basil.ID, XCM: -100, YCM: 100, RadiusCM: 20, PlantedAt: &may},
{PlantID: basil.ID, XCM: 0, YCM: 150, RadiusCM: 20, PlantedAt: &june, Count: &three},
{PlantID: basil.ID, XCM: 100, YCM: 100, RadiusCM: 20, PlantedAt: &june},
} {
if _, err := s.CreatePlanting(ctx, owner, bed.ID, in); err != nil {
t.Fatalf("place basil: %v", err)
}
}
desc, err := s.DescribeGarden(ctx, owner, g.ID)
if err != nil {
t.Fatalf("DescribeGarden: %v", err)
}
groups := map[string]DescribeGroup{}
for _, gr := range desc.Objects[0].Plantings {
groups[gr.Plant] = gr
}
if len(groups) != 2 {
t.Fatalf("groups = %d (%+v), want one per plant", len(groups), desc.Objects[0].Plantings)
}
b := groups["Beans"]
if b.Plops <= maxListedPlops {
t.Fatalf("the beans fill made %d plops; the test needs more than %d to exercise the listing cap", b.Plops, maxListedPlops)
}
if b.Each != nil {
t.Errorf("a %d-plop group listed its plops individually", b.Plops)
}
if b.Where != "north half" {
t.Errorf("beans where = %q, want %q", b.Where, "north half")
}
if b.PlantedAt != may {
t.Errorf("beans plantedAt = %q, want %q", b.PlantedAt, may)
}
if b.Plants != b.Plops {
t.Errorf("grid beans: plants %d ≠ plops %d (one plant per grid plop)", b.Plants, b.Plops)
}
ba := groups["Basil"]
if ba.Plops != 3 || len(ba.Each) != 3 {
t.Errorf("basil: plops %d, each %d; want 3 and 3 (a small group lists its plops)", ba.Plops, len(ba.Each))
}
if ba.Where != "south half" {
t.Errorf("basil where = %q, want %q", ba.Where, "south half")
}
if ba.PlantedAt != may+"…"+june {
t.Errorf("basil plantedAt = %q, want the range %q", ba.PlantedAt, may+"…"+june)
}
// Two derived counts (π·20²/25² ≈ 2 each) plus the explicit 3.
if want := 2*derivedCount(20, 25) + 3; ba.Plants != want {
t.Errorf("basil plants = %d, want %d", ba.Plants, want)
}
for _, e := range ba.Each {
if e.PlantedAt == "" || e.Version == 0 || e.ID == 0 {
t.Errorf("listed plop %+v is missing id, version or date", e)
}
}
// The big group's ids are a call away, narrowed to one plant.
listed, err := s.ListObjectPlantings(ctx, owner, bed.ID, &beans.ID)
if err != nil {
t.Fatalf("ListObjectPlantings: %v", err)
}
if len(listed) != b.Plops {
t.Errorf("listed %d beans, want %d", len(listed), b.Plops)
}
for _, p := range listed {
if p.PlantID != beans.ID || p.PlantedAt != may || p.Plant != "Beans" {
t.Errorf("listed plop %+v, want a May bean", p)
break
}
}
// A stranger gets not-found, like everything else behind the garden ACL.
stranger := seedUser(t, s, "[email protected]")
if _, err := s.ListObjectPlantings(ctx, stranger, bed.ID, nil); !errors.Is(err, domain.ErrNotFound) {
t.Errorf("stranger ListObjectPlantings err = %v, want ErrNotFound", err)
}
}
// TestSummarizeWhere pins the words a group's location comes out in: the
// compass names NamedRegion understands when the group fits one, "throughout"
// for a whole-bed fill, a short list for a few scattered plops, and a bounding
// box for anything else — never a column down the middle called a "half".
func TestSummarizeWhere(t *testing.T) {
o := &domain.GardenObject{WidthCM: 200, HeightCM: 100}
at := func(pts ...[2]float64) []domain.Planting {
out := make([]domain.Planting, 0, len(pts))
for _, p := range pts {
out = append(out, domain.Planting{XCM: p[0], YCM: p[1]})
}
return out
}
for _, tc := range []struct {
name string
in []domain.Planting
want string
}{
{"single", at([2]float64{0, -10}), "north"},
{"ne corner", at([2]float64{10, -10}, [2]float64{80, -40}), "NE corner"},
{"south half", at([2]float64{-80, 10}, [2]float64{80, 40}), "south half"},
{"column down the middle", at([2]float64{0, -40}, [2]float64{0, 0}, [2]float64{0, 40}), "north, center, south"},
{"whole bed", at([2]float64{-90, -40}, [2]float64{90, -40}, [2]float64{-90, 40}, [2]float64{90, 40}, [2]float64{0, 0}), "throughout"},
{"middle third", at([2]float64{-30, -40}, [2]float64{30, -40}, [2]float64{-30, 0}, [2]float64{30, 0}, [2]float64{-30, 40}, [2]float64{30, 40}), "x -30…30, y -40…40 cm from the centre"},
} {
if got := summarizeWhere(o, tc.in); got != tc.want {
t.Errorf("%s: summarizeWhere = %q, want %q", tc.name, got, tc.want)
}
}
}
// TestClearPlantingsOnePlantOnTheDayTold — "take the beets out, leave the
// garlic", dated the gardener's day: the whole-bed clear's narrower sibling, and
// what the assistant needed instead of 116 single removals.
func TestClearPlantingsOnePlantOnTheDayTold(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Mixed", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed := seedFillBed(t, s, owner, g.ID, 400, 200)
garlic := seedNamedPlant(t, s, owner, "Garlic", 15)
beet := seedNamedPlant(t, s, owner, "Beet", 10)
if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "west", PlantID: garlic.ID}); err != nil {
t.Fatalf("fill garlic: %v", err)
}
beets, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "east", PlantID: beet.ID})
if err != nil {
t.Fatalf("fill beets: %v", err)
}
day := "2026-08-22"
n, err := s.ClearPlantings(ctx, owner, bed.ID, ClearOptions{PlantID: &beet.ID, RemovedAt: &day})
if err != nil {
t.Fatalf("ClearPlantings: %v", err)
}
if n != len(beets) {
t.Errorf("cleared %d, want the %d beets", n, len(beets))
}
rows, err := s.store.ListPlantingsForObject(ctx, bed.ID)
if err != nil {
t.Fatalf("list: %v", err)
}
for _, r := range rows {
switch {
case r.PlantID == beet.ID && (r.RemovedAt == nil || *r.RemovedAt != day):
t.Errorf("beet %d removedAt = %v, want %q", r.ID, r.RemovedAt, day)
case r.PlantID == garlic.ID && r.RemovedAt != nil:
t.Errorf("garlic %d was removed by a clear aimed at the beets", r.ID)
}
}
sets, _, err := s.GardenHistory(ctx, owner, g.ID, 0, 0)
if err != nil {
t.Fatalf("history: %v", err)
}
if want := fmt.Sprintf("Removed Beet from %s (%d plantings)", objectLabel(bed), n); sets[0].Summary != want {
t.Errorf("summary = %q, want %q", sets[0].Summary, want)
}
// Nothing left of that plant clears nothing, cleanly; a bad date is refused
// before anything is touched.
if n, err := s.ClearPlantings(ctx, owner, bed.ID, ClearOptions{PlantID: &beet.ID}); err != nil || n != 0 {
t.Errorf("second clear = (%d, %v), want (0, nil)", n, err)
}
bad := "22/08/2026"
if _, err := s.ClearPlantings(ctx, owner, bed.ID, ClearOptions{RemovedAt: &bad}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("bad date err = %v, want ErrInvalidInput", err)
}
}
// TestFillByRectangleAttributesSeed — a fill can be aimed at any rectangle of the
// object's local frame (the middle third, a strip along one edge), not only a
// compass name, and can charge its plops to a seed lot so the lot's "remaining"
// means something.
func TestFillByRectangleAttributesSeed(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Rect", WidthCM: 2000, HeightCM: 2000})
if err != nil {
t.Fatalf("garden: %v", err)
}
bed := seedFillBed(t, s, owner, g.ID, 240, 120)
beet := seedNamedPlant(t, s, owner, "Beet", 10)
lot, err := s.CreateSeedLot(ctx, owner, SeedLotInput{PlantID: beet.ID, Quantity: 500, Unit: "seeds"})
if err != nil {
t.Fatalf("lot: %v", err)
}
created, err := s.Fill(ctx, owner, bed.ID, FillSpec{
Region: Region{MinX: -40, MinY: -60, MaxX: 40, MaxY: 60}, PlantID: beet.ID, Layout: FillGrid, SeedLotID: &lot.ID,
})
if err != nil {
t.Fatalf("Fill: %v", err)
}
if len(created) == 0 {
t.Fatal("the rectangle fill planted nothing")
}
for _, p := range created {
if p.XCM < -40 || p.XCM > 40 || p.YCM < -60 || p.YCM > 60 {
t.Errorf("plop at (%v,%v) is outside the rectangle", p.XCM, p.YCM)
}
if p.SeedLotID == nil || *p.SeedLotID != lot.ID {
t.Errorf("plop %d seedLotId = %v, want the lot", p.ID, p.SeedLotID)
}
}
got, err := s.GetSeedLot(ctx, owner, lot.ID)
if err != nil {
t.Fatalf("GetSeedLot: %v", err)
}
if got.Used != float64(len(created)) || got.Remaining != 500-float64(len(created)) {
t.Errorf("lot used/remaining = %v/%v, want %d/%v", got.Used, got.Remaining, len(created), 500-float64(len(created)))
}
// Someone else's lot, or a lot of another plant, refuses the whole fill.
garlic := seedNamedPlant(t, s, owner, "Garlic", 15)
if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "all", PlantID: garlic.ID, SeedLotID: &lot.ID}); err == nil {
t.Error("a fill charged to a lot of a different plant succeeded")
}
// No name and no rectangle is "nowhere", not "one plop in the middle" (which
// is what hexCenters makes of a zero-area region).
for _, r := range []Region{{}, {MinX: 10, MinY: -10, MaxX: 10, MaxY: 10}, {MinX: 20, MinY: 0, MaxX: -20, MaxY: 10}} {
if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{Region: r, PlantID: garlic.ID}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("empty rectangle %+v: err = %v, want ErrInvalidInput", r, err)
}
}
// A rectangle that misses the bed (it is 240 wide, so ±120) — or only
// touches its edge — is an error, not a successful fill of nothing.
for _, r := range []Region{{MinX: 200, MinY: -10, MaxX: 300, MaxY: 10}, {MinX: 120, MinY: -10, MaxX: 200, MaxY: 10}} {
if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{Region: r, PlantID: garlic.ID}); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("off-bed rectangle %+v: err = %v, want ErrInvalidInput", r, err)
}
}
// Partly outside is fine: the part inside gets planted.
if created, err := s.Fill(ctx, owner, bed.ID, FillSpec{Region: Region{MinX: 80, MinY: -10, MaxX: 300, MaxY: 10}, PlantID: garlic.ID}); err != nil || len(created) == 0 {
t.Errorf("overhanging rectangle: %d plops, %v; want some", len(created), err)
}
}
+84 -8
View File
@@ -3,6 +3,7 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"math" "math"
"strings" "strings"
"time" "time"
@@ -89,12 +90,18 @@ func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, i
return nil, err return nil, err
} }
radius := in.RadiusCM
if radius == 0 {
// Unspecified means ONE plant: the editor's tap-to-place radius, half the
// spacing. A clump (1.5× spacing) is what a fill makes, not a placement.
radius = plant.SpacingCM / 2
}
p := &domain.Planting{ p := &domain.Planting{
ObjectID: objectID, ObjectID: objectID,
PlantID: in.PlantID, PlantID: in.PlantID,
XCM: in.XCM, XCM: in.XCM,
YCM: in.YCM, YCM: in.YCM,
RadiusCM: in.RadiusCM, RadiusCM: radius,
Count: in.Count, Count: in.Count,
Label: trimStringPtr(in.Label), Label: trimStringPtr(in.Label),
PlantedAt: in.PlantedAt, PlantedAt: in.PlantedAt,
@@ -179,14 +186,83 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
} }
// RemovePlanting soft-removes a single plop — the one-plop counterpart to // RemovePlanting soft-removes a single plop — the one-plop counterpart to
// ClearObject, used by the agent's remove_planting tool. It stamps removed_at // ClearObject, used by the agent's remove_planting tool. removedAt (YYYY-MM-DD)
// from the service clock (s.now()), same as ClearObject and the fill path, so the // is the day the caller knows it happened — the gardener's local day; nil
// removal date can't diverge by which caller set it; then delegates to // stamps the service clock's UTC today, the same default ClearObject and the
// UpdatePlanting for the editor-role check, version guard and history record. // fill path use. Delegates to UpdatePlanting for the editor-role check, version
func (s *Service) RemovePlanting(ctx context.Context, actorID, plantingID, version int64) (*domain.Planting, error) { // guard and history record.
today := s.now().UTC().Format(dateLayout) func (s *Service) RemovePlanting(ctx context.Context, actorID, plantingID, version int64, removedAt *string) (*domain.Planting, error) {
if !validDatePtr(removedAt) {
return nil, fmt.Errorf("%w: removedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
}
on := s.now().UTC().Format(dateLayout)
if removedAt != nil {
on = *removedAt
}
return s.UpdatePlanting(ctx, actorID, plantingID, return s.UpdatePlanting(ctx, actorID, plantingID,
PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, version) PlantingPatch{SetRemovedAt: true, RemovedAt: &on}, version)
}
// MoveInput says where a plop goes: a position in the local frame of ToObjectID,
// or of the plop's current object when ToObjectID is nil.
type MoveInput struct {
ToObjectID *int64
XCM, YCM float64
}
// MovePlanting relocates one plop — within its object, or into another plantable
// object of the same garden — keeping its plant, size, count and planting date.
// Removing and re-placing is not the same thing: "move the tomatoes to the other
// bed" is not "pull them up and plant new ones today", and the live assistant
// did exactly that for want of this. Version-guarded like UpdatePlanting; a
// within-object move IS an UpdatePlanting of the position.
func (s *Service) MovePlanting(ctx context.Context, actorID, plantingID int64, in MoveInput, version int64) (*domain.Planting, error) {
pl, err := s.store.GetPlanting(ctx, plantingID)
if err != nil {
return nil, err // ErrNotFound
}
if in.ToObjectID == nil || *in.ToObjectID == pl.ObjectID {
return s.UpdatePlanting(ctx, actorID, plantingID, PlantingPatch{XCM: &in.XCM, YCM: &in.YCM}, version)
}
from, g, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor)
if err != nil {
return nil, err
}
to, toGarden, err := s.objectForRole(ctx, actorID, *in.ToObjectID, roleEditor)
if err != nil {
return nil, err
}
if toGarden.ID != g.ID {
return nil, fmt.Errorf("%w: a planting can only move within its own garden", domain.ErrInvalidInput)
}
if !to.Plantable {
return nil, fmt.Errorf("%w: %s can't hold plants", domain.ErrInvalidInput, objectLabel(to))
}
// By id, not through the actor's catalog: the plop may be of a variety the
// actor can't see (a shared editor, the owner's private plant), and moving it
// isn't choosing it.
plant, err := s.store.GetPlant(ctx, pl.PlantID)
if err != nil {
return nil, err
}
before := *pl
pl.ObjectID = to.ID
pl.XCM, pl.YCM = in.XCM, in.YCM
if err := finalizePlanting(pl, to, true); err != nil {
return nil, err
}
pl.Version = version
updated, err := s.store.UpdatePlanting(ctx, pl)
if err != nil {
if errors.Is(err, domain.ErrVersionConflict) && updated != nil {
s.enrichDerived(ctx, updated)
}
return updated, err
}
s.record(ctx, g.ID, actorID, "Moved "+plant.Name+" from "+objectLabel(from)+" to "+objectLabel(to),
changeUpdate(domain.EntityPlanting, updated.ID, &before, updated))
updated.DerivedCount = derivedCount(updated.RadiusCM, plant.SpacingCM)
return updated, nil
} }
// plantingEditSummary describes a plop edit for the history list. Soft-removal // plantingEditSummary describes a plop edit for the history list. Soft-removal
+107 -3
View File
@@ -207,11 +207,22 @@ func TestPlantingBoundsCheck(t *testing.T) {
}); err != nil { }); err != nil {
t.Errorf("edge-of-bounds center should be allowed: %v", err) t.Errorf("edge-of-bounds center should be allowed: %v", err)
} }
// Non-positive radius rejected. // A negative radius is rejected; an unspecified (zero) one means ONE plant —
// half the plant's spacing, the editor's tap-to-place size — so a caller that
// just says "put a tomato here" gets a tomato-sized plop, not an error.
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{ if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 0, PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: -1,
}); !errors.Is(err, domain.ErrInvalidInput) { }); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("zero radius err = %v, want ErrInvalidInput", err) t.Errorf("negative radius err = %v, want ErrInvalidInput", err)
}
one, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 0,
})
if err != nil {
t.Fatalf("zero radius: %v, want the one-plant default", err)
}
if one.RadiusCM != plant.SpacingCM/2 || one.DerivedCount != 1 {
t.Errorf("zero radius → radius %v (count %d), want spacing/2 = %v (count 1)", one.RadiusCM, one.DerivedCount, plant.SpacingCM/2)
} }
} }
@@ -376,3 +387,96 @@ func TestDeletePlanting(t *testing.T) {
t.Errorf("planting still present after delete: %d", len(full.Plantings)) t.Errorf("planting still present after delete: %d", len(full.Plantings))
} }
} }
// TestMovePlantingAcrossBedsKeepsTheDate — "move the tomatoes to the other bed"
// is not "pull them up and plant new ones today". The assistant had only the
// latter for want of this, and the plants lost their planting date on the way.
func TestMovePlantingAcrossBedsKeepsTheDate(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g := seedGarden(t, s, owner)
from, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{Kind: domain.KindBed, Name: "A", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200})
if err != nil {
t.Fatalf("bed A: %v", err)
}
to, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{Kind: domain.KindBed, Name: "B", XCM: 900, YCM: 500, WidthCM: 200, HeightCM: 200})
if err != nil {
t.Fatalf("bed B: %v", err)
}
path, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{Kind: domain.KindPath, Name: "Path", XCM: 700, YCM: 900, WidthCM: 400, HeightCM: 100})
if err != nil {
t.Fatalf("path: %v", err)
}
if path.Plantable {
no := false
if path, err = s.UpdateObject(ctx, owner, path.ID, ObjectPatch{Plantable: &no}, path.Version); err != nil {
t.Fatalf("make the path unplantable: %v", err)
}
}
plant := seedOwnPlant(t, s, owner, 30)
may := "2026-05-20"
pl, err := s.CreatePlanting(ctx, owner, from.ID, PlantingInput{PlantID: plant.ID, XCM: 10, YCM: 10, RadiusCM: 15, PlantedAt: &may})
if err != nil {
t.Fatalf("plant: %v", err)
}
moved, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &to.ID, XCM: -50, YCM: 20}, pl.Version)
if err != nil {
t.Fatalf("MovePlanting: %v", err)
}
if moved.ObjectID != to.ID || moved.XCM != -50 || moved.YCM != 20 {
t.Errorf("moved to object %d at (%v,%v), want B (%d) at (-50,20)", moved.ObjectID, moved.XCM, moved.YCM, to.ID)
}
if moved.PlantedAt == nil || *moved.PlantedAt != may {
t.Errorf("plantedAt after the move = %v, want %q kept", moved.PlantedAt, may)
}
if moved.Version != pl.Version+1 || moved.DerivedCount == 0 {
t.Errorf("moved row version %d (count %d), want %d and a derived count", moved.Version, moved.DerivedCount, pl.Version+1)
}
// It reads as a move in history, and undo puts it back in A.
sets, _, err := s.GardenHistory(ctx, owner, g.ID, 0, 0)
if err != nil {
t.Fatalf("history: %v", err)
}
if want := "Moved " + plant.Name + " from A to B"; sets[0].Summary != want {
t.Errorf("summary = %q, want %q", sets[0].Summary, want)
}
if _, conflicts, err := s.RevertChangeSet(ctx, owner, sets[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts)
}
back, err := s.store.GetPlanting(ctx, pl.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if back.ObjectID != from.ID || back.XCM != 10 {
t.Errorf("after undo the plop is in object %d at x=%v, want A (%d) at 10", back.ObjectID, back.XCM, from.ID)
}
// Refused: a position outside the target, a target that can't hold plants, a
// bed in another garden — and a stale version conflicts like any edit.
cur := back
if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &to.ID, XCM: 500, YCM: 0}, cur.Version); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("out-of-bounds move err = %v, want ErrInvalidInput", err)
}
if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &path.ID, XCM: 0, YCM: 0}, cur.Version); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("move into a path err = %v, want ErrInvalidInput", err)
}
other := seedGarden(t, s, owner)
far, err := s.CreateObject(ctx, owner, other.ID, ObjectInput{Kind: domain.KindBed, Name: "Far", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200})
if err != nil {
t.Fatalf("far bed: %v", err)
}
if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &far.ID, XCM: 0, YCM: 0}, cur.Version); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("move into another garden err = %v, want ErrInvalidInput", err)
}
if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{XCM: 5, YCM: 5}, cur.Version-1); !errors.Is(err, domain.ErrVersionConflict) {
t.Errorf("stale version err = %v, want ErrVersionConflict", err)
}
// A within-bed move is just a position change.
within, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{XCM: 5, YCM: 5}, cur.Version)
if err != nil || within.ObjectID != from.ID || within.XCM != 5 {
t.Errorf("within-bed move = %+v, %v; want the same bed at x=5", within, err)
}
}
+15 -1
View File
@@ -200,7 +200,21 @@ func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary s
return return
} }
if sc := scopeFrom(ctx); sc != nil { if sc := scopeFrom(ctx); sc != nil {
sc.append(revs) if sc.gardenID == gardenID {
sc.append(revs)
return
}
// The scope is for ANOTHER garden — an agent turn on garden A that the
// model pointed at an object in garden B. Joining the scope would file B's
// revisions under A's history, where B's undo can't see them and A's undo
// would revert rows in a garden the person isn't looking at. Record them
// where they belong, as their own change set, keeping the source and run
// id so the entry still reads as the agent's work.
own := &changeScope{gardenID: gardenID, actorID: actorID, source: sc.source, summary: summary, agentRunID: sc.agentRunID}
own.append(revs)
if _, err := s.commitScope(ctx, own, nil); err != nil {
slog.Error("service: record change set outside the open scope", "error", err, "garden", gardenID, "summary", summary)
}
return return
} }
// Auto-scope: one operation, its own change set. Written through the same // Auto-scope: one operation, its own change set. Written through the same
+52 -5
View File
@@ -73,7 +73,7 @@ func TestFillRegionIsOneChangeSet(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() ctx := context.Background()
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump) created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("FillNamedRegion: %v", err) t.Fatalf("FillNamedRegion: %v", err)
} }
@@ -320,7 +320,7 @@ func TestRevertClearObject(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil { if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err) t.Fatalf("fill: %v", err)
} }
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID) before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
@@ -688,7 +688,7 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil { if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err) t.Fatalf("fill: %v", err)
} }
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID) before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
@@ -725,7 +725,7 @@ func TestRevertResultCarriesItsCounts(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil { if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err) t.Fatalf("fill: %v", err)
} }
// A second, different kind of change, so the breakdown has more than one row // A second, different kind of change, so the breakdown has more than one row
@@ -830,7 +830,7 @@ func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{ cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
Source: domain.SourceAgent, Summary: "plant beans in the second bed", Source: domain.SourceAgent, Summary: "plant beans in the second bed",
}, func(ctx context.Context) error { }, func(ctx context.Context) error {
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil { if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
return err return err
} }
cancel() // the client disconnects, mid-turn, after the work landed cancel() // the client disconnects, mid-turn, after the work landed
@@ -893,3 +893,50 @@ func TestAutoScopedMutationRecordsEvenIfTheCallerWentAway(t *testing.T) {
t.Errorf("undo left x at %v, want %v", back.XCM, bed.XCM) t.Errorf("undo left x at %v, want %v", back.XCM, bed.XCM)
} }
} }
// TestRecordOutsideTheOpenScopeFilesUnderItsOwnGarden — a scope is for ONE
// garden, but nothing stops a mutation inside it from touching another garden
// the actor can edit (the agent, pointed at "my other garden"). Those revisions
// belong to the garden they changed, as their own change set carrying the
// scope's source and run id — not to the open scope, whose undo would then
// quietly revert rows in a garden nobody is looking at.
func TestRecordOutsideTheOpenScopeFilesUnderItsOwnGarden(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
a := seedGarden(t, s, owner)
b := seedGarden(t, s, owner)
bedB, err := s.CreateObject(ctx, owner, b.ID, ObjectInput{Kind: domain.KindBed, Name: "Bed", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200})
if err != nil {
t.Fatalf("bed: %v", err)
}
beforeB, _, _ := s.GardenHistory(ctx, owner, b.ID, 0, 0)
run := "run-1"
cs, err := s.WithChangeSet(ctx, owner, a.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "a turn on A", AgentRunID: &run},
func(ctx context.Context) error {
name := "Renamed from A"
_, err := s.UpdateObject(ctx, owner, bedB.ID, ObjectPatch{Name: &name}, bedB.Version)
return err
})
if err != nil {
t.Fatalf("WithChangeSet: %v", err)
}
if cs != nil {
t.Errorf("the scope on A wrote change set %d, but nothing in A changed", cs.ID)
}
afterB, _, _ := s.GardenHistory(ctx, owner, b.ID, 0, 0)
if len(afterB) != len(beforeB)+1 {
t.Fatalf("B's history grew by %d, want 1", len(afterB)-len(beforeB))
}
got := afterB[0]
if got.Source != domain.SourceAgent || got.AgentRunID == nil || *got.AgentRunID != run {
t.Errorf("B's entry = source %q run %v, want the scope's (agent, %q)", got.Source, got.AgentRunID, run)
}
if _, conflicts, err := s.RevertChangeSet(ctx, owner, got.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts)
}
if d, err := s.DescribeGarden(ctx, owner, b.ID); err != nil || len(d.Objects) != 1 || d.Objects[0].Name != "Bed" {
t.Errorf("after undo B is %+v (%v), want the bed's name back", d, err)
}
}
+5 -2
View File
@@ -99,9 +99,12 @@ func TestRemainingReturnsWhenAPlantingIsRemoved(t *testing.T) {
lot := seedLot(t, s, owner, plant.ID, 100, nil) lot := seedLot(t, s, owner, plant.ID, 100, nil)
ctx := context.Background() ctx := context.Background()
ten := 10 // Dated explicitly: left to default, plantedAt is the real UTC day, and the
// removal below has to come after it — a test that only passed before
// 2026-08-01 is the kind of clock bomb this avoids.
ten, planted := 10, "2026-07-01"
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID, PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID, PlantedAt: &planted,
}) })
if err != nil { if err != nil {
t.Fatalf("CreatePlanting: %v", err) t.Fatalf("CreatePlanting: %v", err)
+2 -2
View File
@@ -268,13 +268,13 @@ func (d *DB) CreatePlantings(ctx context.Context, plantings []*domain.Planting)
func (d *DB) UpdatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) { func (d *DB) UpdatePlanting(ctx context.Context, p *domain.Planting) (*domain.Planting, error) {
updated, err := scanPlanting(d.sql.QueryRowContext(ctx, updated, err := scanPlanting(d.sql.QueryRowContext(ctx,
`UPDATE plantings `UPDATE plantings
SET plant_id = ?, x_cm = ?, y_cm = ?, radius_cm = ?, count = ?, label = ?, SET object_id = ?, plant_id = ?, x_cm = ?, y_cm = ?, radius_cm = ?, count = ?, label = ?,
planted_at = ?, removed_at = ?, seed_lot_id = ?, planted_at = ?, removed_at = ?, seed_lot_id = ?,
version = version + 1, version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now') updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ? WHERE id = ? AND version = ?
RETURNING `+plantingColumns, RETURNING `+plantingColumns,
p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.RemovedAt, p.SeedLotID, p.ObjectID, p.PlantID, p.XCM, p.YCM, p.RadiusCM, p.Count, p.Label, p.PlantedAt, p.RemovedAt, p.SeedLotID,
p.ID, p.Version, p.ID, p.Version,
)) ))
if errors.Is(err, sql.ErrNoRows) { if errors.Is(err, sql.ErrNoRows) {
+32 -8
View File
@@ -1,4 +1,4 @@
import { useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert' import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -6,24 +6,37 @@ import { Dialog } from '@/components/ui/Dialog'
import { TextField } from '@/components/ui/Field' import { TextField } from '@/components/ui/Field'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { errorMessage } from '@/lib/api' import { errorMessage } from '@/lib/api'
import { useCopyGarden, type Garden } from '@/lib/gardens' import { useCopyGarden, useGardens, type Garden } from '@/lib/gardens'
import { parsePlanName, planNameFor } from '@/lib/plan' import { nextPlanYear, parsePlanName, planNameFor } from '@/lib/plan'
/** /**
* Duplicate a garden — the way to scheme a season: the copy is a separate * Duplicate a garden — the way to scheme a season: the copy is a separate
* garden you rearrange freely while this one stays put. Beds and everything * garden you rearrange freely while this one stays put. Beds and everything
* currently planted come along; the share link and shares don't. The name is * currently planted come along; the share link and shares don't. The name is
* prefilled as "<name> — <next year>", which is what the editor's season * prefilled as "<name> — <year>" for the next year that doesn't already have a
* control and the `plan` tag read back (see lib/plan.ts). On success we land in * plan, which is what the editor's season control and the `plan` tag read back
* the copy, since the point of copying is to start editing it. * (see lib/plan.ts). On success we land in the copy, since the point of copying
* is to start editing it.
*/ */
export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) { export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
const copy = useCopyGarden() const copy = useCopyGarden()
const navigate = useNavigate() const navigate = useNavigate()
const gardens = useGardens()
const names = (gardens.data ?? []).map((g) => g.name)
const base = parsePlanName(garden.name)?.base ?? garden.name const base = parsePlanName(garden.name)?.base ?? garden.name
const year = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1 const from = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
const year = nextPlanYear(base, names, from)
const [name, setName] = useState(() => planNameFor(base, year)) const [name, setName] = useState(() => planNameFor(base, year))
const [touched, setTouched] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
// The gardens list can still be loading when this opens; until the person
// edits the name, keep the proposal in step with what the list says is free.
useEffect(() => {
if (!touched) setName(planNameFor(base, year))
}, [base, year, touched])
// The API allows duplicate names; say so rather than let two gardens read as
// the same season's plan.
const taken = names.some((n) => n.trim() === name.trim())
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
e.preventDefault() e.preventDefault()
@@ -45,8 +58,19 @@ export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () =>
A copy of <span className="font-semibold text-text">{garden.name}</span> to scheme in rearrange freely, the A copy of <span className="font-semibold text-text">{garden.name}</span> to scheme in rearrange freely, the
original stays put. Beds and what's planted come along; shares and the public link don't. original stays put. Beds and what's planted come along; shares and the public link don't.
</p> </p>
<TextField label="Name" name="name" required autoFocus value={name} onChange={(e) => setName(e.target.value)} /> <TextField
label="Name"
name="name"
required
autoFocus
value={name}
onChange={(e) => {
setTouched(true)
setName(e.target.value)
}}
/>
<p className="text-xs text-ink-mute">Keep the {year} and it shows up as that season's plan in the editor.</p> <p className="text-xs text-ink-mute">Keep the {year} and it shows up as that season's plan in the editor.</p>
{taken && <Alert tone="info">You already have a garden called “{name.trim()}” — pick another name so the two don't read as the same plan.</Alert>}
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" onClick={onClose} disabled={copy.isPending}> <Button type="button" onClick={onClose} disabled={copy.isPending}>
+12 -7
View File
@@ -5,7 +5,7 @@ import { IconButton } from '@/components/ui/Button'
import { Tag } from '@/components/ui/Tag' import { Tag } from '@/components/ui/Tag'
import type { Garden } from '@/lib/gardens' import type { Garden } from '@/lib/gardens'
import { useGardenFull } from '@/lib/objects' import { useGardenFull } from '@/lib/objects'
import { planYearOf } from '@/lib/plan' import { parsePlanName, planYearOf } from '@/lib/plan'
import { sharesQueryOptions } from '@/lib/shares' import { sharesQueryOptions } from '@/lib/shares'
import { formatSize } from '@/lib/units' import { formatSize } from '@/lib/units'
import { kindPlural } from '@/editor/kinds' import { kindPlural } from '@/editor/kinds'
@@ -15,10 +15,11 @@ import { GardenThumb } from './GardenThumb'
const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure'] const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure']
/** /**
* One garden as a card: the plot thumbnail (a link into the editor), name + an * One garden as a card: the plot thumbnail (a link into the editor), name, size,
* optional `plan` tag, size, a counts line, who it's shared with, and a footer * a counts line, who it's shared with, and a footer of Open + share / copy /
* of Open + share / copy / edit / delete. A garden shared WITH you shows its * edit / delete. A plan copy shows its base name with a `<year> plan` tag. A
* role and a leave action instead of the owner's tools. * garden shared WITH you shows its role and a leave action instead of the
* owner's tools.
*/ */
export function GardenCard({ export function GardenCard({
garden, garden,
@@ -40,7 +41,11 @@ export function GardenCard({
const owner = currentUserId != null && garden.ownerId === currentUserId const owner = currentUserId != null && garden.ownerId === currentUserId
const full = useGardenFull(garden.id) const full = useGardenFull(garden.id)
const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner }) const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner })
const plan = parsePlanName(garden.name)
const planYear = planYearOf(garden.name) const planYear = planYearOf(garden.name)
// A plan's year is the point of its name, and the first thing truncation
// would eat ("Back Yard — 20…"); show the base name and put the year on the tag.
const title = planYear != null && plan ? plan.base : garden.name
const meta = useMemo(() => { const meta = useMemo(() => {
const data = full.data const data = full.data
@@ -79,9 +84,9 @@ export function GardenCard({
<div className="flex flex-1 flex-col gap-2 px-[18px] py-4"> <div className="flex flex-1 flex-col gap-2 px-[18px] py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="min-w-0 truncate font-heading text-lg" title={garden.name}> <span className="min-w-0 truncate font-heading text-lg" title={garden.name}>
{garden.name} {title}
</span> </span>
{planYear != null && <Tag tone="accent">plan</Tag>} {planYear != null && <Tag tone="accent">{planYear} plan</Tag>}
<span className="ml-auto flex-none text-[12.5px] font-semibold text-ink-mute"> <span className="ml-auto flex-none text-[12.5px] font-semibold text-ink-mute">
{formatSize(garden.widthCm, garden.heightCm, garden.unitPref)} {formatSize(garden.widthCm, garden.heightCm, garden.unitPref)}
</span> </span>
+35 -31
View File
@@ -9,13 +9,15 @@ import { errorMessage } from '@/lib/api'
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens' import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
import { import {
cmFromFtIn, cmFromFtIn,
convertDimensionField,
dimensionField,
dimensionInputMode, dimensionInputMode,
dimensionUnitLabel, dimensionUnitLabel,
editDimensionField,
formatCm, formatCm,
formatDimensionInput,
isValidDimensionCm, isValidDimensionCm,
MIN_GARDEN_GRID_CM, MIN_GARDEN_GRID_CM,
parseDimension, type LengthField,
type UnitPref, type UnitPref,
} from '@/lib/units' } from '@/lib/units'
@@ -36,8 +38,11 @@ function entryHint(unit: UnitPref): string {
/** /**
* "A new garden" (no garden) or edit (garden given). Dimensions are typed in the * "A new garden" (no garden) or edit (garden given). Dimensions are typed in the
* chosen unit and stored as centimeters; switching units converts what's typed so * chosen unit and stored as centimeters: each field is a LengthField, so
* the physical size holds. A 409 rebases the form onto the server's fresh row. * switching units re-shows the same centimeters and a Save sends exactly what
* was loaded unless the person typed over it (re-parsing the display string is
* how 900 cm once became 899.922). A 409 rebases the form onto the server's
* fresh row.
*/ */
export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () => void }) { export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
const isEdit = !!garden const isEdit = !!garden
@@ -48,14 +53,10 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
const initialUnit: UnitPref = garden?.unitPref ?? 'imperial' const initialUnit: UnitPref = garden?.unitPref ?? 'imperial'
const [name, setName] = useState(garden?.name ?? '') const [name, setName] = useState(garden?.name ?? '')
const [unit, setUnit] = useState<UnitPref>(initialUnit) const [unit, setUnit] = useState<UnitPref>(initialUnit)
const [width, setWidth] = useState(() => const [width, setWidth] = useState<LengthField>(() => dimensionField(garden?.widthCm ?? cmFromFtIn(DEFAULT_W_FT), initialUnit))
garden ? formatDimensionInput(garden.widthCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_W_FT), 'imperial'), const [height, setHeight] = useState<LengthField>(() => dimensionField(garden?.heightCm ?? cmFromFtIn(DEFAULT_H_FT), initialUnit))
) const [gridSize, setGridSize] = useState<LengthField>(() =>
const [height, setHeight] = useState(() => dimensionField(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
garden ? formatDimensionInput(garden.heightCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_H_FT), 'imperial'),
)
const [gridSize, setGridSize] = useState(() =>
formatDimensionInput(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
) )
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false) const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
const [notes, setNotes] = useState(garden?.notes ?? '') const [notes, setNotes] = useState(garden?.notes ?? '')
@@ -65,17 +66,13 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
const [formError, setFormError] = useState<string | null>(null) const [formError, setFormError] = useState<string | null>(null)
function changeUnit(next: UnitPref) { function changeUnit(next: UnitPref) {
const convert = (s: string) => { setWidth((f) => convertDimensionField(f, next))
const cm = parseDimension(s, unit) setHeight((f) => convertDimensionField(f, next))
return cm === null ? s : formatDimensionInput(cm, next) setGridSize((f) => convertDimensionField(f, next))
}
setWidth(convert(width))
setHeight(convert(height))
setGridSize(convert(gridSize))
setUnit(next) setUnit(next)
} }
const gridSizeCm = parseDimension(gridSize, unit) const gridSizeCm = gridSize.cm
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
@@ -86,8 +83,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
setFormError('Give the garden a name.') setFormError('Give the garden a name.')
return return
} }
const widthCm = parseDimension(width, unit) const widthCm = width.cm
const heightCm = parseDimension(height, unit) const heightCm = height.cm
if (widthCm === null || heightCm === null) { if (widthCm === null || heightCm === null) {
setFormError(entryHint(unit)) setFormError(entryHint(unit))
return return
@@ -101,6 +98,13 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
return return
} }
const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim(), gridSizeCm, snapToGrid } const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim(), gridSizeCm, snapToGrid }
// Nothing changed: close without a request. A PATCH that writes the same
// row still bumps the version and lands an "Edited garden settings" step
// in History that undoes nothing.
if (isEdit && (Object.keys(input) as (keyof typeof input)[]).every((k) => input[k] === garden[k])) {
onClose()
return
}
try { try {
if (isEdit) await update.mutateAsync({ id: garden.id, ...input, version }) if (isEdit) await update.mutateAsync({ id: garden.id, ...input, version })
else await create.mutateAsync(input) else await create.mutateAsync(input)
@@ -111,9 +115,9 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
setVersion(current.version) setVersion(current.version)
setName(current.name) setName(current.name)
setUnit(current.unitPref) setUnit(current.unitPref)
setWidth(formatDimensionInput(current.widthCm, current.unitPref)) setWidth(dimensionField(current.widthCm, current.unitPref))
setHeight(formatDimensionInput(current.heightCm, current.unitPref)) setHeight(dimensionField(current.heightCm, current.unitPref))
setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref)) setGridSize(dimensionField(current.gridSizeCm, current.unitPref))
setSnapToGrid(current.snapToGrid) setSnapToGrid(current.snapToGrid)
setNotes(current.notes) setNotes(current.notes)
setConflict('This garden changed elsewhere. The latest values are shown — look them over and save again.') setConflict('This garden changed elsewhere. The latest values are shown — look them over and save again.')
@@ -138,8 +142,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
type="text" type="text"
inputMode={inputMode} inputMode={inputMode}
required required
value={width} value={width.text}
onChange={(e) => setWidth(e.target.value)} onChange={(e) => setWidth(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1" wrapperClassName="flex-1"
/> />
<TextField <TextField
@@ -148,8 +152,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
type="text" type="text"
inputMode={inputMode} inputMode={inputMode}
required required
value={height} value={height.text}
onChange={(e) => setHeight(e.target.value)} onChange={(e) => setHeight(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1" wrapperClassName="flex-1"
/> />
<div className="field"> <div className="field">
@@ -171,8 +175,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
name="gridSize" name="gridSize"
type="text" type="text"
inputMode={inputMode} inputMode={inputMode}
value={gridSize} value={gridSize.text}
onChange={(e) => setGridSize(e.target.value)} onChange={(e) => setGridSize(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1" wrapperClassName="flex-1"
hint={ hint={
gridTooFine && gridSizeCm !== null gridTooFine && gridSizeCm !== null
+2 -1
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { localToWorld } from '@/lib/geometry' import { localToWorld } from '@/lib/geometry'
import type { FullGarden } from '@/lib/objects' import type { FullGarden } from '@/lib/objects'
import { FALLBACK_PLANT_COLOR } from '@/lib/plants'
import { objectStyle, rectRadius } from '@/editor/kinds' import { objectStyle, rectRadius } from '@/editor/kinds'
/** /**
@@ -72,7 +73,7 @@ export function GardenThumb({
const o = byId.get(p.objectId) const o = byId.get(p.objectId)
if (!o) return null if (!o) return null
const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg) const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg)
return <circle key={p.id} cx={w.x} cy={w.y} r={p.radiusCm} fill={plantColor.get(p.plantId) ?? '#97a97c'} /> return <circle key={p.id} cx={w.x} cy={w.y} r={p.radiusCm} fill={plantColor.get(p.plantId) ?? FALLBACK_PLANT_COLOR} />
})} })}
</svg> </svg>
) )
+5 -3
View File
@@ -1,7 +1,9 @@
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { monogramInk } from '@/lib/monogram'
/** A plant marker off the canvas: a solid circle in the plant's color with its /** A plant marker off the canvas: a solid circle in the plant's color with its
* 12 letter monogram in the display face. Size in px. */ * 12 letter monogram in the display face, paper or ink by the color's
* lightness (see monogramInk). Size in px. */
export function Monogram({ export function Monogram({
color, color,
letters, letters,
@@ -16,8 +18,8 @@ export function Monogram({
return ( return (
<span <span
aria-hidden aria-hidden
className={cn('grid flex-none place-items-center rounded-full font-heading leading-none text-paper', className)} className={cn('grid flex-none place-items-center rounded-full font-heading leading-none', className)}
style={{ width: size, height: size, background: color, fontSize: Math.round(size * 0.425) }} style={{ width: size, height: size, background: color, color: monogramInk(color), fontSize: Math.round(size * 0.425) }}
> >
{letters} {letters}
</span> </span>
+1 -1
View File
@@ -95,7 +95,7 @@ export function PlantCard({
<Button variant="ghost" icon="plus" iconSize={12} className="px-2.5 text-[13px]" onClick={onAddLot}> <Button variant="ghost" icon="plus" iconSize={12} className="px-2.5 text-[13px]" onClick={onAddLot}>
Record a lot Record a lot
</Button> </Button>
<span className="ml-auto flex gap-1"> <span className="ml-auto flex flex-wrap justify-end gap-1">
<Button variant="ghost" icon="copy" iconSize={12} className="px-2.5 text-[13px]" onClick={onDuplicate}> <Button variant="ghost" icon="copy" iconSize={12} className="px-2.5 text-[13px]" onClick={onDuplicate}>
Duplicate Duplicate
</Button> </Button>
+17 -9
View File
@@ -15,7 +15,7 @@ import {
type PlantInput, type PlantInput,
} from '@/lib/plants' } from '@/lib/plants'
import { safeExternalUrl } from '@/lib/seedLots' import { safeExternalUrl } from '@/lib/seedLots'
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units' import { editSpacingField, spacingField, spacingUnitLabel, type LengthField, type UnitPref } from '@/lib/units'
import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches' import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches'
// Markers are monograms now, but the API still carries an icon per plant; a // Markers are monograms now, but the API still carries an icon per plant; a
@@ -26,8 +26,10 @@ const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY
/** /**
* "A new plant" — or edit (`plant`), or a fresh create prefilled from another * "A new plant" — or edit (`plant`), or a fresh create prefilled from another
* (`template`, the Duplicate action; the only way to customize a built-in). * (`template`, the Duplicate action; the only way to customize a built-in).
* Spacing is typed in the page's unit and stored in centimeters. A 409 rebases * Spacing is typed in the page's unit and stored in centimeters — as a
* onto the server's current row. * LengthField, so a Save that didn't touch it sends the centimeters that were
* loaded rather than re-parsing "17.7 in" into 44.958. A 409 rebases onto the
* server's current row.
*/ */
export function PlantDialog({ export function PlantDialog({
plant, plant,
@@ -48,7 +50,7 @@ export function PlantDialog({
const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '') const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '')
const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable') const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable')
const [spacing, setSpacing] = useState(String(spacingFromCm(source?.spacingCm ?? 30, unit))) const [spacing, setSpacing] = useState<LengthField>(() => spacingField(source?.spacingCm ?? 30, unit))
const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0])) const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0]))
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '') const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
const [vendor, setVendor] = useState(source?.vendor ?? '') const [vendor, setVendor] = useState(source?.vendor ?? '')
@@ -68,8 +70,8 @@ export function PlantDialog({
setFormError('Give the plant a name.') setFormError('Give the plant a name.')
return return
} }
const spacingCm = cmFromSpacing(parseFloat(spacing), unit) const spacingCm = spacing.cm
if (!Number.isFinite(spacingCm) || spacingCm < 1) { if (spacingCm === null || spacingCm < 1) {
setFormError(`Spacing must be at least 1 ${unitLabel}.`) setFormError(`Spacing must be at least 1 ${unitLabel}.`)
return return
} }
@@ -97,6 +99,12 @@ export function PlantDialog({
vendor: vendor.trim(), vendor: vendor.trim(),
notes: notes.trim(), notes: notes.trim(),
} }
// Nothing changed: close without a request, so a look-and-Save doesn't bump
// the version for every garden that shares the plant.
if (isEdit && (Object.keys(input) as (keyof PlantInput)[]).every((k) => input[k] === (k === 'color' ? expandHex(plant.color) : plant[k]))) {
onClose()
return
}
try { try {
if (isEdit) await update.mutateAsync({ id: plant.id, ...input, version }) if (isEdit) await update.mutateAsync({ id: plant.id, ...input, version })
else await create.mutateAsync(input) else await create.mutateAsync(input)
@@ -107,7 +115,7 @@ export function PlantDialog({
setVersion(current.version) setVersion(current.version)
setName(current.name) setName(current.name)
setCategory(current.category) setCategory(current.category)
setSpacing(String(spacingFromCm(current.spacingCm, unit))) setSpacing(spacingField(current.spacingCm, unit))
setColor(expandHex(current.color)) setColor(expandHex(current.color))
setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '') setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '')
setVendor(current.vendor) setVendor(current.vendor)
@@ -142,8 +150,8 @@ export function PlantDialog({
step="any" step="any"
min="1" min="1"
required required
value={spacing} value={spacing.text}
onChange={(e) => setSpacing(e.target.value)} onChange={(e) => setSpacing(editSpacingField(e.target.value, unit))}
wrapperClassName="flex-1" wrapperClassName="flex-1"
/> />
</div> </div>
+10 -3
View File
@@ -88,6 +88,10 @@ export function AssistantTab({ gardenId, canEdit, undo, large = false }: { garde
return ( return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5"> <div className="flex min-h-0 flex-1 flex-col gap-2.5">
{/* The thread scrolls on its own so the composer stays put: with the whole
tab scrolling, a long conversation pushed the input off the bottom and
every new message scrolled it further away. */}
<div className="flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto">
{!canEdit && <Alert tone="info">You can only view this garden, so the assistant can't change anything in it.</Alert>} {!canEdit && <Alert tone="info">You can only view this garden, so the assistant can't change anything in it.</Alert>}
{history.isPending && <p className="text-[13px] text-ink-mute">Loading the conversation…</p>} {history.isPending && <p className="text-[13px] text-ink-mute">Loading the conversation…</p>}
{history.isError && <Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>} {history.isError && <Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>}
@@ -103,8 +107,10 @@ export function AssistantTab({ gardenId, canEdit, undo, large = false }: { garde
{m.body} {m.body}
</div> </div>
) : ( ) : (
<div key={m.id} className="flex max-w-[90%] flex-col items-start gap-1 self-start"> <div key={m.id} className="flex min-w-0 max-w-[90%] flex-col items-start gap-1 self-start">
<div className={cn('rounded-[18px_18px_18px_4px] border border-divider bg-bg px-[13px] py-[9px] leading-[1.45]', bubbleText)}> {/* min-w-0 / max-w-full: a wide markdown table scrolls inside its own
wrapper instead of widening the bubble past the panel. */}
<div className={cn('min-w-0 max-w-full rounded-[18px_18px_18px_4px] border border-divider bg-bg px-[13px] py-[9px] leading-[1.45]', bubbleText)}>
<MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{m.body}</span>}> <MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{m.body}</span>}>
<Suspense fallback={<span className="whitespace-pre-wrap">{m.body}</span>}> <Suspense fallback={<span className="whitespace-pre-wrap">{m.body}</span>}>
<MarkdownMessage>{m.body}</MarkdownMessage> <MarkdownMessage>{m.body}</MarkdownMessage>
@@ -136,7 +142,8 @@ export function AssistantTab({ gardenId, canEdit, undo, large = false }: { garde
{warning && <Alert tone="info">{warning}</Alert>} {warning && <Alert tone="info">{warning}</Alert>}
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div ref={bottom} /> <div ref={bottom} />
<div className="mt-auto flex flex-col gap-1.5 pt-1.5"> </div>
<div className="flex flex-col gap-1.5 pt-1.5">
{messages.length > 0 && !pending && ( {messages.length > 0 && !pending && (
<button <button
type="button" type="button"
+4 -3
View File
@@ -10,8 +10,9 @@ import {
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
} from 'react' } from 'react'
import { clampScale, type Point } from '@/lib/geometry' import { clampScale, type Point } from '@/lib/geometry'
import { monogramInk } from '@/lib/monogram'
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects' import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
import type { Plant } from '@/lib/plants' import { FALLBACK_PLANT_COLOR, type Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings' import type { EditorPlanting } from '@/lib/plantings'
import { formatSize } from '@/lib/units' import { formatSize } from '@/lib/units'
import { kindDef, objectStyle, rectRadius } from './kinds' import { kindDef, objectStyle, rectRadius } from './kinds'
@@ -589,7 +590,7 @@ export const Canvas = forwardRef<
> >
{/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */} {/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */}
<circle r={Math.max(p.radiusCm, 10 / s)} fill="transparent" /> <circle r={Math.max(p.radiusCm, 10 / s)} fill="transparent" />
<circle r={p.radiusCm} fill={plant?.color ?? '#97a97c'} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} /> <circle r={p.radiusCm} fill={plant?.color ?? FALLBACK_PLANT_COLOR} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} />
</g> </g>
) )
})} })}
@@ -642,7 +643,7 @@ export const Canvas = forwardRef<
textAnchor="middle" textAnchor="middle"
dominantBaseline="central" dominantBaseline="central"
fontSize={r * 1.05} fontSize={r * 1.05}
fill="var(--color-paper)" fill={monogramInk(plant?.color ?? FALLBACK_PLANT_COLOR)}
style={{ fontFamily: 'var(--font-heading)' }} style={{ fontFamily: 'var(--font-heading)' }}
> >
{letters.get(p.plantId) ?? '?'} {letters.get(p.plantId) ?? '?'}
+38 -3
View File
@@ -1,13 +1,14 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ColorDot } from '@/components/plants/Monogram' import { ColorDot } from '@/components/plants/Monogram'
import { Button, IconButton } from '@/components/ui/Button' import { Button, IconButton } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { TextAreaField, TextField } from '@/components/ui/Field' import { TextAreaField, TextField } from '@/components/ui/Field'
import { Icon } from '@/components/ui/Icon' import { Icon } from '@/components/ui/Icon'
import { Tag } from '@/components/ui/Tag' import { Tag } from '@/components/ui/Tag'
import { Toggle } from '@/components/ui/Toggle' import { Toggle } from '@/components/ui/Toggle'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { useDeleteObject, useRemovePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects' import { useDeleteObject, useRemovePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
import type { Plant } from '@/lib/plants' import { FALLBACK_PLANT_COLOR, type Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings' import type { EditorPlanting } from '@/lib/plantings'
import { import {
cmFromSpacing, cmFromSpacing,
@@ -22,7 +23,7 @@ import {
spacingUnitLabel, spacingUnitLabel,
type UnitPref, type UnitPref,
} from '@/lib/units' } from '@/lib/units'
import { kindDef, kindPlural } from './kinds' import { kindDef, kindPlural, objectDisplayName } from './kinds'
import { MIN_OBJECT_CM, plopCount } from './shared' import { MIN_OBJECT_CM, plopCount } from './shared'
import { useEditorStore } from './store' import { useEditorStore } from './store'
import type { EditorObject } from './types' import type { EditorObject } from './types'
@@ -40,6 +41,13 @@ export function rosterText(o: EditorObject, plantings: EditorPlanting[], plantsB
return 'Growing: ' + [...roster].map(([n, c]) => `${c} ${n}`).join(' · ') return 'Growing: ' + [...roster].map(([n, c]) => `${c} ${n}`).join(' · ')
} }
/** How many plants an object holds right now (its plops × their counts). */
export function plantCountIn(o: EditorObject, plantings: EditorPlanting[], plantsById: Map<number, Plant>): number {
let n = 0
for (const p of plantings) if (p.objectId === o.id) n += plopCount(p, plantsById.get(p.plantId))
return n
}
/** A collapsible block of the less-often-needed fields. */ /** A collapsible block of the less-often-needed fields. */
function Details({ open, onToggle, children }: { open: boolean; onToggle: () => void; children: ReactNode }) { function Details({ open, onToggle, children }: { open: boolean; onToggle: () => void; children: ReactNode }) {
return ( return (
@@ -66,6 +74,7 @@ export function ObjectInspector({
canEdit, canEdit,
focused, focused,
roster, roster,
plantCount,
noteCount, noteCount,
large, large,
onPlantThis, onPlantThis,
@@ -79,6 +88,8 @@ export function ObjectInspector({
/** Already inside this bed (so "Plant this" is redundant). */ /** Already inside this bed (so "Plant this" is redundant). */
focused: boolean focused: boolean
roster: string roster: string
/** Live plants in it (see plantCountIn) — Remove asks first when this is > 0. */
plantCount: number
noteCount: number noteCount: number
/** Phone: 16px inputs, 44px targets. */ /** Phone: 16px inputs, 44px targets. */
large?: boolean large?: boolean
@@ -89,6 +100,7 @@ export function ObjectInspector({
const update = useUpdateObject(gardenId) const update = useUpdateObject(gardenId)
const del = useDeleteObject(gardenId) const del = useDeleteObject(gardenId)
const rootRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
const [name, setName] = useState(object.name) const [name, setName] = useState(object.name)
const [details, setDetails] = useState(false) const [details, setDetails] = useState(false)
const [width, setWidth] = useState(formatDimensionInput(object.widthCm, unit)) const [width, setWidth] = useState(formatDimensionInput(object.widthCm, unit))
@@ -178,6 +190,13 @@ export function ObjectInspector({
iconClassName="text-accent-700" iconClassName="text-accent-700"
disabled={del.isPending} disabled={del.isPending}
onClick={() => { onClick={() => {
// An empty object goes straight away (one Undo brings it back); a
// planted one takes its plants with it, which is worth a question —
// on the phone this button sits right beside "Plant this".
if (plantCount > 0) {
setConfirmRemove(true)
return
}
onDeleted() onDeleted()
del.mutate(object.id) del.mutate(object.id)
}} }}
@@ -259,6 +278,22 @@ export function ObjectInspector({
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} onBlur={() => notes !== object.notes && patch({ notes })} /> <TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} onBlur={() => notes !== object.notes && patch({ notes })} />
</fieldset> </fieldset>
</Details> </Details>
{confirmRemove && (
<ConfirmDialog
title={`Remove ${objectDisplayName(object)}?`}
confirmLabel="Remove it"
busyLabel="Removing…"
errorFallback="Could not remove it."
onConfirm={async () => {
await del.mutateAsync(object.id)
onDeleted()
}}
onClose={() => setConfirmRemove(false)}
>
It has {plantCount === 1 ? 'one plant' : `${plantCount} plants`} in it, and they go with it. One Undo brings
everything back.
</ConfirmDialog>
)}
</div> </div>
) )
} }
@@ -321,7 +356,7 @@ export function PlopInspector({
return ( return (
<div ref={rootRef} className="flex flex-col gap-3"> <div ref={rootRef} className="flex flex-col gap-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<ColorDot color={plant?.color ?? '#97a97c'} size={18} /> <ColorDot color={plant?.color ?? FALLBACK_PLANT_COLOR} size={18} />
<span className="min-w-0 truncate font-heading text-[17px]">{plant?.name ?? 'Unknown plant'}</span> <span className="min-w-0 truncate font-heading text-[17px]">{plant?.name ?? 'Unknown plant'}</span>
{noteCount > 0 && ( {noteCount > 0 && (
<button type="button" className="tag tag-accent-2 ml-auto cursor-pointer border-0" onClick={onNotes}> <button type="button" className="tag tag-accent-2 ml-auto cursor-pointer border-0" onClick={onNotes}>
+1 -1
View File
@@ -4,9 +4,9 @@ import { Button, IconButton } from '@/components/ui/Button'
import { TextAreaField, TextField } from '@/components/ui/Field' import { TextAreaField, TextField } from '@/components/ui/Field'
import { errorMessage } from '@/lib/api' import { errorMessage } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { today } from '@/lib/dates'
import { import {
formatObservedAt, formatObservedAt,
today,
useCreateJournalEntry, useCreateJournalEntry,
useDeleteJournalEntry, useDeleteJournalEntry,
useJournal, useJournal,
+18 -1
View File
@@ -8,6 +8,7 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { z } from 'zod' import { z } from 'zod'
import { API_BASE, api } from './api' import { API_BASE, api } from './api'
import { today } from './dates'
import { gardenFullKey } from './objects' import { gardenFullKey } from './objects'
import { historyKey } from './history' import { historyKey } from './history'
@@ -97,14 +98,26 @@ export interface AgentStep {
const TOOL_LABELS: Record<string, string> = { const TOOL_LABELS: Record<string, string> = {
list_gardens: 'Looking at your gardens', list_gardens: 'Looking at your gardens',
describe_garden: 'Reading the garden', describe_garden: 'Reading the garden',
list_plantings: 'Reading a bed',
create_object: 'Adding a bed', create_object: 'Adding a bed',
move_object: 'Moving a bed', move_object: 'Moving a bed',
update_object: 'Changing a bed',
delete_object: 'Removing a bed',
place_planting: 'Planting', place_planting: 'Planting',
fill_region: 'Filling a bed', fill_region: 'Filling a bed',
move_planting: 'Moving a plant',
remove_planting: 'Pulling a plant',
remove_plantings: 'Pulling plants',
clear_object: 'Clearing a bed', clear_object: 'Clearing a bed',
find_plant: 'Looking up a plant', find_plant: 'Looking up a plant',
create_plant: 'Adding a plant to your catalog', create_plant: 'Adding a plant to your catalog',
update_plant: 'Updating your catalog',
add_journal_entry: 'Writing a journal note', add_journal_entry: 'Writing a journal note',
read_journal: 'Reading the journal',
read_history: 'Reading the history',
list_seed_lots: 'Checking your seed',
record_seed_lot: 'Recording seed',
copy_garden: 'Copying the garden',
} }
export function describeStep(step: AgentStep): string { export function describeStep(step: AgentStep): string {
@@ -154,10 +167,14 @@ export async function streamChat(
): Promise<void> { ): Promise<void> {
let res: Response let res: Response
try { try {
// `today` is the browser's local day, for the same reason every other
// dated write sends it: the assistant dates what it plants, removes and
// journals with it, and tells the model what day it is. Left to the server
// the date is UTC's; left to the model it was a year from its training data.
res = await fetch(`${API_BASE}/agent/chat`, { res = await fetch(`${API_BASE}/agent/chat`, {
method: 'POST', method: 'POST',
headers: { 'content-type': 'application/json' }, headers: { 'content-type': 'application/json' },
body: JSON.stringify({ gardenId, message }), body: JSON.stringify({ gardenId, message, today: today() }),
credentials: 'same-origin', credentials: 'same-origin',
signal, signal,
}) })
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { today } from './dates'
describe('today', () => {
it('is the local calendar day, zero-padded', () => {
// 21:30 local on Aug 22 is Aug 23 in UTC for anyone west of Greenwich; the
// gardener still planted on the 22nd.
expect(today(new Date(2026, 7, 22, 21, 30))).toBe('2026-08-22')
expect(today(new Date(2026, 0, 5, 0, 1))).toBe('2026-01-05')
})
})
+11
View File
@@ -0,0 +1,11 @@
// The day it is where the person is. Everything the UI stamps with "today" — a
// journal note, a placed or filled plant — uses the browser's local date,
// because a gardener planting at 9 pm in Ohio planted today, not (in UTC)
// tomorrow. The server's own defaults are UTC and only apply when a date is
// omitted, which is for API callers and the agent; the UI never omits one.
/** Today as YYYY-MM-DD in the browser's local time zone. */
export function today(now = new Date()): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
-7
View File
@@ -127,13 +127,6 @@ export function useDeleteJournalEntry(gardenId: number) {
}) })
} }
/** 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 /** A date-only string as a short human label, without dragging the value
* through a Date (which would shift it by the timezone offset). */ * through a Date (which would shift it by the timezone offset). */
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { monogramFor, monogramMap, speciesName } from './monogram' import { monogramFor, monogramInk, monogramMap, speciesName } from './monogram'
describe('speciesName', () => { describe('speciesName', () => {
it('drops a variety suffix and a parenthetical', () => { it('drops a variety suffix and a parenthetical', () => {
@@ -65,3 +65,22 @@ describe('monogramMap', () => {
expect(monogramFor('🌱')).toBe('?') expect(monogramFor('🌱')).toBe('?')
}) })
}) })
describe('monogramInk', () => {
it('keeps paper lettering on colors dark enough to carry it', () => {
expect(monogramInk('#c8553d')).toBe('var(--color-paper)') // tomato
expect(monogramInk('#7a8a5e')).toBe('var(--color-paper)') // sage
expect(monogramInk('#5f8f45')).toBe('var(--color-paper)')
})
it('switches to the dark marker ink on pale colors', () => {
expect(monogramInk('#d9d2c5')).toBe('var(--color-marker-ink)') // garlic
expect(monogramInk('#8bc98b')).toBe('var(--color-marker-ink)') // cabbage
expect(monogramInk('#fff')).toBe('var(--color-marker-ink)')
})
it('falls back to paper for anything it cannot read', () => {
expect(monogramInk('tomato')).toBe('var(--color-paper)')
expect(monogramInk('')).toBe('var(--color-paper)')
})
})
+40
View File
@@ -61,3 +61,43 @@ export function monogramFor(name: string): string {
const ls = letters(name) const ls = letters(name)
return ls.length ? ls[0].toUpperCase() : '?' return ls.length ? ls[0].toUpperCase() : '?'
} }
// --- Lettering color --------------------------------------------------------
// Letters are paper on the marker's color — which reads on tomato red and sage,
// and vanishes on garlic's #d9d2c5. Pale markers take the dark marker ink
// instead. Both inks are theme-stable (the marker's own color is), so the
// choice depends only on the color, never on light/dark mode.
const PAPER = 'var(--color-paper)'
const INK = 'var(--color-marker-ink)'
// Below this relative luminance, paper still clears ~2.5:1 against the marker;
// above it, the dark ink does better. Sage (#7a8a5e, 0.23) keeps paper; cabbage
// green (#8bc98b, 0.49), marigold orange and garlic flip to ink.
const PAPER_MAX_LUMINANCE = 0.37
/** WCAG relative luminance of a #rgb / #rrggbb color; null if unparseable. */
function luminance(color: string): number | null {
const m = color.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i)
if (!m) return null
const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1]
const channel = (i: number) => {
const v = parseInt(hex.slice(i, i + 2), 16) / 255
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4)
}
// Memoized by color string: the canvas asks for every visible plop on every
// frame of a pan, and a catalog has a dozen distinct colors, not thousands.
const inkByColor = new Map<string, string>()
/** The CSS color the monogram letters take on a marker of `color`. */
export function monogramInk(color: string): string {
let ink = inkByColor.get(color)
if (ink === undefined) {
const l = luminance(color)
ink = l !== null && l > PAPER_MAX_LUMINANCE ? INK : PAPER
inkByColor.set(color, ink)
}
return ink
}
+8 -4
View File
@@ -7,6 +7,7 @@ import { useCallback } from 'react'
import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { z } from 'zod' import { z } from 'zod'
import { ApiError, api } from './api' import { ApiError, api } from './api'
import { today } from './dates'
import { gardenSchema } from './gardens' import { gardenSchema } from './gardens'
import { plantSchema, type Plant } from './plants' import { plantSchema, type Plant } from './plants'
import { serverPlantingSchema, type ServerPlanting } from './plantings' import { serverPlantingSchema, type ServerPlanting } from './plantings'
@@ -260,13 +261,17 @@ export interface PlantingCreate {
label?: string | null label?: string | null
/** Attributes the plop to a purchase, so that lot can report what's left. */ /** Attributes the plop to a purchase, so that lot can report what's left. */
seedLotId?: number seedLotId?: number
/** YYYY-MM-DD; defaults to the browser's local today (never the server's UTC one). */
plantedAt?: string
} }
export function useCreatePlanting(gardenId: number) { export function useCreatePlanting(gardenId: number) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise<ServerPlanting> => mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise<ServerPlanting> =>
serverPlantingSchema.parse(await api.post(`/objects/${objectId}/plantings`, body)), serverPlantingSchema.parse(
await api.post(`/objects/${objectId}/plantings`, { plantedAt: today(), ...body }),
),
onSuccess: (created) => { onSuccess: (created) => {
patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: [...full.plantings, created] })) patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: [...full.plantings, created] }))
}, },
@@ -353,7 +358,7 @@ export function useFillObject(gardenId: number) {
layout: FillLayout layout: FillLayout
}): Promise<number> => { }): Promise<number> => {
const res = fillResultSchema.parse( const res = fillResultSchema.parse(
await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout }), await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout, plantedAt: today() }),
) )
return res.created return res.created
}, },
@@ -393,8 +398,7 @@ export function useRemovePlanting(gardenId: number) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async ({ id, version }: { id: number; version: number }): Promise<ServerPlanting> => { mutationFn: async ({ id, version }: { id: number; version: number }): Promise<ServerPlanting> => {
const today = new Date().toISOString().slice(0, 10) return serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, { removedAt: today(), version }))
return serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, { removedAt: today, version }))
}, },
onMutate: async ({ id }) => { onMutate: async ({ id }) => {
await qc.cancelQueries({ queryKey: fullKey(gardenId) }) await qc.cancelQueries({ queryKey: fullKey(gardenId) })
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { parsePlanName, planGardensOf, planNameFor, planYearOf } from './plan' import { nextPlanYear, parsePlanName, planGardensOf, planNameFor, planYearOf } from './plan'
describe('plan names', () => { describe('plan names', () => {
it('round-trips the default copy name', () => { it('round-trips the default copy name', () => {
@@ -35,3 +35,15 @@ describe('plan names', () => {
expect(planGardensOf('Home Garden', gardens).map((g) => g.id)).toEqual([3, 2]) expect(planGardensOf('Home Garden', gardens).map((g) => g.id)).toEqual([3, 2])
}) })
}) })
describe('nextPlanYear', () => {
it('skips years that already have a plan copy, however the dash was typed', () => {
const names = ['Back Yard', 'Back Yard — 2027', 'Back Yard - 2028', 'Front Strip — 2029']
expect(nextPlanYear('Back Yard', names, 2027)).toBe(2029)
expect(nextPlanYear('Front Strip', names, 2027)).toBe(2027)
})
it('is the starting year when nothing is taken', () => {
expect(nextPlanYear('Plot', [], 2030)).toBe(2030)
})
})
+14
View File
@@ -40,3 +40,17 @@ export function planGardensOf<G extends { id: number; name: string }>(base: stri
} }
return out.sort((a, b) => a.year - b.year) return out.sort((a, b) => a.year - b.year)
} }
/** The first year from `from` on that `base` has no plan copy for among
* `names`, so a new copy never proposes a name that is already taken — two
* "Back Yard — 2027"s would both be offered as the 2027 plan. */
export function nextPlanYear(base: string, names: readonly string[], from: number): number {
const taken = new Set<number>()
for (const n of names) {
const p = parsePlanName(n)
if (p && p.base === base.trim()) taken.add(p.year)
}
let year = from
while (taken.has(year)) year += 1
return year
}
+4
View File
@@ -8,6 +8,10 @@ import { z } from 'zod'
import { ApiError, api } from './api' import { ApiError, api } from './api'
export const PLANT_CATEGORIES = ['vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover'] as const export const PLANT_CATEGORIES = ['vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover'] as const
/** The marker color for a plop whose plant isn't in the catalog you can see (a
* shared garden's private plant): the sage the curated swatches start with. */
export const FALLBACK_PLANT_COLOR = '#97a97c'
export type PlantCategory = (typeof PLANT_CATEGORIES)[number] export type PlantCategory = (typeof PLANT_CATEGORIES)[number]
export const CATEGORY_LABELS: Record<PlantCategory, string> = { export const CATEGORY_LABELS: Record<PlantCategory, string> = {
+32
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
cmFromFtIn, cmFromFtIn,
convertDimensionField,
dimensionField,
editDimensionField,
editSpacingField,
spacingField,
cmFromMeters, cmFromMeters,
cmFromSpacing, cmFromSpacing,
dimensionInputMode, dimensionInputMode,
@@ -242,3 +247,30 @@ describe('formatSize / formatLength', () => {
expect(formatLength(1219, 'imperial')).toBe('40') expect(formatLength(1219, 'imperial')).toBe('40')
}) })
}) })
describe('LengthField', () => {
it('keeps the stored centimeters through a unit switch and back', () => {
let f = dimensionField(900, 'imperial')
expect(f.text).toBe('29 6.3″')
f = convertDimensionField(f, 'metric')
expect(f.text).toBe('9')
f = convertDimensionField(f, 'imperial')
expect(f).toEqual({ text: '29 6.3″', cm: 900 })
})
it('moves the centimeters only when the text is edited', () => {
expect(editDimensionField("15' 6\"", 'imperial').cm).toBe(472.44)
const typo = editDimensionField('nope', 'imperial')
expect(typo.cm).toBeNull()
// A typo survives a unit switch as typed rather than turning into a number.
expect(convertDimensionField(typo, 'metric')).toEqual(typo)
})
it('spacing: 45 cm reads 17.7 in and stays 45 cm until typed over', () => {
const f = spacingField(45, 'imperial')
expect(f).toEqual({ text: '17.7', cm: 45 })
expect(editSpacingField('18', 'imperial').cm).toBe(45.72)
expect(editSpacingField('', 'imperial').cm).toBeNull()
expect(editSpacingField('25', 'metric').cm).toBe(25)
})
})
+42
View File
@@ -227,3 +227,45 @@ export function formatLength(cm: number, unit: UnitPref): string {
export function formatSize(widthCm: number, heightCm: number, unit: UnitPref): string { export function formatSize(widthCm: number, heightCm: number, unit: UnitPref): string {
return `${formatLength(widthCm, unit)} × ${formatLength(heightCm, unit)}` return `${formatLength(widthCm, unit)} × ${formatLength(heightCm, unit)}`
} }
// --- Typed-length fields ----------------------------------------------------
// A dialog field that takes a length holds TWO things: the text the person sees
// and the centimeters it means. The centimeters change only when the person
// types; re-showing the field in another unit, or saving without touching it,
// reuses them as they are. Parsing the displayed text back on save is how a
// no-change Save once turned 900 cm into 899.922 — "29 6.3″" is the nearest
// tenth of an inch, not the number that was loaded.
/** A length as typed and as stored; `cm` is null while the text doesn't parse. */
export interface LengthField {
text: string
cm: number | null
}
/** A dimension field (meters, or feet and inches) showing `cm`. */
export function dimensionField(cm: number, unit: UnitPref): LengthField {
return { text: formatDimensionInput(cm, unit), cm }
}
/** The person typed `text` into a dimension field. */
export function editDimensionField(text: string, unit: UnitPref): LengthField {
return { text, cm: parseDimension(text, unit) }
}
/** Re-show a dimension field in another unit; the centimeters don't move. A
* field that doesn't parse keeps its text, so the typo stays visible. */
export function convertDimensionField(field: LengthField, unit: UnitPref): LengthField {
return field.cm === null ? field : dimensionField(field.cm, unit)
}
/** A spacing field (cm, or inches) showing `cm`. */
export function spacingField(cm: number, unit: UnitPref): LengthField {
return { text: String(spacingFromCm(cm, unit)), cm }
}
/** The person typed `text` into a spacing field. */
export function editSpacingField(text: string, unit: UnitPref): LengthField {
const trimmed = text.trim()
const n = Number(trimmed)
return { text, cm: trimmed !== '' && Number.isFinite(n) ? cmFromSpacing(n, unit) : null }
}
+2 -1
View File
@@ -13,7 +13,7 @@ import { AssistantTab } from '@/editor/AssistantTab'
import { Canvas, type CanvasHandle } from '@/editor/Canvas' import { Canvas, type CanvasHandle } from '@/editor/Canvas'
import { ClearBedDialog } from '@/editor/ClearBedDialog' import { ClearBedDialog } from '@/editor/ClearBedDialog'
import { HistoryTab } from '@/editor/HistoryTab' import { HistoryTab } from '@/editor/HistoryTab'
import { GardenSummary, ObjectInspector, PlopInspector, rosterText } from '@/editor/Inspector' import { GardenSummary, ObjectInspector, PlopInspector, plantCountIn, rosterText } from '@/editor/Inspector'
import { JournalTab, type JournalAttach } from '@/editor/JournalTab' import { JournalTab, type JournalAttach } from '@/editor/JournalTab'
import { KindSwatch } from '@/editor/KindSwatch' import { KindSwatch } from '@/editor/KindSwatch'
import { OBJECT_KINDS, objectDisplayName } from '@/editor/kinds' import { OBJECT_KINDS, objectDisplayName } from '@/editor/kinds'
@@ -390,6 +390,7 @@ function Editor({
canEdit={canEdit} canEdit={canEdit}
focused={focusId === selectedObject.id} focused={focusId === selectedObject.id}
roster={rosterText(selectedObject, plantings, plantsById)} roster={rosterText(selectedObject, plantings, plantsById)}
plantCount={plantCountIn(selectedObject, plantings, plantsById)}
noteCount={journalCounts.data?.get(selectedObject.id) ?? 0} noteCount={journalCounts.data?.get(selectedObject.id) ?? 0}
large={isMobile} large={isMobile}
onPlantThis={() => plantThis(selectedObject)} onPlantThis={() => plantThis(selectedObject)}
+28 -9
View File
@@ -113,11 +113,16 @@ function WhoGetsInCard({ data }: { data: SettingsResponse }) {
) )
} }
type ModelField = 'agentModel' | 'visionModel'
function AssistantCard({ data }: { data: SettingsResponse }) { function AssistantCard({ data }: { data: SettingsResponse }) {
const update = useUpdateSettings() const update = useUpdateSettings()
const { settings, effective } = data const { settings, effective } = data
const [model, setModel] = useState(settings.agentModel) const [model, setModel] = useState(settings.agentModel)
const [vModel, setVModel] = useState(settings.visionModel) const [vModel, setVModel] = useState(settings.visionModel)
// A rejected model spec stays in its field with the server's reason under it
// (the field keeps the typo so it can be fixed); everything else toasts.
const [fieldError, setFieldError] = useState<{ field: ModelField; message: string } | null>(null)
// Re-sync the fields when the stored row changes underneath (a save, a // Re-sync the fields when the stored row changes underneath (a save, a
// refetch), so what's shown is what's saved. // refetch), so what's shown is what's saved.
@@ -136,14 +141,20 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
version: settings.version, version: settings.version,
}, },
{ {
onSuccess: () => toast.info('Saved the assistant picked it up.'), onSuccess: () => {
setFieldError(null)
toast.info('Saved — the assistant picked it up.')
},
onError: (err) => { onError: (err) => {
const current = conflictSettings(err) const current = conflictSettings(err)
toast.error( if (current) {
current toast.error('Someone else changed these settings just now — showing their version. Re-apply yours if you still want it.')
? 'Someone else changed these settings just now showing their version. Re-apply yours if you still want it.' return
: errorMessage(err, "Couldn't save settings."), }
) const message = errorMessage(err, "Couldn't save settings.")
const field = (['agentModel', 'visionModel'] as const).find((f) => patch[f] !== undefined)
if (field) setFieldError({ field, message })
else toast.error(message)
}, },
}, },
) )
@@ -157,10 +168,15 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
? 'off — key still in env' ? 'off — key still in env'
: `configured · not running (${shortModel(effective.model)})` : `configured · not running (${shortModel(effective.model)})`
const commit = (field: 'agentModel' | 'visionModel', value: string) => { const commit = (field: ModelField, value: string) => {
const v = value.trim() const v = value.trim()
if (v !== settings[field]) save({ [field]: v }) if (v !== settings[field]) save({ [field]: v })
// Back to what's saved (the typo was cleared): nothing to send, and the
// old reason would be about a value no longer in the field.
else setFieldError((e) => (e?.field === field ? null : e))
} }
const errorFor = (field: ModelField) =>
fieldError?.field === field ? <span className="text-accent-700">{fieldError.message}</span> : null
return ( return (
<Card title="Garden assistant" tag={<Tag tone="accent-2">{status}</Tag>}> <Card title="Garden assistant" tag={<Tag tone="accent-2">{status}</Tag>}>
@@ -180,7 +196,7 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
onChange={(e) => setModel(e.target.value)} onChange={(e) => setModel(e.target.value)}
onBlur={() => commit('agentModel', model)} onBlur={() => commit('agentModel', model)}
onKeyDown={(e) => e.key === 'Enter' && commit('agentModel', model)} onKeyDown={(e) => e.key === 'Enter' && commit('agentModel', model)}
hint="A majordomo model spec; a comma-separated list is a failover chain." hint={errorFor('agentModel') ?? 'A majordomo model spec; a comma-separated list is a failover chain.'}
/> />
<TextField <TextField
label="Vision model — reads seed packets; must be vision-capable" label="Vision model — reads seed packets; must be vision-capable"
@@ -190,7 +206,10 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
onChange={(e) => setVModel(e.target.value)} onChange={(e) => setVModel(e.target.value)}
onBlur={() => commit('visionModel', vModel)} onBlur={() => commit('visionModel', vModel)}
onKeyDown={(e) => e.key === 'Enter' && commit('visionModel', vModel)} onKeyDown={(e) => e.key === 'Enter' && commit('visionModel', vModel)}
hint={effective.visionReady ? `Scanning is on with ${shortModel(effective.visionModel)}.` : 'Scanning is off until a model and a key are both present.'} hint={
errorFor('visionModel') ??
(effective.visionReady ? `Scanning is on with ${shortModel(effective.visionModel)}.` : 'Scanning is off until a model and a key are both present.')
}
/> />
<div className="text-xs leading-relaxed text-ink-mute"> <div className="text-xs leading-relaxed text-ink-mute">
The API key stays in the environment on purpose a secret in the database would ride along in every backup. The API key stays in the environment on purpose a secret in the database would ride along in every backup.
+4 -1
View File
@@ -21,8 +21,11 @@
--color-accent: #c67139; --color-accent: #c67139;
--color-accent-2: #7a8a5e; --color-accent-2: #7a8a5e;
--color-divider: color-mix(in srgb, #201e1d 16%, transparent); --color-divider: color-mix(in srgb, #201e1d 16%, transparent);
/* The monogram ink on plant markers; does not change between modes. */ /* Monogram lettering on plant markers: paper on dark colors, marker-ink on
pale ones (lib/monogram.ts picks). Neither changes between modes — the
marker's own color doesn't either. */
--color-paper: #fffaf1; --color-paper: #fffaf1;
--color-marker-ink: #201e1d;
--color-neutral-100: #f9f4ed; --color-neutral-100: #f9f4ed;
--color-neutral-200: #eee7db; --color-neutral-200: #eee7db;