10 — Development Workflow¶
The day-in-the-life: build the Engine, add a feature, run the tests, and the conventions that keep the codebase sound.
§02 — Getting Started got The Aetherium Engine running on your machine and built a four-agent team. This section is the mechanical layer underneath that: the exact commands you run while working on the Engine, the four cross-cutting patterns every service reuses, the eight-step shape of adding a feature, and the migration, testing, and hygiene habits that keep a build from breaking. It assumes the Engine is already running — if it isn't, start with §02 and come back.
The Engine is developed across two operating systems on the same hardware: editing happens wherever you are, and the source of truth is the Git remote. The loop is git pull → edit and run → git commit → git push, with the other environment pulling when it next comes up. There are no manual file copies and no symlinks between the two trees — the remote is the only sync mechanism. The commands below give the Windows (PowerShell) and Linux (bash) form of every step so either environment is first-class.
Build and run¶
The solution lives at Aetherium_Refactor/Aetherium_Refactor.slnx and holds four projects: the server (Aetherium_Refactor), the shared library (Aetherium.Shared), the console run monitor (Aetherium.Monitor), and the test project (Aetherium_Refactor.Tests). (A separate Aetherium.Benchmarks project exists on disk but sits outside the solution — see §11 — Benchmarking; it is not part of the standard build.)
The web client is not in the solution. Aetherium.Web is a React application with its own toolchain, and dotnet build neither knows nor cares about it — see Building the web client below.
For a full-solution compile:
For day-to-day work, run the server project directly and let hot reload pick up edits:
The app listens on http://localhost:5257. The repo also ships wrapper scripts — start-aetherium.ps1 (with -Watch and -Background flags) on Windows, start-aetherium-linux.sh on Linux — that set the AETHERIUM_* storage paths, bring up Docker dependencies (PostgreSQL, and SearXNG on Linux), run health checks, and then start the Engine. One thing the scripts do not do: start your inference server. LM Studio at http://localhost:1234 is an external runtime dependency you launch yourself.
The Linux script has one extra wrinkle worth knowing: when the source tree lives on an NTFS partition, it builds there but copies the output to a local ext4 path and runs the binary from the copy. That avoids a memory-mapping issue the .NET runtime hits when loading assemblies straight off NTFS. If you develop on a native Linux filesystem this is a no-op; if you dual-boot off a shared NTFS drive, it's why the script runs the way it does.
No pinned SDK
There is no global.json pinning the .NET SDK version, so your environment's SDK is what builds the Engine. Targeting .NET 10 is assumed throughout. The Engine has been built against SDK 10.0.102.
Building the web client¶
Aetherium.Web builds with its own toolchain into the server's wwwroot/app, and the server
serves it at /app. Opening http://localhost:5257 redirects there.
During development run both: dotnet watch for the server and bun run dev for the client, with
the Vite dev server proxying API calls to the running Engine.
The build order is load-bearing
.NET compiles its static-asset manifest during dotnet publish, so bun run build must run
before it. Building the client afterwards looks like it worked and changes nothing that
gets published. scripts/deploy.sh does this in the right order. If the client is missing
from a build, the server logs a warning at startup and /app serves a page explaining how to
build it rather than a bare 404.
The client's API types are generated from the server's OpenAPI document, never hand-written:
scripts/generate-contract.sh # server -> docs/contract/openapi.v1.json
bun run generate:api # that -> src/api/schema.d.ts
The .slnx vs .csproj publish gotcha¶
This is the canonical home for a gotcha that §02 and §05 — Solution Layout point back to. Building the .slnx works fine. Publishing it does not.
dotnet publish against the solution file builds every project but never runs the Publish target on the server, so you get no publishable server output. This is steady-state dotnet publish behavior with solution files, not a transient bug that will be fixed — so the rule is permanent: always target the server .csproj directly when you publish.
Production deploys wrap this command in scripts/deploy.sh; the deploy story is §14 — Deployment.
The four cross-cutting patterns¶
§06 — Key Services named four patterns that thread through every service. Here is each one at code depth: what it is, why the Engine uses it, the real code shape, and the rule.
IDbContextFactory per scope¶
AetheriumDbContext is never injected directly. Each unit of work resolves a fresh, short-lived context from a thread-safe factory. DbContext is not thread-safe, and agent orchestration runs many concurrent database operations — a per-operation context is what keeps EF Core from corrupting state across threads.
private readonly IDbContextFactory<AetheriumDbContext> _contextFactory;
public async Task<List<Project>> GetAllAsync()
{
await using var context = await _contextFactory.CreateDbContextAsync();
return await context.Projects.ToListAsync();
}
Rule: inject IDbContextFactory<AetheriumDbContext>, create a context per operation with await using. Never constructor-inject AetheriumDbContext.
Channel-based work queues¶
Where producer/consumer paths must not race, the Engine uses System.Threading.Channels. The clearest case is the LLM scheduler: ModelSchedulerService serializes every LLM request behind bounded channels so two agents asking for two different models never trigger an unload-reload thrash on the same LM Studio instance.
private readonly Channel<ModelTask> _taskChannel = Channel.CreateBounded<ModelTask>(
new BoundedChannelOptions(100) { FullMode = BoundedChannelFullMode.Wait });
public async Task SubmitTaskAsync(ModelTask task)
{
if (task.Priority > 0) await _lowPriorityChannel.Writer.WriteAsync(task);
else await _taskChannel.Writer.WriteAsync(task);
}
A single consumer, ProcessQueueAsync (hosted by ModelSchedulerBackgroundService), drains the normal queue before the low-priority one and sets the result on each task's TaskCompletionSource<ChatDetailedResponse>, unblocking the caller.
Rule: submit work via SubmitTaskAsync and await the task's ResultSource.Task — never call the LLM client directly from a producer.
BackgroundService hosts¶
Long-running, off-the-request-path work runs in hosted services registered at startup — queue draining, container cleanup, health probes, log ingestion, knowledge seeding. The Engine registers nine of them (seven derive from BackgroundService; two — ToolCatalogValidator and WorkspaceScannerService — implement IHostedService directly). The thin-wrapper shape keeps the logic testable: a singleton holds the queue and logic, the hosted service just runs the loop.
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await Task.Delay(TimeSpan.FromSeconds(15), stoppingToken);
await _scheduler.RefreshLoadedModelsAsync(stoppingToken);
await _scheduler.ProcessQueueAsync(stoppingToken); // the long-running loop
}
Register both halves in Program.cs:
builder.Services.AddSingleton<ModelSchedulerService>();
builder.Services.AddHostedService<ModelSchedulerBackgroundService>();
Rule: long-running work belongs in a hosted service registered with AddHostedService<T>(). Keep the testable logic in a separate singleton.
Singleton-with-scoped-deps via IServiceProvider¶
A singleton cannot constructor-inject a scoped service — that captures the scoped instance for the application's lifetime (the captive-dependency bug). When a singleton needs a DbContext or another scoped service, it injects IServiceProvider and opens a scope per unit of work.
using var scope = _serviceProvider.CreateScope();
var contextFactory = scope.ServiceProvider
.GetRequiredService<IDbContextFactory<AetheriumDbContext>>();
await using var context = await contextFactory.CreateDbContextAsync();
Rule: never inject scoped services into a singleton constructor. Inject IServiceProvider, CreateScope() per operation inside a using, resolve scoped dependencies from scope.ServiceProvider, and let the scope dispose.
Adding a feature¶
A new feature that spans the database, the API, and the UI moves through the same eight steps every time. The layer that bites is the service — it must use the factory pattern — so that one carries a snippet. On the client side the thing to remember is that the types are generated, not written.
- Model — define the entity in
Aetherium.Shared/Models/. - DTO — define the request/response shapes in
Aetherium.Shared/DTOs/. Keep the API contract separate from the persistence model. - DbSet + migration — add the
DbSet<T>toAetheriumDbContextand create a migration (see the next section). -
Service — implement it in
Services/usingIDbContextFactory:public class NoteService(IDbContextFactory<AetheriumDbContext> contextFactory) { public async Task<Note> CreateAsync(CreateNoteRequest request) { await using var context = await contextFactory.CreateDbContextAsync(); var note = new Note { Title = request.Title, Body = request.Body }; context.Notes.Add(note); await context.SaveChangesAsync(); return note; } } -
Controller — expose it from
Controllers/: -
Regenerate the contract — return
ActionResult<T>from the controller so the endpoint has a schema, then run the generator. An action that returnsIActionResultwith an anonymous object produces no schema and surfaces in TypeScript asunknown, which is deliberate: an endpoint with no contract should look like one. -
Client call + screen — add the call to
Aetherium.Web/src/api/client.tsusing the generated types, then add the screen undersrc/routes/and register it insrc/App.tsx. Never hand-write a type that mirrors a server DTO;src/api/schema.d.tsis generated and hand-mirrored copies are what make a two-language codebase rot. - Navigation — add the entry to
GROUPSinAetherium.Web/src/ui/Shell.tsxfor a global section, or toPROJECT_TABSinsrc/ui/ProjectNav.tsxif the screen belongs to a single project.
For tools, MCP servers, agent roles, and templates — which follow a different path than a standard CRUD feature — see §12 — Extending the Engine.
Database migrations¶
The Engine uses EF Core against PostgreSQL (Npgsql). Migrations live in Aetherium_Refactor/Aetherium_Refactor/Migrations/, named with the EF default <timestamp>_<PascalCaseName> convention. The context is registered as a DbContextFactory, and pending migrations apply automatically at startup via Database.MigrateAsync() — so running the app is enough to bring your local database current. The explicit commands below are for creating migrations and for out-of-band control.
# Create a migration after changing the model
dotnet ef migrations add AddNotes --project Aetherium_Refactor\Aetherium_Refactor\Aetherium_Refactor.csproj
# Apply pending migrations
dotnet ef database update --project Aetherium_Refactor\Aetherium_Refactor\Aetherium_Refactor.csproj
# Roll back to an earlier migration
dotnet ef database update PreviousMigrationName --project Aetherium_Refactor\Aetherium_Refactor\Aetherium_Refactor.csproj
# Remove the last (un-applied) migration
dotnet ef migrations remove --project Aetherium_Refactor\Aetherium_Refactor\Aetherium_Refactor.csproj
# Create a migration after changing the model
dotnet ef migrations add AddNotes --project Aetherium_Refactor/Aetherium_Refactor/Aetherium_Refactor.csproj
# Apply pending migrations
dotnet ef database update --project Aetherium_Refactor/Aetherium_Refactor/Aetherium_Refactor.csproj
# Roll back to an earlier migration
dotnet ef database update PreviousMigrationName --project Aetherium_Refactor/Aetherium_Refactor/Aetherium_Refactor.csproj
# Remove the last (un-applied) migration
dotnet ef migrations remove --project Aetherium_Refactor/Aetherium_Refactor/Aetherium_Refactor.csproj
The server .csproj is its own startup project, so no separate --startup-project flag is needed. The Postgres connection string is read from the ConnectionStrings:DefaultConnection key; the committed default lives in appsettings.json and you override it locally in appsettings.Local.json (gitignored), where any real credential uses the REPLACE_ME_VIA_LOCAL_OVERRIDE placeholder until you set it.
migrations list can read stale
dotnet ef migrations list --no-build reports against the last build and may omit your newest migrations. Drop --no-build (or build first) for an accurate list.
Migrations are real schema changes, not hypotheticals — the most recent one (WidenRoleRoleType) widens the Roles.RoleType column from varchar(50) to varchar(255). Production migration strategy is part of §14 — Deployment.
Testing¶
Server tests live in Aetherium_Refactor/Aetherium_Refactor.Tests/ — xUnit with Moq, EF Core InMemory for data, and Testcontainers for the integration tests that need a real Postgres. Client tests live beside what they cover in Aetherium.Web/src/ and run with bun test. Run the server suite:
For service tests, the InMemory provider stands in for Postgres. Because services use the factory pattern, the mocked factory must hand out fresh contexts that share one store — a shared InMemoryDatabaseRoot plus a per-test Guid database name does exactly that:
_databaseRoot = new InMemoryDatabaseRoot();
_databaseName = Guid.NewGuid().ToString();
mockFactory.Setup(f => f.CreateDbContextAsync(It.IsAny<CancellationToken>()))
.ReturnsAsync(() => new AetheriumDbContext(
new DbContextOptionsBuilder<AetheriumDbContext>()
.UseInMemoryDatabase(_databaseName, _databaseRoot)
.Options));
Two guards keep the published contract honest, and both fail the build rather than drifting. LiveEventCatalogHonestyTests scans the server source for SignalR broadcasts and asserts the catalog at /api/capabilities/live names every one — and that it names nothing that is never broadcast. CapabilitiesHonestyTests does the same for the tool and card declarations. Both were confirmed to fail by deliberately breaking the code they guard; a catalog nobody verifies is a catalog that quietly stops being true.
The suite covers the heavy services (the LLM router, the model scheduler, container lifecycle, prompt enhancement, team generation), every MCP server class and the tool router, the REST API and SignalR via WebApplicationFactory, and end-to-end MCP pipeline smoke tests. When you add a feature, the service test is the one that catches regressions cheaply — write it against the InMemory pattern above.
Two more surfaces sit at the repo root, neither part of dotnet test: tests/ holds a single bash script (lmlink-api-test.sh) that exercises the unified LM Studio model API, and Testing/ holds sample team-definition YAML fixtures for ad-hoc imports. New automated tests go in the matching Aetherium_Refactor.Tests/ subfolder — Services/, McpServers/, Api/, or Integration/.
Memory-bank discipline¶
The Engine's own development is organized around a persistent context layer in .memory/ — the directory you cloned with the repo. It bridges sessions: it records what shipped, what is in flight, and what was learned, so the next session picks up without rebuilding the picture. §05 — Solution Layout describes the four canonical files — activeContext.md (where we are now), progress.md (the chronological log), systemPatterns.md (hard-won conventions), and productContext.md (the long-lived framing).
The cadence is simple: update activeContext.md and progress.md at the end of each meaningful session. In Claude Code, the update-memory-bank skill (in .claude/skills/) automates the update — it overwrites the active context, appends a timestamped progress entry, and adds any genuinely new pattern without rewriting existing ones.
This is how the project is developed, not a mandate on how you must work. If you're contributing back, adopting the rhythm keeps the shared context honest. If you're running your own fork, treat it as an available habit, not an obligation.
Pre-commit hygiene¶
Before any commit, run this standing checklist. It is the local mechanical gate; the contribution conventions and the project-specific disciplines a change must respect are §19 — Contributing.
- Tests pass. Run the suite; don't commit a red build.
- No secrets. Confirm
appsettings.jsonstill holds placeholders, not real values, and scan your diff for any leaked secret before committing. Real credentials belong only in the gitignoredappsettings.Local.json. - No machine paths. No absolute developer paths or LAN addresses in committed code, config, or docs — use repo-relative paths and
localhost. - Casing. The local override file is
appsettings.Local.json(capitalL) — .NET on Linux is case-sensitive and won't load a lowercase variant. - Memory bank updated for significant work, per the section above.
Where to go next¶
- §06 — Key Services — the services the four patterns above run.
- §12 — Extending the Engine — adding tools, MCP servers, agent roles, and templates (a different path than a standard feature).
- §14 — Deployment — production builds, migrations, and the deploy script.
- §19 — Contributing — commit conventions, the hygiene gate, and the disciplines a contribution must respect.