Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb594f34d8 | ||
|
|
74f6b9a876 | ||
|
|
84f249a774 | ||
|
|
a13acedd90 | ||
|
|
15734c9195 | ||
|
|
48057fe1f3 | ||
|
|
33e048cea9 | ||
|
|
4c4abe23c6 | ||
|
|
d26db3f6e3 | ||
|
|
95b9d611c6 | ||
|
|
5bdaf21828 | ||
|
|
7a6f6963f9 | ||
|
|
04cdf815df | ||
|
|
3cfa72cb25 | ||
|
|
8552f1d152 | ||
|
|
7088f382bc |
@@ -129,21 +129,19 @@ 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.
|
||||
**One sweep, not a loop.** Take Gadfly's *initial* review, fix what's real, and
|
||||
merge once the build is green. Do NOT re-trigger Gadfly on the fix commits and
|
||||
wait for it again — a re-review-per-fix loop burns ~10 min a pass and drags a
|
||||
small PR out for an hour (Steve's explicit call, 2026-07-22). The initial review
|
||||
is the check; your own build/test/judgment covers the fixes. Gadfly is advisory
|
||||
and never blocks merge, so green build + fixes applied is enough.
|
||||
|
||||
**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.
|
||||
(Mechanics, if you ever *do* need a manual re-run: the workflow triggers on
|
||||
`opened`/`reopened`/`ready_for_review`, not `synchronize`, so pushes don't
|
||||
re-review; a `@gadfly review` comment does. A comment without that exact phrase
|
||||
still runs and exits green in ~2s — a skip that looks like a pass, so judge a
|
||||
real review by its ~10-min duration, not its status. Gadfly edits its consensus
|
||||
comment in place, so `updated_at` moves but `created_at` doesn't.)
|
||||
|
||||
Workflow- and config-only changes (CI, this file, docs) go straight to `main`
|
||||
without the PR dance.
|
||||
|
||||
@@ -65,6 +65,8 @@ POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts
|
||||
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
|
||||
POST /gardens/:id/objects PATCH,DELETE /objects/:id
|
||||
POST /objects/:id/plantings PATCH,DELETE /plantings/:id
|
||||
POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect
|
||||
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
|
||||
GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
|
||||
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
|
||||
GET,POST /gardens/:id/journal PATCH,DELETE /journal/:id (editor writes; author edits own)
|
||||
@@ -74,6 +76,8 @@ GET,DELETE /gardens/:id/agent/history (the actor's o
|
||||
GET /capabilities ← what this instance can do RIGHT NOW (tracks the live agent, not just config)
|
||||
GET,PATCH /settings ← instance-wide config (admin only): agent model + on/off
|
||||
GET,POST /gardens/:id/shares PATCH,DELETE /gardens/:id/shares/:userId (invite by email)
|
||||
GET,POST,DELETE /gardens/:id/share-link ← the public read-only token for this garden
|
||||
GET /public/gardens/:token ← UNAUTHENTICATED read-only /full; the token is the capability
|
||||
```
|
||||
|
||||
**Sync:** plain REST + optimistic UI + last-write-wins with a version guard. Every PATCH/DELETE carries the row's `version`; the server increments on write and returns **409 + the current row** on mismatch; the client rolls back and refetches. No websockets/CRDT — the right cost for household-scale co-editing. Drags PATCH once on drop, not per frame.
|
||||
|
||||
@@ -5,9 +5,11 @@ go 1.26.2
|
||||
require (
|
||||
gitea.stevedudenhoeffer.com/steve/majordomo v0.0.0-20260718232210-a941f5ff4a3f
|
||||
github.com/coreos/go-oidc/v3 v3.20.0
|
||||
github.com/gen2brain/heic v0.7.1
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/samber/slog-gin v1.15.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/oauth2 v0.36.0
|
||||
modernc.org/sqlite v1.34.4
|
||||
)
|
||||
@@ -33,6 +35,7 @@ require (
|
||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/ebitengine/purego v0.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.4 // indirect
|
||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 // indirect
|
||||
@@ -51,14 +54,15 @@ require (
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/tetratelabs/wazero v1.12.0 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
go.opentelemetry.io/otel v1.29.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.29.0 // indirect
|
||||
golang.org/x/arch v0.8.0 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/sys v0.44.0 // indirect
|
||||
golang.org/x/text v0.40.0 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
|
||||
@@ -26,12 +26,16 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/ebitengine/purego v0.10.1 h1:dewVBCBT2GaMu1SrNTYxQhgQBethzfhiwvZiLGP/qyY=
|
||||
github.com/ebitengine/purego v0.10.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
|
||||
github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
|
||||
github.com/gen2brain/heic v0.7.1 h1:Aha1sZdKEeZeWl5o0xkSg7NBRhhkrlokGVCRri+2Qcc=
|
||||
github.com/gen2brain/heic v0.7.1/go.mod h1:ja42wMJc4fpnKsfdUJxeZa2YqqRnes1wS0xqs5+8o5w=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
@@ -126,6 +130,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
|
||||
github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
@@ -144,11 +150,13 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -163,27 +171,27 @@ golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
|
||||
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
|
||||
+44
-1
@@ -118,6 +118,21 @@ func (h *handlers) agentChat(c *gin.Context) {
|
||||
send(chatEvent{Done: turn})
|
||||
}
|
||||
|
||||
// sseWriteTimeout bounds ONE write to the stream, not the stream itself.
|
||||
//
|
||||
// It is refreshed per frame, which is the only shape that satisfies both ends:
|
||||
// the server's absolute WriteTimeout would cut a long turn (#78), while removing
|
||||
// the deadline entirely would let a client that stops reading block a write
|
||||
// forever once the socket buffer fills — pinning the run goroutine and this
|
||||
// stream's mutex with it, and taking the keep-alive down too since it needs the
|
||||
// same lock. Generous, because it is a backstop against a stuck peer and not a
|
||||
// pacing mechanism.
|
||||
//
|
||||
// A var, not a const, ONLY so the test can shrink it to prove the deadline is
|
||||
// refreshed per frame rather than set once — a set-once 30s deadline would pass
|
||||
// a test whose whole run is under a second. Production never reassigns it.
|
||||
var sseWriteTimeout = 30 * time.Second
|
||||
|
||||
// eventStream serializes writes to one SSE response.
|
||||
//
|
||||
// The mutex is load-bearing, not decoration: step events are sent from the
|
||||
@@ -126,6 +141,7 @@ func (h *handlers) agentChat(c *gin.Context) {
|
||||
// frames long before it crashes anything.
|
||||
type eventStream struct {
|
||||
c *gin.Context
|
||||
rc *http.ResponseController
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
@@ -134,12 +150,34 @@ type eventStream struct {
|
||||
// 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.
|
||||
//
|
||||
// Taking the write deadline off the server's absolute WriteTimeout and onto a
|
||||
// per-write one is what makes a turn longer than 30s possible at all (#78).
|
||||
// WriteTimeout is an ABSOLUTE deadline from when the request header was read,
|
||||
// not an idle timeout, so a streaming response is cut mid-turn however recently
|
||||
// it wrote. Without this the 4-minute runTimeout is unreachable and the
|
||||
// keep-alive below tops out at one tick — pacing a connection that is destroyed
|
||||
// underneath it.
|
||||
//
|
||||
// That failure is INVISIBLE from in here: writes past the deadline return
|
||||
// err == nil and their bytes are dropped, so there is nothing to detect on the
|
||||
// write path. Only the client sees it, as a truncated stream it reports as a
|
||||
// dropped connection. Hence a deadline set up front and refreshed per frame,
|
||||
// rather than anything checked after the fact.
|
||||
func openEventStream(c *gin.Context) *eventStream {
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
s := &eventStream{c: c, rc: http.NewResponseController(c.Writer)}
|
||||
// Probe once here rather than reporting per frame: a writer that can't take
|
||||
// deadlines will fail identically on every write, and the operator needs to
|
||||
// hear it once. If this fails the stream still works — it is just back to
|
||||
// being cut at WriteTimeout, which is worth saying out loud.
|
||||
if err := s.rc.SetWriteDeadline(time.Now().Add(sseWriteTimeout)); err != nil {
|
||||
slog.Error("api: SSE write deadlines unavailable; long turns will be truncated at the server WriteTimeout", "error", err)
|
||||
}
|
||||
c.Writer.Flush()
|
||||
return &eventStream{c: c}
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *eventStream) send(ev chatEvent) {
|
||||
@@ -154,6 +192,11 @@ func (s *eventStream) send(ev chatEvent) {
|
||||
func (s *eventStream) write(frame string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
// Refresh for THIS write, so the stream as a whole is unbounded but no single
|
||||
// write is. Error deliberately unchecked: openEventStream already reported
|
||||
// whether deadlines work at all, and this call can only fail the same way, so
|
||||
// checking here would log once per frame to say the same thing.
|
||||
_ = s.rc.SetWriteDeadline(time.Now().Add(sseWriteTimeout))
|
||||
_, _ = io.WriteString(s.c.Writer, frame)
|
||||
s.c.Writer.Flush()
|
||||
}
|
||||
|
||||
@@ -122,6 +122,11 @@ func New(cfg *config.Config, svc *service.Service) *gin.Engine {
|
||||
objects.PATCH("/:id", h.updateObject)
|
||||
objects.DELETE("/:id", h.deleteObject)
|
||||
objects.POST("/:id/plantings", h.createPlanting) // place a plop in this object
|
||||
// Bulk ops. These wrap the same service methods the agent tools call, so an
|
||||
// instance with no model configured still gets the most valuable operation in
|
||||
// the app — and so "clear bed" is ONE change set rather than one per plop.
|
||||
objects.POST("/:id/fill", h.fillObject)
|
||||
objects.POST("/:id/clear", h.clearObject)
|
||||
|
||||
// Plantings ("plops") are addressed by their own id; the service resolves the
|
||||
// owning object/garden for the permission check.
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
|
||||
"gitea.stevedudenhoeffer.com/steve/pansy/internal/service"
|
||||
)
|
||||
|
||||
// Bulk operations on a plantable object (#82): fill a region with one plant, and
|
||||
// clear everything out of it.
|
||||
//
|
||||
// These were reachable only through the agent toolbox until now, which meant 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 on an
|
||||
// instance with no model configured. They are thin adapters over the same
|
||||
// service methods `internal/agent/tools.go` calls, so the permission checks and
|
||||
// the one-change-set-per-operation guarantee come along unchanged.
|
||||
|
||||
// fillRect is an explicit rectangle in the object's local frame, the alternative
|
||||
// to a compass name. A named type (not an inline anonymous struct) to match the
|
||||
// rest of internal/api and so it can carry its own validity check.
|
||||
type fillRect struct {
|
||||
MinX float64 `json:"minXCm"`
|
||||
MinY float64 `json:"minYCm"`
|
||||
MaxX float64 `json:"maxXCm"`
|
||||
MaxY float64 `json:"maxYCm"`
|
||||
}
|
||||
|
||||
// degenerate reports whether the rect encloses no area. Such a rect (including
|
||||
// the all-zeros an empty `"rect": {}` decodes to) would otherwise slip through
|
||||
// and plant a single plop at the object's centre — a surprising result for what
|
||||
// is really malformed input.
|
||||
func (r fillRect) degenerate() bool {
|
||||
return r.MaxX <= r.MinX || r.MaxY <= r.MinY
|
||||
}
|
||||
|
||||
// objectFillRequest is the body for POST /objects/:id/fill.
|
||||
//
|
||||
// A region is given EITHER by compass name ("ne", "south half", "all") or as an
|
||||
// explicit rect in the object's local frame. The named form is what a person
|
||||
// means and what the agent uses; the rect is for a future drag-a-box affordance.
|
||||
// Exactly one must be supplied — accepting both and silently preferring one
|
||||
// would make a client bug look like a geometry bug.
|
||||
type objectFillRequest struct {
|
||||
PlantID int64 `json:"plantId" binding:"required"`
|
||||
Region string `json:"region"`
|
||||
Rect *fillRect `json:"rect"`
|
||||
// SpacingOverrideCM plants tighter or looser than the plant's mature spacing
|
||||
// without editing the catalog entry.
|
||||
SpacingOverrideCM *float64 `json:"spacingOverrideCm"`
|
||||
}
|
||||
|
||||
func (h *handlers) fillObject(c *gin.Context) {
|
||||
id, ok := parseIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req objectFillRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "a plantId and a region are required")
|
||||
return
|
||||
}
|
||||
named, hasRect := req.Region != "", req.Rect != nil
|
||||
if named == hasRect {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT",
|
||||
`supply exactly one of "region" (e.g. "all", "ne", "south half") or "rect"`)
|
||||
return
|
||||
}
|
||||
|
||||
actor := mustActor(c).ID
|
||||
var (
|
||||
created []domain.Planting
|
||||
err error
|
||||
)
|
||||
if rect := req.Rect; rect != nil {
|
||||
// Reject a zero-area rect here rather than let it plant one stray plop.
|
||||
// (Binding the pointer to `rect` also keeps the deref visibly guarded,
|
||||
// instead of reading req.Rect.MinX under an invariant from a line above.)
|
||||
if rect.degenerate() {
|
||||
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "rect must enclose a positive area")
|
||||
return
|
||||
}
|
||||
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
|
||||
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM)
|
||||
} else {
|
||||
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM)
|
||||
}
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
// 200, not 201: a fill can legitimately create nothing (the region is already
|
||||
// planted), and there is no single resource to point a Location at.
|
||||
c.JSON(http.StatusOK, gin.H{"plantings": created, "created": len(created)})
|
||||
}
|
||||
|
||||
// clearObject soft-removes every active plop in an object.
|
||||
//
|
||||
// Distinct from deleting the object, and — unlike the client-side loop this
|
||||
// replaces — it lands as ONE change set, so undoing a cleared bed is one click
|
||||
// rather than one per plop.
|
||||
func (h *handlers) clearObject(c *gin.Context) {
|
||||
id, ok := parseIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
n, err := h.svc.ClearObject(c.Request.Context(), mustActor(c).ID, id)
|
||||
if err != nil {
|
||||
writeServiceError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"cleared": n})
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func fillPath(id int64) string { return objectPath(id) + "/fill" }
|
||||
func clearPath(id int64) string { return objectPath(id) + "/clear" }
|
||||
|
||||
// makeFillPlant creates a custom plant and returns its id. (A near-identical
|
||||
// createPlantAPI landed alongside the seed-lot tests; consolidating the two into
|
||||
// one shared helper is a fine follow-up, kept separate here only to avoid a
|
||||
// merge collision on the shared symbol.)
|
||||
func makeFillPlant(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
|
||||
t.Helper()
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
|
||||
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
|
||||
}
|
||||
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||
}
|
||||
|
||||
// seedFillableBed makes a garden with one plantable bed and a custom plant,
|
||||
// returning (gardenID, objectID, plantID).
|
||||
func seedFillableBed(t *testing.T, r *gin.Engine, cookie *http.Cookie, w, h, spacing float64) (int64, int64, int64) {
|
||||
t.Helper()
|
||||
gid := createGardenAPI(t, r, cookie, "G")
|
||||
rec := doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
||||
"kind": "bed", "widthCm": w, "heightCm": h, "plantable": true,
|
||||
}, cookie)
|
||||
if rec.Code != http.StatusCreated {
|
||||
t.Fatalf("create bed: status %d, body %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
objID := int64(decodeMap(t, rec.Body.Bytes())["id"].(float64))
|
||||
plantID := makeFillPlant(t, r, cookie, "Fillable", spacing)
|
||||
return gid, objID, plantID
|
||||
}
|
||||
|
||||
// countChangeSets reads the history page and reports how many change sets exist.
|
||||
func countChangeSets(t *testing.T, r *gin.Engine, cookie *http.Cookie, gardenID int64) int {
|
||||
t.Helper()
|
||||
w := doJSON(t, r, http.MethodGet, historyPath(gardenID), nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("history: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
sets, _ := decodeMap(t, w.Body.Bytes())["changeSets"].([]any)
|
||||
return len(sets)
|
||||
}
|
||||
|
||||
// TestFillAndClearAPI covers the two routes end to end through the router.
|
||||
//
|
||||
// These exist because both operations were previously reachable ONLY through the
|
||||
// agent toolbox, so on an instance with no model configured the most valuable
|
||||
// bulk operation in the app did not exist at all.
|
||||
func TestFillAndClearAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
_, objID, plantID := seedFillableBed(t, r, cookie, 200, 200, 20)
|
||||
|
||||
// Fill by compass name.
|
||||
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID, "region": "all",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
body := decodeMap(t, w.Body.Bytes())
|
||||
created := int(body["created"].(float64))
|
||||
if created == 0 {
|
||||
t.Fatalf("fill created nothing: %s", w.Body.String())
|
||||
}
|
||||
if plops, _ := body["plantings"].([]any); len(plops) != created {
|
||||
t.Errorf("created=%d but returned %d plantings", created, len(plops))
|
||||
}
|
||||
|
||||
// Clear it: one call, and it reports what it removed.
|
||||
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("clear: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != created {
|
||||
t.Errorf("cleared %d, want %d (everything the fill made)", n, created)
|
||||
}
|
||||
|
||||
// Clearing an already-empty bed is a no-op, not an error.
|
||||
w = doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("second clear: status %d", w.Code)
|
||||
}
|
||||
if n := int(decodeMap(t, w.Body.Bytes())["cleared"].(float64)); n != 0 {
|
||||
t.Errorf("second clear removed %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillRegionSelectionAPI: exactly one of region/rect, and a rect fills only
|
||||
// its own corner of the bed.
|
||||
func TestFillRegionSelectionAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
_, objID, plantID := seedFillableBed(t, r, cookie, 400, 400, 20)
|
||||
|
||||
// Neither → 400. Both → 400. Accepting both and silently preferring one
|
||||
// would make a client bug look like a geometry bug.
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{"plantId": plantID}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("no region = %d, want 400", w.Code)
|
||||
}
|
||||
both := map[string]any{
|
||||
"plantId": plantID, "region": "all",
|
||||
"rect": map[string]any{"minXCm": -50, "minYCm": -50, "maxXCm": 50, "maxYCm": 50},
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), both, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("both region and rect = %d, want 400", w.Code)
|
||||
}
|
||||
// An unknown compass name is rejected rather than silently filling nothing.
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID, "region": "middle-ish",
|
||||
}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("bad region name = %d, want 400", w.Code)
|
||||
}
|
||||
|
||||
// A zero-area rect is malformed input, not "plant one at the centre". An empty
|
||||
// `"rect": {}` decodes to all-zeros and must be caught the same way.
|
||||
for _, rect := range []map[string]any{
|
||||
{}, // {} → 0,0,0,0
|
||||
{"minXCm": 10, "minYCm": 10, "maxXCm": 10, "maxYCm": 50}, // zero width
|
||||
{"minXCm": 10, "minYCm": 50, "maxXCm": 50, "maxYCm": 50}, // zero height
|
||||
{"minXCm": 50, "minYCm": 50, "maxXCm": 10, "maxYCm": 10}, // inverted
|
||||
} {
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID),
|
||||
map[string]any{"plantId": plantID, "rect": rect}, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("degenerate rect %v = %d, want 400", rect, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// A rect confined to the NE corner produces plops only there. Local frame:
|
||||
// +x east, -y north.
|
||||
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID,
|
||||
"rect": map[string]any{"minXCm": 0, "minYCm": -200, "maxXCm": 200, "maxYCm": 0},
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("rect fill: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
plops, _ := decodeMap(t, w.Body.Bytes())["plantings"].([]any)
|
||||
if len(plops) == 0 {
|
||||
t.Fatal("rect fill created nothing")
|
||||
}
|
||||
for _, raw := range plops {
|
||||
p := raw.(map[string]any)
|
||||
if x, y := p["xCm"].(float64), p["yCm"].(float64); x < 0 || y > 0 {
|
||||
t.Errorf("plop at (%v,%v) outside the NE rect", x, y)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestClearObjectIsOneChangeSetAPI is the regression test for the behaviour this
|
||||
// endpoint exists to restore.
|
||||
//
|
||||
// The UI used to clear a bed with a loop of PATCHes, and since every service
|
||||
// mutation auto-scopes its own change set, clearing a 40-plop bed wrote 40 of
|
||||
// them — 40 presses of Undo to put the bed back. CLAUDE.md states the rule
|
||||
// directly: multi-row operations record together so they undo as one unit.
|
||||
func TestClearObjectIsOneChangeSetAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
gid, objID, plantID := seedFillableBed(t, r, cookie, 300, 300, 20)
|
||||
|
||||
w := doJSON(t, r, http.MethodPost, fillPath(objID), map[string]any{
|
||||
"plantId": plantID, "region": "all",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("fill: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
created := int(decodeMap(t, w.Body.Bytes())["created"].(float64))
|
||||
if created < 4 {
|
||||
t.Fatalf("need several plops to make this meaningful, got %d", created)
|
||||
}
|
||||
|
||||
before := countChangeSets(t, r, cookie, gid)
|
||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, cookie); w.Code != http.StatusOK {
|
||||
t.Fatalf("clear: status %d", w.Code)
|
||||
}
|
||||
if after := countChangeSets(t, r, cookie, gid); after != before+1 {
|
||||
t.Errorf("clearing %d plops added %d change sets, want exactly 1", created, after-before)
|
||||
}
|
||||
}
|
||||
|
||||
// TestFillClearPermissionsAPI: a viewer may look but not fill or clear, and a
|
||||
// stranger gets 404 because existence is masked.
|
||||
func TestFillClearPermissionsAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
owner := registerAndCookie(t, r, "[email protected]")
|
||||
viewer := registerAndCookie(t, r, "[email protected]")
|
||||
stranger := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
gid, objID, plantID := seedFillableBed(t, r, owner, 200, 200, 20)
|
||||
if w := doJSON(t, r, http.MethodPost, sharesPath(gid),
|
||||
map[string]any{"email": "[email protected]", "role": "viewer"}, owner); w.Code != http.StatusCreated {
|
||||
t.Fatalf("share as viewer: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
fillBody := map[string]any{"plantId": plantID, "region": "all"}
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, viewer); w.Code != http.StatusForbidden {
|
||||
t.Errorf("viewer fill = %d, want 403 (they can see it but may not do that)", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, viewer); w.Code != http.StatusForbidden {
|
||||
t.Errorf("viewer clear = %d, want 403", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, stranger); w.Code != http.StatusNotFound {
|
||||
t.Errorf("stranger fill = %d, want 404 (existence masked)", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, stranger); w.Code != http.StatusNotFound {
|
||||
t.Errorf("stranger clear = %d, want 404", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPost, fillPath(objID), fillBody, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous fill = %d, want 401", w.Code)
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodPost, clearPath(objID), nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("anonymous clear = %d, want 401", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func seedLotPath(id int64) string {
|
||||
return "/api/v1/seed-lots/" + strconv.FormatInt(id, 10)
|
||||
}
|
||||
|
||||
// decodeList decodes a bare JSON array body. Seed lot listing returns the array
|
||||
// directly rather than wrapping it (unlike /journal's {"entries": …}), so a
|
||||
// helper that assumed an object would quietly read nothing.
|
||||
func decodeList(t *testing.T, body []byte) []any {
|
||||
t.Helper()
|
||||
var out []any
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
t.Fatalf("decode list: %v (%s)", err, body)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// createPlantAPI makes a custom plant and returns its id.
|
||||
func createPlantAPI(t *testing.T, r *gin.Engine, cookie *http.Cookie, name string, spacing float64) int64 {
|
||||
t.Helper()
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/plants", map[string]any{
|
||||
"name": name, "category": "vegetable", "spacingCm": spacing, "color": "#4a7c3f", "icon": "🌱",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create plant %q: status %d, body %s", name, w.Code, w.Body.String())
|
||||
}
|
||||
return int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||
}
|
||||
|
||||
// TestSeedLotCrudAPI walks the whole seed lot lifecycle over HTTP.
|
||||
//
|
||||
// It exists for the reason CLAUDE.md gives: service tests cannot see a route
|
||||
// that was never registered, or one registered with the wrong :param name. Every
|
||||
// other handler file had a sibling API test; this group did not, which is the
|
||||
// state PATCH/DELETE /journal/:id shipped in — implemented, unit-tested, and
|
||||
// completely unreachable.
|
||||
func TestSeedLotCrudAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
plantID := createPlantAPI(t, r, cookie, "Music Garlic", 15)
|
||||
|
||||
// Create.
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||
"plantId": plantID, "vendor": "Johnny's", "sku": "2761",
|
||||
"quantity": 100, "unit": "seeds", "packedForYear": 2026, "costCents": 495,
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
lot := decodeMap(t, w.Body.Bytes())
|
||||
id := int64(lot["id"].(float64))
|
||||
if lot["vendor"] != "Johnny's" || lot["unit"] != "seeds" {
|
||||
t.Errorf("unexpected lot: %+v", lot)
|
||||
}
|
||||
|
||||
// GET by id — the route most likely to be missing or mis-registered.
|
||||
w = doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := decodeMap(t, w.Body.Bytes()); int64(got["id"].(float64)) != id {
|
||||
t.Errorf("get returned id %v, want %d", got["id"], id)
|
||||
}
|
||||
|
||||
// List, and the ?plantId= filter.
|
||||
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("list: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if n := len(decodeList(t, w.Body.Bytes())); n != 1 {
|
||||
t.Fatalf("list returned %d lots, want 1: %s", n, w.Body.String())
|
||||
}
|
||||
|
||||
other := createPlantAPI(t, r, cookie, "Cherokee Purple", 45)
|
||||
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+strconv.FormatInt(other, 10), nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("filtered list: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
|
||||
t.Errorf("filter by a plant with no lots returned %d, want 0", n)
|
||||
}
|
||||
// A bad plantId filter is 400, whether non-numeric or out of range — the
|
||||
// handler rejects id < 1, not just unparseable strings.
|
||||
for _, bad := range []string{"nope", "0", "-1"} {
|
||||
if w := doJSON(t, r, http.MethodGet, "/api/v1/seed-lots?plantId="+bad, nil, cookie); w.Code != http.StatusBadRequest {
|
||||
t.Errorf("plantId=%q filter = %d, want 400", bad, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH with the current version.
|
||||
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
|
||||
"vendor": "Fedco", "version": lot["version"],
|
||||
}, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("patch: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
updated := decodeMap(t, w.Body.Bytes())
|
||||
if updated["vendor"] != "Fedco" {
|
||||
t.Errorf("vendor = %v, want Fedco", updated["vendor"])
|
||||
}
|
||||
if updated["version"].(float64) != lot["version"].(float64)+1 {
|
||||
t.Errorf("patch didn't bump version: %v -> %v", lot["version"], updated["version"])
|
||||
}
|
||||
|
||||
// A stale version conflicts and carries the current row back, so the client
|
||||
// can rebase without a second request.
|
||||
w = doJSON(t, r, http.MethodPatch, seedLotPath(id), map[string]any{
|
||||
"vendor": "stale", "version": lot["version"],
|
||||
}, cookie)
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Fatalf("stale patch: status %d, want 409", w.Code)
|
||||
}
|
||||
if cur, ok := decodeMap(t, w.Body.Bytes())["current"].(map[string]any); !ok || cur["vendor"] != "Fedco" {
|
||||
t.Errorf("409 body missing the current row: %s", w.Body.String())
|
||||
}
|
||||
|
||||
// DELETE.
|
||||
if w := doJSON(t, r, http.MethodDelete, seedLotPath(id), nil, cookie); w.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := doJSON(t, r, http.MethodGet, seedLotPath(id), nil, cookie); w.Code != http.StatusNotFound {
|
||||
t.Errorf("get after delete = %d, want 404", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedLotRemainingIsDerivedAPI checks that `remaining` reflects plantings
|
||||
// through the HTTP surface, not just in the service.
|
||||
//
|
||||
// DESIGN.md makes derivation load-bearing — "a decremented column drifts the
|
||||
// moment a planting is edited behind its back" — so a route that returned a
|
||||
// stored or stale figure would break the invariant silently, and the number is
|
||||
// the whole reason anyone opens the seed shelf.
|
||||
func TestSeedLotRemainingIsDerivedAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
cookie := registerAndCookie(t, r, "[email protected]")
|
||||
plantID := createPlantAPI(t, r, cookie, "Beans", 10)
|
||||
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||
"plantId": plantID, "quantity": 50, "unit": "seeds",
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create lot: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||
|
||||
gid := createGardenAPI(t, r, cookie, "G")
|
||||
w = doJSON(t, r, http.MethodPost, objectsPath(gid), map[string]any{
|
||||
"kind": "bed", "widthCm": 100, "heightCm": 100, "plantable": true,
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create object: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
oid := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||
|
||||
// Plant 12 of them against the lot.
|
||||
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
|
||||
"plantId": plantID, "xCm": 0, "yCm": 0, "radiusCm": 20, "count": 12, "seedLotId": lotID,
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("create planting: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("get lot: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
got := decodeMap(t, w.Body.Bytes())
|
||||
if rem, ok := got["remaining"].(float64); !ok || rem != 38 {
|
||||
t.Errorf("remaining = %v, want 38 (50 bought - 12 planted): %s", got["remaining"], w.Body.String())
|
||||
}
|
||||
|
||||
// Over-plant it: 45 more (57 total against 50 bought) drives remaining
|
||||
// NEGATIVE. That's deliberate — the number is a derived truth about what
|
||||
// you've committed, not a floor clamped at zero, and "you've planted more than
|
||||
// you bought" is exactly the signal a gardener wants rather than a hidden -7.
|
||||
w = doJSON(t, r, http.MethodPost, objectPlantingsPath(oid), map[string]any{
|
||||
"plantId": plantID, "xCm": 40, "yCm": 40, "radiusCm": 20, "count": 45, "seedLotId": lotID,
|
||||
}, cookie)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("over-plant: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, cookie)
|
||||
if rem, ok := decodeMap(t, w.Body.Bytes())["remaining"].(float64); !ok || rem != -7 {
|
||||
t.Errorf("remaining after over-planting = %v, want -7 (50 - 57)", rem)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedLotsArePrivateAPI checks the ACL through the router.
|
||||
//
|
||||
// Lots are private to the buyer and deliberately never travel with a shared
|
||||
// garden, so another user must not be able to read or edit one. Per the
|
||||
// project's convention, no-access is ErrNotFound rather than ErrForbidden —
|
||||
// existence is masked — so every one of these is a 404, not a 403.
|
||||
func TestSeedLotsArePrivateAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
alice := registerAndCookie(t, r, "[email protected]")
|
||||
bob := registerAndCookie(t, r, "[email protected]")
|
||||
|
||||
plantID := createPlantAPI(t, r, alice, "Alice's garlic", 15)
|
||||
w := doJSON(t, r, http.MethodPost, "/api/v1/seed-lots", map[string]any{
|
||||
"plantId": plantID, "vendor": "Secret Vendor", "quantity": 10, "unit": "bulbs",
|
||||
}, alice)
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("alice create: status %d, body %s", w.Code, w.Body.String())
|
||||
}
|
||||
lotID := int64(decodeMap(t, w.Body.Bytes())["id"].(float64))
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
method string
|
||||
body any
|
||||
}{
|
||||
{"get", http.MethodGet, nil},
|
||||
{"patch", http.MethodPatch, map[string]any{"vendor": "hijacked", "version": 1}},
|
||||
{"delete", http.MethodDelete, nil},
|
||||
} {
|
||||
if w := doJSON(t, r, tc.method, seedLotPath(lotID), tc.body, bob); w.Code != http.StatusNotFound {
|
||||
t.Errorf("bob %s = %d, want 404 (existence is masked)", tc.name, w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// Bob's own listing must not include it either.
|
||||
w = doJSON(t, r, http.MethodGet, "/api/v1/seed-lots", nil, bob)
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("bob list: status %d", w.Code)
|
||||
}
|
||||
if n := len(decodeList(t, w.Body.Bytes())); n != 0 {
|
||||
t.Errorf("bob sees %d of alice's lots, want 0: %s", n, w.Body.String())
|
||||
}
|
||||
|
||||
// And it's still intact for alice.
|
||||
if w := doJSON(t, r, http.MethodGet, seedLotPath(lotID), nil, alice); w.Code != http.StatusOK {
|
||||
t.Errorf("alice lost access to her own lot: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeedLotsRequireAuthAPI: the group is behind requireAuth, and an
|
||||
// unauthenticated caller gets 401 rather than an empty list.
|
||||
func TestSeedLotsRequireAuthAPI(t *testing.T) {
|
||||
r := authEngine(t, localCfg())
|
||||
for _, tc := range []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodGet, "/api/v1/seed-lots"},
|
||||
{http.MethodPost, "/api/v1/seed-lots"},
|
||||
{http.MethodGet, seedLotPath(1)},
|
||||
{http.MethodPatch, seedLotPath(1)},
|
||||
{http.MethodDelete, seedLotPath(1)},
|
||||
} {
|
||||
if w := doJSON(t, r, tc.method, tc.path, nil, nil); w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s = %d, want 401", tc.method, tc.path, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// streamFrames spins up a real http.Server with the given WriteTimeout and an
|
||||
// SSE handler that emits `frames` data frames, one every `tick`, then returns.
|
||||
// It reports how many frames the client actually received and any read error —
|
||||
// the only vantage point from which the deadline failures in #78/#87 are
|
||||
// visible, since the writes themselves return nil when the bytes are dropped.
|
||||
func streamFrames(t *testing.T, serverWriteTimeout, tick time.Duration, frames int) (int, error) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
r := gin.New()
|
||||
r.GET("/stream", func(c *gin.Context) {
|
||||
s := openEventStream(c)
|
||||
for i := 0; i < frames; i++ {
|
||||
time.Sleep(tick)
|
||||
s.send(chatEvent{Error: "frame"})
|
||||
}
|
||||
})
|
||||
|
||||
srv := httptest.NewUnstartedServer(r)
|
||||
srv.Config.WriteTimeout = serverWriteTimeout
|
||||
srv.Start()
|
||||
defer srv.Close()
|
||||
|
||||
resp, err := srv.Client().Get(srv.URL + "/stream")
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
got := 0
|
||||
sc := bufio.NewScanner(resp.Body)
|
||||
for sc.Scan() {
|
||||
if strings.HasPrefix(sc.Text(), "data: ") {
|
||||
got++
|
||||
}
|
||||
}
|
||||
return got, sc.Err()
|
||||
}
|
||||
|
||||
// TestEventStreamOutlivesServerWriteTimeout is the regression test for #78.
|
||||
//
|
||||
// http.Server.WriteTimeout is an ABSOLUTE deadline measured from when the
|
||||
// request header was read — not an idle timeout — so a streaming response is cut
|
||||
// once it passes, however recently the handler wrote. pansy sets it to 30s while
|
||||
// an agent turn may run for minutes. openEventStream must override it.
|
||||
//
|
||||
// This has to be asserted from the CLIENT side because the failure cannot be
|
||||
// observed from the handler: writes made after the deadline return err == nil
|
||||
// and their bytes are silently discarded. A test that checked the return of
|
||||
// io.WriteString would pass against the bug.
|
||||
func TestEventStreamOutlivesServerWriteTimeout(t *testing.T) {
|
||||
// sseWriteTimeout stays at its 30s default here, so the per-frame refresh
|
||||
// keeps the stream alive with a huge margin — CI slowness only ever makes
|
||||
// this pass more surely. The server's 300ms WriteTimeout is the thing being
|
||||
// overridden; frames straddle it (300ms/600ms/900ms).
|
||||
got, err := streamFrames(t, 300*time.Millisecond, 300*time.Millisecond, 3)
|
||||
if err != nil {
|
||||
t.Errorf("client read error after %d/3 frames: %v", got, err)
|
||||
}
|
||||
if got != 3 {
|
||||
t.Errorf("client received %d frames, want 3 — the stream was cut at the server WriteTimeout", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventStreamRefreshesDeadlinePerFrame guards the #87 fix specifically: the
|
||||
// write deadline is refreshed on EVERY frame, not set once.
|
||||
//
|
||||
// A set-once deadline is a plausible "simplification" and it reintroduces the
|
||||
// unbounded-block risk the per-frame refresh exists to prevent — yet it would
|
||||
// sail through the test above, whose whole run is far under sseWriteTimeout. So
|
||||
// shrink sseWriteTimeout below the stream's total duration and send frames whose
|
||||
// gap stays comfortably under it: per-frame refresh delivers them all, while a
|
||||
// deadline set once at open would expire mid-stream and cut it short.
|
||||
func TestEventStreamRefreshesDeadlinePerFrame(t *testing.T) {
|
||||
orig := sseWriteTimeout
|
||||
sseWriteTimeout = 400 * time.Millisecond
|
||||
t.Cleanup(func() { sseWriteTimeout = orig })
|
||||
|
||||
// The server WriteTimeout is generous (5s), so it isn't the limiter — the
|
||||
// per-frame sseWriteTimeout is. 8 frames at a 100ms tick span 800ms, well past
|
||||
// the 400ms deadline, but each 100ms gap is a 4× margin under it.
|
||||
got, err := streamFrames(t, 5*time.Second, 100*time.Millisecond, 8)
|
||||
if err != nil {
|
||||
t.Errorf("client read error after %d/8 frames: %v", got, err)
|
||||
}
|
||||
if got != 8 {
|
||||
t.Errorf("client received %d frames, want 8 — a set-once deadline would cut the stream at ~%v; the refresh must be per-frame",
|
||||
got, sseWriteTimeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
// Package imagenorm normalizes an uploaded image to a JPEG the rest of pansy
|
||||
// (and majordomo's vision path) can rely on, decoding the formats a phone
|
||||
// actually produces.
|
||||
//
|
||||
// # Why this exists
|
||||
//
|
||||
// majordomo's own media pipeline is stdlib-based, so it cannot decode HEIC or
|
||||
// WebP — and HEIC is the iPhone camera default. The seed-packet feature's very
|
||||
// first input is "a photo from my phone", so without this the feature fails on
|
||||
// the exact device that motivates it. Normalizing at the upload boundary means
|
||||
// everything downstream only ever sees JPEG.
|
||||
//
|
||||
// # The CGO constraint
|
||||
//
|
||||
// pansy is CGO_ENABLED=0 (a single static binary — the reason modernc/sqlite was
|
||||
// chosen over the C one), so a libheif *binding* is out. github.com/gen2brain/heic
|
||||
// runs libheif as WebAssembly via wazero: pure Go, no cgo, and it registers with
|
||||
// image.Decode like any other format. golang.org/x/image/webp is pure Go too.
|
||||
//
|
||||
// # Import-driven registry
|
||||
//
|
||||
// Go's image decoders register via blank imports, and the failure mode is
|
||||
// backwards from intuition: forget "image/png" and PNG uploads fail with
|
||||
// "unknown format" while the exotic HEIC still works. So the blank imports below
|
||||
// are load-bearing, and TestNormalizeAllFormats exercises all four formats to
|
||||
// keep them so.
|
||||
package imagenorm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"io"
|
||||
|
||||
"golang.org/x/image/draw"
|
||||
|
||||
// Decoders, registered with image.Decode by side effect. All four matter:
|
||||
// jpeg/png are the common cases, heic is the iPhone default, webp is common
|
||||
// on the web. Dropping any one silently breaks that format's uploads.
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
|
||||
_ "github.com/gen2brain/heic"
|
||||
_ "golang.org/x/image/webp"
|
||||
)
|
||||
|
||||
// Defaults chosen for the seed-packet path against ollama-cloud's limits (8
|
||||
// images, 20 MiB, 2048px, jpeg+png). We re-encode to JPEG well under all of them.
|
||||
const (
|
||||
// DefaultMaxDim is the longest-edge ceiling. 2048 matches ollama-cloud's
|
||||
// MaxDim; anything larger is downscaled. A packet photo has plenty of detail
|
||||
// left at 2048.
|
||||
DefaultMaxDim = 2048
|
||||
// DefaultMaxBytes caps the *input* we will read. A phone photo is 3–8 MB; 25
|
||||
// MiB leaves headroom for a large HEIC without inviting a decompression bomb
|
||||
// as an unbounded read. The re-encoded output is far smaller.
|
||||
DefaultMaxBytes = 25 << 20
|
||||
// maxDecodePixels bounds the DECODED bitmap regardless of input byte size, so
|
||||
// a small file claiming enormous dimensions (a decompression bomb) is refused
|
||||
// before its ~4-bytes/px bitmap is allocated. 50 MP ≈ 200 MB peak — above any
|
||||
// current phone camera (a 48 MP sensor is 48 MP) while capping the
|
||||
// amplification a hostile header can force. maxDecodePixels and maxDimension
|
||||
// are internal safety floors, not knobs — unlike MaxDim/MaxBytes there's no
|
||||
// reason for a caller to raise them.
|
||||
maxDecodePixels = 50_000_000
|
||||
// maxDimension caps EACH side independently. It exists to make the pixel-count
|
||||
// check overflow-safe: without it, a header claiming ~2^32 on a side could
|
||||
// wrap int64(w)*int64(h) negative and slip past maxDecodePixels. No real image
|
||||
// is 50k px on a side.
|
||||
maxDimension = 50_000
|
||||
// jpegQuality for the normalized output. 85 is visually clean and keeps the
|
||||
// file small; the model reads text off it, not fine gradients.
|
||||
jpegQuality = 85
|
||||
)
|
||||
|
||||
// Options tunes Normalize. The zero value uses the Default* constants.
|
||||
type Options struct {
|
||||
MaxDim int // longest edge; 0 → DefaultMaxDim
|
||||
MaxBytes int // input read cap; 0 → DefaultMaxBytes
|
||||
}
|
||||
|
||||
func (o Options) maxDim() int {
|
||||
if o.MaxDim > 0 {
|
||||
return o.MaxDim
|
||||
}
|
||||
return DefaultMaxDim
|
||||
}
|
||||
|
||||
func (o Options) maxBytes() int {
|
||||
if o.MaxBytes > 0 {
|
||||
return o.MaxBytes
|
||||
}
|
||||
return DefaultMaxBytes
|
||||
}
|
||||
|
||||
// ErrTooLarge means the input exceeded the byte cap or decoded to an absurd
|
||||
// pixel count. ErrUnsupported means the bytes weren't a decodable image format —
|
||||
// or a decoder panicked on them (see the recover in Normalize).
|
||||
var (
|
||||
ErrTooLarge = errors.New("imagenorm: image too large")
|
||||
ErrUnsupported = errors.New("imagenorm: unsupported or corrupt image")
|
||||
)
|
||||
|
||||
// Normalize reads an image of any supported format (JPEG, PNG, HEIC, WebP),
|
||||
// downscales it to fit opts.MaxDim on its longest edge, and returns it re-encoded
|
||||
// as JPEG, plus the decoded format name (e.g. "heic") — handy for logging what a
|
||||
// phone actually sent. On any error the returned bytes are nil and format is "".
|
||||
//
|
||||
// Errors, by cause:
|
||||
// - input over opts.MaxBytes, or a decoded canvas over maxDecodePixels /
|
||||
// maxDimension → ErrTooLarge, refused before the bitmap is allocated;
|
||||
// - bytes that aren't a decodable image, or a decoder that panics on them →
|
||||
// ErrUnsupported;
|
||||
// - a genuine read or JPEG-encode I/O failure → a wrapped error (not a
|
||||
// sentinel), since those are the caller's stream/environment, not the image.
|
||||
//
|
||||
// It bounds work against a hostile upload three ways: the byte cap, the
|
||||
// pre-decode pixel/dimension check, and a recover around the third-party decoders
|
||||
// (a malformed HEIC/WebP shouldn't take the process down).
|
||||
//
|
||||
// Two known gaps, both deferred to the upload handler that wires this in (#81):
|
||||
// - EXIF ORIENTATION is not applied, so a portrait phone photo tagged
|
||||
// "rotate 90°" comes out sideways. That's best fixed and tested with a real
|
||||
// oriented photo end-to-end, which the library has no consumer for yet.
|
||||
// - There is no context: image.Decode is CPU-bound and not cancellable
|
||||
// mid-decode, so a caller that needs a hard deadline should run Normalize
|
||||
// under its own timeout. The size guards keep the work finite regardless.
|
||||
func Normalize(r io.Reader, opts Options) (out []byte, format string, err error) {
|
||||
// Cap the read at MaxBytes+1 so we can tell "exactly at the cap" from "over".
|
||||
// maxBytes() is always a sane positive (default 25 MiB); guard the +1 anyway.
|
||||
byteCap := opts.maxBytes()
|
||||
limit := int64(byteCap) + 1
|
||||
if limit < 1 {
|
||||
limit = int64(DefaultMaxBytes) + 1
|
||||
}
|
||||
raw, err := io.ReadAll(io.LimitReader(r, limit))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("imagenorm: read: %w", err)
|
||||
}
|
||||
if len(raw) > byteCap {
|
||||
return nil, "", ErrTooLarge
|
||||
}
|
||||
|
||||
// Check dimensions BEFORE a full decode, so a decompression bomb is refused
|
||||
// before it allocates its bitmap. The per-side maxDimension check runs first
|
||||
// so the pixel-count multiply below can't overflow.
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", ErrUnsupported
|
||||
}
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 ||
|
||||
cfg.Width > maxDimension || cfg.Height > maxDimension ||
|
||||
int64(cfg.Width)*int64(cfg.Height) > maxDecodePixels {
|
||||
return nil, "", ErrTooLarge
|
||||
}
|
||||
|
||||
img, format, err := decodeSafely(raw)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
img = downscale(img, opts.maxDim())
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: jpegQuality}); err != nil {
|
||||
return nil, "", fmt.Errorf("imagenorm: encode jpeg: %w", err)
|
||||
}
|
||||
return buf.Bytes(), format, nil
|
||||
}
|
||||
|
||||
// decodeSafely decodes raw, converting both a decode error and a decoder PANIC
|
||||
// into ErrUnsupported. The recover matters because the image comes from an
|
||||
// untrusted upload and the HEIC/WebP decoders are third-party (libheif via WASM,
|
||||
// x/image/webp): a malformed file that panics one of them must fail this one
|
||||
// request, not crash the process.
|
||||
func decodeSafely(raw []byte) (img image.Image, format string, err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
img, format, err = nil, "", ErrUnsupported
|
||||
}
|
||||
}()
|
||||
img, format, err = image.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, "", ErrUnsupported
|
||||
}
|
||||
return img, format, nil
|
||||
}
|
||||
|
||||
// downscale returns img shrunk so its longest edge is at most maxDim, preserving
|
||||
// aspect ratio. An image already within bounds is returned unchanged (no
|
||||
// re-sampling, no quality loss beyond the JPEG round-trip). Uses Catmull-Rom for
|
||||
// a sharp result on text, which is what a packet photo is mostly made of.
|
||||
func downscale(img image.Image, maxDim int) image.Image {
|
||||
b := img.Bounds()
|
||||
w, h := b.Dx(), b.Dy()
|
||||
longest := max(w, h)
|
||||
if longest <= maxDim || longest == 0 {
|
||||
return img
|
||||
}
|
||||
scale := float64(maxDim) / float64(longest)
|
||||
nw, nh := max(int(float64(w)*scale), 1), max(int(float64(h)*scale), 1)
|
||||
dst := image.NewRGBA(image.Rect(0, 0, nw, nh))
|
||||
draw.CatmullRom.Scale(dst, dst.Bounds(), img, b, draw.Over, nil)
|
||||
return dst
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package imagenorm
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"hash/crc32"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"image/png"
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// pngBytes and jpegBytes generate in-memory fixtures for the two formats Go can
|
||||
// encode; heic/webp come from testdata (Go has no encoder for them).
|
||||
func pngBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for y := range h {
|
||||
for x := range w {
|
||||
m.Pix[m.PixOffset(x, y)+0] = uint8(x)
|
||||
m.Pix[m.PixOffset(x, y)+3] = 255
|
||||
}
|
||||
}
|
||||
var b bytes.Buffer
|
||||
if err := png.Encode(&b, m); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func jpegBytes(t *testing.T, w, h int) []byte {
|
||||
t.Helper()
|
||||
m := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
var b bytes.Buffer
|
||||
if err := jpeg.Encode(&b, m, nil); err != nil {
|
||||
t.Fatalf("encode jpeg: %v", err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func readTestdata(t *testing.T, name string) []byte {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile("testdata/" + name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// TestNormalizeAllFormats is the load-bearing test: every format pansy claims to
|
||||
// accept must round-trip to a valid JPEG. It exists specifically to catch a
|
||||
// dropped blank import — the failure mode where the common format (PNG) breaks
|
||||
// while the exotic one (HEIC) works, because someone deleted `_ "image/png"`.
|
||||
func TestNormalizeAllFormats(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
input []byte
|
||||
wantFormat string
|
||||
}{
|
||||
{"png", pngBytes(t, 120, 90), "png"},
|
||||
{"jpeg", jpegBytes(t, 120, 90), "jpeg"},
|
||||
{"heic", readTestdata(t, "sample.heic"), "heic"},
|
||||
{"webp", readTestdata(t, "sample.webp"), "webp"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, format, err := Normalize(bytes.NewReader(tc.input), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize(%s): %v", tc.name, err)
|
||||
}
|
||||
if format != tc.wantFormat {
|
||||
t.Errorf("format = %q, want %q", format, tc.wantFormat)
|
||||
}
|
||||
// The output must itself be a decodable JPEG.
|
||||
_, outFormat, err := image.Decode(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("output isn't a valid image: %v", err)
|
||||
}
|
||||
if outFormat != "jpeg" {
|
||||
t.Errorf("output format = %q, want jpeg", outFormat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeDownscales checks a large image is shrunk to fit MaxDim on its
|
||||
// longest edge with aspect ratio preserved, and a small one is left alone.
|
||||
func TestNormalizeDownscales(t *testing.T) {
|
||||
// A 2:1 image twice as wide as DefaultMaxDim → clamped to DefaultMaxDim on the
|
||||
// long edge with aspect preserved. Derived from the constant, not hard-coded,
|
||||
// so the test tracks the default rather than silently asserting a magic number.
|
||||
longEdge := DefaultMaxDim * 2
|
||||
big := pngBytes(t, longEdge, longEdge/2)
|
||||
out, _, err := Normalize(bytes.NewReader(big), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize: %v", err)
|
||||
}
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode out: %v", err)
|
||||
}
|
||||
if cfg.Width != DefaultMaxDim {
|
||||
t.Errorf("width = %d, want %d (longest edge clamped)", cfg.Width, DefaultMaxDim)
|
||||
}
|
||||
if cfg.Height != DefaultMaxDim/2 {
|
||||
t.Errorf("height = %d, want %d (aspect preserved)", cfg.Height, DefaultMaxDim/2)
|
||||
}
|
||||
|
||||
// A small image within bounds keeps its dimensions.
|
||||
small := pngBytes(t, 100, 80)
|
||||
out, _, err = Normalize(bytes.NewReader(small), Options{})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize small: %v", err)
|
||||
}
|
||||
cfg, _, err = image.DecodeConfig(bytes.NewReader(out))
|
||||
if err != nil {
|
||||
t.Fatalf("decode small out: %v", err)
|
||||
}
|
||||
if cfg.Width != 100 || cfg.Height != 80 {
|
||||
t.Errorf("small image resized to %dx%d, want 100x80", cfg.Width, cfg.Height)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeRejectsOversizeInput: an input past the byte cap is ErrTooLarge,
|
||||
// refused without a full decode.
|
||||
func TestNormalizeRejectsOversizeInput(t *testing.T) {
|
||||
big := pngBytes(t, 500, 500)
|
||||
_, _, err := Normalize(bytes.NewReader(big), Options{MaxBytes: 100})
|
||||
if err != ErrTooLarge {
|
||||
t.Errorf("over-cap input err = %v, want ErrTooLarge", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizeRejectsGarbage: unreadable-as-image bytes and a truncated image
|
||||
// both fail cleanly with ErrUnsupported, not a panic.
|
||||
func TestNormalizeRejectsGarbage(t *testing.T) {
|
||||
_, _, err := Normalize(bytes.NewReader([]byte("not an image at all")), Options{})
|
||||
if err != ErrUnsupported {
|
||||
t.Errorf("garbage err = %v, want ErrUnsupported", err)
|
||||
}
|
||||
// A truncated image (valid header, cut body) also fails cleanly, not a panic.
|
||||
png := pngBytes(t, 100, 100)
|
||||
_, _, err = Normalize(bytes.NewReader(png[:len(png)/2]), Options{})
|
||||
if err != ErrUnsupported {
|
||||
t.Errorf("truncated image err = %v, want ErrUnsupported", err)
|
||||
}
|
||||
}
|
||||
|
||||
// pngHeader builds a valid PNG signature + IHDR chunk (with a correct CRC, which
|
||||
// DecodeConfig verifies) for the given dimensions, and nothing else. It's enough
|
||||
// for image.DecodeConfig to report width/height without a real bitmap — exactly
|
||||
// what's needed to exercise the pre-decode size guard with a tiny input.
|
||||
func pngHeader(w, h uint32) []byte {
|
||||
var buf bytes.Buffer
|
||||
buf.Write([]byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a})
|
||||
ihdr := make([]byte, 13)
|
||||
binary.BigEndian.PutUint32(ihdr[0:], w)
|
||||
binary.BigEndian.PutUint32(ihdr[4:], h)
|
||||
ihdr[8] = 8 // bit depth
|
||||
ihdr[9] = 6 // colour type: RGBA
|
||||
// compression/filter/interlace already 0.
|
||||
binary.Write(&buf, binary.BigEndian, uint32(len(ihdr)))
|
||||
chunk := append([]byte("IHDR"), ihdr...)
|
||||
buf.Write(chunk)
|
||||
binary.Write(&buf, binary.BigEndian, crc32.ChecksumIEEE(chunk))
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// TestNormalizeRejectsPixelBomb is the guard the review found untested: a small
|
||||
// input (a bare ~40-byte PNG header) claiming an enormous canvas is refused with
|
||||
// ErrTooLarge from DecodeConfig alone, before image.Decode allocates anything.
|
||||
// Covers both the per-side maxDimension trip and the pixel-count trip — and, via
|
||||
// the near-2^16-per-side case, that the count math doesn't overflow.
|
||||
func TestNormalizeRejectsPixelBomb(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
w, h uint32
|
||||
}{
|
||||
{"huge single side", 60000, 10}, // > maxDimension on width
|
||||
{"huge area within side cap", 40000, 40000}, // sides < cap, area 1.6 GP > maxDecodePixels
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
hdr := pngHeader(tc.w, tc.h)
|
||||
if len(hdr) > 100 {
|
||||
t.Fatalf("header unexpectedly large (%d bytes) — not a bomb test", len(hdr))
|
||||
}
|
||||
// Sanity: the header really does decode to those dimensions.
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(hdr))
|
||||
if err != nil {
|
||||
t.Fatalf("crafted PNG header didn't parse: %v", err)
|
||||
}
|
||||
if uint32(cfg.Width) != tc.w || uint32(cfg.Height) != tc.h {
|
||||
t.Fatalf("header reports %dx%d, want %dx%d", cfg.Width, cfg.Height, tc.w, tc.h)
|
||||
}
|
||||
if _, _, err := Normalize(bytes.NewReader(hdr), Options{}); err != ErrTooLarge {
|
||||
t.Errorf("pixel bomb %dx%d err = %v, want ErrTooLarge", tc.w, tc.h, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
# imagenorm test fixtures
|
||||
|
||||
Go has no encoder for HEIC or WebP, so these small samples are committed rather
|
||||
than generated at test time. They are used by `TestNormalizeAllFormats` to prove
|
||||
every accepted format round-trips to JPEG (and, mainly, to catch a dropped blank
|
||||
import — see the package doc).
|
||||
|
||||
- `sample.heic` — a 240×160 gradient, created from a Go-generated PNG with macOS
|
||||
`sips -s format heic`. HEVC still-image profile.
|
||||
- `sample.webp` — a 16×16 image copied from CPython's stdlib test corpus
|
||||
(`Lib/test/test_email/data/python.webp`), verified to decode with
|
||||
`golang.org/x/image/webp` before committing. Used only as a decode fixture.
|
||||
|
||||
Neither contains anything meaningful; they exist purely to be decoded.
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
|
After Width: | Height: | Size: 432 B |
@@ -5,23 +5,25 @@ 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
|
||||
return (
|
||||
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
|
||||
<div className="flex flex-col gap-4">
|
||||
<p className="text-sm text-muted">
|
||||
Remove all <span className="font-medium text-fg">{n}</span> {n === 1 ? 'plant' : 'plants'} from{' '}
|
||||
Remove all <span className="font-medium text-fg">{plopCount}</span>{' '}
|
||||
{plopCount === 1 ? 'plant' : 'plants'} from{' '}
|
||||
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
|
||||
</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
@@ -31,8 +33,8 @@ export function ClearBedModal({
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={clear.isPending || n === 0}
|
||||
onClick={() => clear.mutate(plops, { onSuccess: onClose })}
|
||||
disabled={clear.isPending || plopCount === 0}
|
||||
onClick={() => clear.mutate(objectId, { onSuccess: onClose })}
|
||||
>
|
||||
{clear.isPending ? 'Clearing…' : 'Clear bed'}
|
||||
</Button>
|
||||
|
||||
+17
-17
@@ -328,28 +328,28 @@ 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). */
|
||||
const clearResultSchema = z.object({ cleared: z.number() })
|
||||
|
||||
/** 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> => {
|
||||
// No body — clear takes none; passing undefined sends none rather than an
|
||||
// empty {}. The response is just a count; validate it rather than cast.
|
||||
const res = clearResultSchema.parse(await api.post(`/objects/${objectId}/clear`))
|
||||
return res.cleared
|
||||
},
|
||||
// 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