Skip to content

Architecture

How Tuneline Studio is built: a React app, a Supabase project, a FastAPI control plane on Vercel, and a runner that executes pipelines on the user's chosen compute.

docs/CONTRACTS.md in the repository is the source of truth for every interface described here (tables, RPCs, run spec, runner events, API routes). Read it before changing anything that crosses a boundary, and update it in the same change.

Overview

flowchart LR
    subgraph Browser
        SPA["React SPA<br/>(Vite, React Flow)"]
    end
    subgraph Supabase
        Auth["Auth"]
        DB[("Postgres<br/>+ row level security")]
        Vault["Vault<br/>(user API keys,<br/>server key)"]
        Storage["Storage<br/>(datasets bucket)"]
        RT["Realtime"]
        Cron["pg_cron + pg_net"]
    end
    subgraph Vercel
        API["FastAPI control plane<br/>/api/*"]
        CDN["Static web build<br/>(public/)"]
    end
    subgraph Compute["Where runs execute"]
        Cloud["Modal · RunPod ·<br/>HF Jobs · Kaggle"]
        NB["Notebook<br/>(Colab, Jupyter)"]
        Worker["tuneline worker<br/>(your computer)"]
        Runner["tuneline run"]
    end
    HF["Hugging Face Hub<br/>(user's account)"]

    SPA -- "supabase-js: sign-in, own rows,<br/>uploads, live updates" --> Supabase
    SPA -- "fetch /api/* with<br/>Bearer access token" --> API
    CDN -. serves .-> SPA
    API -- "server_* RPCs<br/>+ server key" --> DB
    API -- "launch with the<br/>user's keys" --> Cloud
    Cron -- "POST /api/cron/reconcile<br/>every 5 min" --> API
    Cloud --> Runner
    NB --> Runner
    Worker -- "worker_claim" --> DB
    Worker --> Runner
    Runner -- "runner_* RPCs<br/>+ run token" --> DB
    Runner -- "models, datasets,<br/>cache" --> HF
    API -- "endpoints" --> Cloud
Part What it does Where the code is
Web app Single-page React app: canvas, run pages, datasets, registry, settings. Talks to Supabase directly for the signed-in user's own rows and to the API for everything that needs provider keys or the server. web/
Supabase Auth (GitHub and Google sign-in; email and password for existing accounts), Postgres with row level security on every table, Vault for API keys, Storage for uploaded datasets, Realtime for live run pages, pg_cron for background checks. supabase/migrations/
Control plane FastAPI app deployed as one Vercel Python function (app.py). Validates and compiles pipelines, launches jobs with the user's own keys, estimates, Hub search, deployments, playground, reconcile. Also serves the web build, the runner installers and the runner wheel. app.py, tuneline/api/
Runner The tuneline command. Fetches a run with its token, executes the blocks in order, reports events, reuses cached results and pushes artifacts to the user's Hugging Face account. tuneline/runner/
Launchers and deployers Start jobs on Modal, RunPod, Hugging Face Jobs, Kaggle, notebooks or a local worker; create vLLM endpoints on RunPod Serverless, Hugging Face Inference Endpoints and Modal. tuneline/launchers/, tuneline/deployers/

Tuneline is multi-user: anyone can sign up, every table is per user under row level security, each user stores their own provider keys and pays for their own compute, and models go to their own Hugging Face account.

Trust boundaries

Three kinds of credentials reach the database, and each unlocks a narrow set of functions:

Caller Credential Can do
Browser Public key + the user's access token Read and write the user's own rows under RLS; set_secret, delete_secret, create_worker, cancel_run. Never reads a key's value (only its last four characters).
API Public key + the server key (TUNELINE_SERVER_KEY, checked against the Vault secret tuneline_server_key) server_* functions: read a user's keys, create and update runs, issue run tokens, manage deployments, set config. The API has no service-role key.
Runner Public key + a run token (tlr_…) runner_* functions for that one run: fetch the spec and only the keys the pipeline needs, report events, look up the cache, read a golden set.
Worker Public key + a worker token (tlw_…) worker_heartbeat, worker_claim for that user's local runs.

Tokens are stored only as SHA-256 hashes. Run tokens expire after 14 days (at most 30), and 2 hours after the run ends; worker tokens last until the computer is removed. See docs/SECURITY.md.

Run lifecycle

