Moves the agent model out of env-only config into an admin-editable Settings
section, and enforces is_admin for the first time — it has been in the schema
since migration 0001, plumbed to the client, and checked nowhere.
Backend:
- Migration 0010: instance_settings, a single-row (CHECK id=1) table — pansy's
first instance-level state. Holds agent_model ('' = inherit env) and
agent_enabled (NULL = inherit env), version-guarded like every mutable row.
SECRETS STAY IN ENV: OLLAMA_CLOUD_API_KEY is never stored here.
- requireAdmin at the service seam (authoritative) plus a cheap middleware
early-403. Non-admin gets 403, not 404 — settings existence isn't masked.
- EffectiveAgent resolves DB-over-env (model, enabled); key always from env.
- The live Runner is hot-swapped, not built once. agentHolder holds it behind
an atomic.Pointer; the chat routes are now registered UNCONDITIONALLY and
nil-check agent.get(), so a settings change turns the assistant on/off/onto a
new model with no restart and no race against in-flight readers. /capabilities
reads the pointer, so it reports what's live, not what booted.
- internal/agentmodel is a new leaf package holding the one place that knows how
to turn a spec into a model. Both agent (to run) and service (to validate a
spec before storing it) import it; it can't live in agent, which imports
service. Settings PATCH validates the spec via Parse, so a typo is a 400 now
rather than a broken assistant on the next turn.
Frontend:
- /settings route (admin guard), a Settings page (model field, tri-state
enabled, live status), nav link shown only to admins.
- useCapabilities drops staleTime:Infinity — the assistant can now change under
a running page — and the settings save invalidates it.
Contract change: chat routes always exist, so "assistant off" is a runtime 503
+ capabilities:false, not a missing route. Updated the test that asserted the
old shape.
Verified live against the built binary: disable flips capabilities to false and
logs it; re-enable with a new model swaps it back; a bad spec is rejected 400;
the setting persists across a restart. Swap is race-clean under `go test -race`.
Docs: README (precedence + key-stays-in-env), DESIGN (decision + routes),
CLAUDE (don't re-add conditional route registration; key never in the DB).
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
10 KiB
CLAUDE.md
Working notes for Claude Code on pansy. DESIGN.md is the architecture document and stays authoritative; this file is the operational stuff you'd otherwise rediscover every session.
This is a vibe-coded project — say so
pansy is written by an LLM, with a human directing. That is not a footnote, it is a property of the software people should know before they trust it with their garden plans.
Rules, not preferences:
- The README carries a prominent, near-the-top disclosure. It does not get moved below the fold, softened into "AI-assisted", or quietly dropped in a rewrite.
- If you rewrite the README, the disclosure survives the rewrite.
- Anywhere else the project introduces itself (a landing page, a docs site, a package description), it says the same thing.
If you find yourself editing that section, the only acceptable direction is clearer and more honest, never quieter.
Keep the docs true
Docs rot silently and nobody notices until someone follows them and it doesn't work. Treat them as part of the change, not follow-up work:
- README.md — update it in the same commit whenever you add or change an environment variable, a route worth knowing about, a build or run command, or anything in the Docker/Compose example. The env var table and the compose snippet are the two things people copy, so they're the two that hurt most when they're stale.
- DESIGN.md — update it when the architecture actually changes: a new table, a new package, a new API surface, a decision that supersedes one written there. Not for every implementation detail.
- CLAUDE.md — this file. Add a convention here the moment you find yourself rediscovering it.
- Examples must run. If you change something an example depends on, fix the example. A snippet that references an issue number as "once #5 lands" after #5 has landed is a bug in the docs.
When you touch a file, glance at whether the comments around your change are still true. Stale comments are worse than none — the next reader believes them.
Build and test
pansy is a standalone Go module inside a parent workspace, so GOWORK=off is
required or the build picks up sibling modules:
GOWORK=off go build ./...
GOWORK=off go test ./...
cd web && npx tsc --noEmit && npx vitest run && npm run build
make test # both halves
make build # web bundle → embed → CGO_ENABLED=0 static binary
gofmt -l internal/ before committing. Note internal/service/plants_test.go is
already unformatted on main — leave it alone unless you're touching it, so the
diff stays about your change.
Architecture in one paragraph
internal/store (hand-written SQL, modernc.org/sqlite, pure Go) →
internal/service (the seam: every permission check and invariant) →
internal/api (thin gin handlers: decode, call service, encode). Agent tools in
internal/agent are equally thin adapters over the same service methods, so
they inherit permission enforcement for free. If you are about to put a rule in a
handler, put it in the service instead.
Frontend: React 19 + Vite + Tailwind 4 + TanStack Router/Query, built into
internal/webdist/dist and embedded with embed.FS.
Conventions that bite if you miss them
- Everything is centimeters, stored as SQLite
REAL. Imperial is a display and entry concern only, inweb/src/lib/units.ts. Two distinct scales live there: dimension (m/ft — gardens, objects, the garden grid) and spacing (cm/in — plant spacing, bed grid). Mixing them is what #47 was about. - Optimistic concurrency everywhere. Every mutable row has
version; PATCH and DELETE carry it; the store returns(current row, ErrVersionConflict)on mismatch and the API answers 409 with the row under"current". - No-access is
ErrNotFound, notErrForbidden. Existence is masked deliberately.ErrForbiddenmeans "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,
-yis north. Moving or rotating a bed moves its plants free. - A plop is a clump, not a plant.
defaultPlopRadiusis1.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 readsremoved_at IS NULL. Hard delete is a different operation. - Migrations are numbered
.sqlfiles ininternal/store/migrations/, run at startup, embedded. Never edit one that has shipped. - 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 singlerecordcall so they undo as one unit. - The history write is detached from cancellation on purpose.
commitScopecallscontext.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 incommitScopewhere 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/:idonce 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
Branch → PR → Gadfly reviews it automatically → consider every finding and
fix what's real → merge when the pipeline is green. Do not grade Gadfly findings.
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.
Planning happens in Gitea issues first: standalone issues under a tracking epic, implemented in later per-issue sessions.
Environment
PANSY_PORT, PANSY_DB, PANSY_BASE_URL, PANSY_REGISTRATION,
PANSY_LOCAL_AUTH, PANSY_OIDC_* — note it's PANSY_DB and PANSY_PORT, not
the _PATH/_ADDR names you might guess. Authentik is the primary IdP;
OIDC-first with local passwords as fallback.
Agent config: OLLAMA_CLOUD_API_KEY, PANSY_AGENT_MODEL (default
ollama-cloud/glm-5.2:cloud) and PANSY_AGENT_ENABLED, set in Komodo. Model
strings pass verbatim to majordomo.Parse, so a comma-separated spec gives
failover for free — don't parse that grammar in pansy.
The model and enabled flag are also admin-editable at runtime in Settings (#79); the env vars are just defaults (precedence: DB setting → env → default). Two things this makes load-bearing:
- The live Runner is hot-swapped, not built once. It sits behind an
atomic.Pointerininternal/api(agentHolder), and its routes are registered unconditionally with a nil-check onagent.get(). Do NOT go back to registering the chat routes only when a key is present — a settings change has to be able to turn the assistant on without a restart, which a missing route can't./capabilitiesreads the pointer, so it reflects the live state. OLLAMA_CLOUD_API_KEYstays in the environment, never the DB. Model selection is a setting; the key is not. A secret ininstance_settingslands in every backup and in the undo history's blast radius. The Settings API reports whether a key is present, never its value.- The "how to turn a spec into a model" knowledge lives once in
internal/agentmodel, imported by bothagent(to run) andservice(to validate a spec before storing it). It can't live inagent—agentimportsservice, so aservice→agentimport would cycle.
majordomo is a real dependency now, resolved from the Gitea instance as a
pseudo-version. There is no replace directive and there must not be one: a
replace pointing at ../majordomo builds on your laptop and breaks the Docker
build, which has no sibling checkout. executus is a sibling repo at ../ and
is not a dependency.
The majordomo build tag is gone. Don't reintroduce it — an untagged CI that
never compiles the agent is worse than a slightly larger binary.