Skip to content

Keel in Go: one binary for control plane, edge, agent and CLI - #30

Merged
ThallesP merged 262 commits into
mainfrom
go-single-binary
Oct 12, 2026
Merged

ThallesP merged 262 commits into
mainfrom
go-single-binary

Conversation

@ThallesP

@ThallesP ThallesP commented Oct 8, 2026 •

Copy link
Copy Markdown
Owner

Experiment: Keel's backend rewritten in Go as one keel binary. It replaces self-hosted Convex, Better Auth, the Bun worker and the keel-proxy image. The dashboard and the CLI now run on its HTTP API. Durable workflows are left for a later run, as agreed.

What the binary is

Command Runs Replaces
keel serve control plane container: HTTP API, WebSocket, SQLite, Swarm driver, embedded dashboard Convex backend + functions, Better Auth, keel-web
keel proxy edge container: Caddy + caddy-l4 + the keel plugin as libraries apps/proxy image
keel agent keel-agent Swarm global service, created by serve apps/worker + scripts/deploy-worker.sh
keel login, ship, … laptops and agents apps/cli (output contract unchanged)

serve and proxy stay two containers on purpose. The process that parses internet traffic never holds the Docker socket.

Stack

  • HTTP: Huma on net/http. openapi.json is generated from the handlers and committed; CI fails when it drifts.
  • Errors: RFC 9457 problems carrying the CLI's own codes, so the CLI no longer matches error strings.
  • Dashboard: Kubb generates TanStack Query hooks from the spec. Query keys start with the request path.
  • Realtime: one centrifuge WebSocket per tab. The server publishes invalidation topics, which are API path prefixes. Writes also return them in a Keel-Invalidate header, so a mutation sees its own write.
  • Data: SQLite through modernc.org/sqlite and sqlc, with embedded migrations, one writer and a read pool.
  • Layers: domain (pure rules), app (use cases and the ports they need), adapters (sqlite, swarm, caddy, axiom, realtime, jobs, password), transport/http. Contract: docs/go/ARCHITECTURE.md.
  • Scheduling: Convex's scheduler and crons become an in-memory job runner. A start-up Recover pass re-observes Swarm, re-arms timeouts, re-queues applies and resyncs the proxy.
  • Auth: sessions are random tokens stored hashed. Passwords use argon2id. Device login for the CLI, invite-only sign-up, CSRF origin check.
  • tsnet: opt-in in the agent (KEEL_TS_AUTHKEY). Swarm already needs host tailscaled, so it isn't the default.

How it was built

docs/go/spec/ holds seven porting specs written from the TypeScript, plus a completeness pass. Seven backend areas were then built in parallel, each reviewed against its spec, and merged with integration fixes. The CLI and the dashboard followed on the generated API.

Then the port was made idiomatic. There is no Convex migration (Keel has no users), so parity with the TypeScript stopped being a goal:

  • JavaScript emulation removed. No JSONObject, no js* helpers, no JS string lengths or whitespace. Axiom rows, Docker events, proxy reports, Caddy config and worker config decode into typed structs.
  • Zero values over pointers. Unset columns are NOT NULL DEFAULT 0/'', integer fields are integers on the wire, and runtime state lives on App instead of package-level maps.
  • Unused code deleted. That covers wrappers, rename layers, pointer helpers, the settings table, test-only exports and dead fakes. Go code carries no comments (tools/nocomments in CI).

Verified

  • go build, go vet, go test ./... pass; GOOS=darwin go vet ./... is clean. Area tests cover every exact algorithm: status, task summary, reconcile, variable references, port allocation, the Caddy config builder, the device flow, log shipping.
  • Swarm integration test (new CI job swarm): serve and agent in one process against a real Swarm. It covers deploy, a variable shipped as an update (checked in the service spec), stop, start, redeploy and delete. It passes locally against an isolated Docker-in-Docker Swarm.
  • Dashboard in headless Chromium against the embedded build and the isolated Swarm, with no console errors:
    • sign-up, the canvas, and adding a service that deploys live;
    • "Ship · 1 change", rolling out, then settled; the Logs tab streaming from Docker;
    • the Observability gate, the account menu, the invite dialog, and CLI device approval.
  • CLI end to end against a real serve: login, whoami, project, service, var, status, ship, redeploy, deployment, tracing, run, logs and logout, with the JSON envelope and exit codes unchanged.
  • API checks: invite-only sign-up refuses an uninvited email; an invited one joins as a member and spends the invitation.

