Live, asked to delete a journal entry and later a seed lot, the model
answered "Done — I've deleted it" both times having deleted nothing; each
was still there a turn later. The prompt already forbade that. Now the run
catches it: honestReply appends a correction when the reply claims a change
("Done", "I've deleted…") and no non-read-only tool call succeeded, and logs
the steps so the mechanism can be read off the log next time.
Alongside: the id-taking tools turn a bare "not found" into a message that
names what was missing and which tool lists the ids ("nothing was changed"),
the two delete descriptions say to look the id up in THIS turn, and the
prompt says an error result means the thing did not happen.
Co-Authored-By: Claude Fable 5 <[email protected]>
1169 lines
58 KiB
Go
1169 lines
58 KiB
Go
package agent
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"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 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, "2026-08-22")
|
||
// agent.Run(ctx, model, box, "fill the NE corner with garlic")
|
||
//
|
||
// 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 {
|
||
box, _ := newToolbox(svc, actorID, today)
|
||
return box
|
||
}
|
||
|
||
// newToolbox is NewToolbox plus the adapter behind it, which Run keeps hold of:
|
||
// the adapter remembers what undo_change reverted, and a turn that only undid
|
||
// something has no other handle to offer as its change.
|
||
func newToolbox(svc *service.Service, actorID int64, today string) (*llm.Toolbox, *adapter) {
|
||
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, notes, version, objects (with sizes/positions/version), "+
|
||
"and each object's 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/update_planting, and xCm/yCm in the object's "+
|
||
"local frame so a move can keep their layout); a large one (a grid-filled bed) does not — "+
|
||
"use list_plantings for those, or act on the whole group with remove_plantings. Without a "+
|
||
"year it describes what is growing now; with one it is that season's view — every plop "+
|
||
"whose time in the ground overlapped the year, pulled ones included, each group saying how "+
|
||
"many were removed and when. That is how to answer \"what was in this bed last year?\" and "+
|
||
"to check rotation before replanting. list_years says which years have data.",
|
||
a.describeGarden),
|
||
llm.DefineTool("list_years",
|
||
"List the years this garden has planting records for, newest first — the years "+
|
||
"describe_garden can show as a season view.",
|
||
a.listYears),
|
||
llm.DefineTool("list_plantings",
|
||
"List one object's active plops one by one, each with its id, version, position (xCm/yCm "+
|
||
"in the object's local frame), 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),
|
||
llm.DefineTool("move_object",
|
||
"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). 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. "+
|
||
"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("update_planting",
|
||
"Correct ONE plop's record without moving it: the date it was planted (plantedAt), its "+
|
||
"plant count (count, or clearCount to go back to deriving it from area and spacing), "+
|
||
"its label, its radius, or the seed lot it came from. Use for \"those tomatoes actually "+
|
||
"went in on May 20\" or \"that clump is five plants\". Needs the plop's id and version "+
|
||
"(describe_garden or list_plantings). Only the fields you pass change.",
|
||
a.updatePlanting),
|
||
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\". Dated today unless removedAt says when it "+
|
||
"actually came out (\"I harvested the garlic on Aug 1\").",
|
||
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. Dated today unless "+
|
||
"removedAt says when they actually came out.",
|
||
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 "+
|
||
"something else. Dated today unless removedAt says when the bed was actually cleared.",
|
||
a.clearObject),
|
||
llm.DefineTool("find_plant",
|
||
"Look up plants in the user's catalog by name or category, to get the plantId that "+
|
||
"place_planting and fill_region need. Returns SEVERAL candidates when the query is "+
|
||
"ambiguous — \"garlic\" may match both the built-in \"Garlic\" and a custom \"German Red "+
|
||
"Garlic\" — so pick the one that fits what the user asked for, or ask them which. Each "+
|
||
"result also reports how much seed the user has left of it, when they have recorded any.",
|
||
a.findPlant),
|
||
llm.DefineTool("create_plant",
|
||
"Add a new plant to the user's own catalog, for when they name a variety that isn't in it "+
|
||
"yet. Check find_plant first — creating a duplicate of something that already exists is "+
|
||
"worse than reusing it. The plant belongs to the user, not to any garden.",
|
||
a.createPlant),
|
||
llm.DefineTool("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 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. "+
|
||
"Narrow it with objectId (one bed), or a from/to date range (YYYY-MM-DD). Most "+
|
||
"recently observed first. Each entry carries the id and version that "+
|
||
"update_journal_entry and delete_journal_entry need. Use this to answer \"what did I "+
|
||
"note about the west bed?\" or \"what happened last spring?\".",
|
||
a.readJournal),
|
||
llm.DefineTool("update_journal_entry",
|
||
"Correct a journal entry the user wrote — its text, or the date it describes — instead "+
|
||
"of adding a second entry that contradicts the first: \"that note was about the "+
|
||
"cucumbers, not the cantaloupe\". Needs the entry's id and version from read_journal. "+
|
||
"Only the user's own entries can be edited.",
|
||
a.updateJournalEntry),
|
||
llm.DefineTool("delete_journal_entry",
|
||
"Delete a journal entry, by its id from read_journal. This is permanent — the journal is "+
|
||
"not in the undo history — so delete only the entry the user pointed at. Get the id "+
|
||
"from read_journal in this turn: ids from earlier turns are not in front of you.",
|
||
a.deleteJournalEntry),
|
||
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 its id, 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, and to find the id undo_change needs.",
|
||
a.readHistory),
|
||
llm.DefineTool("undo_change",
|
||
"Undo one change from the history by its id (from read_history): it reverts everything "+
|
||
"that change did, as a new change that can itself be undone. This is how to do \"undo "+
|
||
"the beets\" or \"put it back the way it was\" — find the change in read_history, then "+
|
||
"undo it; never claim to have undone something without calling this. A change already "+
|
||
"marked undone needs no second undo. Anything edited since that change is left alone "+
|
||
"and reported under conflicts; tell the user about those.",
|
||
a.undoChange),
|
||
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 "+
|
||
"you pass change. Needs the object's current version from describe_garden. Example: "+
|
||
"\"make that bed 60cm wider\" — read its widthCm from describe_garden, add 60, pass the "+
|
||
"sum.",
|
||
a.updateObject),
|
||
llm.DefineTool("delete_object",
|
||
"Delete an object from a garden entirely, along with its plantings. This is the "+
|
||
"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("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 "+
|
||
"remaining\" number find_plant reports.",
|
||
a.listSeedLots),
|
||
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\". 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),
|
||
llm.DefineTool("update_garden",
|
||
"Change a garden the user owns: rename it, resize it (widthCm/heightCm), switch its units "+
|
||
"(metric|imperial), set its grid (gridSizeCm, snapToGrid), or rewrite its notes. Only the "+
|
||
"fields you pass change. Needs the garden's current version from describe_garden. The "+
|
||
"notes are the gardener's standing facts about the place — zone, frost dates, soil, sun, "+
|
||
"how they like things done — and you are given them at the start of every conversation, "+
|
||
"so when the user tells you something worth remembering (\"we're in zone 6a\", \"last "+
|
||
"frost is usually around May 10\"), add it here. notes replaces the WHOLE text: take the "+
|
||
"current notes from describe_garden, add the new line, and pass all of it.",
|
||
a.updateGarden),
|
||
llm.DefineTool("create_garden",
|
||
"Start a NEW garden for the user — a different place (the front yard, the allotment), "+
|
||
"sized in cm and owned by them. Not for a season plan: a plan is a copy_garden of the "+
|
||
"real garden. The new garden opens from the gardens list; this conversation stays with "+
|
||
"the one it started in.",
|
||
a.createGarden),
|
||
llm.DefineTool("update_seed_lot",
|
||
"Correct a seed lot the user recorded — quantity, unit, vendor, link, purchase date, "+
|
||
"packed-for year, germination rate or notes: \"it was three packets, not two\". Needs "+
|
||
"the lot's id and version from list_seed_lots. Only the fields you pass change; the "+
|
||
"plant a lot is for cannot change (record a new lot instead).",
|
||
a.updateSeedLot),
|
||
llm.DefineTool("delete_seed_lot",
|
||
"Delete a seed lot the user recorded, by its id from list_seed_lots. Plantings attributed "+
|
||
"to it stay in the garden, just no longer linked to a purchase. Permanent — seed lots "+
|
||
"are not in the undo history — so delete only the lot the user pointed at. Get the id "+
|
||
"from list_seed_lots in this turn: ids from earlier turns are not in front of you.",
|
||
a.deleteSeedLot),
|
||
llm.DefineTool("delete_plant",
|
||
"Delete a plant from the user's own catalog — a duplicate or a mistake. Refused while "+
|
||
"anything is planted with it (in any garden, past seasons included) or a seed lot "+
|
||
"records it; say so rather than removing those to make it deletable. Built-in plants "+
|
||
"can't be deleted. Permanent — the catalog is not in the undo history.",
|
||
a.deletePlant),
|
||
llm.DefineTool("delete_planting",
|
||
"Delete ONE plop outright — for a plop that was never really planted (a misplacement, a "+
|
||
"duplicate), as opposed to remove_planting, which records that a real plant came out "+
|
||
"and keeps it in the season history. Recorded in the history, so it can be undone. "+
|
||
"Needs the plop's id (describe_garden or list_plantings).",
|
||
a.deletePlanting),
|
||
llm.DefineTool("list_shares",
|
||
"List who a garden the user owns is shared with — each person's email, name, role "+
|
||
"(viewer|editor) and userId — and whether its public read-only link is on.",
|
||
a.listShares),
|
||
llm.DefineTool("share_garden",
|
||
"Share a garden the user owns with another pansy account by its exact email, as a viewer "+
|
||
"or an editor; an existing share is changed to the new role. OUTWARD-FACING: only with "+
|
||
"confirmed=true, which you may pass only after the user has said yes, in this "+
|
||
"conversation, to sharing THIS garden with THAT email at THAT role — if they have not, "+
|
||
"say what you would do and ask. The other person must already have an account.",
|
||
a.shareGarden),
|
||
llm.DefineTool("remove_share",
|
||
"Stop sharing a garden with someone, by their email (see list_shares). OUTWARD-FACING: "+
|
||
"only with confirmed=true, after the user has said yes to removing that person from "+
|
||
"this garden; otherwise say what you would do and ask.",
|
||
a.removeShare),
|
||
llm.DefineTool("public_link",
|
||
"The garden's public read-only link, which anyone holding it can open without an account. "+
|
||
"action get reports whether it is on and the link. enable turns it on (or reports the "+
|
||
"existing link), rotate issues a fresh link so the old one stops working, disable turns "+
|
||
"it off. OUTWARD-FACING: enable, rotate and disable need confirmed=true, which you may "+
|
||
"pass only after the user has said yes to that exact action in this conversation.",
|
||
a.publicLink),
|
||
), a
|
||
}
|
||
|
||
// 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
|
||
|
||
mu sync.Mutex
|
||
// reverts is every change set undo_change produced this turn. A revert is
|
||
// its own change set (it points back at the one it undid, and the target is
|
||
// marked undone), so it never joins the turn's scope — which leaves a turn
|
||
// that only undid something with no change of its own. Run reports the last
|
||
// revert as that turn's handle, so "Undo this" under the reply can redo it.
|
||
reverts []int64
|
||
}
|
||
|
||
// lastRevert is the newest change set undo_change produced this turn, if any.
|
||
func (a *adapter) lastRevert() *int64 {
|
||
a.mu.Lock()
|
||
defer a.mu.Unlock()
|
||
if len(a.reverts) == 0 {
|
||
return nil
|
||
}
|
||
id := a.reverts[len(a.reverts)-1]
|
||
return &id
|
||
}
|
||
|
||
// 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. A date the model typed
|
||
// is checked here, so every dated tool refuses a prose date the same way.
|
||
func (a *adapter) day(explicit string) (*string, error) {
|
||
if strings.TrimSpace(explicit) != "" {
|
||
d, err := parseDay(explicit)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return &d, nil
|
||
}
|
||
if a.today != "" {
|
||
d := a.today
|
||
return &d, nil
|
||
}
|
||
return nil, nil
|
||
}
|
||
|
||
func (a *adapter) listGardens(ctx context.Context, _ struct{}) (any, error) {
|
||
return a.svc.ListGardens(ctx, a.actor)
|
||
}
|
||
|
||
func (a *adapter) describeGarden(ctx context.Context, args struct {
|
||
GardenID int64 `json:"gardenId" description:"id of the garden to describe"`
|
||
Year *int `json:"year" description:"optional: describe that year's season instead of what is growing now — every plop in the ground at any point in the year, pulled ones included"`
|
||
}) (any, error) {
|
||
return a.svc.DescribeGarden(ctx, a.actor, args.GardenID, args.Year)
|
||
}
|
||
|
||
func (a *adapter) listYears(ctx context.Context, args struct {
|
||
GardenID int64 `json:"gardenId" description:"garden whose planting years to list"`
|
||
}) (any, error) {
|
||
years, err := a.svc.GardenYears(ctx, a.actor, args.GardenID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// The service pads the list with ITS current year (UTC); the gardener's may
|
||
// differ around New Year. Theirs is the one "this year" means to them — and
|
||
// it is not always the newest, so the list is re-sorted rather than
|
||
// prepended to.
|
||
if y, ok := yearOf(a.today); ok {
|
||
present := false
|
||
for _, have := range years {
|
||
if have == y {
|
||
present = true
|
||
break
|
||
}
|
||
}
|
||
if !present {
|
||
years = append(years, y)
|
||
sort.Sort(sort.Reverse(sort.IntSlice(years)))
|
||
}
|
||
}
|
||
return map[string]any{"years": years}, nil
|
||
}
|
||
|
||
// yearOf is the year of a YYYY-MM-DD date, or false for anything else.
|
||
func yearOf(date string) (int, bool) {
|
||
if len(date) < 4 {
|
||
return 0, false
|
||
}
|
||
y, err := strconv.Atoi(date[:4])
|
||
if err != nil {
|
||
return 0, false
|
||
}
|
||
return y, true
|
||
}
|
||
|
||
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"`
|
||
Name string `json:"name" description:"optional label for the object"`
|
||
Shape string `json:"shape" description:"rect or circle (default rect)"`
|
||
XCM float64 `json:"xCm" description:"center x in garden cm"`
|
||
YCM float64 `json:"yCm" description:"center y in garden cm"`
|
||
WidthCM float64 `json:"widthCm" description:"width in cm (a circle's diameter)"`
|
||
HeightCM float64 `json:"heightCm" description:"height in cm"`
|
||
}) (any, error) {
|
||
return a.svc.CreateObject(ctx, a.actor, args.GardenID, service.ObjectInput{
|
||
Kind: args.Kind, Name: args.Name, Shape: args.Shape,
|
||
XCM: args.XCM, YCM: args.YCM, WidthCM: args.WidthCM, HeightCM: args.HeightCM,
|
||
})
|
||
}
|
||
|
||
func (a *adapter) moveObject(ctx context.Context, args struct {
|
||
ObjectID int64 `json:"objectId" description:"object to move"`
|
||
XCM float64 `json:"xCm" description:"new center x in garden cm"`
|
||
YCM float64 `json:"yCm" description:"new center y in garden cm"`
|
||
Version int64 `json:"version" description:"the object's current version (from describe_garden)"`
|
||
}) (any, error) {
|
||
return a.svc.UpdateObject(ctx, a.actor, args.ObjectID,
|
||
service.ObjectPatch{XCM: &args.XCM, YCM: &args.YCM}, args.Version)
|
||
}
|
||
|
||
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:"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) {
|
||
on, err := a.day(args.PlantedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
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: on, 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; 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) {
|
||
on, err := a.day(args.PlantedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
spec := service.FillSpec{
|
||
RegionName: args.Region, PlantID: args.PlantID, SpacingOverride: args.SpacingOverride,
|
||
Layout: service.FillLayout(args.Mode), PlantedAt: on, 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) {
|
||
pl, err := a.svc.MovePlanting(ctx, a.actor, args.PlantingID,
|
||
service.MoveInput{ToObjectID: args.ToObjectID, XCM: args.XCM, YCM: args.YCM}, args.Version)
|
||
return pl, whenMissing(err, "planting", args.PlantingID, "list_plantings")
|
||
}
|
||
|
||
func (a *adapter) findPlant(ctx context.Context, args struct {
|
||
Query string `json:"query" description:"plant name or category to search for, e.g. \"cucumber\" or \"herb\"; empty lists the catalog"`
|
||
}) (any, error) {
|
||
return a.svc.FindPlants(ctx, a.actor, args.Query)
|
||
}
|
||
|
||
func (a *adapter) createPlant(ctx context.Context, args struct {
|
||
Name string `json:"name" description:"the variety's name, e.g. \"German Red Garlic\""`
|
||
Category string `json:"category" description:"vegetable | herb | flower | fruit | tree_shrub | cover"`
|
||
SpacingCM float64 `json:"spacingCm" description:"mature in-row spacing in cm; this is what fill_region packs to"`
|
||
Color string `json:"color" description:"hex color for the plant on the canvas, e.g. #8a5a8a"`
|
||
Icon string `json:"icon" description:"a single emoji to draw it with, e.g. 🧄"`
|
||
DaysToMaturity *int `json:"daysToMaturity" description:"optional days from planting to harvest"`
|
||
SourceURL string `json:"sourceUrl" description:"optional http(s) link to where the seed came from"`
|
||
Vendor string `json:"vendor" description:"optional vendor name, e.g. \"Johnny's Selected Seeds\""`
|
||
}) (any, error) {
|
||
return a.svc.CreatePlant(ctx, a.actor, service.PlantInput{
|
||
Name: args.Name, Category: args.Category, SpacingCM: args.SpacingCM,
|
||
Color: args.Color, Icon: args.Icon, DaysToMaturity: args.DaysToMaturity,
|
||
SourceURL: args.SourceURL, Vendor: args.Vendor,
|
||
})
|
||
}
|
||
|
||
func (a *adapter) 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) {
|
||
on, err := a.day(args.ObservedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return a.svc.CreateJournalEntry(ctx, a.actor, args.GardenID, service.JournalInput{
|
||
ObjectID: args.ObjectID, Body: args.Body, ObservedAt: on,
|
||
})
|
||
}
|
||
|
||
func (a *adapter) clearObject(ctx context.Context, args struct {
|
||
ObjectID int64 `json:"objectId" description:"object to remove all plants from"`
|
||
RemovedAt string `json:"removedAt" description:"optional date the plants came out, YYYY-MM-DD; defaults to today"`
|
||
}) (any, error) {
|
||
on, err := a.day(args.RemovedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID, service.ClearOptions{RemovedAt: on})
|
||
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)"`
|
||
RemovedAt string `json:"removedAt" description:"optional date they came out, YYYY-MM-DD; defaults to today"`
|
||
}) (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)
|
||
}
|
||
on, err := a.day(args.RemovedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
n, err := a.svc.ClearPlantings(ctx, a.actor, args.ObjectID,
|
||
service.ClearOptions{PlantID: &args.PlantID, RemovedAt: on})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]int{"removed": n}, nil
|
||
}
|
||
|
||
func (a *adapter) updatePlanting(ctx context.Context, args struct {
|
||
PlantingID int64 `json:"plantingId" description:"plop to correct (its id from describe_garden or list_plantings)"`
|
||
Version int64 `json:"version" description:"the plop's current version"`
|
||
PlantedAt *string `json:"plantedAt" description:"optional corrected planting date, YYYY-MM-DD"`
|
||
Count *int `json:"count" description:"optional explicit plant count for the plop"`
|
||
ClearCount bool `json:"clearCount" description:"optional: drop an explicit count and derive it from area and spacing again"`
|
||
Label *string `json:"label" description:"optional label for the plop; empty clears it"`
|
||
RadiusCM *float64 `json:"radiusCm" description:"optional new radius in cm"`
|
||
SeedLotID *int64 `json:"seedLotId" description:"optional seed lot (from list_seed_lots) to attribute it to"`
|
||
ClearSeedLot bool `json:"clearSeedLot" description:"optional: detach it from its seed lot"`
|
||
}) (any, error) {
|
||
patch := service.PlantingPatch{RadiusCM: args.RadiusCM}
|
||
if args.PlantedAt != nil {
|
||
on, err := parseDay(*args.PlantedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
patch.SetPlantedAt, patch.PlantedAt = true, &on
|
||
}
|
||
switch {
|
||
case args.ClearCount && args.Count != nil:
|
||
return nil, fmt.Errorf("%w: give a count or clearCount, not both", domain.ErrInvalidInput)
|
||
case args.ClearCount:
|
||
patch.SetCount = true
|
||
case args.Count != nil:
|
||
patch.SetCount, patch.Count = true, args.Count
|
||
}
|
||
// SetLabel with a nil Label clears it: an empty string from the model means
|
||
// "no label", not a label that happens to be empty.
|
||
if args.Label != nil {
|
||
patch.SetLabel = true
|
||
if l := strings.TrimSpace(*args.Label); l != "" {
|
||
patch.Label = &l
|
||
}
|
||
}
|
||
switch {
|
||
case args.ClearSeedLot && args.SeedLotID != nil:
|
||
return nil, fmt.Errorf("%w: give a seedLotId or clearSeedLot, not both", domain.ErrInvalidInput)
|
||
case args.ClearSeedLot:
|
||
patch.SetSeedLotID = true
|
||
case args.SeedLotID != nil:
|
||
patch.SetSeedLotID, patch.SeedLotID = true, args.SeedLotID
|
||
}
|
||
pl, err := a.svc.UpdatePlanting(ctx, a.actor, args.PlantingID, patch, args.Version)
|
||
return pl, whenMissing(err, "planting", args.PlantingID, "list_plantings")
|
||
}
|
||
|
||
// whenMissing turns the store's bare "not found" into a message that says what
|
||
// was not found and where the ids come from, so the model re-reads instead of
|
||
// guessing at another id — or, as the live one did, reporting success over
|
||
// the error.
|
||
func whenMissing(err error, what string, id int64, from string) error {
|
||
if errors.Is(err, domain.ErrNotFound) {
|
||
return fmt.Errorf("%w: no %s with id %d that you can act on — %s lists the ids; nothing was changed", domain.ErrNotFound, what, id, from)
|
||
}
|
||
return err
|
||
}
|
||
|
||
// parseDay checks a date the model typed, so a malformed one fails with a
|
||
// message about the date rather than as a bare "invalid input" from the store.
|
||
func parseDay(s string) (string, error) {
|
||
s = strings.TrimSpace(s)
|
||
if _, err := time.Parse(dateLayout, s); err != nil {
|
||
return "", fmt.Errorf("%w: %q is not a YYYY-MM-DD date", domain.ErrInvalidInput, s)
|
||
}
|
||
return s, 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"`
|
||
From string `json:"from" description:"optional earliest observed date, YYYY-MM-DD"`
|
||
To string `json:"to" description:"optional latest observed date, YYYY-MM-DD"`
|
||
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) {
|
||
q := service.JournalQuery{ObjectID: args.ObjectID, Limit: 50, Offset: args.Offset}
|
||
if args.From != "" {
|
||
q.From = &args.From
|
||
}
|
||
if args.To != "" {
|
||
q.To = &args.To
|
||
}
|
||
entries, hasMore, err := a.svc.ListJournal(ctx, a.actor, args.GardenID, q)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
// hasMore is actionable now: re-call with offset += len(entries) to page.
|
||
return map[string]any{"entries": entries, "hasMore": hasMore}, nil
|
||
}
|
||
|
||
func (a *adapter) updateJournalEntry(ctx context.Context, args struct {
|
||
EntryID int64 `json:"entryId" description:"journal entry to correct (its id from read_journal)"`
|
||
Version int64 `json:"version" description:"the entry's current version (from read_journal)"`
|
||
Body *string `json:"body" description:"optional corrected text"`
|
||
ObservedAt *string `json:"observedAt" description:"optional corrected date it happened, YYYY-MM-DD"`
|
||
}) (any, error) {
|
||
if args.Body == nil && args.ObservedAt == nil {
|
||
return nil, fmt.Errorf("%w: say what to change — the body, the date, or both", domain.ErrInvalidInput)
|
||
}
|
||
observed := args.ObservedAt
|
||
if observed != nil {
|
||
on, err := parseDay(*observed)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
observed = &on
|
||
}
|
||
e, err := a.svc.UpdateJournalEntry(ctx, a.actor, args.EntryID,
|
||
service.JournalPatch{Body: args.Body, ObservedAt: observed}, args.Version)
|
||
return e, whenMissing(err, "journal entry", args.EntryID, "read_journal")
|
||
}
|
||
|
||
func (a *adapter) deleteJournalEntry(ctx context.Context, args struct {
|
||
EntryID int64 `json:"entryId" description:"journal entry to delete (its id from read_journal)"`
|
||
}) (any, error) {
|
||
if err := a.svc.DeleteJournalEntry(ctx, a.actor, args.EntryID); err != nil {
|
||
return nil, whenMissing(err, "journal entry", args.EntryID, "read_journal")
|
||
}
|
||
return map[string]any{"deleted": args.EntryID}, nil
|
||
}
|
||
|
||
// undoResult is what undo_change reports: the change it reverted, the new
|
||
// change set that did so (undoable in turn), and what it had to leave alone.
|
||
type undoResult struct {
|
||
UndoneID int64 `json:"undoneId"`
|
||
// ChangeSet is the revert itself — the history entry that can be undone
|
||
// to redo — and Summary its row in the history ("Undid: …").
|
||
ChangeSet *int64 `json:"changeSetId,omitempty"`
|
||
Summary string `json:"summary,omitempty"`
|
||
Changes string `json:"changes"`
|
||
Conflicts []domain.RevertConflict `json:"conflicts"`
|
||
Note string `json:"note,omitempty"`
|
||
}
|
||
|
||
func (a *adapter) undoChange(ctx context.Context, args struct {
|
||
ChangeSetID int64 `json:"changeSetId" description:"the change to undo — its id from read_history"`
|
||
}) (any, error) {
|
||
if args.ChangeSetID == 0 {
|
||
return nil, fmt.Errorf("%w: changeSetId is required — find the change in read_history first", domain.ErrInvalidInput)
|
||
}
|
||
cs, conflicts, err := a.svc.RevertChangeSet(ctx, a.actor, args.ChangeSetID, domain.SourceAgent)
|
||
if err != nil {
|
||
return nil, whenMissing(err, "change", args.ChangeSetID, "read_history")
|
||
}
|
||
res := undoResult{UndoneID: args.ChangeSetID, Conflicts: conflicts}
|
||
if cs == nil {
|
||
// Nothing applied: every revision was a conflict, or the set was empty.
|
||
res.Changes = "nothing"
|
||
res.Note = "Nothing was reverted — everything that change touched has been edited since, or there was nothing left to undo."
|
||
return res, nil
|
||
}
|
||
res.ChangeSet = &cs.ID
|
||
res.Summary = cs.Summary
|
||
res.Changes = describeCounts(cs.Counts)
|
||
if len(conflicts) > 0 {
|
||
res.Note = "Part of the change was left alone because it had been edited since; see conflicts."
|
||
}
|
||
a.mu.Lock()
|
||
a.reverts = append(a.reverts, cs.ID)
|
||
a.mu.Unlock()
|
||
return res, 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 {
|
||
ObjectID int64 `json:"objectId" description:"object to change"`
|
||
Version int64 `json:"version" description:"the object's current version (from describe_garden)"`
|
||
Name *string `json:"name" description:"optional new label"`
|
||
WidthCM *float64 `json:"widthCm" description:"optional new width in cm (a circle's diameter)"`
|
||
HeightCM *float64 `json:"heightCm" description:"optional new height in cm"`
|
||
RotationDeg *float64 `json:"rotationDeg" description:"optional new rotation in degrees"`
|
||
Plantable *bool `json:"plantable" description:"optional: whether the object can hold plants"`
|
||
}) (any, error) {
|
||
return a.svc.UpdateObject(ctx, a.actor, args.ObjectID, service.ObjectPatch{
|
||
Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM,
|
||
RotationDeg: args.RotationDeg, Plantable: args.Plantable,
|
||
}, args.Version)
|
||
}
|
||
|
||
func (a *adapter) deleteObject(ctx context.Context, args struct {
|
||
ObjectID int64 `json:"objectId" description:"object to delete (with its plantings)"`
|
||
}) (any, error) {
|
||
if err := a.svc.DeleteObject(ctx, a.actor, args.ObjectID); err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]any{"deleted": args.ObjectID}, nil
|
||
}
|
||
|
||
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)"`
|
||
RemovedAt string `json:"removedAt" description:"optional date it came out, YYYY-MM-DD; defaults to today"`
|
||
}) (any, error) {
|
||
// Soft-remove via the service, dated the day the gardener said, else their
|
||
// local day like every other tool here (the service clock's UTC day when
|
||
// that isn't known).
|
||
on, err := a.day(args.RemovedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
pl, err := a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version, on)
|
||
return pl, whenMissing(err, "planting", args.PlantingID, "list_plantings")
|
||
}
|
||
|
||
func (a *adapter) listSeedLots(ctx context.Context, args struct {
|
||
PlantID *int64 `json:"plantId" description:"optional: only lots for this plant"`
|
||
}) (any, error) {
|
||
return a.svc.ListSeedLots(ctx, a.actor, args.PlantID)
|
||
}
|
||
|
||
func (a *adapter) recordSeedLot(ctx context.Context, args struct {
|
||
PlantID int64 `json:"plantId" description:"plant the seed is for (from find_plant); must be the user's own or a built-in"`
|
||
Quantity float64 `json:"quantity" description:"how much was bought, in the given unit"`
|
||
Unit string `json:"unit" description:"what quantity counts, e.g. packets | seeds | grams"`
|
||
Vendor string `json:"vendor" description:"optional vendor name"`
|
||
SourceURL string `json:"sourceUrl" description:"optional http(s) link to where it was bought"`
|
||
PackedForYear *int `json:"packedForYear" description:"optional 'packed for' year from the packet"`
|
||
Notes string `json:"notes" description:"optional free-text notes"`
|
||
}) (any, error) {
|
||
return a.svc.CreateSeedLot(ctx, a.actor, service.SeedLotInput{
|
||
PlantID: args.PlantID, Quantity: args.Quantity, Unit: args.Unit,
|
||
Vendor: args.Vendor, SourceURL: args.SourceURL,
|
||
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)
|
||
}
|
||
|
||
func (a *adapter) updateGarden(ctx context.Context, args struct {
|
||
GardenID int64 `json:"gardenId" description:"garden to change (the user must own it)"`
|
||
Version int64 `json:"version" description:"the garden's current version (from describe_garden)"`
|
||
Name *string `json:"name" description:"optional new name"`
|
||
WidthCM *float64 `json:"widthCm" description:"optional new width in cm"`
|
||
HeightCM *float64 `json:"heightCm" description:"optional new height in cm"`
|
||
UnitPref *string `json:"units" description:"optional: metric | imperial — how the gardener wants lengths shown"`
|
||
Notes *string `json:"notes" description:"optional replacement for the WHOLE notes text (merge the current notes in yourself); empty clears them"`
|
||
GridSizeCM *float64 `json:"gridSizeCm" description:"optional grid spacing for the editor, in cm"`
|
||
SnapToGrid *bool `json:"snapToGrid" description:"optional: whether objects snap to that grid"`
|
||
}) (any, error) {
|
||
if args.Name == nil && args.WidthCM == nil && args.HeightCM == nil && args.UnitPref == nil &&
|
||
args.Notes == nil && args.GridSizeCM == nil && args.SnapToGrid == nil {
|
||
return nil, fmt.Errorf("%w: say what to change about the garden", domain.ErrInvalidInput)
|
||
}
|
||
// UpdateGarden takes the whole row, so start from the current one and
|
||
// overlay what the model passed — the same merge the editor's settings
|
||
// dialog does, and the only way a one-field change leaves the rest alone.
|
||
g, err := a.svc.GetGarden(ctx, a.actor, args.GardenID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
in := service.GardenInput{
|
||
Name: g.Name, WidthCM: g.WidthCM, HeightCM: g.HeightCM, UnitPref: g.UnitPref,
|
||
Notes: g.Notes, GridSizeCM: g.GridSizeCM, SnapToGrid: g.SnapToGrid,
|
||
}
|
||
if args.Name != nil {
|
||
in.Name = *args.Name
|
||
}
|
||
if args.WidthCM != nil {
|
||
in.WidthCM = *args.WidthCM
|
||
}
|
||
if args.HeightCM != nil {
|
||
in.HeightCM = *args.HeightCM
|
||
}
|
||
if args.UnitPref != nil {
|
||
in.UnitPref = strings.ToLower(strings.TrimSpace(*args.UnitPref))
|
||
}
|
||
if args.Notes != nil {
|
||
in.Notes = *args.Notes
|
||
}
|
||
if args.GridSizeCM != nil {
|
||
in.GridSizeCM = *args.GridSizeCM
|
||
}
|
||
if args.SnapToGrid != nil {
|
||
in.SnapToGrid = *args.SnapToGrid
|
||
}
|
||
return a.svc.UpdateGarden(ctx, a.actor, args.GardenID, in, args.Version)
|
||
}
|
||
|
||
func (a *adapter) createGarden(ctx context.Context, args struct {
|
||
Name string `json:"name" description:"the garden's name"`
|
||
WidthCM float64 `json:"widthCm" description:"optional width in cm (default 1000)"`
|
||
HeightCM float64 `json:"heightCm" description:"optional height in cm (default 1000)"`
|
||
UnitPref string `json:"units" description:"optional: metric (default) | imperial — how the gardener wants lengths shown"`
|
||
Notes string `json:"notes" description:"optional standing notes about the place — zone, frost dates, soil"`
|
||
GridSizeCM float64 `json:"gridSizeCm" description:"optional editor grid spacing in cm (default 100)"`
|
||
SnapToGrid bool `json:"snapToGrid" description:"optional: snap objects to that grid"`
|
||
}) (any, error) {
|
||
return a.svc.CreateGarden(ctx, a.actor, service.GardenInput{
|
||
Name: args.Name, WidthCM: args.WidthCM, HeightCM: args.HeightCM,
|
||
UnitPref: strings.ToLower(strings.TrimSpace(args.UnitPref)), Notes: args.Notes,
|
||
GridSizeCM: args.GridSizeCM, SnapToGrid: args.SnapToGrid,
|
||
})
|
||
}
|
||
|
||
func (a *adapter) updateSeedLot(ctx context.Context, args struct {
|
||
LotID int64 `json:"lotId" description:"seed lot to correct (its id from list_seed_lots)"`
|
||
Version int64 `json:"version" description:"the lot's current version (from list_seed_lots)"`
|
||
Quantity *float64 `json:"quantity" description:"optional corrected quantity, in the lot's unit"`
|
||
Unit *string `json:"unit" description:"optional unit: seeds | grams | ounces | packets | bulbs | plants"`
|
||
Vendor *string `json:"vendor" description:"optional vendor name"`
|
||
SourceURL *string `json:"sourceUrl" description:"optional http(s) link to where it was bought; empty clears it"`
|
||
PurchasedAt *string `json:"purchasedAt" description:"optional purchase date, YYYY-MM-DD"`
|
||
PackedForYear *int `json:"packedForYear" description:"optional 'packed for' year from the packet"`
|
||
GerminationPct *float64 `json:"germinationPct" description:"optional germination rate, 0–100"`
|
||
Notes *string `json:"notes" description:"optional replacement notes"`
|
||
}) (any, error) {
|
||
if args.Quantity == nil && args.Unit == nil && args.Vendor == nil && args.SourceURL == nil &&
|
||
args.PurchasedAt == nil && args.PackedForYear == nil && args.GerminationPct == nil && args.Notes == nil {
|
||
return nil, fmt.Errorf("%w: say what to change about the lot", domain.ErrInvalidInput)
|
||
}
|
||
purchased := args.PurchasedAt
|
||
if purchased != nil {
|
||
on, err := parseDay(*purchased)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
purchased = &on
|
||
}
|
||
patch := service.SeedLotPatch{
|
||
Vendor: args.Vendor, SourceURL: args.SourceURL, Quantity: args.Quantity, Unit: args.Unit, Notes: args.Notes,
|
||
SetPurchasedAt: purchased != nil, PurchasedAt: purchased,
|
||
SetPackedForYear: args.PackedForYear != nil, PackedForYear: args.PackedForYear,
|
||
SetGerminationPct: args.GerminationPct != nil, GerminationPct: args.GerminationPct,
|
||
}
|
||
l, err := a.svc.UpdateSeedLot(ctx, a.actor, args.LotID, patch, args.Version)
|
||
return l, whenMissing(err, "seed lot", args.LotID, "list_seed_lots")
|
||
}
|
||
|
||
func (a *adapter) deleteSeedLot(ctx context.Context, args struct {
|
||
LotID int64 `json:"lotId" description:"seed lot to delete (its id from list_seed_lots)"`
|
||
}) (any, error) {
|
||
if err := a.svc.DeleteSeedLot(ctx, a.actor, args.LotID); err != nil {
|
||
return nil, whenMissing(err, "seed lot", args.LotID, "list_seed_lots")
|
||
}
|
||
return map[string]any{"deleted": args.LotID}, nil
|
||
}
|
||
|
||
func (a *adapter) deletePlant(ctx context.Context, args struct {
|
||
PlantID int64 `json:"plantId" description:"plant to delete from the user's catalog (from find_plant)"`
|
||
}) (any, error) {
|
||
err := a.svc.DeletePlant(ctx, a.actor, args.PlantID)
|
||
if errors.Is(err, domain.ErrPlantInUse) {
|
||
// The sentinel's text is for a log line; the model needs to know what
|
||
// to tell the person, and what not to do about it.
|
||
return nil, fmt.Errorf("%w: the plant is still used — by plantings (past seasons count) or a seed lot — so it stays; tell the user rather than removing those", domain.ErrPlantInUse)
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]any{"deleted": args.PlantID}, nil
|
||
}
|
||
|
||
func (a *adapter) deletePlanting(ctx context.Context, args struct {
|
||
PlantingID int64 `json:"plantingId" description:"plop to delete outright (its id from describe_garden or list_plantings)"`
|
||
}) (any, error) {
|
||
if err := a.svc.DeletePlanting(ctx, a.actor, args.PlantingID); err != nil {
|
||
return nil, whenMissing(err, "planting", args.PlantingID, "list_plantings")
|
||
}
|
||
return map[string]any{"deleted": args.PlantingID}, nil
|
||
}
|
||
|
||
// errUnconfirmed is the refusal every outward-facing tool gives without
|
||
// confirmed=true: the action is named so the model can ask about it precisely.
|
||
func errUnconfirmed(action string) error {
|
||
return fmt.Errorf("%w: not done — %s is outward-facing, so say exactly what you would do and ask the user first; pass confirmed=true only once they have said yes", domain.ErrInvalidInput, action)
|
||
}
|
||
|
||
// shareView is one share as the tools report it, with the link state alongside
|
||
// in list_shares.
|
||
type shareView struct {
|
||
UserID int64 `json:"userId"`
|
||
Email string `json:"email"`
|
||
DisplayName string `json:"displayName,omitempty"`
|
||
Role string `json:"role"`
|
||
}
|
||
|
||
func (a *adapter) listShares(ctx context.Context, args struct {
|
||
GardenID int64 `json:"gardenId" description:"garden whose shares to list (the user must own it)"`
|
||
}) (any, error) {
|
||
shares, err := a.svc.ListShares(ctx, a.actor, args.GardenID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
out := make([]shareView, 0, len(shares))
|
||
for i := range shares {
|
||
out = append(out, toShareView(&shares[i]))
|
||
}
|
||
link, err := a.svc.GetPublicShareLink(ctx, a.actor, args.GardenID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]any{"shares": out, "publicLink": a.linkOf(link)}, nil
|
||
}
|
||
|
||
// linkView is a public link as the tools report it: on/off, and the address
|
||
// when on. The token itself is only ever shown as part of that address.
|
||
type linkView struct {
|
||
Enabled bool `json:"enabled"`
|
||
URL string `json:"url,omitempty"`
|
||
}
|
||
|
||
func (a *adapter) linkOf(link *service.PublicShareLink) linkView {
|
||
v := linkView{Enabled: link.Enabled}
|
||
if link.Enabled {
|
||
v.URL = a.svc.PublicShareURL(link.Token)
|
||
}
|
||
return v
|
||
}
|
||
|
||
func toShareView(sh *domain.ShareWithUser) shareView {
|
||
return shareView{UserID: sh.UserID, Email: sh.Email, DisplayName: sh.DisplayName, Role: sh.Role}
|
||
}
|
||
|
||
func (a *adapter) shareGarden(ctx context.Context, args struct {
|
||
GardenID int64 `json:"gardenId" description:"garden to share (the user must own it)"`
|
||
Email string `json:"email" description:"the other person's pansy account email, exactly as the user gave it"`
|
||
Role string `json:"role" enum:"viewer,editor" description:"viewer can look; editor can change the garden"`
|
||
Confirmed bool `json:"confirmed" description:"true only after the user has said yes to this share in this conversation"`
|
||
}) (any, error) {
|
||
email := strings.TrimSpace(args.Email)
|
||
role := strings.ToLower(strings.TrimSpace(args.Role))
|
||
if email == "" || role == "" {
|
||
return nil, fmt.Errorf("%w: an email and a role (viewer or editor) are required", domain.ErrInvalidInput)
|
||
}
|
||
if !args.Confirmed {
|
||
return nil, errUnconfirmed(fmt.Sprintf("sharing this garden with %s as %s", email, role))
|
||
}
|
||
_, err := a.svc.AddShare(ctx, a.actor, args.GardenID, email, role)
|
||
switch {
|
||
case errors.Is(err, domain.ErrShareUserNotFound):
|
||
return nil, fmt.Errorf("%w — they need to sign in to pansy once before a garden can be shared with them", err)
|
||
case errors.Is(err, domain.ErrShareExists):
|
||
// Already shared: "share it with them as an editor" means change the role.
|
||
existing, ferr := a.findShare(ctx, args.GardenID, email)
|
||
if ferr != nil {
|
||
return nil, ferr
|
||
}
|
||
if existing.Role == role {
|
||
return map[string]any{"share": toShareView(existing), "note": "already shared with them at that role; nothing changed"}, nil
|
||
}
|
||
if _, err := a.svc.UpdateShareRole(ctx, a.actor, args.GardenID, existing.UserID, role); err != nil {
|
||
return nil, err
|
||
}
|
||
existing.Role = role
|
||
return map[string]any{"share": toShareView(existing), "note": "they already had access; their role is now " + role}, nil
|
||
case err != nil:
|
||
return nil, err
|
||
}
|
||
// Read the share back so every path reports the same shape, name included.
|
||
created, err := a.findShare(ctx, args.GardenID, email)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]any{"share": toShareView(created)}, nil
|
||
}
|
||
|
||
// findShare resolves an email to the garden's share for it, case-insensitively
|
||
// — the model has the person's address from the conversation, not their id.
|
||
func (a *adapter) findShare(ctx context.Context, gardenID int64, email string) (*domain.ShareWithUser, error) {
|
||
shares, err := a.svc.ListShares(ctx, a.actor, gardenID)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
for i := range shares {
|
||
if strings.EqualFold(strings.TrimSpace(shares[i].Email), strings.TrimSpace(email)) {
|
||
return &shares[i], nil
|
||
}
|
||
}
|
||
return nil, fmt.Errorf("%w: this garden is not shared with %s (list_shares shows who it is shared with)", domain.ErrInvalidInput, email)
|
||
}
|
||
|
||
func (a *adapter) removeShare(ctx context.Context, args struct {
|
||
GardenID int64 `json:"gardenId" description:"garden to stop sharing (the user must own it)"`
|
||
Email string `json:"email" description:"the person's email, as list_shares reports it"`
|
||
Confirmed bool `json:"confirmed" description:"true only after the user has said yes to removing this person in this conversation"`
|
||
}) (any, error) {
|
||
email := strings.TrimSpace(args.Email)
|
||
if email == "" {
|
||
return nil, fmt.Errorf("%w: say whose access to remove, by email", domain.ErrInvalidInput)
|
||
}
|
||
if !args.Confirmed {
|
||
return nil, errUnconfirmed("removing " + email + " from this garden")
|
||
}
|
||
share, err := a.findShare(ctx, args.GardenID, email)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if err := a.svc.RemoveShare(ctx, a.actor, args.GardenID, share.UserID); err != nil {
|
||
return nil, err
|
||
}
|
||
return map[string]any{"removed": toShareView(share)}, nil
|
||
}
|
||
|
||
func (a *adapter) publicLink(ctx context.Context, args struct {
|
||
GardenID int64 `json:"gardenId" description:"garden whose public link this is about (the user must own it)"`
|
||
Action string `json:"action" enum:"get,enable,rotate,disable" description:"get reports it; enable turns it on; rotate replaces it so the old link stops working; disable turns it off"`
|
||
Confirmed bool `json:"confirmed" description:"true only after the user has said yes to enabling, rotating or disabling the link in this conversation; not needed for get"`
|
||
}) (any, error) {
|
||
action := strings.ToLower(strings.TrimSpace(args.Action))
|
||
if action == "" {
|
||
action = "get"
|
||
}
|
||
// What each outward-facing action does, in the words the model should ask
|
||
// with. Checked before the confirmation gate, so an unknown action is told
|
||
// it is unknown rather than asked to confirm nothing in particular.
|
||
asks := map[string]string{
|
||
"get": "",
|
||
"enable": "turning the public link on, so anyone with the link can see this garden",
|
||
"rotate": "rotating the public link, so the old link stops working",
|
||
"disable": "turning the public link off, so the link stops working",
|
||
}
|
||
ask, known := asks[action]
|
||
if !known {
|
||
return nil, fmt.Errorf("%w: action must be get, enable, rotate or disable", domain.ErrInvalidInput)
|
||
}
|
||
if ask != "" && !args.Confirmed {
|
||
return nil, errUnconfirmed(ask)
|
||
}
|
||
var (
|
||
link *service.PublicShareLink
|
||
err error
|
||
)
|
||
switch action {
|
||
case "get":
|
||
link, err = a.svc.GetPublicShareLink(ctx, a.actor, args.GardenID)
|
||
case "enable":
|
||
link, err = a.svc.EnablePublicShareLink(ctx, a.actor, args.GardenID, false)
|
||
case "rotate":
|
||
link, err = a.svc.EnablePublicShareLink(ctx, a.actor, args.GardenID, true)
|
||
default: // disable
|
||
if err = a.svc.DisablePublicShareLink(ctx, a.actor, args.GardenID); err == nil {
|
||
link = &service.PublicShareLink{Enabled: false}
|
||
}
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return a.linkOf(link), nil
|
||
}
|