Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 26 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,9 @@ jobs:
strategy:
fail-fast: false
matrix:
# pyproject says requires-python ">=3.12", but the pinned
# memu-py==1.4.0 dependency requires >=3.13, so 3.13 is the real
# floor (and what the dev environment runs). Add more versions here
# if that pin is ever relaxed.
# Matches pyproject's requires-python floor of >=3.13, which is set by
# the pinned memu-py==1.4.0. Add more versions here if that pin is ever
# relaxed and the floor drops.
python-version: ["3.13"]
steps:
- name: Checkout
Expand All @@ -38,15 +37,32 @@ jobs:
- name: Set up uv
uses: astral-sh/setup-uv@v6
with:
# Pinned deliberately. uv owns the lockfile format (uv.lock records a
# `version` and `revision`) and it is also the tool asserting the lock
# is current, so letting CI float to whatever uv shipped this morning
# puts a moving part underneath the reproducibility this file exists to
# provide. Bump this alongside a `uv lock` refresh.
version: "0.12.0"
enable-cache: true
# Key the uv cache on the dependency source (this repo has no uv.lock).
cache-dependency-glob: "pyproject.toml"

- name: Create virtual environment
run: uv venv --python ${{ matrix.python-version }}
# uv.lock is the single input that determines the resolution.
cache-dependency-glob: "uv.lock"

- name: Install project and test dependencies
run: uv pip install -e ".[test]"
# `uv sync` resolves from uv.lock, creates .venv, and installs the
# project editable — so this run gets the same versions today and in six
# months. It also stops the cache deciding versions: before the lock, a
# restored cache carrying stale index metadata was the only reason CI
# kept resolving mcp 1.29.0 for three weeks after mcp 2.0.0 had broken
# every fresh install (#316).
#
# --locked asserts uv.lock is already in sync with pyproject.toml and
# fails if not, rather than silently re-locking. That is what makes this
# one job sufficient: any dependency change — added, removed, or
# re-bounded — invalidates the lock, so it cannot be merged without a
# relock, and the relock moves the pins that this job then tests. It
# does NOT fail merely because upstream published something new, since
# the lock is used as-is.
run: uv sync --extra test --locked --python ${{ matrix.python-version }}

- name: Run test suite
run: .venv/bin/pytest tests/ -v
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,8 @@ The installer handles everything — installs dependencies (Python, Node.js, uv)

```bash
git clone https://github.com/ClickHouse/nerve.git && cd nerve
uv venv --python 3.13 && source .venv/bin/activate
uv pip install -e .
uv sync # creates .venv from uv.lock
source .venv/bin/activate
cd web && npm ci && npm run build && cd ..
nerve init
nerve start -f
Expand Down
2 changes: 1 addition & 1 deletion docs/observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ key).
- **Spans aren't appearing.** Check `/api/observability/status` —
if `auth_ok: false`, the keys are wrong. If `enabled: false` despite
keys being set, look at startup logs for an `ImportError` on the
`langfuse` package itself (run `uv pip install -e .` to refresh).
`langfuse` package itself (run `uv sync` to refresh).
- **Spans are tagged but session_id is missing.** That can happen if the
installed Langfuse SDK doesn't accept `session_id=` kwarg in
`propagate_attributes`. Upgrade to a newer Langfuse Python SDK.
Expand Down
135 changes: 125 additions & 10 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,23 @@ The fastest way to get Nerve running:
```bash
git clone https://github.com/ClickHouse/nerve.git nerve
cd nerve
pip install -e . # or: uv pip install -e .
uv sync # creates .venv from uv.lock
source .venv/bin/activate # puts `nerve` on PATH
cd web && npm install && npm run build && cd ..
nerve init # Interactive wizard — handles everything
nerve init # Interactive wizard — handles everything
nerve start
```

`uv sync` creates `.venv` but does not activate it, so `nerve` is not on `PATH` until
you do. If you'd rather not activate, prefix commands with `uv run` instead —
`uv run nerve init`.

The `nerve init` wizard walks you through deployment, mode selection, API keys, workspace setup, and cron configuration. Nothing is written until you confirm.

## Prerequisites

### Server deployment
- Python 3.12+
- Python 3.13+
- Node.js 18+ (for web UI build)
- Anthropic API key **or** Claude subscription (via CLIProxyAPI proxy)

Expand All @@ -34,27 +39,27 @@ The `nerve init` wizard walks you through deployment, mode selection, API keys,
git clone https://github.com/ClickHouse/nerve.git nerve
cd nerve

# Create virtual environment
uv venv
# Create .venv and install Nerve at the locked versions
uv sync
source .venv/bin/activate

# Install Nerve
uv pip install -e .

# Build web UI
cd web && npm install && npm run build && cd ..

# Run the setup wizard
nerve init
```