Not verified, or known gaps

  • No upgrade from Convex-era Keel. Re-running install.sh on a server that runs the Convex-based Keel starts an empty control plane; its data stays in keel_convex-data.
  • External services. Axiom (sign-in, queries, ingest, the OTLP relay) is tested only against fakes and golden files from the TypeScript providers. Certificates through keel proxy are unit-tested only, not with real ACME.
  • Session cookie. It is a host cookie, and cookies aren't scoped by port. On an install reached over plain http by IP, another server on the same host and IP can see it. Over the tailnet https name (tailscale serve) this doesn't apply.
  • Not built in either backend: the Add-server join flow, placement pinning, and the volumes feature.
  • Apply concurrency. Applies are serialized per node, but there's no global limit (Convex ran at most 16 actions at once).
  • Binary size. About 110 MB unstripped, 76 MB stripped; Caddy is most of it. -tags keel_noproxy builds a lean CLI.

Test plan

  • CI: go, swarm, web, install jobs green
  • Fresh install.sh on a VM, then deploy, expose over https, Sign in with Axiom

🤖 Generated with Claude Code

ThallesP and others added 30 commits October 8, 2026 02:57
Copied from apps/cli unchanged; the old module stays until its commands
are ported onto the HTTP API. CONFLICT, UNAVAILABLE and FORBIDDEN join the
code vocabulary (codes are only ever added).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…embedded dashboard

- serve constructs the jobs scheduler and the realtime server, sets
  app.Jobs/app.Events, mounts GET /api/ws, runs app.Recover once the
  listener is up, and shuts down in order: HTTP, recovery pass, jobs,
  WebSockets, adapters, database.
- adapters.go: one TODO hook per external adapter (swarm, proxy, axiom)
  for the integration step.
- /config.js: window.__KEEL__ = {"apiUrl","version"} (cli-install.md B3).
- apps/web/embed.go: dist embedded with -tags embedweb, nil otherwise;
  main sets cli.WebFS.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… + Better Auth scrypt)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Domain: node defaults, engines, seeded credentials, naming, placement,
reference syntax and resolution (depth 5, encodeURIComponent), referrer
graph, reference rewriting. App: projects.ts, environments.ts, nodes.ts,
variables.ts use cases; computeEnv and markReferrersDirty seams. SQLite:
CanvasTx queries, unique node name per environment (0002_canvas.sql).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…nd their SQLite store

beginDeployment and the Ship use case, the deployment reads, step writers with the
500-line log cap, apply (per-node serialized, skips a superseded revision before
createOrUpdate), observe (one node, sweep, servers), the debounce, reconcile of the
affected environment, the 5-minute timeout and the deploy part of recovery.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Domain rules of proxy-ingress.md §3 (shortHash, default and moved
domains, validDomain, port allocation, cert hints), the expose/unexpose
use cases with their exact checks and messages, followPort,
ScheduleProxySync (Jobs key proxy:sync, serialized passes that go again
when endpoints moved), failure attribution, statuses written only when
they change, cert reports and the start-up pass (default domains follow
the public IP, sync, 2-minute resync).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Root Dockerfile: bun builds apps/web/dist (turbo prune, so it follows
whatever workspace packages web depends on), Go embeds it with
-tags embedweb and stamps cli.Version, alpine runtime with CA certs and
tzdata, ENTRYPOINT keel, CMD serve, /data volume, port 8080.
Makefile: dev (keel serve on 127.0.0.1:3400, state in ./.keel), test,
openapi, web, build, image.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Projects, environment summary, nodes (list, create, update, position,
duplicate, start, stop, delete) and variables (list, referenceable, set,
delete) per the resolved API decisions; NodeView per projects.md 4.3.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Use cases against a temp SQLite with fake Jobs/Proxy/Publisher: loaded
config and statuses, unchanged resync writes nothing, cert states,
listener blame and retry, whole-load failures, a sync that goes again
when endpoints move during a load, cert reports racing a sync, and the
default-domain move at start.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
adapters/swarm: toSpec, image inspect/pull (in-stream errors fail the pull),
service create/update/remove, observation reads, server count, and the keel-agent
global service that replaces scripts/deploy-worker.sh. Routes shipEnvironment,
getLatestDeployment, getDeployment (null envelopes) and listNodeDeployments; the
agent's events route with the Convex bearer, body limit and parsing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
exposeNode / unexposeNode / getControlPlane (tag ingress) and the raw
cert-report route with the Convex answers: worker bearer compared in
constant time, 256 KiB counted in UTF-16 units, bad json / bad report.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…e_modules

