REST fill/clear on objects; clear-bed becomes one change set (#82)
FillRegion and ClearObject were reachable only through the agent toolbox, so
on an instance with no model configured the most valuable bulk operation in a
garden planner — and the one carrying the most carefully reasoned geometry in
the codebase — did not exist at all. internal/service/ops.go said these lived
on *Service so "any future REST surface" would inherit the ACL checks; this is
that surface.
POST /objects/:id/fill takes a region EITHER by compass name ("all", "ne",
"south half") or as an explicit rect in the object's local frame, and refuses
both-or-neither: silently preferring one would make a client bug look like a
geometry bug. It answers 200 rather than 201 because a fill can legitimately
create nothing (the region is already planted) and there is no single resource
to point a Location at.
POST /objects/:id/clear replaces a client-side loop of PATCHes. That loop
wrote one change set per plop, so clearing a 40-plop bed took 40 presses of
Undo to put back — while the agent's clear_object, for the identical
user-facing action, undid in one click. CLAUDE.md states the rule it violated:
multi-row operations record together so they undo as one unit. Moving it
server-side also removes the partial-failure case the loop had to reconcile.
TestClearObjectIsOneChangeSetAPI pins that: fill a bed, count change sets,
clear it, assert the count rose by exactly one.
DESIGN.md's API block gains the two new routes, and the four it was already
missing: GET/POST/DELETE /gardens/:id/share-link and the unauthenticated
GET /public/gardens/:token. An unauthenticated surface absent from the
architecture doc is the one worth fixing on sight.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
@@ -5,18 +5,20 @@ import { useClearObject } from '@/lib/objects'
|
||||
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
|
||||
* are kept with removed_at, so history survives). */
|
||||
export function ClearBedModal({
|
||||
objectId,
|
||||
objectName,
|
||||
plops,
|
||||
plopCount,
|
||||
gardenId,
|
||||
onClose,
|
||||
}: {
|
||||
objectId: number
|
||||
objectName: string
|
||||
plops: { id: number; version: number }[]
|
||||
plopCount: number
|
||||
gardenId: number
|
||||
onClose: () => void
|
||||
}) {
|
||||
const clear = useClearObject(gardenId)
|
||||
const n = plops.length
|
||||
const n = plopCount
|
||||
return (
|
||||
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
@@ -32,7 +34,7 @@ export function ClearBedModal({
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={clear.isPending || n === 0}
|
||||
onClick={() => clear.mutate(plops, { onSuccess: onClose })}
|
||||
onClick={() => clear.mutate(objectId, { onSuccess: onClose })}
|
||||
>
|
||||
{clear.isPending ? 'Clearing…' : 'Clear bed'}
|
||||
</Button>
|
||||
|
||||
+13
-17
@@ -328,28 +328,24 @@ export function useUpdatePlanting(gardenId: number) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Clear a bed: soft-remove every active plop in an object (a loop of PATCHes;
|
||||
* a bulk ClearObject endpoint arrives with the agent seam, #19). Invalidates
|
||||
* once at the end. Pass the object's active plops (id + current version). */
|
||||
/** Clear a bed: soft-remove every active plop in an object (#82).
|
||||
*
|
||||
* ONE request, and so ONE change set. This used to be a loop of PATCHes, which
|
||||
* meant clearing a 40-plop bed wrote 40 change sets and took 40 presses of Undo
|
||||
* to put back — while the agent's clear_object, for the identical user-facing
|
||||
* action, undid in a single click. The rule it violated is stated in CLAUDE.md:
|
||||
* multi-row operations record all their changes together so they undo as one
|
||||
* unit. Doing it server-side also removes the partial-failure case the old loop
|
||||
* had to reconcile. */
|
||||
export function useClearObject(gardenId: number) {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (plops: { id: number; version: number }[]) => {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
// allSettled, not all: a partial failure still soft-removed some rows
|
||||
// server-side, so we must reconcile the cache rather than roll everything
|
||||
// back. Report how many failed.
|
||||
const results = await Promise.allSettled(
|
||||
plops.map((p) => api.patch(`/plantings/${p.id}`, { removedAt: today, version: p.version })),
|
||||
)
|
||||
const failed = results.filter((r) => r.status === 'rejected').length
|
||||
if (failed > 0) {
|
||||
throw new Error(`${failed} of ${plops.length} plants couldn't be cleared — refresh and try again.`)
|
||||
}
|
||||
mutationFn: async (objectId: number): Promise<number> => {
|
||||
const res = (await api.post(`/objects/${objectId}/clear`, {})) as { cleared?: number }
|
||||
return res?.cleared ?? 0
|
||||
},
|
||||
// Reconcile on success OR partial failure, so the cache matches the server.
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: fullKey(gardenId) }),
|
||||
onError: (err) => toast.error(err instanceof Error ? err.message : 'Could not clear the bed.'),
|
||||
onError: (err) => toast.error(objectErrorMessage(err, 'Could not clear the bed.')),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -526,8 +526,9 @@ export function GardenEditorPage() {
|
||||
|
||||
{clearing && focusedObject && (
|
||||
<ClearBedModal
|
||||
objectId={focusedObject.id}
|
||||
objectName={objectDisplayName(focusedObject)}
|
||||
plops={focusedPlops.map((p) => ({ id: p.id, version: p.version }))}
|
||||
plopCount={focusedPlops.length}
|
||||
gardenId={gid}
|
||||
onClose={() => setClearing(false)}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user