sequenceDiagram
    participant U as Browser
    participant A as API (Vercel)
    participant D as Supabase
    participant P as Provider / worker
    participant R as tuneline run
    U->>A: POST /api/runs {graph, target, target_config}
    A->>A: validate graph, check keys, estimate, compile spec
    A->>D: server_create_run (+ run token for cloud targets)
    A->>P: launch job with the user's provider keys
    A-->>U: {run, launch, estimate, warnings}
    P->>R: pip install runner wheel, tuneline run --token tlr_...
    R->>D: runner_fetch (spec, needed keys, profile)
    loop every step in spec.order
        R->>D: runner_cache_lookup
        R->>D: runner_report (step, log, metric, artifact events)
    end
    R->>D: runner_report (run: succeeded / failed / cancelled)
    D-->>U: Realtime updates of runs, run_steps, run_logs, run_metrics, artifacts
    D->>A: every 5 min: POST /api/cron/reconcile (server key)
    A->>P: check job status, remove finished pods, refresh endpoints
  1. Create. POST /api/runs checks the graph's shape and validates it, checks that the user saved every mandatory key (for the blocks and for the target), resolves uploaded datasets to 7-day signed URLs, resolves registry model versions and golden sets, estimates memory, time and cost, picks a GPU if none was chosen, and compiles the graph into a run spec (tuneline/spec.py: nodes with all params, the order, datasets, required_secrets, settings such as use_cache, max_hours, app_url). server_create_run stores the run as queued, with a run token for every target except local.
  2. Launch. For Modal, RunPod, Hugging Face Jobs and Kaggle the API sets launching, starts the job with the user's keys (read from Vault) and records provider_job_id. Every job runs the same script: install tuneline[train,gguf] from <app>/runner/tuneline-<version>-py3-none-any.whl, then tuneline run --token "$TUNELINE_RUN_TOKEN". For Notebook the run stays queued until the user downloads the notebook (GET /api/runs/{id}/notebook) or copies a command (GET /api/runs/{id}/command), each with a fresh token. For My computer the worker claims it: worker_claim picks the oldest queued local run for that user (and computer), moves it to launching and issues a token, and the worker starts a child tuneline run with the token in its environment.
  3. Execute. The runner calls runner_fetch, exports the keys as environment variables (never logged), reports run: running and step: queued for every node, then walks spec.order. For each step it computes the cache key, reuses cached outputs when allowed (cached), otherwise runs the block (running with progress, logs and metrics), saves each output and reports one artifact event per output port and succeeded with a small outputs summary. A failed upstream step makes later steps skipped.
  4. Events. Events are batched (flushed every 2 seconds or 200 events, at most 500 events per call) and sent with runner_report, at least every 15 seconds with a heartbeat when idle. The database writes them to run_steps, run_logs, run_metrics, artifacts, model_versions and deployments; Realtime pushes them to the open run page.
  5. Finish. The runner reports run: succeeded | failed | cancelled and exits 0 or 1. It stops by itself at max_hours or when max_cost_usd is reached at the estimated hourly price, and at the next safe point when a report answer says cancel_requested.
  6. Reconcile. Triggers on runs set finished_at and shorten token lifetimes when a run ends, and close steps left queued/running in a failed or cancelled run. The tuneline-tick cron job (every 5 minutes) applies the provider-free rules itself (a local or notebook run without heartbeat for 20 minutes fails; a cloud launch that never recorded a job id fails after 10 minutes) and, when there are active cloud runs or deployments, calls POST /api/cron/reconcile with the server key from Vault. The API then asks each provider for the job's status, removes finished RunPod pods and refreshes endpoints. Vercel Cron calls the same route once a day as a fallback.

Run statuses: queued → launching → running → succeeded | failed | cancelled. Step statuses: queued, running, succeeded, failed, skipped, cached, cancelled.

Data model

All tables live in schema public, have a user_id (or are keyed by the user) and allow owners only under row level security. Private helpers (private.run_tokens, private.config) are not reachable from the browser.

Table Holds Written by
profiles Display name, Hugging Face username, settings Sign-up trigger; the user; the API (hf_username after a key test)
user_secrets Name, Vault secret id and last-four hint of each API key (never the value) set_secret / delete_secret
pipelines Name, description, template, graph (the canvas) Browser
datasets, dataset_versions Datasets and their versions: source (upload, Hub, URL), storage path, checksum, format, columns, stats, sample rows Browser
runs One run: status, target and target config, graph, compiled spec, estimate, provider job id and state, worker, cost, error, timestamps, token hash API, runner
run_steps Status, progress, message, cache key and outputs of each step Runner
run_logs, run_metrics Log lines; metric points (train/loss, eval/loss, report/<metric>, …) Runner
artifacts Each output of each step: port, kind, URI, cache key, size, meta Runner
models, model_versions The model registry: models and their versions (adapter, merged or GGUF), metrics, model card, stage Runner; browser (stage promotion)
deployments Endpoints: provider, model URI, config, status, URL, provider state API, runner
eval_sets, eval_examples Golden sets and their examples Browser (Playground)
workers The user's computers: name, system info, last seen create_worker; worker RPCs
private.run_tokens Extra run tokens (hashes) with expiry server_issue_run_token
private.config app_url and log_retention_days server_set_config