`uv sync` installs the exact versions in `uv.lock` — see
[Dependency versions](#dependency-versions) for how that is maintained.

### Option B: Docker

```bash
git clone https://github.com/ClickHouse/nerve.git nerve
cd nerve
pip install -e . # Needed to run the wizard on the host
nerve init # Choose "docker" at the deployment step
uv sync # Needed to run the wizard on the host
uv run nerve init # Choose "docker" at the deployment step
```

The wizard handles everything: generates Dockerfile + docker-compose.yml, builds the image, starts the container, and continues setup inside it. You never write Docker files manually.
Expand Down Expand Up @@ -260,6 +265,116 @@ sudo systemctl status nerve
journalctl -u nerve -f
```

## Dependency versions

`uv.lock` pins the exact version of every dependency, transitive ones included.
`uv sync` installs from it, so the same commit resolves to the same versions
whenever and wherever you install. That is why `uv sync` is the documented install
rather than `uv pip install -e .`.

Two honest limits on "reproducible": the lock is universal but its entries carry
platform and Python markers, so different platforms legitimately get different
*files* for the same pinned versions; and artifacts are not vendored, so an
air-gapped install still needs a populated uv cache or a local wheelhouse.

```bash
uv sync # runtime dependencies
uv sync --extra test # ...plus the test extra
```

`uv sync` creates and manages `.venv` itself, installs Nerve editable, and
removes anything not in the lock — so the environment matches the lock exactly
rather than accumulating leftovers.

> **`uv pip install -e .` does not read `uv.lock`.** uv's pip-compatible layer has
> no lockfile awareness, so that command resolves against the bounds in
> `pyproject.toml` and can install different versions. It still works, and it is
> what you want when deliberately testing against current upstream — but it is not
> a reproducible install.

### Upgrading an installation that predates the lockfile

`nerve upgrade` runs the updater code that is **already loaded in memory**, then
pulls. So the first upgrade across the commit that introduced `uv.lock` still uses
the old, unpinned installer — the lock only takes effect from the *second* upgrade
onward. Two consequences worth knowing before you run it:

- That first upgrade resolves dependencies fresh, so it can pick up something newer than the lock intends.
- If you are on Python 3.12, it will **fail after `git pull` has already advanced the checkout**, because the new `pyproject.toml` requires 3.13+. You are left with new source and an old environment.

Either is straightforward to recover from — do the install step yourself, on 3.13+:

```bash
cd <your nerve checkout>
uv sync --locked --inexact
nerve restart
```

Subsequent `nerve upgrade` runs install from the lock automatically.

### Changing a dependency

Edit `pyproject.toml`, then relock and commit `uv.lock` alongside it:

```bash
uv lock
```

CI runs `uv sync --locked`, which fails if `uv.lock` and `pyproject.toml` have
drifted apart — so a dependency change without a relock is caught rather than
silently re-resolved.

Relocking preserves existing pins, so it won't sweep in unrelated upgrades. To
move one deliberately:

```bash
uv lock --upgrade-package mcp # one dependency
uv lock --upgrade # everything
```

### How CI enforces this

`ci.yml` installs with `uv sync --locked`, which does two jobs at once:

- A PR is never broken by an upstream release that has nothing to do with it — the versions come from the lock.
- A dependency change cannot merge without a relock. `--locked` fails if `uv.lock` disagrees with `pyproject.toml` for *any* reason: a dependency added, removed, or simply re-bounded. Since the relock then moves the pins, the versions CI tests are always the ones the change actually selects.

That second property is why no separate unpinned CI job is needed. Widen a bound and
the lock is invalidated; relock and the pin moves; CI tests the moved pin.

### What is and isn't covered

| Path | Installs from `uv.lock`? |
|---|---|
| `uv sync` (quick start, server install, `install.sh`) | yes |
| `nerve upgrade` | yes — `uv sync --locked --inexact` when a `uv.lock` is present |
| CI (`ci.yml`) | yes — `uv sync --locked` |
| Docker | yes — the image installs from the lock at build time and the entrypoint syncs the project |
| `uv pip install -e .` / plain `pip install -e .` | **no** — resolves from `pyproject.toml` bounds |

Docker keeps its environment at `/opt/nerve-venv`, outside the `/nerve` bind mount,
because a `.venv` under `/nerve` would be shadowed by the host's checkout (and a
host-created one may not be Linux-compatible).

> **Existing Docker deployments need one manual step.** `nerve init` does not
> overwrite Docker files that already exist, so an install generated before this
> change keeps its old pip-based `Dockerfile` and entrypoint. To pick up the locked
> build, delete them and regenerate:
>
> ```bash
> rm Dockerfile docker-entrypoint.sh
> nerve init # choose "docker" again; regenerates both
> docker compose build --no-cache
> ```
>
> Keep `docker-compose.yml` — it is unchanged. Check any local edits you made to
> the old files before deleting them.

`nerve upgrade` uses `--inexact`, so it won't uninstall optional extras you added
yourself, and `--frozen`, so it never rewrites `uv.lock` as a side effect of
upgrading. If uv is missing, or the checkout has no `uv.lock`, it falls back to the
previous `pip install -e .` behaviour.

## Troubleshooting

### Database Schema Issues
Expand Down
4 changes: 2 additions & 2 deletions docs/worker-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ Worker mode deploys Nerve as a task-focused autonomous agent. Give it a job desc
# Clone and install
git clone https://github.com/ClickHouse/nerve.git nerve
cd nerve
uv venv && source .venv/bin/activate
uv pip install -e .
uv sync
source .venv/bin/activate

# Initialize in worker mode
nerve init --mode worker
Expand Down
60 changes: 39 additions & 21 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ set -euo pipefail
NERVE_REPO="https://github.com/ClickHouse/nerve.git"
NERVE_BRANCH="${NERVE_BRANCH:-main}"
INSTALL_DIR="${NERVE_INSTALL_DIR:-$HOME/nerve}"
MIN_PYTHON_MINOR=12
# 13, not 12: pyproject's requires-python is >=3.13 (set by memu-py==1.4.0).
# Accepting 3.12 here meant the installer would provision a Python that then
# failed at `uv pip install -e .` with an opaque dependency conflict.
MIN_PYTHON_MINOR=13
PREFERRED_PYTHON_MINOR=13
# Vite 7 (see web/package.json) requires Node 20.19+ or 22.12+.
MIN_NODE_VERSION="20.19.0"
Expand Down Expand Up @@ -53,7 +56,7 @@ confirm() {

command_exists() { command -v "$1" >/dev/null 2>&1; }

# Compare versions: version_ge "3.13" "3.12" → true
# Compare versions: version_ge "3.13" "3.13" → true
version_ge() {
local a="$1" b="$2"
[ "$(printf '%s\n%s' "$a" "$b" | sort -V | head -n1)" = "$b" ]
Expand Down Expand Up @@ -194,11 +197,11 @@ ensure_uv() {
success "uv $(uv --version 2>&1 | grep -oE '[0-9]+\.[0-9]+\.[0-9]+')"
}

# --- Dependency: Python 3.12+ ---
# --- Dependency: Python 3.13+ ---

ensure_python() {
# Check for existing Python >= 3.12
for candidate in python3.13 python3.12 python3; do
# Check for an existing Python >= 3.$MIN_PYTHON_MINOR
for candidate in python3.13 python3; do
if command_exists "$candidate"; then
local ver
ver="$(get_python_version "$candidate")"
Expand All @@ -220,7 +223,7 @@ ensure_python() {
fi
fi

# Fallback: try 3.12
# Fallback: retry the minimum supported minor
if uv python install "3.$MIN_PYTHON_MINOR" 2>/dev/null; then
PYTHON_CMD="$(uv python find "3.$MIN_PYTHON_MINOR" 2>/dev/null || echo "")"
if [ -n "$PYTHON_CMD" ]; then
Expand All @@ -234,7 +237,7 @@ ensure_python() {

if [ "$HAS_SUDO" = "0" ] && [ "$OS" = "linux" ]; then
error "Cannot install Python: no sudo and uv python install failed."
error "Install Python 3.12+ manually and re-run."
error "Install Python 3.13+ manually and re-run."
exit 1
fi

Expand Down Expand Up @@ -268,17 +271,29 @@ ensure_python() {
;;
*)
error "Don't know how to install Python on $DISTRO."
error "Install Python 3.12+ manually and re-run."
error "Install Python 3.13+ manually and re-run."
exit 1
;;
esac

if [ -z "${PYTHON_CMD:-}" ] || ! command_exists "$PYTHON_CMD"; then
error "Failed to install Python. Install Python 3.12+ manually and re-run."
error "Failed to install Python. Install Python 3.13+ manually and re-run."
exit 1
fi

success "Python $(get_python_version "$PYTHON_CMD") installed via system packages"
# Existence alone isn't enough. The fallback chains above can settle on an
# older interpreter (dnf/zypper try python3.13, then python3.12, then plain
# python3), and an interpreter below the floor fails much later at
# `uv pip install -e .` with an opaque transitive dependency conflict.
# Fail here, where the cause is obvious, instead.
installed_py_ver="$(get_python_version "$PYTHON_CMD")"
if ! version_ge "$installed_py_ver" "3.$MIN_PYTHON_MINOR"; then
error "Installed Python $installed_py_ver is below the required 3.$MIN_PYTHON_MINOR."
error "Install Python 3.$MIN_PYTHON_MINOR+ manually and re-run."
exit 1
Comment thread
alex-clickhouse marked this conversation as resolved.
fi

success "Python $installed_py_ver installed via system packages"
}

# --- Dependency: Node.js 18+ ---
Expand Down Expand Up @@ -389,17 +404,20 @@ setup_python_env() {

cd "$INSTALL_DIR" || exit 1

if [ ! -d ".venv" ]; then
info "Creating virtualenv..."
uv venv --python "3.$PREFERRED_PYTHON_MINOR" 2>/dev/null \
|| uv venv --python "3.$MIN_PYTHON_MINOR" 2>/dev/null \
|| uv venv
else
info "Using existing virtualenv"
fi

info "Installing dependencies..."
uv pip install -e . --quiet
# `uv sync` creates and manages .venv itself, so there's no separate venv
# step: it installs the exact versions in uv.lock and Nerve editable.
# Locked rather than re-resolved, so a fresh install gets the dependency set
# that CI actually tested.
# --locked: install the committed lock, and fail rather than silently
# re-resolving and rewriting uv.lock in the user's checkout.
# --inexact: this script doubles as an upgrade path for an existing install,
# and `uv sync` is exact by default — without this, rerunning it would
# uninstall anything the user added on top (optional extras, local tools).
info "Installing dependencies from uv.lock..."
local sync_flags=(--locked --inexact --quiet)
uv sync "${sync_flags[@]}" --python "3.$PREFERRED_PYTHON_MINOR" 2>/dev/null \
|| uv sync "${sync_flags[@]}" --python "3.$MIN_PYTHON_MINOR" 2>/dev/null \
|| uv sync "${sync_flags[@]}"
success "Python environment ready"
}

Expand Down
Loading
Loading