Agent: what a day of live use asked for
Twenty-one prompts against the live assistant found one fabricated success,
a model that believed it was 2025, and a describe_garden that was ~450 plop
entries per turn. This is the set of fixes, each traceable to a finding:
- The gardener's LOCAL day travels with the turn (`today` on POST /agent/chat,
sent by the UI like plantedAt) into the system prompt and every dated tool
default. Left to guess, the model dated journal entries a year back; left to
the server, a 9 pm fill landed on UTC's tomorrow.
- describe_garden groups plops by plant — count, where, planted date, days to
maturity — and lists ids only for groups of ≤ 8; list_plantings spells a big
group out on demand and remove_plantings acts on one plant in a bed ("take
the beets out, leave the garlic"), which used to mean 116 single removals.
- New tools: move_planting (keeps the planting date; across beds via the new
MovePlanting, which is why the store's UPDATE now writes object_id),
update_plant, read_history, copy_garden (the "<garden> — <year>" plan
convention). fill_region takes an explicit local rectangle and a seedLotId;
place_planting's radius defaults to one plant (spacing/2) instead of a guess.
- The system prompt states the date and the gardener's units, forbids claiming
a change no tool made, says it cannot undo and points at the Undo button,
asks before clearing beds on an ambiguous sentence, and stops narrating its
own plantings into the journal.
- A mutation aimed at ANOTHER garden inside a turn is recorded under that
garden as its own change set, not filed into the open scope.
- UI: the thread scrolls inside the Assistant panel so the composer stays
put; every tool has a step label; wide tables stay inside the bubble.
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
+346
-70
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
@@ -179,39 +180,79 @@ func validFillLayout(l FillLayout) (FillLayout, bool) {
|
||||
// when nil — the UI always sends its local day, so the default is for API and
|
||||
// agent callers. Returns the plops it created.
|
||||
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
|
||||
return s.Fill(ctx, actorID, objectID, FillSpec{
|
||||
Region: region, PlantID: plantID, SpacingOverride: spacingOverride, Layout: layout, PlantedAt: plantedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// FillSpec is everything a fill needs besides the object it fills: where (a
|
||||
// compass RegionName, or an explicit Region in the object's local frame when the
|
||||
// name is empty), what, and how.
|
||||
type FillSpec struct {
|
||||
// RegionName is a compass name for NamedRegion ("ne", "south half", "all").
|
||||
// When it is empty, Region is used as given.
|
||||
RegionName string
|
||||
Region Region
|
||||
PlantID int64
|
||||
// SpacingOverride replaces the plant's own spacing for this fill, in cm.
|
||||
SpacingOverride *float64
|
||||
// Layout is clump (the default) or grid; see FillLayout.
|
||||
Layout FillLayout
|
||||
// PlantedAt dates every plop the fill makes (YYYY-MM-DD). nil means the
|
||||
// service's UTC today; a caller that knows the person's local day sends it.
|
||||
PlantedAt *string
|
||||
// SeedLotID attributes every plop to one of the actor's seed lots, so the lot
|
||||
// can report what it has left. Optional.
|
||||
SeedLotID *int64
|
||||
}
|
||||
|
||||
// Fill plants one plant across part of an object the actor can edit, per spec.
|
||||
// FillRegion and FillNamedRegion are the two older spellings of it.
|
||||
func (s *Service) Fill(ctx context.Context, actorID, objectID int64, spec FillSpec) ([]domain.Planting, error) {
|
||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt)
|
||||
region := spec.Region
|
||||
if strings.TrimSpace(spec.RegionName) != "" {
|
||||
if region, err = NamedRegion(o, spec.RegionName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s.fillLoaded(ctx, actorID, o, region, spec)
|
||||
}
|
||||
|
||||
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
||||
// already loaded and authorized (roleEditor). It validates the layout, rejects a
|
||||
// fillLoaded is the body of Fill given an object already loaded and authorized
|
||||
// (roleEditor) and its region resolved. It validates the layout, rejects a
|
||||
// non-finite region, clamps the region to the object's bounds, refuses fills over
|
||||
// maxFillPlops, and inserts the whole batch in one transaction rather than one
|
||||
// round-trip per plop.
|
||||
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
|
||||
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, spec FillSpec) ([]domain.Planting, error) {
|
||||
if !o.Plantable {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
if !validDatePtr(plantedAt) {
|
||||
if !validDatePtr(spec.PlantedAt) {
|
||||
return nil, fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
|
||||
}
|
||||
layout, ok := validFillLayout(layout)
|
||||
layout, ok := validFillLayout(spec.Layout)
|
||||
if !ok {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
plant, err := s.visiblePlant(ctx, actorID, plantID)
|
||||
plant, err := s.visiblePlant(ctx, actorID, spec.PlantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Checked before anything is planted, as CreatePlanting does: a lot of the
|
||||
// wrong variety, or someone else's, refuses the whole fill.
|
||||
if err := s.checkSeedLotForPlanting(ctx, actorID, spec.SeedLotID, spec.PlantID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spacing := plant.SpacingCM
|
||||
if spacingOverride != nil {
|
||||
if !isFinite(*spacingOverride) || *spacingOverride < minPlantSpacingCM || *spacingOverride > maxPlantSpacingCM {
|
||||
if spec.SpacingOverride != nil {
|
||||
if !isFinite(*spec.SpacingOverride) || *spec.SpacingOverride < minPlantSpacingCM || *spec.SpacingOverride > maxPlantSpacingCM {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
spacing = *spacingOverride
|
||||
spacing = *spec.SpacingOverride
|
||||
}
|
||||
radius := plopRadiusFor(spacing, layout)
|
||||
if !isFinite(radius) || radius <= 0 {
|
||||
@@ -241,8 +282,8 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
||||
return nil, err
|
||||
}
|
||||
plantedOn := s.now().UTC().Format(dateLayout)
|
||||
if plantedAt != nil {
|
||||
plantedOn = *plantedAt
|
||||
if spec.PlantedAt != nil {
|
||||
plantedOn = *spec.PlantedAt
|
||||
}
|
||||
batch := make([]*domain.Planting, 0, len(centers))
|
||||
// Only the plops that were ALREADY here can cover a candidate: every plop this
|
||||
@@ -255,7 +296,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
||||
if coveredByExisting(c.x, c.y, radius, existing) {
|
||||
continue
|
||||
}
|
||||
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &plantedOn})
|
||||
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: spec.PlantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &plantedOn, SeedLotID: spec.SeedLotID})
|
||||
}
|
||||
created, err := s.store.CreatePlantings(ctx, batch)
|
||||
if err != nil {
|
||||
@@ -385,15 +426,14 @@ func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
|
||||
// instead of a resolved Region — the ergonomic form for agent tools, which don't
|
||||
// hold the object's geometry. It resolves the name against the object, then fills.
|
||||
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
|
||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
if strings.TrimSpace(regionName) == "" {
|
||||
// Fill would read a blank name as "use the (zero) Region" and plant
|
||||
// nothing; here a blank name is the caller's mistake, as it always was.
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
region, err := NamedRegion(o, regionName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt)
|
||||
return s.Fill(ctx, actorID, objectID, FillSpec{
|
||||
RegionName: regionName, PlantID: plantID, SpacingOverride: spacingOverride, Layout: layout, PlantedAt: plantedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// ClearObject soft-removes every active plop in an object the actor can edit (one
|
||||
@@ -402,10 +442,29 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64,
|
||||
// non-plantable after it was planted must still be clearable (you can always
|
||||
// remove existing plops, only not add new ones).
|
||||
func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int, error) {
|
||||
return s.ClearPlantings(ctx, actorID, objectID, ClearOptions{})
|
||||
}
|
||||
|
||||
// ClearOptions narrows ClearPlantings.
|
||||
type ClearOptions struct {
|
||||
// PlantID limits the clear to one plant — "pull the beets out, leave the
|
||||
// garlic" — nil clears every plant.
|
||||
PlantID *int64
|
||||
// RemovedAt is the removal date (YYYY-MM-DD). nil means the service's UTC
|
||||
// today; a caller that knows the person's local day sends it.
|
||||
RemovedAt *string
|
||||
}
|
||||
|
||||
// ClearPlantings is ClearObject with options: all of an object's active plops, or
|
||||
// only one plant's. The whole clear is one change set either way.
|
||||
func (s *Service) ClearPlantings(ctx context.Context, actorID, objectID int64, opts ClearOptions) (int, error) {
|
||||
o, g, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !validDatePtr(opts.RemovedAt) {
|
||||
return 0, fmt.Errorf("%w: removedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
|
||||
}
|
||||
// Snapshot the rows the bulk UPDATE is about to touch, since it reports only a
|
||||
// count — then clear exactly those ids. Clearing "every active plop" instead
|
||||
// would let a plop created between this read and the UPDATE be removed with no
|
||||
@@ -414,12 +473,32 @@ func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
what := "" // names the plant in the summary when the clear is for one plant
|
||||
if opts.PlantID != nil {
|
||||
only := make([]domain.Planting, 0, len(before))
|
||||
for i := range before {
|
||||
if before[i].PlantID == *opts.PlantID {
|
||||
only = append(only, before[i])
|
||||
}
|
||||
}
|
||||
before = only
|
||||
// The summary is read by a person, so name the plant, not its id. A plant
|
||||
// that no longer exists just goes unnamed.
|
||||
if plant, err := s.store.GetPlant(ctx, *opts.PlantID); err == nil {
|
||||
what = plant.Name
|
||||
} else if !errors.Is(err, domain.ErrNotFound) {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
ids := make([]int64, 0, len(before))
|
||||
for i := range before {
|
||||
ids = append(ids, before[i].ID)
|
||||
}
|
||||
today := s.now().UTC().Format(dateLayout)
|
||||
n, err := s.store.ClearObjectPlantings(ctx, objectID, today, ids)
|
||||
removedOn := s.now().UTC().Format(dateLayout)
|
||||
if opts.RemovedAt != nil {
|
||||
removedOn = *opts.RemovedAt
|
||||
}
|
||||
n, err := s.store.ClearObjectPlantings(ctx, objectID, removedOn, ids)
|
||||
if err != nil || n == 0 {
|
||||
return n, err
|
||||
}
|
||||
@@ -447,7 +526,14 @@ func (s *Service) ClearObject(ctx context.Context, actorID, objectID int64) (int
|
||||
}
|
||||
changes = append(changes, changeUpdate(domain.EntityPlanting, b.ID, &b, a))
|
||||
}
|
||||
s.record(ctx, g.ID, actorID, fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n), changes...)
|
||||
summary := fmt.Sprintf("Cleared %s (%d plantings)", objectLabel(o), n)
|
||||
if opts.PlantID != nil {
|
||||
if what == "" {
|
||||
what = "plantings"
|
||||
}
|
||||
summary = fmt.Sprintf("Removed %s from %s (%d plantings)", what, objectLabel(o), n)
|
||||
}
|
||||
s.record(ctx, g.ID, actorID, summary, changes...)
|
||||
return n, nil
|
||||
}
|
||||
|
||||
@@ -461,39 +547,75 @@ type DescribeResult struct {
|
||||
Objects []DescribeObject `json:"objects"`
|
||||
}
|
||||
|
||||
// DescribeObject is one object plus its active plantings, for DescribeResult.
|
||||
// Version is included so an agent can move/edit the object (the mutation guard).
|
||||
// DescribeObject is one object plus its active plantings grouped by plant, for
|
||||
// DescribeResult. Version is included so an agent can move/edit the object (the
|
||||
// mutation guard).
|
||||
type DescribeObject struct {
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Shape string `json:"shape"`
|
||||
WidthCM float64 `json:"widthCm"`
|
||||
HeightCM float64 `json:"heightCm"`
|
||||
XCM float64 `json:"xCm"`
|
||||
YCM float64 `json:"yCm"`
|
||||
RotationDeg float64 `json:"rotationDeg"`
|
||||
Plantable bool `json:"plantable"`
|
||||
Version int64 `json:"version"`
|
||||
Plantings []DescribePlanting `json:"plantings"`
|
||||
ID int64 `json:"id"`
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Shape string `json:"shape"`
|
||||
WidthCM float64 `json:"widthCm"`
|
||||
HeightCM float64 `json:"heightCm"`
|
||||
XCM float64 `json:"xCm"`
|
||||
YCM float64 `json:"yCm"`
|
||||
RotationDeg float64 `json:"rotationDeg"`
|
||||
Plantable bool `json:"plantable"`
|
||||
Version int64 `json:"version"`
|
||||
Plantings []DescribeGroup `json:"plantings"`
|
||||
}
|
||||
|
||||
// DescribePlanting is one plop with a rough compass location, for DescribeResult.
|
||||
// ID + Version are included so an agent can address a single plop — remove it or
|
||||
// move it — the same way DescribeObject.Version lets it edit an object.
|
||||
// maxListedPlops is the largest group DescribeGroup.Each spells out plop by plop.
|
||||
// Up to it, a group is a handful of placements someone may address one at a time
|
||||
// ("pull the basil out of the corner"). Past it — a grid-filled bed is hundreds —
|
||||
// the ids are noise that costs a model more than it informs, and the group is
|
||||
// addressed as a whole (ClearPlantings) or listed on demand (ListObjectPlantings).
|
||||
// The live instance's first describe of a grid-filled garden was ~450 plop
|
||||
// entries, on every turn.
|
||||
const maxListedPlops = 8
|
||||
|
||||
// DescribeGroup summarizes every active plop of one plant in an object — the
|
||||
// unit a person talks about ("the cucumbers in the west bed") — with the count,
|
||||
// a rough location, and when it went in.
|
||||
type DescribeGroup struct {
|
||||
PlantID int64 `json:"plantId"`
|
||||
Plant string `json:"plant"`
|
||||
// Plops is how many placements make up the group; Plants the effective plant
|
||||
// count across them (explicit counts, else derived from area and spacing).
|
||||
Plops int `json:"plops"`
|
||||
Plants int `json:"plants"`
|
||||
// Where is a rough location: a compass region when the group sits in one
|
||||
// ("north half", "NE corner"), "throughout" when it spans the object, a short
|
||||
// list of locations, or — for anything else — its bounding box in local cm.
|
||||
Where string `json:"where"`
|
||||
// PlantedAt is the planting date, or "first…last" when the plops differ.
|
||||
PlantedAt string `json:"plantedAt,omitempty"`
|
||||
// DaysToMaturity is the plant's, when the catalog knows it — with PlantedAt,
|
||||
// enough to say when the harvest is due.
|
||||
DaysToMaturity *int `json:"daysToMaturity,omitempty"`
|
||||
// Each lists the plops individually (id, version, location) only when the
|
||||
// group has at most maxListedPlops of them.
|
||||
Each []DescribePlanting `json:"each,omitempty"`
|
||||
}
|
||||
|
||||
// DescribePlanting is one plop with a rough compass location. ID + Version let
|
||||
// an agent address a single plop — remove it or move it — the same way
|
||||
// DescribeObject.Version lets it edit an object.
|
||||
type DescribePlanting struct {
|
||||
ID int64 `json:"id"`
|
||||
Version int64 `json:"version"`
|
||||
PlantID int64 `json:"plantId"`
|
||||
Plant string `json:"plant"`
|
||||
Count int `json:"count"`
|
||||
Location string `json:"location"`
|
||||
RadiusCM float64 `json:"radiusCm"`
|
||||
ID int64 `json:"id"`
|
||||
Version int64 `json:"version"`
|
||||
PlantID int64 `json:"plantId"`
|
||||
Plant string `json:"plant"`
|
||||
Count int `json:"count"`
|
||||
Location string `json:"location"`
|
||||
RadiusCM float64 `json:"radiusCm"`
|
||||
PlantedAt string `json:"plantedAt,omitempty"`
|
||||
}
|
||||
|
||||
// DescribeGarden returns a structured summary — dimensions, objects, and each
|
||||
// object's active plantings (plant, effective count, rough location) — for a
|
||||
// garden the actor can view. Built on GardenFull so it inherits the ACL check.
|
||||
// object's active plantings grouped by plant (count, rough location, planting
|
||||
// date) — for a garden the actor can view. Built on GardenFull so it inherits
|
||||
// the ACL check.
|
||||
func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (*DescribeResult, error) {
|
||||
full, err := s.GardenFull(ctx, actorID, gardenID, nil)
|
||||
if err != nil {
|
||||
@@ -517,33 +639,187 @@ func (s *Service) DescribeGarden(ctx context.Context, actorID, gardenID int64) (
|
||||
UnitPref: full.Garden.UnitPref,
|
||||
Objects: make([]DescribeObject, 0, len(full.Objects)),
|
||||
}
|
||||
for _, o := range full.Objects {
|
||||
do := DescribeObject{
|
||||
for i := range full.Objects {
|
||||
o := &full.Objects[i]
|
||||
res.Objects = append(res.Objects, DescribeObject{
|
||||
ID: o.ID, Kind: o.Kind, Name: o.Name, Shape: o.Shape,
|
||||
WidthCM: o.WidthCM, HeightCM: o.HeightCM, XCM: o.XCM, YCM: o.YCM,
|
||||
RotationDeg: o.RotationDeg, Plantable: o.Plantable, Version: o.Version,
|
||||
Plantings: []DescribePlanting{},
|
||||
}
|
||||
for _, pl := range plopsByObject[o.ID] {
|
||||
count := pl.DerivedCount
|
||||
if pl.Count != nil {
|
||||
count = *pl.Count
|
||||
}
|
||||
do.Plantings = append(do.Plantings, DescribePlanting{
|
||||
ID: pl.ID,
|
||||
Version: pl.Version,
|
||||
PlantID: pl.PlantID,
|
||||
Plant: plantByID[pl.PlantID].Name,
|
||||
Count: count,
|
||||
Location: describeLocation(pl.XCM, pl.YCM),
|
||||
RadiusCM: pl.RadiusCM,
|
||||
})
|
||||
}
|
||||
res.Objects = append(res.Objects, do)
|
||||
Plantings: describeGroups(o, plopsByObject[o.ID], plantByID),
|
||||
})
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// ListObjectPlantings lists an object's active plops one by one — the ids that
|
||||
// DescribeGarden summarizes away for a large group. plantID narrows it to one
|
||||
// plant. Viewer role, like DescribeGarden.
|
||||
func (s *Service) ListObjectPlantings(ctx context.Context, actorID, objectID int64, plantID *int64) ([]DescribePlanting, error) {
|
||||
if _, _, err := s.objectForRole(ctx, actorID, objectID, roleViewer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plops, err := s.store.ListActivePlantingsForObject(ctx, objectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Plants looked up by id, not through the actor's catalog: a plop in a shared
|
||||
// garden may be of the owner's private variety, and it still has a name.
|
||||
plants := map[int64]domain.Plant{}
|
||||
out := make([]DescribePlanting, 0, len(plops))
|
||||
for _, pl := range plops {
|
||||
if plantID != nil && pl.PlantID != *plantID {
|
||||
continue
|
||||
}
|
||||
plant, ok := plants[pl.PlantID]
|
||||
if !ok {
|
||||
p, err := s.store.GetPlant(ctx, pl.PlantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
plant = *p
|
||||
plants[pl.PlantID] = plant
|
||||
}
|
||||
pl.DerivedCount = derivedCount(pl.RadiusCM, plant.SpacingCM)
|
||||
out = append(out, describePlanting(pl, plant.Name))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// describeGroups groups an object's active plops by plant, in the order the
|
||||
// plants first appear, so the same garden always describes the same way.
|
||||
func describeGroups(o *domain.GardenObject, plops []domain.Planting, plantByID map[int64]domain.Plant) []DescribeGroup {
|
||||
byPlant := map[int64][]domain.Planting{}
|
||||
var order []int64
|
||||
for _, pl := range plops {
|
||||
if _, seen := byPlant[pl.PlantID]; !seen {
|
||||
order = append(order, pl.PlantID)
|
||||
}
|
||||
byPlant[pl.PlantID] = append(byPlant[pl.PlantID], pl)
|
||||
}
|
||||
groups := make([]DescribeGroup, 0, len(order))
|
||||
for _, pid := range order {
|
||||
members := byPlant[pid]
|
||||
plant := plantByID[pid]
|
||||
g := DescribeGroup{
|
||||
PlantID: pid, Plant: plant.Name, Plops: len(members),
|
||||
Where: summarizeWhere(o, members), PlantedAt: dateRange(members),
|
||||
DaysToMaturity: plant.DaysToMaturity,
|
||||
}
|
||||
for _, pl := range members {
|
||||
g.Plants += effectiveCount(pl)
|
||||
}
|
||||
if len(members) <= maxListedPlops {
|
||||
g.Each = make([]DescribePlanting, 0, len(members))
|
||||
for _, pl := range members {
|
||||
g.Each = append(g.Each, describePlanting(pl, plant.Name))
|
||||
}
|
||||
}
|
||||
groups = append(groups, g)
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func describePlanting(pl domain.Planting, plantName string) DescribePlanting {
|
||||
d := DescribePlanting{
|
||||
ID: pl.ID, Version: pl.Version, PlantID: pl.PlantID, Plant: plantName,
|
||||
Count: effectiveCount(pl), Location: describeLocation(pl.XCM, pl.YCM), RadiusCM: pl.RadiusCM,
|
||||
}
|
||||
if pl.PlantedAt != nil {
|
||||
d.PlantedAt = *pl.PlantedAt
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// effectiveCount is the plant count a plop stands for: its explicit count, else
|
||||
// the one derived from its area and the plant's spacing.
|
||||
func effectiveCount(pl domain.Planting) int {
|
||||
if pl.Count != nil {
|
||||
return *pl.Count
|
||||
}
|
||||
return pl.DerivedCount
|
||||
}
|
||||
|
||||
// dateRange is the planting date shared by a group's plops, "first…last" when
|
||||
// they were planted on different days, or "" when none is dated. ISO dates
|
||||
// order as strings, so min/max need no parsing.
|
||||
func dateRange(plops []domain.Planting) string {
|
||||
first, last := "", ""
|
||||
for _, pl := range plops {
|
||||
if pl.PlantedAt == nil || *pl.PlantedAt == "" {
|
||||
continue
|
||||
}
|
||||
if first == "" || *pl.PlantedAt < first {
|
||||
first = *pl.PlantedAt
|
||||
}
|
||||
if *pl.PlantedAt > last {
|
||||
last = *pl.PlantedAt
|
||||
}
|
||||
}
|
||||
if first == last {
|
||||
return first
|
||||
}
|
||||
return first + "…" + last
|
||||
}
|
||||
|
||||
// summarizeWhere names where a group of plops sits in its object, in the words
|
||||
// NamedRegion understands when that is exact ("north half", "NE corner"), and
|
||||
// otherwise as honestly as it can: "throughout" for a group spanning most of the
|
||||
// object, a short list of rough locations, or the bounding box of the plop
|
||||
// centres in local cm — which is what a fill needs to put something back there.
|
||||
func summarizeWhere(o *domain.GardenObject, plops []domain.Planting) string {
|
||||
if len(plops) == 1 {
|
||||
return describeLocation(plops[0].XCM, plops[0].YCM)
|
||||
}
|
||||
minX, maxX := plops[0].XCM, plops[0].XCM
|
||||
minY, maxY := plops[0].YCM, plops[0].YCM
|
||||
for _, pl := range plops[1:] {
|
||||
minX, maxX = math.Min(minX, pl.XCM), math.Max(maxX, pl.XCM)
|
||||
minY, maxY = math.Min(minY, pl.YCM), math.Max(maxY, pl.YCM)
|
||||
}
|
||||
const eps = 1e-6
|
||||
// A half is "everything on one side of the centre line, and not just ON it":
|
||||
// a column of plops down the middle is neither the west half nor the east.
|
||||
north := maxY <= eps && minY < -eps
|
||||
south := minY >= -eps && maxY > eps
|
||||
west := maxX <= eps && minX < -eps
|
||||
east := minX >= -eps && maxX > eps
|
||||
switch {
|
||||
case north && west:
|
||||
return "NW corner"
|
||||
case north && east:
|
||||
return "NE corner"
|
||||
case south && west:
|
||||
return "SW corner"
|
||||
case south && east:
|
||||
return "SE corner"
|
||||
case north:
|
||||
return "north half"
|
||||
case south:
|
||||
return "south half"
|
||||
case west:
|
||||
return "west half"
|
||||
case east:
|
||||
return "east half"
|
||||
}
|
||||
// Centres spanning at least 60% of both dimensions is a whole-object fill
|
||||
// (the outer row sits half a spacing in from each edge).
|
||||
if hw, hh := o.WidthCM/2, o.HeightCM/2; hw > 0 && hh > 0 && maxX-minX >= 1.2*hw && maxY-minY >= 1.2*hh {
|
||||
return "throughout"
|
||||
}
|
||||
var locs []string
|
||||
seen := map[string]bool{}
|
||||
for _, pl := range plops {
|
||||
if l := describeLocation(pl.XCM, pl.YCM); !seen[l] {
|
||||
seen[l] = true
|
||||
locs = append(locs, l)
|
||||
}
|
||||
}
|
||||
if len(locs) <= 3 {
|
||||
return strings.Join(locs, ", ")
|
||||
}
|
||||
return fmt.Sprintf("x %.0f…%.0f, y %.0f…%.0f cm from the centre", minX, maxX, minY, maxY)
|
||||
}
|
||||
|
||||
// describeLocation reverse-maps a local point to a rough compass location — the
|
||||
// inverse of NamedRegion's quarters/halves ("NE corner", "south", "center").
|
||||
func describeLocation(x, y float64) string {
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"testing"
|
||||
@@ -471,12 +472,17 @@ func TestFillScenario(t *testing.T) {
|
||||
t.Fatalf("objects = %d, want 1", len(desc.Objects))
|
||||
}
|
||||
// Tally plant → the set of rough locations it appears in.
|
||||
// Plantings come grouped by plant: a group's Where names the region when the
|
||||
// whole group sits in one, and a small group also lists its plops.
|
||||
locs := map[string]map[string]bool{}
|
||||
for _, p := range desc.Objects[0].Plantings {
|
||||
if locs[p.Plant] == nil {
|
||||
locs[p.Plant] = map[string]bool{}
|
||||
for _, g := range desc.Objects[0].Plantings {
|
||||
if locs[g.Plant] == nil {
|
||||
locs[g.Plant] = map[string]bool{}
|
||||
}
|
||||
locs[g.Plant][g.Where] = true
|
||||
for _, p := range g.Each {
|
||||
locs[g.Plant][p.Location] = true
|
||||
}
|
||||
locs[p.Plant][p.Location] = true
|
||||
}
|
||||
if len(locs["Garlic"]) == 0 || !locs["Garlic"]["NE corner"] {
|
||||
t.Errorf("garlic locations = %v, want NE corner", locs["Garlic"])
|
||||
@@ -538,3 +544,250 @@ func TestFillRegionPlantedAt(t *testing.T) {
|
||||
t.Errorf("bad date err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDescribeGardenGroupsByPlant — describe_garden is what the assistant reads
|
||||
// at the start of every turn, and the live one's first describe of a grid-filled
|
||||
// garden was ~450 plop entries. A group per plant says what a person would say
|
||||
// ("beans across the north half, sown in May"), spells out its plops only when
|
||||
// there are few, and carries the dates the model had no way to know before.
|
||||
func TestDescribeGardenGroupsByPlant(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Grouped", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
bed := seedFillBed(t, s, owner, g.ID, 400, 400)
|
||||
beans := seedNamedPlant(t, s, owner, "Beans", 10)
|
||||
basil := seedNamedPlant(t, s, owner, "Basil", 25)
|
||||
may, june := "2026-05-01", "2026-06-01"
|
||||
|
||||
// A grid fill of the north half: far more plops than get listed, all May.
|
||||
if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "north", PlantID: beans.ID, Layout: FillGrid, PlantedAt: &may}); err != nil {
|
||||
t.Fatalf("fill beans: %v", err)
|
||||
}
|
||||
// Three basil plops in the south half, on two dates, one with an explicit count.
|
||||
three := 3
|
||||
for _, in := range []PlantingInput{
|
||||
{PlantID: basil.ID, XCM: -100, YCM: 100, RadiusCM: 20, PlantedAt: &may},
|
||||
{PlantID: basil.ID, XCM: 0, YCM: 150, RadiusCM: 20, PlantedAt: &june, Count: &three},
|
||||
{PlantID: basil.ID, XCM: 100, YCM: 100, RadiusCM: 20, PlantedAt: &june},
|
||||
} {
|
||||
if _, err := s.CreatePlanting(ctx, owner, bed.ID, in); err != nil {
|
||||
t.Fatalf("place basil: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
desc, err := s.DescribeGarden(ctx, owner, g.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeGarden: %v", err)
|
||||
}
|
||||
groups := map[string]DescribeGroup{}
|
||||
for _, gr := range desc.Objects[0].Plantings {
|
||||
groups[gr.Plant] = gr
|
||||
}
|
||||
if len(groups) != 2 {
|
||||
t.Fatalf("groups = %d (%+v), want one per plant", len(groups), desc.Objects[0].Plantings)
|
||||
}
|
||||
|
||||
b := groups["Beans"]
|
||||
if b.Plops <= maxListedPlops {
|
||||
t.Fatalf("the beans fill made %d plops; the test needs more than %d to exercise the listing cap", b.Plops, maxListedPlops)
|
||||
}
|
||||
if b.Each != nil {
|
||||
t.Errorf("a %d-plop group listed its plops individually", b.Plops)
|
||||
}
|
||||
if b.Where != "north half" {
|
||||
t.Errorf("beans where = %q, want %q", b.Where, "north half")
|
||||
}
|
||||
if b.PlantedAt != may {
|
||||
t.Errorf("beans plantedAt = %q, want %q", b.PlantedAt, may)
|
||||
}
|
||||
if b.Plants != b.Plops {
|
||||
t.Errorf("grid beans: plants %d ≠ plops %d (one plant per grid plop)", b.Plants, b.Plops)
|
||||
}
|
||||
|
||||
ba := groups["Basil"]
|
||||
if ba.Plops != 3 || len(ba.Each) != 3 {
|
||||
t.Errorf("basil: plops %d, each %d; want 3 and 3 (a small group lists its plops)", ba.Plops, len(ba.Each))
|
||||
}
|
||||
if ba.Where != "south half" {
|
||||
t.Errorf("basil where = %q, want %q", ba.Where, "south half")
|
||||
}
|
||||
if ba.PlantedAt != may+"…"+june {
|
||||
t.Errorf("basil plantedAt = %q, want the range %q", ba.PlantedAt, may+"…"+june)
|
||||
}
|
||||
// Two derived counts (π·20²/25² ≈ 2 each) plus the explicit 3.
|
||||
if want := 2*derivedCount(20, 25) + 3; ba.Plants != want {
|
||||
t.Errorf("basil plants = %d, want %d", ba.Plants, want)
|
||||
}
|
||||
for _, e := range ba.Each {
|
||||
if e.PlantedAt == "" || e.Version == 0 || e.ID == 0 {
|
||||
t.Errorf("listed plop %+v is missing id, version or date", e)
|
||||
}
|
||||
}
|
||||
|
||||
// The big group's ids are a call away, narrowed to one plant.
|
||||
listed, err := s.ListObjectPlantings(ctx, owner, bed.ID, &beans.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListObjectPlantings: %v", err)
|
||||
}
|
||||
if len(listed) != b.Plops {
|
||||
t.Errorf("listed %d beans, want %d", len(listed), b.Plops)
|
||||
}
|
||||
for _, p := range listed {
|
||||
if p.PlantID != beans.ID || p.PlantedAt != may || p.Plant != "Beans" {
|
||||
t.Errorf("listed plop %+v, want a May bean", p)
|
||||
break
|
||||
}
|
||||
}
|
||||
// A stranger gets not-found, like everything else behind the garden ACL.
|
||||
stranger := seedUser(t, s, "[email protected]")
|
||||
if _, err := s.ListObjectPlantings(ctx, stranger, bed.ID, nil); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Errorf("stranger ListObjectPlantings err = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSummarizeWhere pins the words a group's location comes out in: the
|
||||
// compass names NamedRegion understands when the group fits one, "throughout"
|
||||
// for a whole-bed fill, a short list for a few scattered plops, and a bounding
|
||||
// box for anything else — never a column down the middle called a "half".
|
||||
func TestSummarizeWhere(t *testing.T) {
|
||||
o := &domain.GardenObject{WidthCM: 200, HeightCM: 100}
|
||||
at := func(pts ...[2]float64) []domain.Planting {
|
||||
out := make([]domain.Planting, 0, len(pts))
|
||||
for _, p := range pts {
|
||||
out = append(out, domain.Planting{XCM: p[0], YCM: p[1]})
|
||||
}
|
||||
return out
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
in []domain.Planting
|
||||
want string
|
||||
}{
|
||||
{"single", at([2]float64{0, -10}), "north"},
|
||||
{"ne corner", at([2]float64{10, -10}, [2]float64{80, -40}), "NE corner"},
|
||||
{"south half", at([2]float64{-80, 10}, [2]float64{80, 40}), "south half"},
|
||||
{"column down the middle", at([2]float64{0, -40}, [2]float64{0, 0}, [2]float64{0, 40}), "north, center, south"},
|
||||
{"whole bed", at([2]float64{-90, -40}, [2]float64{90, -40}, [2]float64{-90, 40}, [2]float64{90, 40}, [2]float64{0, 0}), "throughout"},
|
||||
{"middle third", at([2]float64{-30, -40}, [2]float64{30, -40}, [2]float64{-30, 0}, [2]float64{30, 0}, [2]float64{-30, 40}, [2]float64{30, 40}), "x -30…30, y -40…40 cm from the centre"},
|
||||
} {
|
||||
if got := summarizeWhere(o, tc.in); got != tc.want {
|
||||
t.Errorf("%s: summarizeWhere = %q, want %q", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearPlantingsOnePlantOnTheDayTold — "take the beets out, leave the
|
||||
// garlic", dated the gardener's day: the whole-bed clear's narrower sibling, and
|
||||
// what the assistant needed instead of 116 single removals.
|
||||
func TestClearPlantingsOnePlantOnTheDayTold(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Mixed", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
bed := seedFillBed(t, s, owner, g.ID, 400, 200)
|
||||
garlic := seedNamedPlant(t, s, owner, "Garlic", 15)
|
||||
beet := seedNamedPlant(t, s, owner, "Beet", 10)
|
||||
if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "west", PlantID: garlic.ID}); err != nil {
|
||||
t.Fatalf("fill garlic: %v", err)
|
||||
}
|
||||
beets, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "east", PlantID: beet.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("fill beets: %v", err)
|
||||
}
|
||||
|
||||
day := "2026-08-22"
|
||||
n, err := s.ClearPlantings(ctx, owner, bed.ID, ClearOptions{PlantID: &beet.ID, RemovedAt: &day})
|
||||
if err != nil {
|
||||
t.Fatalf("ClearPlantings: %v", err)
|
||||
}
|
||||
if n != len(beets) {
|
||||
t.Errorf("cleared %d, want the %d beets", n, len(beets))
|
||||
}
|
||||
rows, err := s.store.ListPlantingsForObject(ctx, bed.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
switch {
|
||||
case r.PlantID == beet.ID && (r.RemovedAt == nil || *r.RemovedAt != day):
|
||||
t.Errorf("beet %d removedAt = %v, want %q", r.ID, r.RemovedAt, day)
|
||||
case r.PlantID == garlic.ID && r.RemovedAt != nil:
|
||||
t.Errorf("garlic %d was removed by a clear aimed at the beets", r.ID)
|
||||
}
|
||||
}
|
||||
sets, _, err := s.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if want := fmt.Sprintf("Removed Beet from %s (%d plantings)", objectLabel(bed), n); sets[0].Summary != want {
|
||||
t.Errorf("summary = %q, want %q", sets[0].Summary, want)
|
||||
}
|
||||
|
||||
// Nothing left of that plant clears nothing, cleanly; a bad date is refused
|
||||
// before anything is touched.
|
||||
if n, err := s.ClearPlantings(ctx, owner, bed.ID, ClearOptions{PlantID: &beet.ID}); err != nil || n != 0 {
|
||||
t.Errorf("second clear = (%d, %v), want (0, nil)", n, err)
|
||||
}
|
||||
bad := "22/08/2026"
|
||||
if _, err := s.ClearPlantings(ctx, owner, bed.ID, ClearOptions{RemovedAt: &bad}); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("bad date err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillByRectangleAttributesSeed — a fill can be aimed at any rectangle of the
|
||||
// object's local frame (the middle third, a strip along one edge), not only a
|
||||
// compass name, and can charge its plops to a seed lot so the lot's "remaining"
|
||||
// means something.
|
||||
func TestFillByRectangleAttributesSeed(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, err := s.CreateGarden(ctx, owner, GardenInput{Name: "Rect", WidthCM: 2000, HeightCM: 2000})
|
||||
if err != nil {
|
||||
t.Fatalf("garden: %v", err)
|
||||
}
|
||||
bed := seedFillBed(t, s, owner, g.ID, 240, 120)
|
||||
beet := seedNamedPlant(t, s, owner, "Beet", 10)
|
||||
lot, err := s.CreateSeedLot(ctx, owner, SeedLotInput{PlantID: beet.ID, Quantity: 500, Unit: "seeds"})
|
||||
if err != nil {
|
||||
t.Fatalf("lot: %v", err)
|
||||
}
|
||||
|
||||
created, err := s.Fill(ctx, owner, bed.ID, FillSpec{
|
||||
Region: Region{MinX: -40, MinY: -60, MaxX: 40, MaxY: 60}, PlantID: beet.ID, Layout: FillGrid, SeedLotID: &lot.ID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Fill: %v", err)
|
||||
}
|
||||
if len(created) == 0 {
|
||||
t.Fatal("the rectangle fill planted nothing")
|
||||
}
|
||||
for _, p := range created {
|
||||
if p.XCM < -40 || p.XCM > 40 || p.YCM < -60 || p.YCM > 60 {
|
||||
t.Errorf("plop at (%v,%v) is outside the rectangle", p.XCM, p.YCM)
|
||||
}
|
||||
if p.SeedLotID == nil || *p.SeedLotID != lot.ID {
|
||||
t.Errorf("plop %d seedLotId = %v, want the lot", p.ID, p.SeedLotID)
|
||||
}
|
||||
}
|
||||
got, err := s.GetSeedLot(ctx, owner, lot.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetSeedLot: %v", err)
|
||||
}
|
||||
if got.Used != float64(len(created)) || got.Remaining != 500-float64(len(created)) {
|
||||
t.Errorf("lot used/remaining = %v/%v, want %d/%v", got.Used, got.Remaining, len(created), 500-float64(len(created)))
|
||||
}
|
||||
|
||||
// Someone else's lot, or a lot of another plant, refuses the whole fill.
|
||||
garlic := seedNamedPlant(t, s, owner, "Garlic", 15)
|
||||
if _, err := s.Fill(ctx, owner, bed.ID, FillSpec{RegionName: "all", PlantID: garlic.ID, SeedLotID: &lot.ID}); err == nil {
|
||||
t.Error("a fill charged to a lot of a different plant succeeded")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -89,12 +90,18 @@ func (s *Service) CreatePlanting(ctx context.Context, actorID, objectID int64, i
|
||||
return nil, err
|
||||
}
|
||||
|
||||
radius := in.RadiusCM
|
||||
if radius == 0 {
|
||||
// Unspecified means ONE plant: the editor's tap-to-place radius, half the
|
||||
// spacing. A clump (1.5× spacing) is what a fill makes, not a placement.
|
||||
radius = plant.SpacingCM / 2
|
||||
}
|
||||
p := &domain.Planting{
|
||||
ObjectID: objectID,
|
||||
PlantID: in.PlantID,
|
||||
XCM: in.XCM,
|
||||
YCM: in.YCM,
|
||||
RadiusCM: in.RadiusCM,
|
||||
RadiusCM: radius,
|
||||
Count: in.Count,
|
||||
Label: trimStringPtr(in.Label),
|
||||
PlantedAt: in.PlantedAt,
|
||||
@@ -179,14 +186,89 @@ func (s *Service) UpdatePlanting(ctx context.Context, actorID, plantingID int64,
|
||||
}
|
||||
|
||||
// RemovePlanting soft-removes a single plop — the one-plop counterpart to
|
||||
// ClearObject, used by the agent's remove_planting tool. It stamps removed_at
|
||||
// from the service clock (s.now()), same as ClearObject and the fill path, so the
|
||||
// removal date can't diverge by which caller set it; then delegates to
|
||||
// ClearObject. It stamps removed_at from the service clock (s.now()), the same
|
||||
// UTC day ClearObject and the fill path default to; RemovePlantingOn is the
|
||||
// form for a caller that knows the gardener's local day. Both delegate to
|
||||
// UpdatePlanting for the editor-role check, version guard and history record.
|
||||
func (s *Service) RemovePlanting(ctx context.Context, actorID, plantingID, version int64) (*domain.Planting, error) {
|
||||
today := s.now().UTC().Format(dateLayout)
|
||||
return s.RemovePlantingOn(ctx, actorID, plantingID, version, nil)
|
||||
}
|
||||
|
||||
// RemovePlantingOn is RemovePlanting with the removal date supplied (YYYY-MM-DD)
|
||||
// by a caller that knows the person's local day. nil keeps the service clock's
|
||||
// UTC today.
|
||||
func (s *Service) RemovePlantingOn(ctx context.Context, actorID, plantingID, version int64, removedAt *string) (*domain.Planting, error) {
|
||||
if !validDatePtr(removedAt) {
|
||||
return nil, fmt.Errorf("%w: removedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
|
||||
}
|
||||
on := s.now().UTC().Format(dateLayout)
|
||||
if removedAt != nil {
|
||||
on = *removedAt
|
||||
}
|
||||
return s.UpdatePlanting(ctx, actorID, plantingID,
|
||||
PlantingPatch{SetRemovedAt: true, RemovedAt: &today}, version)
|
||||
PlantingPatch{SetRemovedAt: true, RemovedAt: &on}, version)
|
||||
}
|
||||
|
||||
// MoveInput says where a plop goes: a position in the local frame of ToObjectID,
|
||||
// or of the plop's current object when ToObjectID is nil.
|
||||
type MoveInput struct {
|
||||
ToObjectID *int64
|
||||
XCM, YCM float64
|
||||
}
|
||||
|
||||
// MovePlanting relocates one plop — within its object, or into another plantable
|
||||
// object of the same garden — keeping its plant, size, count and planting date.
|
||||
// Removing and re-placing is not the same thing: "move the tomatoes to the other
|
||||
// bed" is not "pull them up and plant new ones today", and the live assistant
|
||||
// did exactly that for want of this. Version-guarded like UpdatePlanting; a
|
||||
// within-object move IS an UpdatePlanting of the position.
|
||||
func (s *Service) MovePlanting(ctx context.Context, actorID, plantingID int64, in MoveInput, version int64) (*domain.Planting, error) {
|
||||
pl, err := s.store.GetPlanting(ctx, plantingID)
|
||||
if err != nil {
|
||||
return nil, err // ErrNotFound
|
||||
}
|
||||
if in.ToObjectID == nil || *in.ToObjectID == pl.ObjectID {
|
||||
return s.UpdatePlanting(ctx, actorID, plantingID, PlantingPatch{XCM: &in.XCM, YCM: &in.YCM}, version)
|
||||
}
|
||||
from, g, err := s.objectForRole(ctx, actorID, pl.ObjectID, roleEditor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to, toGarden, err := s.objectForRole(ctx, actorID, *in.ToObjectID, roleEditor)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if toGarden.ID != g.ID {
|
||||
return nil, fmt.Errorf("%w: a planting can only move within its own garden", domain.ErrInvalidInput)
|
||||
}
|
||||
if !to.Plantable {
|
||||
return nil, fmt.Errorf("%w: %s can't hold plants", domain.ErrInvalidInput, objectLabel(to))
|
||||
}
|
||||
// By id, not through the actor's catalog: the plop may be of a variety the
|
||||
// actor can't see (a shared editor, the owner's private plant), and moving it
|
||||
// isn't choosing it.
|
||||
plant, err := s.store.GetPlant(ctx, pl.PlantID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
before := *pl
|
||||
pl.ObjectID = to.ID
|
||||
pl.XCM, pl.YCM = in.XCM, in.YCM
|
||||
if err := finalizePlanting(pl, to, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pl.Version = version
|
||||
updated, err := s.store.UpdatePlanting(ctx, pl)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrVersionConflict) && updated != nil {
|
||||
s.enrichDerived(ctx, updated)
|
||||
}
|
||||
return updated, err
|
||||
}
|
||||
s.record(ctx, g.ID, actorID, "Moved "+plant.Name+" from "+objectLabel(from)+" to "+objectLabel(to),
|
||||
changeUpdate(domain.EntityPlanting, updated.ID, &before, updated))
|
||||
updated.DerivedCount = derivedCount(updated.RadiusCM, plant.SpacingCM)
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// plantingEditSummary describes a plop edit for the history list. Soft-removal
|
||||
|
||||
@@ -207,11 +207,22 @@ func TestPlantingBoundsCheck(t *testing.T) {
|
||||
}); err != nil {
|
||||
t.Errorf("edge-of-bounds center should be allowed: %v", err)
|
||||
}
|
||||
// Non-positive radius rejected.
|
||||
// A negative radius is rejected; an unspecified (zero) one means ONE plant —
|
||||
// half the plant's spacing, the editor's tap-to-place size — so a caller that
|
||||
// just says "put a tomato here" gets a tomato-sized plop, not an error.
|
||||
if _, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 0,
|
||||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: -1,
|
||||
}); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("zero radius err = %v, want ErrInvalidInput", err)
|
||||
t.Errorf("negative radius err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
one, err := s.CreatePlanting(context.Background(), owner, bed.ID, PlantingInput{
|
||||
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("zero radius: %v, want the one-plant default", err)
|
||||
}
|
||||
if one.RadiusCM != plant.SpacingCM/2 || one.DerivedCount != 1 {
|
||||
t.Errorf("zero radius → radius %v (count %d), want spacing/2 = %v (count 1)", one.RadiusCM, one.DerivedCount, plant.SpacingCM/2)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -376,3 +387,96 @@ func TestDeletePlanting(t *testing.T) {
|
||||
t.Errorf("planting still present after delete: %d", len(full.Plantings))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMovePlantingAcrossBedsKeepsTheDate — "move the tomatoes to the other bed"
|
||||
// is not "pull them up and plant new ones today". The assistant had only the
|
||||
// latter for want of this, and the plants lost their planting date on the way.
|
||||
func TestMovePlantingAcrossBedsKeepsTheDate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
from, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{Kind: domain.KindBed, Name: "A", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200})
|
||||
if err != nil {
|
||||
t.Fatalf("bed A: %v", err)
|
||||
}
|
||||
to, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{Kind: domain.KindBed, Name: "B", XCM: 900, YCM: 500, WidthCM: 200, HeightCM: 200})
|
||||
if err != nil {
|
||||
t.Fatalf("bed B: %v", err)
|
||||
}
|
||||
path, err := s.CreateObject(ctx, owner, g.ID, ObjectInput{Kind: domain.KindPath, Name: "Path", XCM: 700, YCM: 900, WidthCM: 400, HeightCM: 100})
|
||||
if err != nil {
|
||||
t.Fatalf("path: %v", err)
|
||||
}
|
||||
if path.Plantable {
|
||||
no := false
|
||||
if path, err = s.UpdateObject(ctx, owner, path.ID, ObjectPatch{Plantable: &no}, path.Version); err != nil {
|
||||
t.Fatalf("make the path unplantable: %v", err)
|
||||
}
|
||||
}
|
||||
plant := seedOwnPlant(t, s, owner, 30)
|
||||
may := "2026-05-20"
|
||||
pl, err := s.CreatePlanting(ctx, owner, from.ID, PlantingInput{PlantID: plant.ID, XCM: 10, YCM: 10, RadiusCM: 15, PlantedAt: &may})
|
||||
if err != nil {
|
||||
t.Fatalf("plant: %v", err)
|
||||
}
|
||||
|
||||
moved, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &to.ID, XCM: -50, YCM: 20}, pl.Version)
|
||||
if err != nil {
|
||||
t.Fatalf("MovePlanting: %v", err)
|
||||
}
|
||||
if moved.ObjectID != to.ID || moved.XCM != -50 || moved.YCM != 20 {
|
||||
t.Errorf("moved to object %d at (%v,%v), want B (%d) at (-50,20)", moved.ObjectID, moved.XCM, moved.YCM, to.ID)
|
||||
}
|
||||
if moved.PlantedAt == nil || *moved.PlantedAt != may {
|
||||
t.Errorf("plantedAt after the move = %v, want %q kept", moved.PlantedAt, may)
|
||||
}
|
||||
if moved.Version != pl.Version+1 || moved.DerivedCount == 0 {
|
||||
t.Errorf("moved row version %d (count %d), want %d and a derived count", moved.Version, moved.DerivedCount, pl.Version+1)
|
||||
}
|
||||
|
||||
// It reads as a move in history, and undo puts it back in A.
|
||||
sets, _, err := s.GardenHistory(ctx, owner, g.ID, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if want := "Moved " + plant.Name + " from A to B"; sets[0].Summary != want {
|
||||
t.Errorf("summary = %q, want %q", sets[0].Summary, want)
|
||||
}
|
||||
if _, conflicts, err := s.RevertChangeSet(ctx, owner, sets[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("undo: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
back, err := s.store.GetPlanting(ctx, pl.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if back.ObjectID != from.ID || back.XCM != 10 {
|
||||
t.Errorf("after undo the plop is in object %d at x=%v, want A (%d) at 10", back.ObjectID, back.XCM, from.ID)
|
||||
}
|
||||
|
||||
// Refused: a position outside the target, a target that can't hold plants, a
|
||||
// bed in another garden — and a stale version conflicts like any edit.
|
||||
cur := back
|
||||
if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &to.ID, XCM: 500, YCM: 0}, cur.Version); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("out-of-bounds move err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &path.ID, XCM: 0, YCM: 0}, cur.Version); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("move into a path err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
other := seedGarden(t, s, owner)
|
||||
far, err := s.CreateObject(ctx, owner, other.ID, ObjectInput{Kind: domain.KindBed, Name: "Far", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200})
|
||||
if err != nil {
|
||||
t.Fatalf("far bed: %v", err)
|
||||
}
|
||||
if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{ToObjectID: &far.ID, XCM: 0, YCM: 0}, cur.Version); !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("move into another garden err = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
if _, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{XCM: 5, YCM: 5}, cur.Version-1); !errors.Is(err, domain.ErrVersionConflict) {
|
||||
t.Errorf("stale version err = %v, want ErrVersionConflict", err)
|
||||
}
|
||||
// A within-bed move is just a position change.
|
||||
within, err := s.MovePlanting(ctx, owner, pl.ID, MoveInput{XCM: 5, YCM: 5}, cur.Version)
|
||||
if err != nil || within.ObjectID != from.ID || within.XCM != 5 {
|
||||
t.Errorf("within-bed move = %+v, %v; want the same bed at x=5", within, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,7 +200,21 @@ func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary s
|
||||
return
|
||||
}
|
||||
if sc := scopeFrom(ctx); sc != nil {
|
||||
sc.append(revs)
|
||||
if sc.gardenID == gardenID {
|
||||
sc.append(revs)
|
||||
return
|
||||
}
|
||||
// The scope is for ANOTHER garden — an agent turn on garden A that the
|
||||
// model pointed at an object in garden B. Joining the scope would file B's
|
||||
// revisions under A's history, where B's undo can't see them and A's undo
|
||||
// would revert rows in a garden the person isn't looking at. Record them
|
||||
// where they belong, as their own change set, keeping the source and run
|
||||
// id so the entry still reads as the agent's work.
|
||||
own := &changeScope{gardenID: gardenID, actorID: actorID, source: sc.source, summary: summary, agentRunID: sc.agentRunID}
|
||||
own.append(revs)
|
||||
if _, err := s.commitScope(ctx, own, nil); err != nil {
|
||||
slog.Error("service: record change set outside the open scope", "error", err, "garden", gardenID, "summary", summary)
|
||||
}
|
||||
return
|
||||
}
|
||||
// Auto-scope: one operation, its own change set. Written through the same
|
||||
|
||||
@@ -893,3 +893,50 @@ func TestAutoScopedMutationRecordsEvenIfTheCallerWentAway(t *testing.T) {
|
||||
t.Errorf("undo left x at %v, want %v", back.XCM, bed.XCM)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRecordOutsideTheOpenScopeFilesUnderItsOwnGarden — a scope is for ONE
|
||||
// garden, but nothing stops a mutation inside it from touching another garden
|
||||
// the actor can edit (the agent, pointed at "my other garden"). Those revisions
|
||||
// belong to the garden they changed, as their own change set carrying the
|
||||
// scope's source and run id — not to the open scope, whose undo would then
|
||||
// quietly revert rows in a garden nobody is looking at.
|
||||
func TestRecordOutsideTheOpenScopeFilesUnderItsOwnGarden(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
a := seedGarden(t, s, owner)
|
||||
b := seedGarden(t, s, owner)
|
||||
bedB, err := s.CreateObject(ctx, owner, b.ID, ObjectInput{Kind: domain.KindBed, Name: "Bed", XCM: 500, YCM: 500, WidthCM: 200, HeightCM: 200})
|
||||
if err != nil {
|
||||
t.Fatalf("bed: %v", err)
|
||||
}
|
||||
beforeB, _, _ := s.GardenHistory(ctx, owner, b.ID, 0, 0)
|
||||
|
||||
run := "run-1"
|
||||
cs, err := s.WithChangeSet(ctx, owner, a.ID, ChangeSetOptions{Source: domain.SourceAgent, Summary: "a turn on A", AgentRunID: &run},
|
||||
func(ctx context.Context) error {
|
||||
name := "Renamed from A"
|
||||
_, err := s.UpdateObject(ctx, owner, bedB.ID, ObjectPatch{Name: &name}, bedB.Version)
|
||||
return err
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithChangeSet: %v", err)
|
||||
}
|
||||
if cs != nil {
|
||||
t.Errorf("the scope on A wrote change set %d, but nothing in A changed", cs.ID)
|
||||
}
|
||||
afterB, _, _ := s.GardenHistory(ctx, owner, b.ID, 0, 0)
|
||||
if len(afterB) != len(beforeB)+1 {
|
||||
t.Fatalf("B's history grew by %d, want 1", len(afterB)-len(beforeB))
|
||||
}
|
||||
got := afterB[0]
|
||||
if got.Source != domain.SourceAgent || got.AgentRunID == nil || *got.AgentRunID != run {
|
||||
t.Errorf("B's entry = source %q run %v, want the scope's (agent, %q)", got.Source, got.AgentRunID, run)
|
||||
}
|
||||
if _, conflicts, err := s.RevertChangeSet(ctx, owner, got.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("undo from B: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
if d, err := s.DescribeGarden(ctx, owner, b.ID); err != nil || len(d.Objects) != 1 || d.Objects[0].Name != "Bed" {
|
||||
t.Errorf("after undo B is %+v (%v), want the bed's name back", d, err)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user