Storage bucket datasets is private, 50 MB per file, with paths <user_id>/<dataset_id>/<version>/<file_name>; each user can touch only their own folder. Realtime publishes runs, run_steps, run_logs, run_metrics, artifacts, workers and deployments. The daily cron job tuneline-daily deletes run logs older than log_retention_days (default 60), expired run tokens and old cron history.

Migrations, in order:

File Adds
0001_core Tables, RLS policies, sign-up trigger, run numbering
0002_secrets Vault-backed set_secret, delete_secret, server_get_secrets, the server-key check
0003_runs_rpc Tokens, cancel_run, create_worker, worker_*, runner_* and server_* RPCs
0004_storage_realtime The datasets bucket and its policies, the Realtime publication
0005_cache_lookup runner_cache_lookup
0006_run_tokens Several valid tokens per run; newest-first run listing for reconcile
0007_housekeeping pg_cron and pg_net, private.config, status triggers, tuneline-tick and tuneline-daily
0008_safe_retention The daily clean-up falls back to 60 days instead of failing on a bad log_retention_days

Caching

Every step gets a content-addressed cache key (node_cache_key in tuneline/spec.py): a hash of the block type and version, the settings marked semantic (settings such as the Hub repository name or logging interval are left out, so renaming a repository does not retrain), the cache keys of its inputs ("<upstream key>:<port>"), and the content identity of its data (uploaded file checksum, downloaded file checksum, resolved Hub commit). Each output is stored with the cache key <node key>:<port>.

When settings.use_cache is on and the block is cacheable, the runner calls runner_cache_lookup with the keys of every output. If each one is found and loadable on this machine, the step is reported cached and its artifacts are reported again with meta.cached_from. Lookups only see the same user's artifacts.

Where outputs live so that other machines can reuse them:

  • Datasets are saved as Parquet and, when the user has a Hugging Face token, uploaded to their private dataset repository <hf_username>/tuneline-cache at artifacts/<node key>/<port>.parquet (the node key, because cache keys contain a colon). Without a token they stay on the machine that made them.
  • Models (adapters, merged models) and GGUF files are pushed to the user's Hugging Face repositories when Push to Hugging Face is on; a cache hit checks the repository still exists.
  • Evaluation reports are stored inline in the artifact record (uri: "inline:"). Gates, publishing and deployments are never cached.

Repository map

Path What
web/ React SPA: Vite, TypeScript, Tailwind v4, React Router, TanStack Query, @xyflow/react 12, Recharts, supabase-js. Builds into tuneline/api/webui/.
app.py → tuneline/api/ FastAPI control plane (Vercel function): runs, launches, estimates, Hub search, deployments, playground, reconcile, static files.
tuneline/registry.py, spec.py, templates.py Block registry (24 blocks), graph → run spec compiler and validator, templates.
tuneline/config_defaults.py Public connection defaults baked into the runner and the API (Supabase URL and public keys).
tuneline/runner/ The runner: CLI (run, worker, run-local, doctor), engine, event client, one module per block in nodes/.
tuneline/launchers/, tuneline/deployers/ Modal, RunPod, Hugging Face Jobs, Kaggle, notebook and local launches; endpoints on RunPod Serverless, Hugging Face Inference Endpoints and Modal.
tuneline/estimator.py, tuneline/catalog/ VRAM, time and cost estimator; model, GPU, provider and sample catalogs.
supabase/migrations/ Schema, RLS, RPCs, storage, realtime, pg_cron housekeeping.
web/public/runner/ install.sh / install.ps1, served by the API with the URLs filled in.
tests/ pytest suites: test_api_* (with a fake Supabase) and test_runner_*. Web unit tests and Playwright tests live in web/.
scripts/, DEPLOY.cmd Local setup, deploy and sample-data scripts.
docs/ CONTRACTS.md, SETUP.md, SECURITY.md, VERIFY_ON_FIRST_RUN.md, DEVELOPMENT_NOTES.md, UI_GUIDE.md.
documentation/ This documentation site (MkDocs Material) and its generator script.

Where to go next