Build image / build-and-push (push) Successful in 13s
- public_link checks the action before the confirmation gate, so an unknown action is told so instead of being asked to confirm nothing in particular (the 4/4 finding). - linkView is a struct like shareView; toShareView builds the five share results, and a fresh share is read back so it carries the person's name like every other path. - PublicShareURL trims a trailing slash off a hand-built base URL. Co-Authored-By: Claude Fable 5 <[email protected]>
1151 lines
56 KiB
Go
1151 lines
56 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.",
|
||
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.",
|
||
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) {
|
||
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 {
|
||
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
|
||
}
|
||
return a.svc.UpdatePlanting(ctx, a.actor, args.PlantingID, patch, args.Version)
|
||
}
|
||
|
||
// 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
|
||
}
|
||
return a.svc.UpdateJournalEntry(ctx, a.actor, args.EntryID,
|
||
service.JournalPatch{Body: args.Body, ObservedAt: observed}, args.Version)
|
||
}
|
||
|
||
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, err
|
||
}
|
||
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, err
|
||
}
|
||
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
|
||
}
|
||
return a.svc.RemovePlanting(ctx, a.actor, args.PlantingID, args.Version, on)
|
||
}
|
||
|
||
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,
|
||
}
|
||
return a.svc.UpdateSeedLot(ctx, a.actor, args.LotID, patch, args.Version)
|
||
}
|
||
|
||
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, err
|
||
}
|
||
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, err
|
||
}
|
||
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
|
||
}
|