The local Docker has no buildx: drop cache mounts and $BUILDPLATFORM
(release images are built natively per arch). ignore node_modules keeps
./... patterns out of the JS workspace's dependencies.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ting

Table tests for frames, lines, sinceAfter, dockerSince, eventsSince,
replica, sinkKey and the Axiom URL (expected values from the worker's
TypeScript under node). httptest for /worker/* and Axiom ingest (backoff
schedules via a fake sleep); fake Docker and sinks for the forwarder and
the shipper, including the organization isolation of log routing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Log sinks per organization, Sign in with Axiom (DCR + PKCE), logs tail
(Docker and Axiom), environment logs, trace overview/get/around, the
OTLP relay, the tracing switch, withTracing and the agent prompt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
One image, ghcr.io/thallesp/keel, runs both control-plane containers:
`keel serve` (API, dashboard, SQLite in keel-data, Swarm driver that
manages keel-agent itself) and `keel proxy` (the edge, today's container
settings and volumes, never the Docker socket). The Convex backend, web,
functions image and worker deploy script are gone.

install.sh keeps its contract and only adds: apiUrl, convexImportNeeded
and warnings in the JSON (convexUrl/convexSiteUrl stay, equal to url),
KEEL_ACME_CA passed through, KEEL_SITE_URL in the state file. New
installs write no Convex secret; upgrades keep theirs, keep
keel_convex-data, and warn on every run until the data is imported.

CI: go (gofmt, vet, test on the root module), web (ui + web typecheck,
web build), install end to end against the new auth and device-login
routes, plus an upgrade over a Convex-era data volume. images.yml builds
the one image; the old images' tags stay published.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
adapters/caddy implements app.Proxy: GET /keel/host-addrs, GET
/keel/certs, POST /config/apps (a refused load is app.ProxyRejected with
Caddy's "loading config:" prefixes stripped), a 30 s idle timeout and
the "keel-proxy is not running (no admin socket at ...)" error.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…-only guard

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…gin (app, sqlite, HTTP)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Huma operations for the sink, Sign in with Axiom, logs, traces and the
tracing switch; raw POST /otlp/v1/traces and GET /worker/config (alias
/agent/config).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Reference syntax (ECMAScript whitespace), resolution and depth limit,
encodeURIComponent, referrer graph, naming and placement as table tests;
projects, nodes and variables use cases against a temp database with
fakes for joinOrFound, beginDeployment and the delete schedulers,
including a foreign-organization access test.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
… timeout, recovery, events

Table tests for every exact algorithm; use cases on a temp SQLite with a fake Swarm and a
manual job scheduler on a fake clock: Ship rules and messages, organization isolation,
apply serialization and the stale-revision skip, the debounce, the timeout and its
re-arm after a restart.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ne client

KEEL_TS_AUTHKEY set: the agent reaches KEEL_URL through an ephemeral tsnet
node keel-agent-<hostname> (state in a temp dir, 2 min to join); unset:
http.DefaultTransport over the host network, as today. tsnet adds about
20 MB (keel 20.7 -> 41.2 MB, stripped 14.0 -> 28.2 MB), under the 40 MB
threshold, so it is compiled in without a build tag.

go.mod: tailscale.com v1.104.1 (bumps otel to v1.44 / otelhttp v0.69
through MVS); moby client and api become direct requirements.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…r API on httptest)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
NodeView derivation (deploy step, error, stoppedAt, finishedAt,
publicUrl, endpoints) and its JSON; the canvas routes over httptest:
envelopes, 201/204 statuses, problem codes and sentences, null reads for
foreign members, idempotent deletes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ow, foreign organization)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
internal/proxy embeds Caddy v2.11.7 and caddy-l4 v0.1.2 as libraries
with only the modules proxy-ingress.md §12.2 lists, plus Keel's plugin
ported from apps/proxy: hostns.go verbatim (host-tcp/host-udp), the
admin routes /keel/host-addrs and /keel/certs, the `keel` cert event
handler. Base config from apps/proxy/caddy.json, admin socket at
KEEL_PROXY_SOCKET, --resume from Caddy's autosave, ACME terms agreed as
Caddy's command does. Registered as `keel proxy` through cli.Extra.

Tests: hostns table, reporter filtering/retries, admin routes, the
§6.3 config provisioning with exactly these modules, and the edge end
to end on loopback (admin socket, layer4 proxying, refused load keeps
the old config, resume after restart).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…eployment routes and envelopes)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ge keeps 30 s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Caddy takes the binary from 18 MB to 76 MB; a CLI-only build can drop
it with -tags keel_noproxy (18.8 MB). Default builds keep `keel proxy`.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
ThallesP and others added 24 commits October 10, 2026 06:52
ARCHITECTURE.md still described the first port: the recovery pass run
inline by serve, api/ importing only stdlib, a SITE_URL fallback, the
api.Code* vocabulary and ch.Node without its environment. It now says the
recovery pass is a job with three parts, runtime state lives on App,
write runs AfterCommit funcs, the worker protocol (array-only events, one
config entry per organization sink, typed proxy reports), the shared
caddy.DefaultSocket, RATE_LIMITED, and the CI checks that gate a change.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GET /worker/config is one entry per organization sink with no project
id, POST /worker/events takes only JSON arrays ([] as the resync hello,
one element per event), the agent and observe log through slog and only
on failure, and the agent finds Docker through DOCKER_HOST. The two docs
still described per-project routes, NDJSON, config applied lines and
DOCKER_SOCKET; they also gain the Axiom row decoding's window and column
rules and the embedded tracing prompt.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
root.go registers serve, openapi and agent itself; only the build-tagged
proxy command goes through extra, and main hands the dashboard FS to
Execute. The client returns api types unless the printout differs. The
first account founds the organization at sign-up, not at project create.
Unexpose checks its selector with Expose's rules before looking for the
endpoint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The specs stay as history, but where a section prescribes behaviour the
Go code deliberately dropped (JavaScript string and number semantics,
Better Auth's scrypt, stored device code client and interval, the
keel-worker volume and DOCKER_SOCKET, Sprintf log templates, NDJSON and
single-object worker events, projectId in the worker config, /config.js,
migrations.run and the Convex import), a one-line "Go now" note says what
the code does instead.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
GET /api/auth/device is public and unlimited, so a signed-out lookup
must not queue on the single writer connection, as fcbdca4 intended;
735c9d0 folded it into the claim's write tx. Only a signed-in claim
writes now, and a test holds the writer to pin it.

ClaimDeviceCode returns just the status: the user code it echoed back
is the caller's own input, which the handler already has.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The server has sent {"orgs":[]} since arrays stopped being null on the
wire (4be26ba), but the spec still said null and the Axiom gate tested
`if (orgs)`. An empty array is truthy, so a fresh install showed an
empty org picker and never the Sign in with Axiom button. The spec now
says what the server sends, and the gate checks for a non-empty list.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
fa5762d left the services-only rule to the server, whose INVALID_INPUT
carries no fix and does not name the node. tracing status still checks
locally, so the verbs disagreed. The guard is back, with a test that
also proves the server is never asked.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Deployment.Status, DeployStep.Status, NodeView.Type and NodeView.Status
were plain strings, so DeploymentOf and NodeViewOf converted with
string(...) and the CLI compared against "running", "success",
"failed", "group", "service", "off" and "old". With the domain types
the CLI uses the constants; openapi.json is unchanged.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
applySuperseded was a one-line method over writeStep that re-passed
the deployment and node apply already captures in step.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
canvasTouch read every node of the environment to add one
/api/nodes/<id> topic each, but ch.Environment already publishes the
/api/nodes/ prefix, which the dashboard matches by prefix. The read,
the loop and an error branch in eight callers go.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
deployComputeEnv and deployFollowPort were package-level variables
only so tests could swap them. The deploy tests now set real variables
and a real endpoint, so they check what apply actually sends to Swarm
and that a port change moves the endpoint.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
canvasShip and canvasSchedulers were package-level variables tests
swapped. Shipping is now a field New sets to beginDeployment, which a
test stubs per App. RemoveNode calls the schedulers directly, and the
canvas tests read what they scheduled from a fake Jobs, as the proxy
tests already do.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
app.truncateRunes was a copy of domain.TruncateRunes. app calls the
domain one, and its test table moved next to it.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
endpoints.domain, public_port and status_error were nullable, so the
adapter went through str, nullStr, ptrInt, intOr0 and ptrInt64 and
Endpoint.PublicPort was a *int whose nil Key and Address printed as
"0". The columns are NOT NULL with '' and 0, the unique indexes skip
those, PublicPort is an int, and the three int converters are gone.
The wire is unchanged: publicPort is still left out when unset.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Nothing writes or reads it since Deployment.Sha went (06b0714). The
schema note on node names also said the use cases check first; a
rename relies on the index alone, so it now says what holds.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
orgSinkOf returned *domain.LogSink with nil meaning no sink, so its
callers compared to nil and dereferenced. It returns (sink, ok, err)
now; the OTLP relay needs only the zero value's empty Traces.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
NewControlPlane wrapped a struct literal while the Forwarder next to it
was built inline, and Agent held the whole Config to read ConfigPoll.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
It used a *Position as the "no node yet" marker and dereferenced it at
the end; a found flag and the zero Position say the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Sign-up founds the organization (32412f1), and ensureDefaultProject
answers NO_ORGANIZATION to an account without one, so the founding
branch that refreshed the session after it could not run. It goes,
with useAuth().refreshSession, its only caller, and the comments that
described the old flow.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
It still said the first keel project create founds it, which stopped
being true when sign-up started founding it (32412f1).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
StartedAt, AppliedAt and FinishedAt on steps, FinishedAt on
deployments and Node.ShippedAt were *int64 over nullable columns, so
every write was new(now) and every check a nil compare. The columns are
NOT NULL DEFAULT 0 and the fields int64. The wire is unchanged: the API
fields keep omitempty, so an unset time is still left out.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
apply_error, observed_state, observed_error, the node_id of deployment
steps and log lines, and log_sinks.traces and org were nullable, so
the adapter wrapped every read in str and every write in nullStr while
the domain fields were plain strings. They are NOT NULL DEFAULT ''
now. str and nullStr stay for the foreign keys (parent_id, user_id),
where NULL is what SQLite needs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
app compared Axiom statuses with net/http constants, which breaks the
rule that app never imports net/http; the codes are plain numbers now.
The proxy container got KEEL_PROXY_REPORT_URL and KEEL_WORKER_TOKEN,
but keel proxy reads neither: serve pushes the report URL and token in
the config. serve's KEEL_OTLP_URL keeps the KEEL_ADDR guard the proxy
block used to carry.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- A node's own HOST, PORT or URL row is offered as <NODE>_<KEY>, like
  its provided keys, so picking api.PORT no longer fills in web's own
  PORT.
- The Variables tab reads its two suspense queries with
  useSuspenseQueries, so they no longer wait on each other.
- The bottom panel's error boundary resets on a tab switch too.
- check-comments.sh no longer counts a line starting with a
  dereference (`*x = ...`) as a comment.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ No regressions in the new commits. There's one question for you below: what re-running install.sh now does on a server that still runs the Convex-era Keel.

Reviewed changes

This review covers the 142 commits from 50547c7 to 545240f. The merge base is still 36c2ded. CI's go, web, swarm and install checks pass on 545240f. These also pass locally: go vet, go test ./..., tools/nocomments and the openapi.json drift check. Separate passes compared nine areas against 50547c7: auth, SQLite, deploy, ingress, observability, the agent, the CLI, realtime and jobs, and the dashboard's wire types. Apart from the deliberate removals below, none found a different outcome on any reachable path.

  • Dropped the Convex upgrade path. These are gone: keel import-convex and the importer, Better Auth scrypt verification, the export and import step in install.sh, KEEL_SKIP_CONVEX_IMPORT, /config.js, the CLI's --convex-* flags and its Convex-era discovery hint, and the convexUrl, convexSiteUrl and convexImport* fields in the install JSON. A new CLAUDE.md rule bans backfills and compatibility code.
  • Banned comments in Go. tools/nocomments parses every Go file in CI and in make test. check-comments.sh now counts TypeScript only.
  • Moved generated code. sqlc output is now in internal/gen/sqlc. Kubb, the route tree and varlock output are in apps/web/src/gen.
  • Replaced the JavaScript emulation with plain Go.
    • Typed Axiom rows replace observability_jsvalues.go. Rows of an unexpected shape are skipped with a warning.
    • The golden-file changes come from edited inputs or new scenarios. APL text and scoping are unchanged.
    • Lengths are counted in runes, and database URLs are built with net/url.
  • Unset values are zero, not NULL.
    • 0001_init.sql makes optional text columns NOT NULL DEFAULT '', and unset times and revisions default to 0.
    • The endpoint unique indexes are now partial (domain <> '', public_port <> 0), so blank values don't collide.
    • 0002_canvas.sql was folded into 0001_init.sql. The settings table and deployments.sha were dropped.
    • The API keeps omitempty, so unset fields look the same on the wire.
  • Typed the wire. Ports, replicas and tail are integers in openapi.json. Node type, engine, statuses and roles use domain types, with the same enum values.
  • Narrowed invalidations. A canvas write publishes /api/environments/<id> and /api/nodes/ instead of one topic per node. The dashboard's prefix match covers the same queries.
  • Rewrote the agent.
    • Sinks are keyed by their full config, and events are posted as typed arrays. /worker/events now accepts only a JSON array.
    • State now lives in the keel-agent-state volume.
    • Delivery checkpoints, retries and event order are unchanged.
  • Made some inputs stricter. Unexpose now rejects a malformed selector instead of treating it as a no-op. /proxy/events rejects an empty name.

ℹ️ Re-running install.sh on a Convex-era server now starts an empty Keel without warning

The README's upgrade path is "re-run the install command". On a server running main's Convex-based Keel, the new installer does the following:

  • It overwrites compose.yml.
  • It rewrites .env without INSTANCE_SECRET, BETTER_AUTH_SECRET and CONVEX_SELF_HOSTED_ADMIN_KEY.
  • --remove-orphans removes the old backend and web containers.
  • It starts an empty control plane and tells the user to sign up, with no warning.

The data stays in keel_convex-data, but the old compose file and secrets are gone. The code at 50547c7 said a rollback needs those secrets. The old keel-worker service also keeps running against a backend that no longer exists. The deployed services keep running, but Keel no longer manages them, and exposed services lose their public endpoints. The PR description still lists keel import-convex, scrypt sign-in for imported accounts, and a Convex upgrade in its test plan.

Technical details
# A Convex-era upgrade now starts empty without saying so

## Affected sites
- `install.sh:158` `save_state`: `.env` is rewritten with only the new keys, so the first run of this installer deletes the Convex-era secrets an earlier install saved.
- `install.sh:151` `write_state`: `compose.yml` is overwritten without the old `compose.convex.yml` copy.
- `install.sh:195` and `:248`: `--remove-orphans` removes the old `backend` and `web` containers. Nothing checks for `keel_convex-data` or a running `keel-worker`, and the closing message says to sign up.
- PR description: the `keel import-convex` row, "imported accounts keep their passwords", "Upgrade a Convex install" in the test plan.

## Required outcome
- Whoever re-runs the installer on a Convex-era server knows before anything is replaced that its data won't come along.

## Open questions for the human
- Will any Convex-era install (for example the Asgard copy in the test plan) run this installer? If so, should `install.sh` stop when `keel_convex-data` exists, with a `die` that names the volume, or is a README note enough?
- Should `.env` keep the Convex-era secret lines it doesn't write, so a rollback stays possible?

Pullfrog  | Fix it ➔ | View workflow run | Using claude-opus-5-5 | 𝕏

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. The one new commit fixes the four open findings from earlier reviews: row naming, the suspense waterfall, the tab reset and the comment regex. All review threads on the PR are now resolved.

Reviewed changes

This review covers 532490c, the one commit since the review of 545240f. CI's go, web, swarm and install checks pass on it. These also pass locally: go test for internal/app and internal/domain, check-types for packages/ui and apps/web, oxlint --type-aware (0 errors) and scripts/check-comments.sh.

  • Row keys get prefixed names. A source's own URL, HOST or PORT row now goes through domain.SuggestedKey, the same as its provided keys. So picking api.PORT fills in API_PORT instead of overwriting the service's own PORT. Other row keys keep their names, because SuggestedKey only prefixes those three. connectionSuggestion still only reads provided keys, so suggestions are unchanged.
  • Variables load in parallel. The tab reads both lists with one useSuspenseQueries over the generated …SuspenseQueryOptions. Their query keys and fetchers match the …Suspense hooks they replace, so the path-prefix invalidations still reach them.
  • Errors clear on a tab switch. The bottom panel's ErrorBoundary adds resetKeys={[tab]}, so an error on one tab no longer stays up after switching to another. It still resets through useQueryErrorResetBoundary.
  • Pointer lines don't count as comments. check-comments.sh now counts * only when whitespace, / or the end of the line follows it. Every file's count still equals its .comments-baseline entry, so no file gains room for new comments.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

Re-running the installer there replaced compose.yml and .env, removed
the old backend and web containers and started an empty control plane
without a word: the deployed services kept running unmanaged. With the
keel_convex-data volume present it now dies before changing anything,
and the fix line says how to remove the old install once the services
are off it. It reads no old data; there is still no upgrade path.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ThallesP

Copy link
Copy Markdown
Owner Author

[Claude] On the install question: install.sh now stops before changing anything when the keel_convex-data volume exists, and its fix line says how to remove the old install (766227c). There is still no upgrade path from the Convex-era Keel, by design. The PR description no longer mentions keel import-convex, scrypt sign-in or the Convex upgrade test.

@pullfrog pullfrog Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ No new issues found. The new commit answers the last review's question about Convex-era servers: the installer now stops before it changes anything.

Reviewed changes

This review covers 766227c, the one commit since the review of 532490c. bash -n and shellcheck -S warning pass on install.sh. CI's go, web and swarm checks pass on this commit, and install was still running when I checked.

  • Installer stops on a Convex-era server. ensure_docker now exits with an error when the volume keel_convex-data exists. The check runs right after preflight, before Tailscale, write_state or any compose call, so compose.yml and .env are left alone. The volume name matches main's compose file (name: keel, volume convex-data). The fix: line repeats main's uninstall steps (compose -p keel down, service rm keel-worker), and -p keel down works without a compose file in the current directory. If old svc-* services are left running, the new Keel ignores them: they carry the same keel.service label, but the full sweep only looks up nodes in its own database and never removes services it doesn't know. No CI job creates that volume, so the install job doesn't hit the check.
  • README documents it. The upgrade section now says the Convex-era Keel doesn't upgrade and that the installer stops on a server that still has the volume. That keeps install.sh and its README contract in sync.

One thing outside the code: the PR description's "Not verified, or known gaps" section still says re-running install.sh on a Convex-era server "starts an empty control plane". Since this commit, it stops instead.

Pullfrog  | View workflow run | Using claude-opus-5-5 | 𝕏

@ThallesP
ThallesP merged commit b9c8b08 into main Oct 12, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant