XE Local AI Engine: a single ASP.NET Core node process (XE-Local-AI-Engine.Client) that serves a React SPA,
loopback-only /api/local/v1 endpoints and SignalR hubs, persists to SQLite with per-column encryption, and
supervises llama-server / sd-server child processes for local inference. .NET 10 + Aspire, React 19 +
Vite + pnpm, Python (uv) for training tooling.
This file is instructions, not documentation. Before a non-trivial change, read the index
docs/agent-knowledge.md (§0 and its routing table), then the docs/agent-knowledge/ topic files your change
touches, including their stale-beliefs tables. Follow evidence links when changing a rule or investigating the
same failure. For architecture, start at docs/wiki/Home.md and read the pages for the affected subsystem.
- Do not edit files, run mutating commands, or clean up before a plan is approved.
- On a failure, stop and report the failing command and its output before attempting a fix.
- Parallel work goes in git worktrees under
.tmp/worktrees/; never let two tasks claim the same file. - Agents sharing one worktree share one git index: never
git addthere. Commit through a privateGIT_INDEX_FILE(git read-tree HEADimmediately beforewrite-tree,update-refin the three-argument form), thengit read-tree HEADthe shared index oncegit diff --cachedis empty. - Branch from and target
develop. Nothing lands ondevelopwithout the operator's explicit approval: never merge into, reset or "repair"developmid-task; a feature branch absorbsdevelop, never the reverse. Report the branch and integration steps, then stop. - Commit messages follow Conventional Commits (
fix(scope): …), body only: no session-tracking, co-author or AI trailers. Never set git identity (git -c user.*,git config user.*,GIT_AUTHOR_*/GIT_COMMITTER_*); a plaingit commitinherits the repo config. - Never commit secrets or runtime state:
node.key,*.sqlite,.env,dp-keys/,*.enc. - The repo never depends on optional per-user agent or editor tooling: no tool names in product code, gates,
tests or instructions; express the intent as a rule (dot-prefixed, hidden directory).
.gitignoreis the one exception. Shipped product surface (installers, MCP client runbooks, cloud providers) is not tooling. - Never hand-edit generated output: the hey-api client under
src/core/api/generated/,routeTree.gen.ts, EF migration designer files. Regenerate and commit the result. - Cite
file+ symbol, neverfile:line, for code that is being edited (lines drift, symbols survive). - Agents may propose updates to durable standards or project intelligence; promotion needs human approval.
- Agent-knowledge entries (topic files under
docs/agent-knowledge/) are written as rule → failure prevented → authority, body <= 900 chars. Propose one indocs/agent-knowledge/proposed.mdwhen you pay for a new trap; never quote a count or timing from a doc as current, the script's own output wins.
Solution: XE-Local-AI-Engine.slnx. Full layout and dependency rules: docs/wiki/02-project-layout.md.
XE-Local-AI-Engine.Client— host: FastEndpoints, SignalR hubs, composition root, serves the SPA.XE-Local-AI-Engine.Client.Application— services (chat, agents, scheduler, model-fit, dev mode, training, benchmarks).XE-Local-AI-Engine.Client.Persistence— EF Core + SQLite, encrypted columns; references onlyProviders.Abstractions.XE-Local-AI-Engine.AI.Agent/AI.Contracts— Microsoft Agent Framework wiring / shared DTOs.XE-Local-AI-Engine.Providers.*— runtimes and model sources; each depends only onProviders.Abstractions(reviewed exceptions:LlamaServerandOpenAICompatalso use the leafProviders.OpenAICompatible.Core;Trainingalso usesProviders.Python;LlamaServer,StableDiffusionCpp,WhisperCppandPythonalso useProviders.ProcessSupervision, which references onlyProviders.Abstractions;Pythonreferences just those two).XE-Local-AI-Engine.Providers.Python— shared managed-Python layer: pinned uv acquisition, uv environment allowlist, scrubbed tree-killed runner (ADR 0016). No feature semantics.XE-Local-AI-Engine.Providers.ProcessSupervision— shared child-process containment: Linux process-group, Windows Job Object and plain handles, stderr tail, stale-process reaper. No feature semantics.XE-Local-AI-Engine.AppHost/ServiceDefaults— dev-only Aspire orchestration and telemetry defaults.XE-Local-AI-Engine.WindowsLauncher— Velopack entry point; starts the published host as a child process, no project refs.XE-Local-AI-Engine.Desktop— Avalonia NativeWebView shell: window, activation, optionally engine lifetime; talks to the engine over REST/SignalR only, no engine project refs.XE-Local-AI-Engine.Client.React— the SPA. Has its ownAGENTS.mdfor frontend-only rules.XE-Local-AI-Engine.Tests,AI.Agent.Tests,Client.Persistence.Tests— TUnit;Tests.E2ETests— Playwright, opt-in.Client.Testing— shared host fixtures;Testing.FakeOllama— in-memory fake model server used by tests;Testing.FakeDocker— in-memory fake Docker Engine API so the container tests need no daemon.scripts/— dev lifecycle, validation gates, smoke runners.publish/— packaging.tools/training/— the shipped Python training runtime (ownpyproject.toml; neveruv syncinside it).
scripts/dev-start.sh # isolated Aspire AppHost for THIS checkout; seeds XE-Local-AI-Engine.AppHost/.data/node.key
scripts/dev-status.sh # resource states + endpoint URLs (--json); the port changes on every restart
scripts/dev-stop.sh # the only sanctioned stop pathNever run aspire stop --all or pkill -f <substring>: both cross worktree boundaries and kill other
checkouts' instances. Kill by PID. Run one instance per data directory. Details: scripts/README-dev-stop.md,
docs/agent-knowledge.md §2.
- Kill-by-PID applies to anything you background, load generators included: collect the PIDs (or
setsida process group), put the kill in atrap … EXIT, and confirm withpsbefore ending the turn. Akill $SPINthat never reached its subshells left 23 spinners burning a core each for nine hours. - The app origin serves the SPA bundle that was last built, not the working tree. After a frontend change,
validate UI on the
client-reactVite origin (scripts/dev-status.sh --jsonlists both) or runpnpm run buildbeforedev-start, and record which origin the evidence came from. - A scratch or fresh-DB host must not touch the user-level
~/.local/share/XE-Local-AI-Engine(it rewrites the sharedinstalled-runtime.json): pointXDG_DATA_HOMEat a scratch dir, or source the BYO llama-server override beforedev-start.shfor GPU rounds.
A change is done when these pass. --configuration Release is load-bearing: Debug skips the analyzers
(Meziantou, BannedApiAnalyzers, IDExxxx, the no-bare-TODO rule). Iterate in Debug, finish in Release;
XE_FULL_ANALYSIS=1 forces the analyzers in Debug. A ~1 s incremental build that compiled nothing proves
nothing; use --no-incremental when the evidence matters. The sharper form: a deliberate-break proof is void
unless the Release build reported 0 Error(s) — a build the analyzers failed runs the previous binary and
reports the old green. Keep break scaffolding analyzer-clean and restore it through a shell trap.
Before a gate chain: dotnet build-server shutdown, then export MSBUILDDISABLENODEREUSE=1 and
NUGET_PACKAGES=$HOME/.nuget/packages for every dotnet build/dotnet test in the chain (the gate script below
does both itself) — stale MSBuild worker nodes carry a deleted NUGET_PACKAGES across worktrees (NU5037 / CS0006
on a branch that is fine).
Never queue a gate on a worktree a writer is still editing; the result is void and no guard catches source edits.
Backend (repo root):
dotnet tool restore --tool-manifest dotnet-tools.json
scripts/run-backend-tests.sh- That one script IS the backend gate: one Release build, then
XE-Local-AI-Engine.Teststhroughscripts/run-tests-memory-safe.shand every other test project enrolled from the solution, concurrently, each sibling pinned to a measured--maximum-parallel-tests(XE_TEST_WIDTH_DEFAULT,XE_TEST_WIDTH_<Project>).NO_BUILD=1skips the build,--siblings-onlyskips the batched module,COVERAGE_DIRadds Cobertura + TRX per project. CI'ssiblingsleg calls the same script. - On a memory-constrained development machine, use
XE_TEST_PROFILE=low-memory scripts/run-backend-tests.sh. It runs project lanes serially and defaultsJOBS,PARandXE_TEST_WIDTH_DEFAULTto 1. Explicit width overrides still win; all enrolled tests, the Release build, locks and guards remain required. - Never overlap a build with a
--no-buildtest run. The script takes the build lock once for the whole gate and runs each sibling under the assembly guard. The lock serializes cooperating shells (exit 69 = lock not acquired, nothing ran); the guard detects an uncooperative build (exit 75 = CONTAMINATED, result void, re-run; it is not a red). Checkscripts/build-lock-status.sh(or--json) before queueing a gate: it shows the holder, age, progress and any waiters. The gate sizesJOBSfrom free RAM and prints the result as a>> Sizing:line;XE_TEST_PROFILE=low-memoryis an explicit serial override and should not be used on a box with free RAM. - Cancel it by signalling its process group (
kill -TERM -- -<pgid>), not its PID:with-build-lock.shruns its command in the foreground with no traps, so a PID-only signal kills the wrapper and orphans the lanes. Ctrl-C in a terminal already does the right thing. - With
COVERAGE_DIRthe siblings run UNGUARDED: coverage instruments statically, rewriting each project's own assemblies, which the guard cannot tell from a foreign build. A sibling coverage run therefore detects no unwrapped concurrent build — the lock still covers cooperating shells, and the batched module keeps its own guard. - Scope with
--treenode-filter '/*/*/(ClassA|ClassB)/*', never--filter.--list-testsis authoritative. A no-match filter exits 8. Zero tests is never a pass. - Verify the whole changed test project before hand-off, not only the class you touched.
scripts/run-tests-memory-safe.shstays the standalone run ofXE-Local-AI-Engine.Testsonly (that module's lane inside the gate); it locks and guards itself. Do not wrap it in the guard; if you must, passNO_BUILD=1or every run reports exit 75. Run it alone only to iterate — the gate above is what a change is judged on.
Frontend (XE-Local-AI-Engine.Client.React/):
pnpm install --frozen-lockfile
pnpm run acceptance # validate + coverage thresholds + tooling tests + production bundleacceptance runs the static checks once, then tests and build:bundle on the same unchanged source tree.
Standalone pnpm run build still runs the full lint chain before bundling. build:bundle alone is not a gate.
pnpm run lint is the typecheck; the E2E fixture's build:e2e is a bare vite build, so a green E2E run does
not prove types. After any backend contract change run pnpm run openapi:check (regenerates the hey-api client,
fails on drift) and commit the output. pnpm run licenses:check after a dependency change.
Python (tools/training, scripts/**): scripts/python-validation.sh --scope changed (or full). Needs uv.
Docs inventory: after adding a SignalR hub, LocalApiRoutes family, React features/ dir, wiki page or
project, run python3 scripts/docs-inventory-check.py and name it in the wiki page that enumerates it.
Release scripts (publish/**, scripts/release/**): scripts/lint-release-scripts.sh (shellcheck ≥ 0.10,
PSScriptAnalyzer, Pester; a missing tool fails, never skips).
CI runs .github/workflows/build-and-test.yml on PRs and pushes to develop (five jobs: python-quality,
release-contracts, backend-tests, build-and-test, client-react); release.yml re-runs it before packaging.
backend-tests is a five-leg matrix — the sibling projects on one runner, four TEST_SHARD quarters of
XE-Local-AI-Engine.Tests on four more — and build-and-test cross-checks and merges their coverage under the job
name branch protection requires. Within a leg, projects run concurrently, each with its own --results-directory
(MTP resolves --coverage-output relative to it, so shared directories overwrite each other's Cobertura report);
the commands above are the local gate. Shape and rationale: docs/wiki/13-testing-and-validation.md, docs/agent-knowledge.md §1.
Opt-in live runners (nothing invokes them; ask before running, run before a tester RC):
scripts/run-e2e-local.sh— Playwright; sets the mandatory-p:RunE2ETests=trueand refuses a zero-test pass.scripts/run-gpu-smoke-local.sh— the only gate proving the GPU did the work; exit 5 = infra abort, 1 = product failed.scripts/run-tool-grammar-smoke-local.sh— after changing any tool schema or the llama.cpp pin; its failing negative control is the evidence, a run without it proved nothing.scripts/run-retrieval-eval-local.sh— the real-model retrieval eval (embedder + rerankers on llama-server, JSON + Markdown report); a run without a completed JSON is 1, so is any INVALID measured config or failed negative control. Exit 5 = a server never came up.scripts/run-docker-smoke-local.sh— the four real-daemon container suites, which run ONLY underXE_REQUIRE_DOCKER_TESTS=1and skip everywhere else. CI proves the Engine API wire shape without a daemon againstTesting.FakeDocker; this proves what only a daemon can. Exit 5 = no usable daemon, 1 = product failed.
- Backend tests are TUnit on Microsoft.Testing.Platform, not xUnit. Style:
docs/wiki/17-writing-tests.md. - Every test is independent and self-validating: no assertion, or a silent
returnon an unsupported OS, has verified nothing — skip visibly with a reason. Principles:docs/wiki/17-writing-tests.md§1a. - Never sleep to wait for, or to rule out, an event in a test; use a gate the test controls,
FakeTimeProvider, orAssertEx.EventuallyAsync. A real timer needs a// real-timer:comment saying why. - Mock in this order: the real thing, the repo's fake seam (
FakeOllama,RecordingHubMessageSender, MSW), thenSubstitute.For<T>()/vi.fn(), then a hand-written fake. Never mock the gate, cipher or migration under test. - FastEndpoints, one endpoint per file, calling
Client.Applicationservices directly; no MediatR/CQRS. DTOs, mappers and validators fold intoV1/{Dtos,Mappers,Validators}/;Dtos/keeps a flat namespace. usingdirectives go inside the file-scoped namespace; subfolder namespaces must nest (IDE0130 is an error).- A bare
TODO/FIXME/HACKin a C# comment fails the Release build. - Frontend: feature folders under
src/features/, TanStack Query for server state, Zustand only for UI state, manual Mantine forms (no form library), user-facing strings through react-i18next. - Full list:
docs/wiki/16-code-conventions.md.
docs/agent-knowledge.md— index of hard-won rules (§0 orientation, routing table, §6 not built); topic files indocs/agent-knowledge/by area (§1 build/test/CI, §2 runtime, §3 models, §4 agents, §5 frontend).docs/wiki/— architecture;docs/adr/— decisions;docs/roadmaps/— status records.CONTRIBUTING.md,.github/PULL_REQUEST_TEMPLATE.md— what a PR must state.