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]>
This commit is contained in:
2026-08-23 00:14:41 -04:00
co-authored by Claude Fable 5
parent f0aefb5378
commit bc14bbed0d
18 changed files with 1702 additions and 186 deletions
+250 -42
View File
@@ -2,28 +2,45 @@ package agent
import (
"context"
"fmt"
"strings"
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
)
// 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
// checks (requireGardenRole / objectForRole) apply unchanged. Construct one per
// authenticated agent session:
// single acting user and to the day it is where they are. Every tool call runs
// as actorID, so pansy's permission checks (requireGardenRole / objectForRole)
// 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")
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",
llm.DefineTool("list_gardens",
"List the gardens the user can see (owned and shared), with the user's role on each.",
a.listGardens),
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),
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",
"Add an object (bed, grow_bag, container, in_ground, tree, path, structure) to a garden, positioned by its center in garden cm.",
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.",
a.moveObject),
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),
llm.DefineTool("fill_region",
"Fill part of a plantable object with one plant, hex-packed at the plant's spacing. "+
"region is a compass name, not coordinates: nw|ne|sw|se for the quarter corners, "+
"north|south|east|west (or top|bottom|left|right) for halves, or all for the whole thing. "+
"North is the top of the garden. Example: to replant a whole bed, clear_object then "+
"fill_region with region=all. Filling skips spots already covered by an existing plant, "+
"so it is safe to run twice.",
"Say where EITHER by region a compass name, not coordinates: nw|ne|sw|se for the quarter "+
"corners, north|south|east|west (or top|bottom|left|right) for halves, or all for the whole "+
"thing; north is the top of the garden — OR by an explicit rectangle in the object's local "+
"frame (x0Cm,y0Cm,x1Cm,y1Cm; 0,0 = center, -y = north), for a middle third, a strip along "+
"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),
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 from describe_garden. 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",
"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 "+
@@ -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 "+
"worse than reusing it. The plant belongs to the user, not to any garden.",
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",
"Write a dated observation into the garden's grow journal — what happened, and when. "+
"Attach it to one bed with objectId when it is about that bed. This is for events "+
"(\"powdery mildew on the west bed\", \"first frost\"), not for descriptions of what a "+
"thing is. observedAt defaults to today; set it to backdate.",
"thing is and not for narrating your own plantings. observedAt defaults to today; set "+
"it to backdate.",
a.addJournalEntry),
llm.DefineTool("read_journal",
"Read back the garden's grow journal — the observations add_journal_entry wrote. "+
@@ -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?\" "+
"or \"what happened last spring?\".",
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",
"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 "+
@@ -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 "+
"the same as clearing a bed's plants); prefer clear_object when the bed itself stays.",
a.deleteObject),
llm.DefineTool("remove_planting",
"Remove ONE plop from a bed, leaving the rest — the single-plant answer to clear_object's "+
"all-or-nothing. Soft-removes it (kept for planting history, undoable), like clearing a "+
"bed does. Needs the plop's id and version from describe_garden. Use for \"pull the "+
"basil out of the corner\".",
a.removePlanting),
llm.DefineTool("list_seed_lots",
"List the seed lots (purchases) the user has recorded — vendor, quantity, and what's "+
"left — optionally for one plant via plantId. This is the detail behind the \"seed "+
@@ -96,15 +143,37 @@ func NewToolbox(svc *service.Service, actorID int64) *llm.Toolbox {
llm.DefineTool("record_seed_lot",
"Record a seed purchase for a plant the user owns, so pansy can track how much is left. "+
"Get the plantId from find_plant first. quantity + unit is what was bought (e.g. 2 "+
"\"packets\", or 500 \"seeds\"). Use for \"I bought two packets of Cherokee Purple\".",
"\"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),
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 {
svc *service.Service
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) {
@@ -117,6 +186,13 @@ func (a *adapter) describeGarden(ctx context.Context, args struct {
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 {
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"`
@@ -144,28 +220,69 @@ func (a *adapter) moveObject(ctx context.Context, args struct {
}
func (a *adapter) placePlanting(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"plantable object to plant in"`
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)"`
YCM float64 `json:"yCm" description:"center y in the object's local frame (cm)"`
RadiusCM float64 `json:"radiusCm" description:"plop radius in cm"`
Count *int `json:"count" description:"optional explicit plant count; omit to derive from area ÷ spacing²"`
ObjectID int64 `json:"objectId" description:"plantable object to plant in"`
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)"`
YCM float64 `json:"yCm" description:"center y in the object's local frame (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²"`
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) {
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,
PlantedAt: a.day(args.PlantedAt), SeedLotID: args.SeedLotID,
})
}
func (a *adapter) fillRegion(ctx context.Context, args struct {
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"`
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"`
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) {
// nil: the agent runs server-side with no local day, so the fill dates
// plops UTC-today like its create_planting does.
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode), nil)
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) == "":
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 {
@@ -182,7 +299,7 @@ func (a *adapter) createPlant(ctx context.Context, args struct {
Icon string `json:"icon" description:"a single emoji to draw it with, e.g. 🧄"`
DaysToMaturity *int `json:"daysToMaturity" description:"optional days from planting to harvest"`
SourceURL string `json:"sourceUrl" description:"optional http(s) link to where the seed came from"`
Vendor string `json:"vendor" description:"optional vendor name, e.g. \"Johnny\u0027s Selected Seeds\""`
Vendor string `json:"vendor" description:"optional vendor name, e.g. \"Johnny's Selected Seeds\""`
}) (any, error) {
return a.svc.CreatePlant(ctx, a.actor, service.PlantInput{
Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM,
@@ -191,29 +308,58 @@ 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 {
GardenID int64 `json:"gardenId" description:"garden the observation is about"`
ObjectID *int64 `json:"objectId" description:"optional bed the observation is about; omit for a garden-level note"`
Body string `json:"body" description:"what happened, in plain words"`
ObservedAt string `json:"observedAt" description:"optional date it happened, YYYY-MM-DD; defaults to today"`
}) (any, error) {
in := service.JournalInput{ObjectID: args.ObjectID, Body: args.Body}
if args.ObservedAt != "" {
in.ObservedAt = &args.ObservedAt
}
return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, in)
return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, service.JournalInput{
ObjectID: args.ObjectID, Body: args.Body, ObservedAt: a.day(args.ObservedAt),
})
}
func (a *adapter) clearObject(ctx context.Context, args struct {
ObjectID int64 `json:"objectId" description:"object to remove all plants from"`
}) (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 {
return nil, err
}
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) {
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 {
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"`
@@ -236,6 +382,61 @@ func (a *adapter) readJournal(ctx context.Context, args struct {
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"`
// Undo is set when this entry is itself an undo of an earlier one.
Undo *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, Undo: 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 {
ObjectID int64 `json:"objectId" description:"object to change"`
Version int64 `json:"version" description:"the object's current version (from describe_garden)"`
@@ -264,9 +465,9 @@ func (a *adapter) removePlanting(ctx context.Context, args struct {
PlantingID int64 `json:"plantingId" description:"plop to remove (its id from describe_garden)"`
Version int64 `json:"version" description:"the plop's current version (from describe_garden)"`
}) (any, error) {
// Soft-remove via the service, so removed_at is stamped from the same
// (injectable) clock clear_object uses rather than the adapter's wall clock.
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version)
// Soft-remove via the service, dated the gardener's local day like every
// other tool here (the service clock's UTC day when that isn't known).
return a.svc.RemovePlantingOn(ctx, a.actor, args.PlantingID, args.Version, a.day(""))
}
func (a *adapter) listSeedLots(ctx context.Context, args struct {
@@ -290,3 +491,10 @@ func (a *adapter) recordSeedLot(ctx context.Context, args struct {
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)
}