Skip to content

Backend services and workers

This page covers the day-to-day loop for elitea-main, elitea-llm-gateway, elitea-scheduler and the two agent workers: running against a real database, writing a migration, editing the OpenAPI spec, and testing a worker in isolation.

Run elitea-main against the compose database

Section titled “Run elitea-main against the compose database”

Bring up just the infrastructure services from the standalone stack (certs, then up), then run elitea-main from source against them:

Terminal window
cd services/elitea-main
DATABASE_URL="postgres://elitea:elitea@localhost:${STANDALONE_PG_PORT:-15433}/elitea?sslmode=disable" \
go run ./cmd/elitea-main

task run does the same thing without a custom DATABASE_URL, against whatever the process environment already points at.

Tests gated on ELITEA_TEST_DATABASE_URL skip themselves (with a message naming the variable) when it is unset, so task test stays green without a database and exercises the real thing once you supply one:

Terminal window
ELITEA_TEST_DATABASE_URL="postgres://elitea:elitea@localhost:${STANDALONE_PG_PORT:-15433}/elitea?sslmode=disable" \
go test ./migrations/... -run TestMigrationCorpus

Point it at the standalone stack’s PostgreSQL, or any disposable PostgreSQL (plain PostgreSQL: no extension is needed) — most of these tests provision and drop their own per-run database or schema, so they do not need an empty server.

Migration files live under services/elitea-main/migrations/, in three independently ordered scopes: shared/, tenant/ and agentstate/ (embedded and loaded by the internal/infra/db/migrate package). A separate, single-file directory — internal/infra/db/migrations/001_initial.sql — carries only the legacy initial schema, applied first when a database doesn’t already have it. Each versioned file is named NNNN_description.sql and identified by a SHA-256 checksum of its contents — a migration is checksum-immutable once it exists: never edit an applied file, always add a new one with the next free number.

  1. Pick a free version number

    Terminal window
    task migrations:check -- main

    Reads the base branch as origin holds it now and fails if your new migration’s number is not above its highest. This catches two authors picking the same free number independently — a collision that is only visible once both branches merge.

  2. Add the file to the right scope

    shared/ for cross-tenant tables, tenant/ for anything created once per project schema (p_<id>), agentstate/ for the separate agent-state database. Mixing scopes in one file is not supported — LoadManifest rejects a nested directory or a filename that doesn’t match NNNN_name.sql.

  3. Update the pinned head, if the test names one

    TestEmbeddedHistoriesHaveExpectedHeads in internal/infra/db/migrate/manifest_test.go pins the expected head version per scope. A new highest-numbered migration in a scope that test already covers needs the pin updated in the same change.

services/elitea-main/api/openapi/v2.yaml is the single source both the Go server and the TypeScript client generate from. A change to it ripples through several checked gates, in this order:

  1. Regenerate the Go server interface

    Terminal window
    task openapi

    Runs oapi-codegen into services/elitea-main/internal/api/generated/api.gen.go. ci-go.yml’s “Verify generated OpenAPI server is current” step re-runs the same command and fails the build on any git diff.

  2. Regenerate the TypeScript client

    Terminal window
    cd apps/elitea-web && npx orval

    Writes src/shared/api/generated/**. scripts/check-generated-client.mjs fails a stale client — see elitea-web development.

  3. Update the endpoint manifest

    Add or amend the corresponding entry in apps/elitea-web/src/shared/api/endpoints.manifest.json by hand — it is asserted against the generated client, not generated from the spec itself.

  4. Update the route's public/auth rule if its path changed

    TestCurrentMainRoutePublicRulesMatchPinnedCatalog (services/elitea-main/internal/api/main_public_rules_test.go) pins every route pattern’s authentication rule against a fixed catalog. A renamed or added public path needs an entry there too.

services/elitea-llm-gateway is a separate Go module (Go 1.27), deliberately outside the root go.work. The workspace modules use Go 1.26. With the default GOTOOLCHAIN=auto, a local Go 1.26 toolchain downloads Go 1.27.3 for this module; with GOTOOLCHAIN=local it refuses. Build and test it from inside its own directory, with the workspace explicitly disabled:

Terminal window
cd services/elitea-llm-gateway
GOWORK=off go build ./...
GOWORK=off go test ./...

ci-gateway.yml runs its own pipeline for this module; task test/task vet/task lint at the repository root never open it, and a green root-level run is not evidence about it.

The agent worker is the native Rust runtime:

Terminal window
cd services/elitea-worker-rust
cargo test --locked --all-targets --all-features

The toolchain is pinned in rust-toolchain.toml; rustup picks it up automatically inside the directory. ci-rust.yml runs the same command, plus cargo build --locked --release.

  1. Create services/<name>/ with its Containerfile. A Go service joins the root go.work (unless, like the LLM gateway, it pins its own toolchain); a Rust service is its own Cargo project.
  2. Add its target to docker-bake.hcl, and the service to the compose stacks that need it (deploy/docker-compose.standalone-full.yml at least).
  3. Add it to publish.yml: the build, scan and publish-image matrices and the cleanup step’s image list. image-matrix.yml (scripts/ci/image-matrix.sh) fails a pull request whose copies disagree, so a new image can’t ship unscanned.
  4. Give it a CI path: a workflow of its own, or an entry in the one that already covers its language.
  5. If it runs in a cluster, add it to the Helm chart (deploy/helm/elitea) and document its settings under Deployment.
  6. Internal calls to or from it use gRPC over mTLS with the contract in libs/proto (see AGENTS.md).