- apps/web/ — Next.js 16 frontend (App Router, Tailwind v4, shadcn/ui)
- Runs domain (
/runs,/runs/new,/runs/[id]) — the primary entity: create, read, start/re-run, edit, delete; live progress, loss-curve chart, scoped artifact explorer - Dashboard with MLOps metrics + the cumulative artifact-size curve
- Dataset ingest (repurposed Upload page) + whole-bucket File browser
- Dark mode via
next-themes
- Runs domain (
- services/api/ — FastAPI backend (layered architecture)
- REST API for the Run lifecycle (
/runs*), dataset ingest (/upload), and the whole-bucket file browser (/files*) - The training engine (
repo/trainer.py) — local LoRA/QLoRA fine-tuning; all torch/transformers/peft/unsloth imports are contained here - B2 S3 integration via boto3 (all in
repo/) - Background training thread + in-process run-progress registry
- Health check endpoint with B2 connectivity verification
- Structured JSON logging with request tracing
- Prometheus-format metrics endpoint
- REST API for the Run lifecycle (
- packages/shared/ — TypeScript type definitions
- Mirrors Pydantic models from the API
- Consumed by
apps/web/as workspace dependency
The API follows a strict layered architecture:
types/ Pydantic models — no logic, no imports from other layers
|
config/ Settings (pydantic-settings) — depends only on types
|
repo/ Data access (boto3 B2 client) — no business logic
|
service/ Business logic — calls repo, returns types
|
runtime/ FastAPI routes — calls service, never repo directly
- Dependencies flow downward only:
types->config->repo->service->runtime - No backward imports (e.g., service must not import from runtime)
boto3only allowed inrepo/layer- All boundary data uses Pydantic models (no raw dicts across layers)
- Each file stays under 300 lines
services/api/
main.py App entrypoint, middleware, router registration
app/
types/ Pydantic models (runs, files, upload, stats)
config/ Settings loaded from environment
repo/ Data access: b2_client, runs_store, trainer,
run_progress (B2 + ML SDKs contained here)
service/ Business logic (runs, run_view, upload, files, metadata)
runtime/ FastAPI route handlers (runs, upload, files, ...)
tests/ pytest tests (structural + integration)
requirements.txt Human-edited deps (base, CPU-portable ML stack)
requirements-gpu.txt Optional GPU extra (unsloth/bitsandbytes/xformers)
The Run is the primary entity. Its lifecycle is orchestrated in
service/runs.py:
- Create (
POST /runs) freezes the config toruns/<id>/config.jsonand writes therun.jsonmanifest with statusqueued. - Start (
POST /runs/{id}/start, therunverb) flips status torunningand launches training in a background daemon thread — the request returns immediately. - The thread resolves the dataset from B2, then calls
repo/trainer.py, which auto-detects the device at runtime (CUDA → CPU). On CUDA it runs a real UnslothFastLanguageModelfine-tune (unsloth imported lazily, only here); with no GPU it runs a real transformers + PEFT LoRA fine-tune of a tiny CPU-friendly model. Both produce genuine adapter safetensors and a real loss curve. - Each epoch uploads a checkpoint to
runs/<id>/checkpoints/epoch-<n>/and persists an updated manifest; the final adapter +metrics.jsonare archived on completion. Failures set statusfailedwith the error on the manifest.
In-process run-progress registry (repo/run_progress.py): a thread-safe map
of sub-epoch liveness (current epoch, latest loss, message) merged into
GET /runs/{id} while a run is running. It is per-replica in-process state —
the durable source of truth stays the B2 manifest. Single-replica caveat in
docs/RELIABILITY.md.
datasets/<dataset_id> # ingested JSONL/CSV training file
runs/<run_id>/run.json # manifest (status, config, metrics) — source of truth
runs/<run_id>/config.json # frozen training config
runs/<run_id>/checkpoints/epoch-<n>/... # per-epoch adapter snapshot (safetensors + adapter_config.json)
runs/<run_id>/adapter/... # final LoRA adapter
runs/<run_id>/metrics.json # loss curve + summary
No dependency on bucket-level S3 versioning — the run-keyed prefixes are the versioning mechanism.
- No external SDK leakage:
boto3is only imported inapp/repo/, and the ML SDKs (torch / transformers / peft / unsloth) are only imported inapp/repo/trainer.py— lazily, withunslothreached only on the CUDA branch. All other layers interact with B2 and the trainer through the repo interface. - No raw dicts at boundaries: All data crossing layer boundaries uses typed Pydantic models.
- No cross-layer mutable state: Configuration is read-only after init, and no mutable state is shared between layers. Intra-layer caches/counters (the listing cache in
repo/list_cache.py, the B2 connectivity cache inrepo/b2_client.py, the download counter inrepo/counter.py, the run-progress registry inrepo/run_progress.py, the rate-limit and metrics state inruntime/) are module-local and guarded by athreading.Lock. The listing cache owns a stale-while-revalidate background thread (warmed once at startup bymain.lifespan); each training run owns a background daemon thread that writes checkpoints to B2 and updates the run-progress registry. - Validated inputs: All HTTP inputs validated by FastAPI/Pydantic. File keys reject empty and path-traversal patterns; optional prefix confinement via
ALLOWED_KEY_PREFIX(off by default).
- Local dev —
pnpm devruns both services viaconcurrently- Web:
localhost:3000 - API:
localhost:8000
- Web:
- Railway — two services from the same repository:
webbuilds from the repository root because it consumespackages/shared;apibuilds fromservices/api. The versioned per-service configs and the human-approved staging/production contract live in infra/railway/README.md. External provisioning and deployment remain explicit user-approved actions.
- Backblaze B2 — object storage (S3-compatible API)
- Datasets under
datasets/, run artifacts underruns/<run_id>/(see the key layout above); therun.jsonmanifest is the source of truth per run - Listing/metadata via S3
list_objects_v2/head_object; artifacts written withput_object; downloads viagenerate_presigned_url; scoped deletes viadelete_objects - No application database — B2 is the sole data store
- Datasets under
- Backblaze B2 S3 API — file storage, retrieval, deletion, presigned URLs
See docs/SECURITY.md for full security documentation.
- Frontend -> API — CORS-restricted to configured origins.
CORSMiddlewareis registered LAST inmain.py(outermost) so it wraps every response, including uncaught-exception 500s — otherwise the browser would block error responses and the UI would only see an opaque "network error". See docs/RELIABILITY.md. A per-IP rate-limit middleware sits inner to CORS; see docs/SECURITY.md. - API -> B2 — authenticated via application keys, signature v4
- Client -> B2 — presigned URLs for download (10-min expiry, forced attachment)
- Create run: Browser ->
POST /runs-> service validates config -> repo writesconfig.json+run.json(statusqueued) to B2 -> response - Start/re-run: Browser ->
POST /runs/{id}/start-> service flips status torunning, spawns a background thread -> trainer fine-tunes (device auto-detected) -> per-epoch checkpointput_object+ manifest update -> final adapter +metrics.jsonarchived -> statuscompleted - Run detail (with live progress): Browser polls
GET /runs/{id}-> service reads manifest, lists artifacts, merges the in-process progress registry whilerunning - Ingest dataset: Browser ->
POST /upload(multipart) -> service validates -> repo writes underdatasets/-> response - Presigned download: Browser ->
GET /runs/{id}/artifacts/download?key=...-> service validates the key is withinruns/<id>/-> repo presigns -> browser downloads - Delete run: Browser ->
DELETE /runs/{id}-> service -> repo scopeddelete_objectssweep ofruns/<id>/only
- Structured JSON logging on all requests with
request_id - Request timing middleware (logs duration per request; also the catch-all that converts uncaught exceptions to a typed JSON 500)
/metricsendpoint (Prometheus format: request count, latency, upload count)/healthendpoint (B2 connectivity check)
- Checked-in OpenAPI artifact:
docs/api/openapi.json - Export/check command:
pnpm contract:export/pnpm contract:check - FastAPI freshness test:
services/api/tests/test_openapi_contract.py - Frontend route drift test:
apps/web/src/lib/api-contract.test.ts
The frontend client keeps a small API_CLIENT_ROUTES registry in
apps/web/src/lib/api-client.ts. Tests compare that registry to the checked-in
OpenAPI artifact so route changes fail loudly before the hand-written client can
silently drift from FastAPI. GET /metrics is intentionally server-only.
- Run route handlers:
services/api/app/runtime/runs.py - Run orchestration (background training, manifest lifecycle):
services/api/app/service/runs.py - Read-model shaping (summary/detail/options/stats):
services/api/app/service/run_view.py - Training engine (ML SDKs contained; device autodetect):
services/api/app/repo/trainer.py - Run/dataset B2 store:
services/api/app/repo/runs_store.py - In-process progress registry:
services/api/app/repo/run_progress.py - B2 data access (repo layer):
services/api/app/repo/b2_client.py - Pydantic models:
services/api/app/types/(runs.py,files.py,upload.py,stats.py,formatting.py) - Config (pydantic-settings):
services/api/app/config/settings.py - Structural tests:
services/api/tests/test_structure.py - OpenAPI contract:
docs/api/openapi.json - Frontend API client:
apps/web/src/lib/api-client.ts - Shared TypeScript types:
packages/shared/src/types.ts
- docs/SECURITY.md — security principles and implementation
- docs/RELIABILITY.md — reliability expectations
- AGENTS.md — architectural invariants and agent instructions