Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c12dcfe2a |
@@ -85,12 +85,6 @@ Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
|
||||
deliberately. `ErrForbidden` means "you can see it but may not do that".
|
||||
- **Plops (plantings) live in their parent object's local frame**, origin at the
|
||||
object's center, `-y` is north. Moving or rotating a bed moves its plants free.
|
||||
- **A plop is a clump, not a plant.** `defaultPlopRadius` is `1.5 × spacing`, so a
|
||||
plop is three spacings across and holds `π·r²/spacing²` plants. Reasoning about
|
||||
fills as if one plop were one plant gets the geometry wrong every time — which
|
||||
is how #75 happened: requiring the whole circle inside the bed inset the outer
|
||||
row by 1.5 spacings when the horticultural rule is *half* a spacing. Spacing is
|
||||
a constraint between neighbouring plants; a bed edge is nobody's neighbour.
|
||||
- **Soft removal**: "clear bed" sets `removed_at`; the editor reads
|
||||
`removed_at IS NULL`. Hard delete is a different operation.
|
||||
- **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run
|
||||
@@ -98,29 +92,6 @@ Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
|
||||
- **Every service mutation lands in history** (#48). If you add one, record it —
|
||||
see `internal/service/revisions.go`. Multi-row operations pass all their
|
||||
changes to a single `record` call so they undo as one unit.
|
||||
- **The history write is detached from cancellation on purpose.** `commitScope`
|
||||
calls `context.WithoutCancel` — that is not a mistake to tidy up. By the time
|
||||
a commit runs, the rows it describes are already written, so cancelling it
|
||||
cannot undo anything; it can only leave real changes with no way to undo them.
|
||||
This was a live bug twice (#73): a client disconnect mid-request orphaned 18
|
||||
plantings. Fixing it per-call-site is how it came back, which is why the rule
|
||||
lives in `commitScope` where no caller can forget it.
|
||||
|
||||
## Testing
|
||||
|
||||
Match the test to the failure it would catch:
|
||||
|
||||
- **Anything addressed by its own id needs an API-level test through the router.**
|
||||
Service tests can't see a route that was never registered — PATCH/DELETE
|
||||
`/journal/:id` once shipped fully implemented, fully unit-tested, and
|
||||
completely unreachable.
|
||||
- **Watch for fixtures that assert your assumptions instead of the API.** A test
|
||||
for the undo message passed because the fixture I wrote populated a field the
|
||||
real response leaves empty. If a test builds the thing it's testing against,
|
||||
it is checking your mental model, not the system.
|
||||
- Some things only real use finds. The agent's whole loop is covered by
|
||||
majordomo's scriptable fake provider (`provider/fake`), which is worth using —
|
||||
but the three worst v2 bugs all turned up in one live session afterwards.
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -129,22 +100,6 @@ fix what's real → merge when the pipeline is green. Do not grade Gadfly findin
|
||||
A push to `main` builds the image and deploys to Komodo; the live instance at
|
||||
`pansy.orgrimmar.dudenhoeffer.casa` updates a few minutes later.
|
||||
|
||||
**Gadfly reviews the PR as opened, not as merged.** The workflow triggers on
|
||||
`opened`/`reopened`/`ready_for_review` — deliberately *not* `synchronize` — so
|
||||
every commit you push afterwards, including the ones you push in response to
|
||||
Gadfly itself, is unreviewed unless you ask. Once you've stopped pushing and
|
||||
before you merge, comment **`@gadfly review`** on the PR to re-trigger it. The
|
||||
phrase is required, and this is not hypothetical: on #76 the follow-up commit
|
||||
was the one that contained a real bug.
|
||||
|
||||
**A skipped Gadfly run reports success.** A comment without the trigger phrase
|
||||
still starts the workflow, which logs `comment does not contain trigger phrase`
|
||||
and exits green in ~2 seconds. So "the pipeline is green" does NOT mean "this
|
||||
was reviewed". Confirm a re-review actually ran by its **duration** — a real
|
||||
pass takes ~10 minutes, a skip takes 2 seconds. Don't look for a new consensus
|
||||
comment: Gadfly EDITS its existing status-board and consensus comments in place,
|
||||
so their `created_at` stays at the first review and only `updated_at` moves.
|
||||
|
||||
Workflow- and config-only changes (CI, this file, docs) go straight to `main`
|
||||
without the PR dance.
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ Work is tracked in Gitea issues; the tracking epic links every piece in dependen
|
||||
## Decisions
|
||||
|
||||
- **Placement model:** freeform plops (not a square-foot grid), scaled by real plant spacing. Grid snapping may come later as a toggle.
|
||||
- **Spacing is a plant-to-plant rule, so bed edges get half of it.** A bed edge is not a competitor for soil, light or water, so the outer row owes it half the spacing rather than a full one. `FillRegion` centres its lattice accordingly, and lets a plop — a *clump* three spacings across — cross the edge by up to half a spacing so its outermost plants land at that half-spacing. The rule, the square-foot-chart arithmetic behind it, and the failure mode it prevents are written out once in `hexCenters`; #75 is what getting it wrong looked like.
|
||||
- **Stack:** Go 1.26.x backend, module `gitea.stevedudenhoeffer.com/steve/pansy`; React + TypeScript + Vite + Tailwind frontend, production build embedded via `embed.FS` → one static binary (`CGO_ENABLED=0`).
|
||||
- **Users:** multi-user with ownership. Users own gardens; a garden can be shared with other users as viewer (read) or editor (edit content). Owner additionally shares/deletes.
|
||||
- **Auth:** OIDC-first (Authentik is the primary IdP), local argon2id passwords as an optional fallback.
|
||||
|
||||
@@ -73,8 +73,6 @@ The garden assistant reads three more. Setting none of them leaves the assistant
|
||||
|
||||
The assistant acts without asking first, which is only reasonable because every turn is one undoable change set — see the History panel in the editor.
|
||||
|
||||
**If you set the key and the assistant still doesn't appear**, check that the variable reaches the *container*, not just your orchestrator's stack config — Compose needs it listed under the service's `environment:`. pansy logs `garden assistant disabled` at startup with which of the three conditions failed, so the answer is in the first few lines of the log.
|
||||
|
||||
Local email/password auth is live (`POST /api/v1/auth/register`, `/auth/login`, `/auth/logout`, `GET /auth/me`, `GET /auth/providers`); the session is an HttpOnly cookie (`Secure` when `PANSY_BASE_URL` is https). The first account registered becomes admin, and it may register even when `PANSY_REGISTRATION=closed` to bootstrap the instance.
|
||||
|
||||
OIDC (Authentik-first) is live too: set `PANSY_OIDC_ISSUER`, `PANSY_OIDC_CLIENT_ID`, `PANSY_OIDC_CLIENT_SECRET`, and `PANSY_BASE_URL` (needed for the redirect URI). Register `PANSY_BASE_URL` + `/api/v1/auth/oidc/callback` as the redirect URI in your IdP. `GET /auth/oidc/login` starts an authorization-code + PKCE flow; first login provisions a user just-in-time (a matching *verified* email links to an existing local account instead of duplicating it). Provider discovery is lazy, so a briefly-unreachable IdP never blocks startup or local auth. Set `PANSY_LOCAL_AUTH=false` for pure-Authentik deployments (local register/login are then rejected and hidden from `/auth/providers`).
|
||||
@@ -113,8 +111,7 @@ services:
|
||||
# PANSY_OIDC_ISSUER: https://auth.example.com/application/o/pansy/
|
||||
# PANSY_OIDC_CLIENT_ID: ...
|
||||
# PANSY_OIDC_CLIENT_SECRET: ...
|
||||
# OLLAMA_CLOUD_API_KEY: ${OLLAMA_CLOUD_API_KEY} # enables the garden assistant
|
||||
# PANSY_AGENT_MODEL: ollama-cloud/glm-5.2:cloud
|
||||
# OLLAMA_CLOUD_API_KEY: ... # enables the garden assistant
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
pansy-data:
|
||||
|
||||
+10
-74
@@ -8,8 +8,6 @@ import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
mdagent "gitea.stevedudenhoeffer.com/steve/majordomo/agent"
|
||||
"gitea.stevedudenhoeffer.com/steve/majordomo/llm"
|
||||
@@ -26,11 +24,6 @@ import (
|
||||
// by everything at once, which reads as a hang — and the whole design rests on
|
||||
// watching the canvas change as it happens.
|
||||
|
||||
// keepAliveInterval is how often a quiet stream emits a comment frame. Well
|
||||
// under the 30–60s idle timeout typical of reverse proxies, which is the thing
|
||||
// it exists to stay ahead of.
|
||||
const keepAliveInterval = 20 * time.Second
|
||||
|
||||
// chatRequest is the body of POST /agent/chat.
|
||||
type chatRequest struct {
|
||||
GardenID int64 `json:"gardenId" binding:"required"`
|
||||
@@ -69,21 +62,13 @@ func (h *handlers) agentChat(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
stream := openEventStream(c)
|
||||
send := stream.send
|
||||
|
||||
// A model thinking hard between tool calls sends nothing for a while, and an
|
||||
// idle proxy will cut a quiet connection. Deferred so a panic in the run
|
||||
// can't leak the ticker goroutine; stopping it twice is harmless.
|
||||
stopBeat := stream.keepAlive(keepAliveInterval)
|
||||
defer stopBeat()
|
||||
send := openEventStream(c)
|
||||
|
||||
turn, err := h.agent.Run(c.Request.Context(), actor.ID, req.GardenID, req.Message,
|
||||
replayHistory(history),
|
||||
func(s mdagent.Step) {
|
||||
send(chatEvent{Step: &stepEvent{Index: s.Index, Tools: toolNames(s)}})
|
||||
})
|
||||
stopBeat()
|
||||
if err != nil {
|
||||
// The stream is already open, so an error is an event rather than a
|
||||
// status code — the client has committed to reading a stream by now.
|
||||
@@ -108,74 +93,25 @@ func (h *handlers) agentChat(c *gin.Context) {
|
||||
send(chatEvent{Done: turn})
|
||||
}
|
||||
|
||||
// eventStream serializes writes to one SSE response.
|
||||
//
|
||||
// The mutex is load-bearing, not decoration: step events are sent from the
|
||||
// agent's run goroutine while the keep-alive ticker writes from its own, and two
|
||||
// goroutines writing a ResponseWriter concurrently is a data race that corrupts
|
||||
// frames long before it crashes anything.
|
||||
type eventStream struct {
|
||||
c *gin.Context
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// openEventStream puts the response into SSE mode.
|
||||
// openEventStream puts the response into SSE mode and returns a sender.
|
||||
//
|
||||
// Headers go out before the first write and the stream is flushed immediately,
|
||||
// so a proxy holding the response until it looks complete can't reintroduce
|
||||
// exactly the silence streaming exists to remove.
|
||||
func openEventStream(c *gin.Context) *eventStream {
|
||||
func openEventStream(c *gin.Context) func(chatEvent) {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
c.Writer.Flush()
|
||||
return &eventStream{c: c}
|
||||
}
|
||||
|
||||
func (s *eventStream) send(ev chatEvent) {
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
slog.Error("api: encode chat event", "error", err)
|
||||
return
|
||||
}
|
||||
s.write(fmt.Sprintf("data: %s\n\n", b))
|
||||
}
|
||||
|
||||
func (s *eventStream) write(frame string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
_, _ = io.WriteString(s.c.Writer, frame)
|
||||
s.c.Writer.Flush()
|
||||
}
|
||||
|
||||
// keepAlive writes an SSE comment frame on an interval until the returned
|
||||
// function is called, so a long silence while the model thinks doesn't look like
|
||||
// a dead connection to whatever sits in between. SSE ignores comment frames, so
|
||||
// this costs the client nothing.
|
||||
func (s *eventStream) keepAlive(every time.Duration) func() {
|
||||
done := make(chan struct{})
|
||||
stopped := make(chan struct{})
|
||||
go func() {
|
||||
defer close(stopped)
|
||||
t := time.NewTicker(every)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-s.c.Request.Context().Done():
|
||||
return
|
||||
case <-t.C:
|
||||
s.write(": keep-alive\n\n")
|
||||
}
|
||||
return func(ev chatEvent) {
|
||||
b, err := json.Marshal(ev)
|
||||
if err != nil {
|
||||
slog.Error("api: encode chat event", "error", err)
|
||||
return
|
||||
}
|
||||
}()
|
||||
// Idempotent: the handler stops it explicitly when the run returns and again
|
||||
// via defer, so a panic can't leak the goroutine.
|
||||
var once sync.Once
|
||||
return func() {
|
||||
once.Do(func() { close(done) })
|
||||
<-stopped
|
||||
_, _ = fmt.Fprintf(c.Writer, "data: %s\n\n", b)
|
||||
c.Writer.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+6
-25
@@ -53,10 +53,12 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
// is set; see csrfGuard).
|
||||
v1.Use(h.csrfGuard())
|
||||
v1.GET("/healthz", healthz)
|
||||
// What this instance can actually do, so the UI offers only what works.
|
||||
// Registered after the agent below, because it reports whether the runner
|
||||
// actually built — not merely whether it was configured to.
|
||||
v1.GET("/capabilities", h.capabilities)
|
||||
// What this instance can actually do, so the UI offers only what works. The
|
||||
// agent routes 404 when unconfigured; without this the client would have to
|
||||
// probe for a 404 to find that out, and a dead button is worse than no button.
|
||||
v1.GET("/capabilities", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"agent": cfg.Agent.Ready()})
|
||||
})
|
||||
|
||||
// Auth endpoints are exempt from requireAuth (you can't be logged in yet);
|
||||
// /me is the one that needs a session. Feature routers in later issues attach
|
||||
@@ -129,17 +131,6 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
// The garden assistant, registered only when it can actually be offered —
|
||||
// the same shape as OIDC. An instance with no API key serves the app
|
||||
// normally and simply doesn't have these routes.
|
||||
if !cfg.Agent.Ready() {
|
||||
// Say WHY, at startup, in the logs an operator is already looking at.
|
||||
// Someone who set the key and sees no assistant otherwise has nothing to
|
||||
// check — and "is the variable reaching the container?" is exactly the
|
||||
// question they need answered.
|
||||
slog.Info("api: garden assistant disabled",
|
||||
"enabled", cfg.Agent.Enabled,
|
||||
"hasApiKey", cfg.Agent.OllamaCloudAPIKey != "",
|
||||
"model", cfg.Agent.Model,
|
||||
"hint", "needs OLLAMA_CLOUD_API_KEY set in the container's environment (not just the stack's)")
|
||||
}
|
||||
if cfg.Agent.Ready() {
|
||||
runner, err := agent.NewRunner(svc, cfg)
|
||||
if err != nil {
|
||||
@@ -195,16 +186,6 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
return r
|
||||
}
|
||||
|
||||
// capabilities reports what this instance can actually do, so the UI offers only
|
||||
// what works.
|
||||
//
|
||||
// It reports whether the runner BUILT, not whether it was configured: a
|
||||
// configured-but-unresolvable model leaves the routes unregistered, and saying
|
||||
// "yes" there would offer a chat tab whose first message 404s.
|
||||
func (h *handlers) capabilities(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"agent": h.agent != nil})
|
||||
}
|
||||
|
||||
// healthz is a liveness probe: always returns {"ok": true} when the server is up.
|
||||
func healthz(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
|
||||
+33
-133
@@ -28,9 +28,13 @@ type Region struct {
|
||||
MinX, MinY, MaxX, MaxY float64
|
||||
}
|
||||
|
||||
// contains reports whether a local point lies in the region.
|
||||
func (r Region) contains(x, y float64) bool {
|
||||
return x >= r.MinX && x <= r.MaxX && y >= r.MinY && y <= r.MaxY
|
||||
}
|
||||
|
||||
// clampTo intersects the region with an object's local bounds (±halfW, ±halfH),
|
||||
// so a fill can't plant outside the object it was aimed at. A region that misses
|
||||
// the object entirely comes back empty — see empty().
|
||||
// so an oversized caller-supplied region can't make hexCenters loop forever.
|
||||
func (r Region) clampTo(halfW, halfH float64) Region {
|
||||
return Region{
|
||||
MinX: math.Max(r.MinX, -halfW), MinY: math.Max(r.MinY, -halfH),
|
||||
@@ -38,16 +42,6 @@ func (r Region) clampTo(halfW, halfH float64) Region {
|
||||
}
|
||||
}
|
||||
|
||||
// empty reports whether the region encloses nothing.
|
||||
//
|
||||
// This exists because clampTo expresses "no overlap" by INVERTING the region —
|
||||
// Max clamps below Min — rather than by zeroing it, which is not something a
|
||||
// reader guesses. Naming it once here beats a bare `MaxX < MinX` at each place
|
||||
// that has to care.
|
||||
func (r Region) empty() bool {
|
||||
return r.MaxX < r.MinX || r.MaxY < r.MinY
|
||||
}
|
||||
|
||||
// rect builds a rectangular region.
|
||||
func rect(minX, minY, maxX, maxY float64) Region {
|
||||
return Region{MinX: minX, MinY: minY, MaxX: maxX, MaxY: maxY}
|
||||
@@ -100,11 +94,9 @@ func defaultPlopRadius(spacingCM float64) float64 {
|
||||
// FillRegion lays a hex-packed field of plops of one plant across a region of a
|
||||
// plantable object the actor can edit. Plop radius comes from the plant's spacing
|
||||
// (or spacingOverride) via defaultPlopRadius; centers sit on a hex lattice at 2×
|
||||
// radius pitch, centered in the region, and set in from each edge by the plop's
|
||||
// radius less half a spacing — see hexCenters for why that half-spacing is what
|
||||
// the edge is owed. A candidate is skipped when its plop would sit entirely
|
||||
// inside an existing active plop (so re-filling doesn't stack duplicates).
|
||||
// Returns the plops it created.
|
||||
// radius pitch, kept where the center is inside the region. A candidate is
|
||||
// skipped when its plop would sit entirely inside an existing active plop (so
|
||||
// re-filling doesn't stack duplicates). Returns the plops it created.
|
||||
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64) ([]domain.Planting, error) {
|
||||
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
|
||||
if err != nil {
|
||||
@@ -114,9 +106,9 @@ func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, regio
|
||||
}
|
||||
|
||||
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
|
||||
// already loaded and authorized (roleEditor). It 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.
|
||||
// already loaded and authorized (roleEditor). It 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) ([]domain.Planting, error) {
|
||||
if !o.Plantable {
|
||||
return nil, domain.ErrInvalidInput
|
||||
@@ -137,21 +129,9 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
// A caller-supplied region is arbitrary floats, and non-finite ones survive
|
||||
// everything downstream: clamping keeps them, the inverted-region guard can't
|
||||
// see NaN (it compares false both ways), and fitAxis centres on them happily.
|
||||
// Nothing corrupt reaches the table — SQLite stores NaN as NULL and the NOT
|
||||
// NULL constraint refuses it — but the caller gets an opaque store error for
|
||||
// NaN, and for +Inf a silent zero-plop success. Both are lies about what went
|
||||
// wrong; say "bad input" here instead.
|
||||
if !isFinite(region.MinX) || !isFinite(region.MinY) ||
|
||||
!isFinite(region.MaxX) || !isFinite(region.MaxY) {
|
||||
return nil, domain.ErrInvalidInput
|
||||
}
|
||||
|
||||
region = region.clampTo(o.WidthCM/2, o.HeightCM/2)
|
||||
centers, total := hexCenters(region, radius, spacing, maxFillPlops)
|
||||
if total > maxFillPlops {
|
||||
centers := hexCenters(region, radius)
|
||||
if len(centers) > maxFillPlops {
|
||||
return nil, domain.ErrInvalidInput // region too large for this spacing; ask for less
|
||||
}
|
||||
|
||||
@@ -189,112 +169,32 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
|
||||
|
||||
type localPoint struct{ x, y float64 }
|
||||
|
||||
// hexCenters returns hex-packed lattice centers filling a region: rows radius·√3
|
||||
// apart, alternate rows offset by half a pitch, at a 2×radius pitch. The lattice
|
||||
// is CENTERED, so the leftover is shared between opposite edges instead of piling
|
||||
// up against the far one.
|
||||
//
|
||||
// # How close to the edge the outer row goes
|
||||
//
|
||||
// Spacing is a constraint BETWEEN NEIGHBOURING PLANTS competing for the same
|
||||
// soil, light and water. A bed edge is not a competitor, so the outer row only
|
||||
// owes it HALF the spacing — the half it would otherwise share with a neighbour.
|
||||
// That is the arithmetic inside every square-foot-gardening chart: 4 per square
|
||||
// is 6" apart and 3" from the square's edge; 9 per square is 4" apart and 2"
|
||||
// from the edge. Garlic at 9 per square goes in 2" from the frame, not 6".
|
||||
//
|
||||
// A plop is a CLUMP, not a plant — defaultPlopRadius makes it 1.5×spacing, so
|
||||
// three spacings across — and its plants sit out to its rim. So keeping the whole
|
||||
// circle inside the bed would inset the outer row by a full 1.5 spacings, three
|
||||
// times what the rule allows. Instead the clump may hang over the edge by up to
|
||||
// half a spacing, which puts its outermost plants exactly the half-spacing from
|
||||
// the edge that the rule asks for. Overhang is capped there and nowhere near the
|
||||
// full radius: a clump mostly outside the bed is a drawing of plants in the path.
|
||||
//
|
||||
// Do not "simplify" this back to anchoring at the region's min corner. That is
|
||||
// what #75 was: staggered rows start a full pitch in, and the leftover all lands
|
||||
// on the far edge, where clumps hang outside a bed that nothing clips them to.
|
||||
//
|
||||
// # Counting before building
|
||||
//
|
||||
// hexCenters returns the total alongside the points, and works that total out
|
||||
// BEFORE building anything: a fill large enough to be refused shouldn't allocate
|
||||
// its whole lattice first just to be counted and thrown away. Over `limit` it
|
||||
// returns (nil, total), so the caller can still refuse with the real number.
|
||||
func hexCenters(r Region, radius, spacing float64, limit int) ([]localPoint, int) {
|
||||
// hexCenters returns hex-packed lattice centers whose center lies in the region.
|
||||
// Rows are spaced radius·√3 apart and every other row is offset by radius, the
|
||||
// standard hexagonal packing at a 2×radius pitch. The lattice is anchored one
|
||||
// radius inside the region's min corner so the first plop sits inside it.
|
||||
func hexCenters(r Region, radius float64) []localPoint {
|
||||
if radius <= 0 {
|
||||
return nil, 0
|
||||
}
|
||||
// An empty region has no inside to plant. The old loop-until-past-MaxX form
|
||||
// got this for free by never entering the loop; counting positions up front
|
||||
// does not, and would site a plop off the bed.
|
||||
if r.empty() {
|
||||
return nil, 0
|
||||
return nil
|
||||
}
|
||||
pitch := 2 * radius
|
||||
rowH := pitch * math.Sqrt(3) / 2
|
||||
|
||||
// How far a clump's centre must stay inside the edge: its own radius, less the
|
||||
// half-spacing of overhang the rule allows. Never negative, and never past the
|
||||
// centre of the clump.
|
||||
inset := math.Max(0, radius-math.Max(0, spacing)/2)
|
||||
|
||||
rows, y0 := fitAxis(r.MaxY-r.MinY, rowH, inset)
|
||||
cols, x0 := fitAxis(r.MaxX-r.MinX, pitch, inset)
|
||||
|
||||
// Exact, not an upper bound: staggered rows hold one fewer, so rows*cols would
|
||||
// over-reserve by ~12% — and, more to the point, allocating it is the thing we
|
||||
// are trying to avoid when the answer is "too many".
|
||||
staggered := cols
|
||||
if cols > 1 {
|
||||
staggered = cols - 1
|
||||
}
|
||||
total := (rows+1)/2*cols + rows/2*staggered
|
||||
if total > limit {
|
||||
return nil, total
|
||||
}
|
||||
|
||||
pts := make([]localPoint, 0, total)
|
||||
for row := 0; row < rows; row++ {
|
||||
y := r.MinY + y0 + float64(row)*rowH
|
||||
n, x := cols, r.MinX+x0
|
||||
// The stagger falls out of centering: an offset row holds one fewer plop,
|
||||
// and centering THAT run puts it exactly half a pitch off its neighbours.
|
||||
// A single-column region has nothing to stagger against.
|
||||
if row%2 == 1 && cols > 1 {
|
||||
n, x = staggered, r.MinX+x0+pitch/2
|
||||
const eps = 1e-6
|
||||
var pts []localPoint
|
||||
row := 0
|
||||
for y := r.MinY + radius; y <= r.MaxY+eps; y += rowH {
|
||||
xStart := r.MinX + radius
|
||||
if row%2 == 1 {
|
||||
xStart += radius
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
pts = append(pts, localPoint{x + float64(i)*pitch, y})
|
||||
for x := xStart; x <= r.MaxX+eps; x += pitch {
|
||||
if r.contains(x, y) {
|
||||
pts = append(pts, localPoint{x, y})
|
||||
}
|
||||
}
|
||||
row++
|
||||
}
|
||||
return pts, total
|
||||
}
|
||||
|
||||
// fitAxis returns how many lattice positions fit along a span at `step`, keeping
|
||||
// at least `inset` from each end, and the offset from the span's start that
|
||||
// centers them — so the leftover is split between the two edges rather than all
|
||||
// landing on the far one.
|
||||
//
|
||||
// A span too small to hold even one position at that inset still gets one, in the
|
||||
// middle: filling a bed narrower than a single plop with one plop is a better
|
||||
// answer than refusing to plant it.
|
||||
//
|
||||
// The step<=0 half of that guard is currently unreachable — hexCenters, the only
|
||||
// caller, returns early unless radius > 0, which makes both steps it passes
|
||||
// positive. It stays because dividing by a non-positive step yields ±Inf and then
|
||||
// a garbage int conversion, and a helper this small should not require reading
|
||||
// its caller to know it is safe. Deliberate, not an oversight.
|
||||
func fitAxis(length, step, inset float64) (n int, start float64) {
|
||||
if step <= 0 || length < 2*inset {
|
||||
return 1, length / 2
|
||||
}
|
||||
// The epsilon keeps an exact fit from being lost to floating point — a 60cm
|
||||
// span at a 30cm step should give 2 positions, not 1 because the division
|
||||
// landed on 0.9999999.
|
||||
const eps = 1e-9
|
||||
n = int(math.Floor((length-2*inset)/step+eps)) + 1
|
||||
return n, (length - float64(n-1)*step) / 2
|
||||
return pts
|
||||
}
|
||||
|
||||
// coveredByExisting reports whether a new plop (center, radius) would sit
|
||||
|
||||
@@ -3,8 +3,6 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
"testing"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
@@ -73,165 +71,6 @@ func TestDefaultPlopRadius(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexCentersEdgeInset pins the spacing rule the packing exists to honour:
|
||||
// spacing is a constraint between neighbouring plants, so a bed edge — which is
|
||||
// nobody's neighbour — is owed half a pitch, not a whole one.
|
||||
//
|
||||
// The bug this guards against was visible to anyone who filled a bed: staggered
|
||||
// rows began a full pitch in, leaving a bare strip a whole plop wide down one
|
||||
// side of every other row, while the far edge had plops hanging off it.
|
||||
func TestHexCentersEdgeInset(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
w, h, radius, spacing float64
|
||||
wantRowStarts []float64 // x of the first plop in rows 0 and 1
|
||||
}{
|
||||
// 4ft × 8ft bed, garlic at 15cm spacing → radius 22.5, pitch 45. Three
|
||||
// columns, the outer ones overhanging by 6.5cm — under the 7.5cm the rule
|
||||
// allows. Anchored at the corner this row started at -38.5 and its
|
||||
// staggered neighbour a full 45 further in still.
|
||||
{"4ft bed of garlic", 122, 244, 22.5, 15, []float64{-45, -22.5}},
|
||||
// An exact fit: 90 wide at pitch 30 → 3 columns, no overhang needed.
|
||||
{"exact fit", 90, 90, 15, 10, []float64{-30, -15}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
r := rect(-tc.w/2, -tc.h/2, tc.w/2, tc.h/2)
|
||||
pts, total := hexCenters(r, tc.radius, tc.spacing, maxFillPlops)
|
||||
if len(pts) == 0 {
|
||||
t.Fatal("no centers")
|
||||
}
|
||||
// The count is derived up front so an oversized fill is refused without
|
||||
// building its lattice — which only works if it matches what gets built.
|
||||
if total != len(pts) {
|
||||
t.Errorf("reported total %d, built %d", total, len(pts))
|
||||
}
|
||||
|
||||
// A clump may cross the edge, but only by the half-spacing the rule
|
||||
// allows — never enough to be mostly out in the path.
|
||||
budget := tc.spacing / 2
|
||||
for _, p := range pts {
|
||||
over := math.Max(
|
||||
math.Max(r.MinX-(p.x-tc.radius), (p.x+tc.radius)-r.MaxX),
|
||||
math.Max(r.MinY-(p.y-tc.radius), (p.y+tc.radius)-r.MaxY),
|
||||
)
|
||||
if over > budget+1e-6 {
|
||||
t.Errorf("plop at (%.1f,%.1f) overhangs by %.2f, budget %.2f", p.x, p.y, over, budget)
|
||||
}
|
||||
}
|
||||
|
||||
// The margins match on opposite edges: the leftover is shared, not piled
|
||||
// against the far side.
|
||||
minX, maxX, minY, maxY := pts[0].x, pts[0].x, pts[0].y, pts[0].y
|
||||
for _, p := range pts {
|
||||
minX, maxX = math.Min(minX, p.x), math.Max(maxX, p.x)
|
||||
minY, maxY = math.Min(minY, p.y), math.Max(maxY, p.y)
|
||||
}
|
||||
if w, e := minX-r.MinX, r.MaxX-maxX; math.Abs(w-e) > 1e-6 {
|
||||
t.Errorf("lopsided horizontally: west margin %.2f, east %.2f", w, e)
|
||||
}
|
||||
if n, s := minY-r.MinY, r.MaxY-maxY; math.Abs(n-s) > 1e-6 {
|
||||
t.Errorf("lopsided vertically: north margin %.2f, south %.2f", n, s)
|
||||
}
|
||||
|
||||
// The staggered row is offset by HALF a pitch, not a whole one.
|
||||
starts := map[float64]float64{}
|
||||
for _, p := range pts {
|
||||
if x, ok := starts[p.y]; !ok || p.x < x {
|
||||
starts[p.y] = p.x
|
||||
}
|
||||
}
|
||||
ys := make([]float64, 0, len(starts))
|
||||
for y := range starts {
|
||||
ys = append(ys, y)
|
||||
}
|
||||
sort.Float64s(ys)
|
||||
for i, want := range tc.wantRowStarts {
|
||||
if i >= len(ys) {
|
||||
t.Fatalf("only %d rows, want at least %d", len(ys), len(tc.wantRowStarts))
|
||||
}
|
||||
if got := starts[ys[i]]; math.Abs(got-want) > 1e-6 {
|
||||
t.Errorf("row %d starts at x=%.2f, want %.2f", i, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHexCentersTinyRegion covers a region too small to hold a plop at the
|
||||
// half-pitch inset: planting one in the middle beats refusing to plant at all.
|
||||
//
|
||||
// The off-centre case earns its place — a region symmetric about the origin
|
||||
// can't tell "the middle of the region" from "the origin", so on its own it
|
||||
// would pass for an implementation that just returned (0,0).
|
||||
func TestHexCentersTinyRegion(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
r Region
|
||||
wantX, wantY float64
|
||||
}{
|
||||
{"centred on the origin", rect(-5, -5, 5, 5), 0, 0},
|
||||
{"off in a corner", rect(20, -40, 30, -30), 25, -35},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
pts, _ := hexCenters(tc.r, 15, 10, maxFillPlops)
|
||||
if len(pts) != 1 || pts[0].x != tc.wantX || pts[0].y != tc.wantY {
|
||||
t.Errorf("got %+v, want one plop at (%v,%v)", pts, tc.wantX, tc.wantY)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillRegionRejectsNonFiniteRegion: non-finite bounds survive clamping and
|
||||
// the inverted-region guard (NaN compares false both ways). Without the explicit
|
||||
// check, NaN surfaced as a raw store error ("NOT NULL constraint failed") and
|
||||
// +Inf as a silent success that planted nothing — neither of which tells the
|
||||
// caller what it actually did wrong.
|
||||
func TestFillRegionRejectsNonFiniteRegion(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
|
||||
bed := seedFillBed(t, s, owner, g.ID, 100, 100)
|
||||
plant := seedOwnPlant(t, s, owner, 10)
|
||||
|
||||
nan := math.NaN()
|
||||
for _, r := range []Region{
|
||||
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
|
||||
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
|
||||
} {
|
||||
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil)
|
||||
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
|
||||
}
|
||||
for _, p := range created {
|
||||
if !isFinite(p.XCM) || !isFinite(p.YCM) {
|
||||
t.Errorf("persisted a plop with non-finite coordinates: %+v", p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillRegionOutsideObjectPlantsNothing covers a region that misses the object
|
||||
// entirely. clampTo inverts such a region rather than emptying it, and an
|
||||
// inverted region must plant nothing — not one plop at some point off the bed.
|
||||
func TestFillRegionOutsideObjectPlantsNothing(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Big", WidthCM: 2000, HeightCM: 2000})
|
||||
bed := seedFillBed(t, s, owner, g.ID, 100, 100) // local bounds ±50
|
||||
plant := seedOwnPlant(t, s, owner, 10)
|
||||
|
||||
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50.
|
||||
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("FillRegion: %v", err)
|
||||
}
|
||||
if len(created) != 0 {
|
||||
t.Errorf("filled %d plops for a region outside the bed, want 0: %+v", len(created), created)
|
||||
}
|
||||
}
|
||||
|
||||
// seedFillBed makes a plantable bed of the given size centered in a big garden.
|
||||
func seedFillBed(t *testing.T, s *Service, owner, gardenID int64, w, h float64) *domain.GardenObject {
|
||||
t.Helper()
|
||||
@@ -260,24 +99,16 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("FillRegion: %v", err)
|
||||
}
|
||||
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart, centered: a row of 2
|
||||
// (x=±15), then a staggered row of 1 (x=0) → 3 plops.
|
||||
//
|
||||
// This was 4 while the lattice was anchored at the min corner, and the fourth
|
||||
// sat at x=30 — centred ON the east edge, so half of it lay outside the bed,
|
||||
// well past the half-spacing (5cm here) the rule allows. Packing one fewer
|
||||
// plop is the point of the fix, not a regression in it.
|
||||
if len(created) != 3 {
|
||||
t.Fatalf("filled %d plops, want 3 (60×60 bed, radius 15)", len(created))
|
||||
// Hex lattice on [-30,30]² at pitch 30, rows ~26 apart → 4 plops (2 rows × 2).
|
||||
if len(created) != 4 {
|
||||
t.Fatalf("filled %d plops, want 4 (60×60 bed, radius 15)", len(created))
|
||||
}
|
||||
for _, p := range created {
|
||||
if p.RadiusCM != 15 || p.PlantedAt == nil || p.DerivedCount < 1 {
|
||||
t.Errorf("unexpected created plop: %+v", p)
|
||||
}
|
||||
// This bed fits its lattice exactly, so nothing should need to overhang.
|
||||
if p.XCM-p.RadiusCM < -30 || p.XCM+p.RadiusCM > 30 ||
|
||||
p.YCM-p.RadiusCM < -30 || p.YCM+p.RadiusCM > 30 {
|
||||
t.Errorf("plop overhangs a bed it fits inside: %+v", p)
|
||||
if p.XCM < -30 || p.XCM > 30 || p.YCM < -30 || p.YCM > 30 {
|
||||
t.Errorf("plop center out of bed bounds: %+v", p)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
@@ -130,7 +129,11 @@ func (s *Service) WithChangeSet(ctx context.Context, actorID, gardenID int64, op
|
||||
// which is exactly the situation undo exists for. Record what happened,
|
||||
// mark the summary, and still report the failure.
|
||||
sc.summary = partialSummary(sc.summary)
|
||||
if _, cerr := s.commitScope(ctx, sc, nil); cerr != nil {
|
||||
// Detached from cancellation on purpose. The commonest reason fn failed is
|
||||
// that ctx was cancelled or timed out — and using that same dead context
|
||||
// to record what committed would fail too, losing the history for changes
|
||||
// that really happened. That is precisely the case this path exists for.
|
||||
if _, cerr := s.commitScope(context.WithoutCancel(ctx), sc, nil); cerr != nil {
|
||||
slog.Error("service: partial turn could not be recorded", "error", cerr, "garden", gardenID)
|
||||
}
|
||||
return nil, err
|
||||
@@ -150,19 +153,11 @@ func partialSummary(summary string) string {
|
||||
// commitScope writes a scope's buffered revisions as one change set. revertsID is
|
||||
// set only by RevertChangeSet. A scope with no revisions writes nothing — an
|
||||
// operation that changed nothing doesn't belong in history.
|
||||
//
|
||||
// The write is DETACHED FROM CANCELLATION, always, and that belongs here rather
|
||||
// than at each call site so no caller can be the one that forgets. By the time a
|
||||
// commit runs, the data changes it describes have already been written — so
|
||||
// cancelling it cannot undo anything. It can only lose the record of what
|
||||
// happened and leave real changes with no way to undo them, which is the one
|
||||
// thing the whole change-set design exists to prevent.
|
||||
func (s *Service) commitScope(ctx context.Context, sc *changeScope, revertsID *int64) (*domain.ChangeSet, error) {
|
||||
revs := sc.taken()
|
||||
if len(revs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
ctx = context.WithoutCancel(ctx)
|
||||
return s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
||||
GardenID: sc.gardenID,
|
||||
ActorID: sc.actorID,
|
||||
@@ -203,13 +198,9 @@ func (s *Service) record(ctx context.Context, gardenID, actorID int64, summary s
|
||||
sc.append(revs)
|
||||
return
|
||||
}
|
||||
// Auto-scope: one operation, its own change set. Written through the same
|
||||
// detached path as everything else — a REST client that hangs up right after
|
||||
// its PATCH landed must not leave that change without history, and this is
|
||||
// the path virtually every mutation takes.
|
||||
auto := &changeScope{gardenID: gardenID, actorID: actorID, source: domain.SourceUI, summary: summary}
|
||||
auto.append(revs)
|
||||
if _, err := s.commitScope(ctx, auto, nil); err != nil {
|
||||
if _, err := s.store.WriteChangeSet(ctx, &domain.ChangeSet{
|
||||
GardenID: gardenID, ActorID: actorID, Source: domain.SourceUI, Summary: summary,
|
||||
}, revs); err != nil {
|
||||
slog.Error("service: record change set", "error", err, "garden", gardenID, "summary", summary)
|
||||
}
|
||||
}
|
||||
@@ -320,9 +311,10 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
|
||||
changes, conflict, err := s.applyInverse(ctx, r, applied)
|
||||
if err != nil {
|
||||
// Record what actually landed before surfacing the failure, so the
|
||||
// partial revert is visible and undoable rather than orphaned.
|
||||
// (commitScope detaches from cancellation itself.)
|
||||
if _, cerr := s.commitScope(ctx, sc, &target.ID); cerr != nil {
|
||||
// partial revert is visible and undoable rather than orphaned. Detached
|
||||
// from cancellation for the same reason as WithChangeSet's path: a
|
||||
// timed-out context can't be used to write the record of what it did.
|
||||
if _, cerr := s.commitScope(context.WithoutCancel(ctx), sc, &target.ID); cerr != nil {
|
||||
slog.Error("service: partial revert could not be recorded", "error", cerr, "changeSet", changeSetID)
|
||||
}
|
||||
return nil, nil, err
|
||||
@@ -342,48 +334,9 @@ func (s *Service) RevertChangeSet(ctx context.Context, actorID, changeSetID int6
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Fill in the per-op counts. commitScope returns the freshly inserted row,
|
||||
// which carries no tally — and a caller receiving a change set with an empty
|
||||
// Counts cannot tell "nothing was reverted" from "the tally wasn't loaded".
|
||||
// That ambiguity is not theoretical: it made the chat panel report a
|
||||
// successful undo as "nothing left to undo".
|
||||
if cs != nil {
|
||||
cs.Counts = countRevisions(sc.taken())
|
||||
}
|
||||
return cs, conflicts, nil
|
||||
}
|
||||
|
||||
// countRevisions tallies revisions by (entity type, op).
|
||||
//
|
||||
// This deliberately reproduces in Go what ListChangeSets does in SQL, because a
|
||||
// change set that has just been written has no rows to GROUP BY yet — the
|
||||
// alternative is a second round trip to count what we are already holding.
|
||||
// The parity is a real cross-layer contract, and TestRevertResultCarriesItsCounts
|
||||
// compares the two breakdowns row for row so it can't drift silently.
|
||||
func countRevisions(revs []domain.Revision) []domain.ChangeCount {
|
||||
type key struct{ entityType, op string }
|
||||
seen := map[key]int{}
|
||||
order := []key{}
|
||||
for _, r := range revs {
|
||||
k := key{r.EntityType, r.Op}
|
||||
if _, ok := seen[k]; !ok {
|
||||
order = append(order, k)
|
||||
}
|
||||
seen[k]++
|
||||
}
|
||||
sort.Slice(order, func(i, j int) bool {
|
||||
if order[i].entityType != order[j].entityType {
|
||||
return order[i].entityType < order[j].entityType
|
||||
}
|
||||
return order[i].op < order[j].op
|
||||
})
|
||||
counts := make([]domain.ChangeCount, 0, len(order))
|
||||
for _, k := range order {
|
||||
counts = append(counts, domain.ChangeCount{EntityType: k.entityType, Op: k.op, N: seen[k]})
|
||||
}
|
||||
return counts
|
||||
}
|
||||
|
||||
// entityKey identifies a row across the entity types a revision can name.
|
||||
type entityKey struct {
|
||||
entityType string
|
||||
|
||||
@@ -704,192 +704,3 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
|
||||
t.Errorf("cleared %d of %d with %d revisions — all three should match", n, len(before), len(revs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRevertResultCarriesItsCounts — found by using the thing.
|
||||
//
|
||||
// commitScope returns the freshly inserted row, which carries no tally. A caller
|
||||
// receiving a change set with empty Counts cannot tell "nothing was reverted"
|
||||
// from "the tally wasn't loaded", and the chat panel read that ambiguity the
|
||||
// wrong way: a successful undo reported "nothing left to undo", which is the
|
||||
// worst possible thing to say about an action that just worked.
|
||||
//
|
||||
// It also pins the cross-layer contract that fix created. countRevisions tallies
|
||||
// in Go what ListChangeSets tallies in SQL, and nothing but this test stops the
|
||||
// two drifting — so it compares the FULL per-(entity, op) breakdown, not just
|
||||
// the totals, which would agree even if the groupings had diverged.
|
||||
func TestRevertResultCarriesItsCounts(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
plant := seedOwnPlant(t, s, owner, 15)
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
|
||||
t.Fatalf("fill: %v", err)
|
||||
}
|
||||
// A second, different kind of change, so the breakdown has more than one row
|
||||
// to get wrong.
|
||||
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{Name: strPtr("North Bed")}, bed.Version); err != nil {
|
||||
t.Fatalf("rename: %v", err)
|
||||
}
|
||||
sets := history(t, s, owner, g.ID)
|
||||
rename, fill := sets[0], sets[1]
|
||||
|
||||
undoRename, conflicts, err := s.RevertChangeSet(ctx, owner, rename.ID, domain.SourceUI)
|
||||
if err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("revert rename: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
undoFill, conflicts, err := s.RevertChangeSet(ctx, owner, fill.ID, domain.SourceUI)
|
||||
if err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("revert fill: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
|
||||
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
|
||||
if undo == nil {
|
||||
t.Fatal("a revert that did work returned no change set")
|
||||
}
|
||||
if len(undo.Counts) == 0 {
|
||||
t.Fatalf("change set %d came back with no tally — an empty tally reads as 'nothing happened'", undo.ID)
|
||||
}
|
||||
}
|
||||
// Undoing a fill deletes every plop it created, so the tallies must match.
|
||||
if got, want := countsTotal(undoFill.Counts), countsTotal(fill.Counts); got != want {
|
||||
t.Errorf("undo of the fill reports %d changes, want %d", got, want)
|
||||
}
|
||||
|
||||
// The SQL tally and the Go tally must agree, row for row.
|
||||
listed := history(t, s, owner, g.ID)
|
||||
byID := map[int64][]domain.ChangeCount{}
|
||||
for _, cs := range listed {
|
||||
byID[cs.ID] = cs.Counts
|
||||
}
|
||||
for _, undo := range []*domain.ChangeSet{undoRename, undoFill} {
|
||||
fromSQL, ok := byID[undo.ID]
|
||||
if !ok {
|
||||
t.Fatalf("revert %d is missing from the history list", undo.ID)
|
||||
}
|
||||
if !sameCounts(undo.Counts, fromSQL) {
|
||||
t.Errorf("change set %d: revert returned %+v, the list read says %+v — countRevisions and the SQL grouping have drifted",
|
||||
undo.ID, undo.Counts, fromSQL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sameCounts compares two tallies regardless of order.
|
||||
func sameCounts(a, b []domain.ChangeCount) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
index := func(cs []domain.ChangeCount) map[string]int {
|
||||
m := map[string]int{}
|
||||
for _, c := range cs {
|
||||
m[c.EntityType+"/"+c.Op] = c.N
|
||||
}
|
||||
return m
|
||||
}
|
||||
ia, ib := index(a), index(b)
|
||||
for k, v := range ia {
|
||||
if ib[k] != v {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// countsTotal sums a tally — the server-side twin of the client's totalChanges.
|
||||
func countsTotal(counts []domain.ChangeCount) int {
|
||||
n := 0
|
||||
for _, c := range counts {
|
||||
n += c.N
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// TestSucceededTurnRecordsEvenIfTheCallerWentAway is the production bug.
|
||||
//
|
||||
// The failure path was detached from cancellation; the SUCCESS path was not. An
|
||||
// agent turn whose client disconnects can still COMPLETE — and then the success
|
||||
// path committed with a dead context, the write failed, and real changes were
|
||||
// left with no change set and no way to undo them. Found live with 18 plantings
|
||||
// behind no history at all.
|
||||
//
|
||||
// Nothing about a commit needs the caller to still be there: by the time it
|
||||
// runs, the data it describes has already been written.
|
||||
func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
plant := seedOwnPlant(t, s, owner, 15)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
before := len(history(t, s, owner, g.ID))
|
||||
|
||||
// fn does its work and SUCCEEDS, but the caller goes away before it returns.
|
||||
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
|
||||
Source: domain.SourceAgent, Summary: "plant beans in the second bed",
|
||||
}, func(ctx context.Context) error {
|
||||
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
cancel() // the client disconnects, mid-turn, after the work landed
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("WithChangeSet: %v", err)
|
||||
}
|
||||
if cs == nil {
|
||||
t.Fatal("the turn changed things but produced no change set")
|
||||
}
|
||||
|
||||
after := history(t, s, owner, g.ID)
|
||||
if len(after) != before+1 {
|
||||
t.Fatalf("recorded %d change sets, want 1", len(after)-before)
|
||||
}
|
||||
if after[0].Summary != "plant beans in the second bed" {
|
||||
t.Errorf("summary = %q; a completed turn shouldn't be marked partial", after[0].Summary)
|
||||
}
|
||||
// And it's undoable, which is the entire point.
|
||||
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, cs.ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("the recorded turn should be undoable: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
active, _ := s.store.ListActivePlantingsForObject(context.Background(), bed.ID)
|
||||
if len(active) != 0 {
|
||||
t.Errorf("%d plantings survived the undo", len(active))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoScopedMutationRecordsEvenIfTheCallerWentAway — the same rule for the
|
||||
// path virtually every mutation takes.
|
||||
//
|
||||
// A plain REST PATCH auto-scopes into its own change set. If the client hangs up
|
||||
// between the row landing and the change set being written, that change is
|
||||
// orphaned exactly as an agent turn's was — and this path is used far more.
|
||||
func TestAutoScopedMutationRecordsEvenIfTheCallerWentAway(t *testing.T) {
|
||||
s := newTestService(t, openConfig())
|
||||
owner := seedUser(t, s, "[email protected]")
|
||||
g := seedGarden(t, s, owner)
|
||||
bed := seedBed(t, s, owner, g.ID)
|
||||
before := len(history(t, s, owner, g.ID))
|
||||
|
||||
// The row write and the history write share this context; cancelling after
|
||||
// the mutation returns is the client hanging up mid-request.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
if _, err := s.UpdateObject(ctx, owner, bed.ID, ObjectPatch{XCM: f64Ptr(300)}, bed.Version); err != nil {
|
||||
t.Fatalf("UpdateObject: %v", err)
|
||||
}
|
||||
cancel()
|
||||
|
||||
after := history(t, s, owner, g.ID)
|
||||
if len(after) != before+1 {
|
||||
t.Fatalf("recorded %d change sets, want 1 — the move is otherwise un-undoable", len(after)-before)
|
||||
}
|
||||
if _, conflicts, err := s.RevertChangeSet(context.Background(), owner, after[0].ID, domain.SourceUI); err != nil || len(conflicts) != 0 {
|
||||
t.Fatalf("the recorded move should be undoable: err=%v conflicts=%+v", err, conflicts)
|
||||
}
|
||||
back, _ := s.store.GetObject(context.Background(), bed.ID)
|
||||
if back.XCM != bed.XCM {
|
||||
t.Errorf("undo left x at %v, want %v", back.XCM, bed.XCM)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,9 +119,7 @@ func (d *DB) ListChangeSets(ctx context.Context, gardenID int64, limit, offset i
|
||||
return sets, nil
|
||||
}
|
||||
|
||||
// One grouped query for the whole page rather than N per-row counts. The
|
||||
// service's countRevisions mirrors this grouping for change sets it has just
|
||||
// written; TestRevertResultCarriesItsCounts holds the two in step.
|
||||
// One grouped query for the whole page rather than N per-row counts.
|
||||
countRows, err := d.sql.QueryContext(ctx,
|
||||
`SELECT change_set_id, entity_type, op, COUNT(*)
|
||||
FROM revisions
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Alert } from '@/components/ui/Alert'
|
||||
import { errorMessage } from '@/lib/api'
|
||||
import { Button } from '@/components/ui/Button'
|
||||
import { TextArea } from '@/components/ui/TextArea'
|
||||
import { cn } from '@/lib/cn'
|
||||
@@ -34,17 +33,12 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
||||
// The turn in flight: what we sent, the steps so far, and how it ended.
|
||||
const [pending, setPending] = useState<{ message: string; steps: AgentStep[] } | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [warning, setWarning] = useState<string | null>(null)
|
||||
const abort = useRef<AbortController | null>(null)
|
||||
const bottom = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Deliberately NOT aborted on unmount. Selecting an object auto-switches the
|
||||
// rail to the inspector, so aborting here would mean clicking the canvas
|
||||
// mid-turn silently killed the turn — and the canvas is exactly what you're
|
||||
// meant to be watching. The request continues, the exchange is persisted
|
||||
// server-side, and coming back to this tab shows it. Only Stop aborts, and
|
||||
// even that only stops us READING: the turn keeps running server-side, which
|
||||
// is why its work still lands in History either way.
|
||||
// Abort an in-flight turn when the panel goes away, so a closed tab doesn't
|
||||
// leave a read hanging.
|
||||
useEffect(() => () => abort.current?.abort(), [])
|
||||
|
||||
// Follow the conversation as it grows, including mid-turn as steps arrive.
|
||||
useEffect(() => {
|
||||
@@ -56,7 +50,6 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
||||
if (!message || pending) return
|
||||
setInput('')
|
||||
setError(null)
|
||||
setWarning(null)
|
||||
setPending({ message, steps: [] })
|
||||
|
||||
const controller = new AbortController()
|
||||
@@ -68,24 +61,22 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
||||
{
|
||||
onStep: (step) => {
|
||||
setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p))
|
||||
// The canvas updating under the conversation is the whole point of
|
||||
// putting the chat here, so refresh it as each step lands — but only
|
||||
// it: nothing else can have changed until the turn commits.
|
||||
refresh.canvas()
|
||||
// Refresh as each step lands, not just at the end: the canvas updating
|
||||
// under the conversation is the whole point of putting the chat here.
|
||||
refresh()
|
||||
},
|
||||
onDone: (turn: AgentTurn) => {
|
||||
setPending(null)
|
||||
refresh.everything()
|
||||
refresh()
|
||||
if (turn.truncated) {
|
||||
setError('That turned into more steps than I should take at once — check what changed before continuing.')
|
||||
}
|
||||
},
|
||||
onWarning: setWarning,
|
||||
onError: (message) => {
|
||||
setPending(null)
|
||||
setError(message)
|
||||
// Something may still have landed before it failed.
|
||||
refresh.everything()
|
||||
refresh()
|
||||
},
|
||||
},
|
||||
controller.signal,
|
||||
@@ -101,11 +92,7 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
||||
{messages.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
clear.mutate(undefined, {
|
||||
onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")),
|
||||
})
|
||||
}
|
||||
onClick={() => clear.mutate()}
|
||||
disabled={clear.isPending}
|
||||
className="rounded px-1.5 py-0.5 text-xs text-muted outline-none hover:text-fg focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
@@ -121,13 +108,6 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
||||
)}
|
||||
|
||||
<div className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
|
||||
{history.isPending && <p className="text-sm text-muted">Loading the conversation…</p>}
|
||||
{/* A failed load rendering as an empty thread would look like the
|
||||
conversation had been lost, which is a much worse thing to believe. */}
|
||||
{history.isError && (
|
||||
<Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>
|
||||
)}
|
||||
|
||||
{history.isSuccess && messages.length === 0 && !pending && (
|
||||
<p className="text-sm text-muted">
|
||||
Ask for what you want and it'll do it — “change the garlic bed to cucumbers this year”, “fill the
|
||||
@@ -166,7 +146,6 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
||||
</>
|
||||
)}
|
||||
|
||||
{warning && <Alert tone="info">{warning}</Alert>}
|
||||
{error && <Alert>{error}</Alert>}
|
||||
<div ref={bottom} />
|
||||
</div>
|
||||
@@ -198,7 +177,7 @@ export function ChatPanel({ gardenId, canEdit }: { gardenId: number; canEdit: bo
|
||||
onClick={() => {
|
||||
abort.current?.abort()
|
||||
setPending(null)
|
||||
refresh.everything()
|
||||
refresh()
|
||||
}}
|
||||
>
|
||||
Stop
|
||||
@@ -220,7 +199,7 @@ function Bubble({
|
||||
}: {
|
||||
role: 'user' | 'assistant'
|
||||
body: string
|
||||
children?: ReactNode
|
||||
children?: React.ReactNode
|
||||
}) {
|
||||
const mine = role === 'user'
|
||||
return (
|
||||
|
||||
@@ -116,7 +116,7 @@ function HistoryEntry({
|
||||
<UndoButton
|
||||
changeSet={changeSet}
|
||||
undo={undo}
|
||||
className="w-32 shrink-0 text-right"
|
||||
className="w-32 shrink-0"
|
||||
label={reverted ? 'Undo again' : 'Undo'}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -43,12 +43,8 @@ const TONE_CLASS: Record<Exclude<UndoOutcome['tone'], 'pending'>, string> = {
|
||||
|
||||
function OutcomeNote({ outcome }: { outcome: UndoOutcome }) {
|
||||
if (outcome.tone === 'pending') return null
|
||||
// No text alignment of its own: the container decides. The history list stacks
|
||||
// this to the right of an entry, the chat panel puts it under a left-aligned
|
||||
// message, and a hard-coded text-right made the chat's copy read against its
|
||||
// own column.
|
||||
return (
|
||||
<p role="status" className={cn('text-xs', TONE_CLASS[outcome.tone])}>
|
||||
<p role="status" className={cn('text-right text-xs', TONE_CLASS[outcome.tone])}>
|
||||
{outcome.message}
|
||||
</p>
|
||||
)
|
||||
|
||||
+12
-56
@@ -97,29 +97,10 @@ export function describeStep(step: AgentStep): string {
|
||||
return [...new Set(labels)].join(', ')
|
||||
}
|
||||
|
||||
const chatEventSchema = z.object({
|
||||
step: z.object({ index: z.number(), tools: z.array(z.string()) }).optional(),
|
||||
done: z
|
||||
.object({
|
||||
reply: z.string(),
|
||||
changeSetId: z.number().optional(),
|
||||
steps: z.number(),
|
||||
truncated: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
error: z.string().optional(),
|
||||
// The turn worked but something adjacent to it didn't — currently, the
|
||||
// exchange couldn't be saved. Dropping this on the floor would recreate
|
||||
// exactly the silent swallow the server added it to avoid.
|
||||
warning: z.string().optional(),
|
||||
})
|
||||
|
||||
export interface StreamHandlers {
|
||||
onStep: (step: AgentStep) => void
|
||||
onDone: (turn: AgentTurn) => void
|
||||
onError: (message: string) => void
|
||||
/** The turn succeeded, but something alongside it didn't. */
|
||||
onWarning?: (message: string) => void
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,10 +126,6 @@ export async function streamChat(
|
||||
signal,
|
||||
})
|
||||
} catch {
|
||||
// An abort here is the caller's own doing — Stop, or navigating away — not a
|
||||
// failure to report back to them. The read loop below already knew this; the
|
||||
// request path did not.
|
||||
if (signal?.aborted) return
|
||||
handlers.onError('Could not reach the server.')
|
||||
return
|
||||
}
|
||||
@@ -185,12 +162,13 @@ export async function streamChat(
|
||||
for (const frame of frames) {
|
||||
const line = frame.split('\n').find((l) => l.startsWith('data:'))
|
||||
if (!line) continue
|
||||
// Parsed AND validated: a malformed or unexpected frame shouldn't kill a
|
||||
// working stream, and shouldn't be trusted into the UI either.
|
||||
const parsed = chatEventSchema.safeParse(safeJson(line.slice(5).trim()))
|
||||
if (!parsed.success) continue
|
||||
const e = parsed.data
|
||||
if (e.warning) handlers.onWarning?.(e.warning)
|
||||
let event: unknown
|
||||
try {
|
||||
event = JSON.parse(line.slice(5).trim())
|
||||
} catch {
|
||||
continue // a malformed frame shouldn't kill a working stream
|
||||
}
|
||||
const e = event as { step?: AgentStep; done?: AgentTurn; error?: string }
|
||||
if (e.error) handlers.onError(e.error)
|
||||
else if (e.step) handlers.onStep(e.step)
|
||||
else if (e.done) handlers.onDone(e.done)
|
||||
@@ -198,35 +176,13 @@ export async function streamChat(
|
||||
}
|
||||
}
|
||||
|
||||
function safeJson(raw: string): unknown {
|
||||
try {
|
||||
return JSON.parse(raw)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes for the two moments that need different amounts of work.
|
||||
*
|
||||
* Mid-turn, only the canvas can have changed: the change set isn't written until
|
||||
* the turn commits, and the exchange isn't stored until it finishes. Refetching
|
||||
* those on every step would be up to 2×(N−1) requests per turn for data that
|
||||
* cannot have moved.
|
||||
*/
|
||||
/** Refresh everything a turn may have changed: the canvas, the history list,
|
||||
* and the conversation itself. */
|
||||
export function useAgentRefresh(gardenId: number) {
|
||||
const qc = useQueryClient()
|
||||
const canvas = () => {
|
||||
return () => {
|
||||
void qc.invalidateQueries({ queryKey: gardenFullKey(gardenId) })
|
||||
}
|
||||
return {
|
||||
/** After a step: the garden may have changed under the conversation. */
|
||||
canvas,
|
||||
/** After a turn: the change set and the stored exchange exist now too. */
|
||||
everything: () => {
|
||||
canvas()
|
||||
void qc.invalidateQueries({ queryKey: historyKey(gardenId) })
|
||||
void qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) })
|
||||
},
|
||||
void qc.invalidateQueries({ queryKey: historyKey(gardenId) })
|
||||
void qc.invalidateQueries({ queryKey: agentHistoryKey(gardenId) })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,14 +49,6 @@ describe('describeConflict', () => {
|
||||
describe('describeUndo', () => {
|
||||
const target = changeSet({ counts: [{ entityType: 'object', op: 'update', n: 3 }] })
|
||||
|
||||
// The bug this pair exists for: a real revert response carries a change set
|
||||
// whose counts the server didn't populate. Reading that as "nothing happened"
|
||||
// told the user a successful undo had done nothing.
|
||||
it('trusts the change set, not the tally, for whether anything happened', () => {
|
||||
const out = describeUndo({ id: 5 }, { changeSet: changeSet({ id: 6, counts: [] }), conflicts: [] })
|
||||
expect(out).toEqual({ tone: 'ok', message: 'Undone.' })
|
||||
})
|
||||
|
||||
it('is a plain success when nothing conflicted', () => {
|
||||
const out = describeUndo(target, {
|
||||
changeSet: changeSet({ id: 2, counts: [{ entityType: 'object', op: 'update', n: 3 }] }),
|
||||
@@ -69,7 +61,6 @@ describe('describeUndo', () => {
|
||||
// already a no-op — reachable by undoing a creation whose object is gone.
|
||||
// Claiming "Undone." there reports work that didn't happen.
|
||||
it("doesn't claim to have undone a no-op", () => {
|
||||
// A NULL change set — not an empty tally — is the server's no-op signal.
|
||||
const out = describeUndo(target, { changeSet: null, conflicts: [] })
|
||||
expect(out.tone).toBe('ok')
|
||||
expect(out.message).toBe('Nothing left to undo — this was already reversed.')
|
||||
|
||||
+10
-14
@@ -113,10 +113,9 @@ export function useRevertChangeSet(gardenId: number) {
|
||||
})
|
||||
}
|
||||
|
||||
/** How many rows a change set touched, for "3 changes" in the list. Undefined
|
||||
* counts total zero, which callers read as "no denominator to quote". */
|
||||
export function totalChanges(cs: { counts?: ChangeCount[] }): number {
|
||||
return (cs.counts ?? []).reduce((sum, c) => sum + c.n, 0)
|
||||
/** How many rows a change set touched, for "3 changes" in the list. */
|
||||
export function totalChanges(cs: ChangeSet): number {
|
||||
return cs.counts.reduce((sum, c) => sum + c.n, 0)
|
||||
}
|
||||
|
||||
const ENTITY_NOUNS: Record<ChangeCount['entityType'], [string, string]> = {
|
||||
@@ -224,23 +223,20 @@ export interface UndoOutcome {
|
||||
*/
|
||||
export function describeUndo(target: UndoTarget, result: RevertResult): UndoOutcome {
|
||||
const skipped = result.conflicts.map(describeConflict).join('; ')
|
||||
// A NULL change set is the server's signal that nothing needed doing. An empty
|
||||
// `counts` is not the same thing and must not be read as one — that conflation
|
||||
// made a successful undo report "nothing left to undo", which is the worst
|
||||
// possible thing to tell someone about an action that just worked.
|
||||
const didSomething = result.changeSet != null
|
||||
const applied = result.changeSet ? totalChanges(result.changeSet) : 0
|
||||
if (result.conflicts.length === 0) {
|
||||
// Reachable by undoing a creation whose object is already gone.
|
||||
if (!didSomething) return { tone: 'ok', message: 'Nothing left to undo — this was already reversed.' }
|
||||
// The server answers 200 with a null change set when every revision was
|
||||
// already a no-op — reachable by undoing a creation whose object is gone.
|
||||
// Claiming "Undone." there would be reporting work that didn't happen.
|
||||
if (applied === 0) return { tone: 'ok', message: 'Nothing left to undo — this was already reversed.' }
|
||||
return { tone: 'ok', message: 'Undone.' }
|
||||
}
|
||||
if (!didSomething) {
|
||||
if (applied === 0) {
|
||||
return { tone: 'error', message: `Nothing was undone — ${skipped}.` }
|
||||
}
|
||||
// Only claim a denominator when we have one. "2 of 3" from a caller that
|
||||
// never knew the total would be a number invented to fill a sentence.
|
||||
const total = totalChanges(target)
|
||||
const scale = total > 0 && applied > 0 ? `${applied} of ${total} changes undone` : 'Partly undone'
|
||||
const total = target.counts ? target.counts.reduce((sum, c) => sum + c.n, 0) : 0
|
||||
const scale = total > 0 ? `${applied} of ${total} changes undone` : 'Partly undone'
|
||||
return { tone: 'partial', message: `${scale} — ${skipped}.` }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user