feat(web): coding-agent storage, the model catalog, and the default model setting

Merged
DA
11d ago
+1790-95

Server-only groundwork for coding agents, on the coding-agents branch. Nothing here reaches the desktop, the Eve coding service, or the Start/list/messages/Stop routes.

Storage

  • conversations.kind gains coding_agent. An agent's transcript will be its messages rows and its status its latest turns row; a later PR writes those.
  • New coding_agent table (server/db/coding-agent-schema.ts, migration 0061): id, plan_id (cascade), user_id (cascade), conversation_id (cascade), model, effort, plan_snapshot, repository, idempotency_key (unique per user_id), created_at. No commit column, per Dean. The barrel export puts it under drizzle-schema.test.ts's drift check, and storage-schema.test.ts lists it.
  • server/hosted/coding-agent-store.ts: createCodingAgent (one transaction under the plan's row lock, so a retry carrying the key of a Start already made answers that agent and writes nothing; opens the coding_agent conversation beside the row), listCodingAgents (one plan's agents in start order), readCodingAgent (owner only). A plan another account owns starts nothing and reads as none.

The model catalog

  • server/hosted/model-catalog.ts reads AI Gateway's public GET /v1/models through the ambient HttpClient, Schema-decodes it at the boundary, and keeps the Anthropic and OpenAI models tagged tool-use and reasoning; each model's efforts are the values of the effort entry in its reasoning_options. A model listing no effort is left out, since no Start could name one for it (judgment call; the published catalog's Claude 4/4.5 Sonnet rows are the ones this drops).
  • ModelCatalog is a service built once per instance in server/runtime.ts, cached with Effect.cachedWithTTL for one hour on a success and for no time on a failure, so an outage at the gateway is retried on the next request.
  • GET /api/models (server/models-app.ts, server/routes/models.ts) answers { models: [{ id, name, provider, efforts }] } to a signed-in bearer; the rewrites in api-rewrites.json and vercel.json are regenerated.
  • validateModelChoice(catalog, { model, effort }) accepts only an offered model at an effort it lists; acceptedModelChoice is the same over the service. providerModelOf translates a catalog id to the provider's own (anthropic/claude-opus-5.5 → claude-opus-5-5; OpenAI ids unchanged after the prefix).

The default model and effort

  • account_preference gains coding_agent_model and coding_agent_effort (migration 0062). The same migration makes updated_at nullable with no default: it is the preferences part's own instant, left null by a write carrying only codingAgent, because the desktop reads the answer's updatedAt as a synced snapshot to apply over its local settings, and a Mac that never synced must not take an empty snapshot over the voice it holds (Devin's first finding).
  • /api/account/preferences answers codingAgent: { model, effort } beside preferences, anthropic/claude-opus-5.5 at high until the account chooses. A PUT carries preferences, codingAgent, or both; a part left out stands as it was, so the desktop's existing settings sync (which sends preferences alone and ignores unknown answer keys) never resets the chosen model. A codingAgent write is checked against the catalog: an unknown model or effort is invalid-request, a catalog the instance cannot read is unavailable.

Goldens

fixtures/account-route/ goldens were regenerated under LUKE_UPDATE_FIXTURES=1: the three existing preferences goldens gain the codingAgent key in their bodies (nothing else moves), and ten new exchanges record the chosen-model read, the coding-agent writes (including a first write on an account that never synced, which answers no updatedAt), the kept-model write, the two refusals, the malformed choice, the catalog outage, and the empty body.

Tests

tests/model-catalog.test.ts (filter, effort list, cache over TestClock, failure not cached, boundary refusal, validation, id translation, the default accepted by the fixture catalog), tests/models-app.test.ts, tests/coding-agent-store.test.ts (idempotent create, order, ownership, cascade), and the extended tests/account-app.test.ts and tests/hosted-account-store.test.ts (round-trip of each part on its own). All hermetic: a fake HttpClient layer, PGlite, TestClock.

deletePlan now reads the plan's agents' conversations before the delete cascades the agent rows, and stamps them deleted beside the planning conversation, so their transcripts reach the thirty-day purge (Devin's second finding; covered in coding-agent-store.test.ts).

Follow-ups noted, not done here

  • A stored default whose model later leaves the catalog is answered as stored; the Start route should refuse it and let the menu re-pick.
  • The desktop PRs can lift the /api/models answer schema and the codingAgent shape into @sidecar/hosted when they need a shared wire declaration.

šŸ¤– Generated with Claude Code