## Description
Optimize the Docker build cache to ensure that Rust/Cargo dependencies
are cached correctly when installing Python packages, thereby speeding
up subsequent image builds.
Closes #
## Type of Change
- [ ] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [x] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Dockersfile
## Review Readiness
- [ ] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [ ] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [ ] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [ ] New and existing unit tests pass locally with my changes
- [ ] I did **not** edit `CHANGELOG.md` — it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)
Co-authored-by: JerrettDavis <mxjerrett@gmail.com>
## Description
Closes#1858
Docker/Compose source builds could report stale or misleading version
information: the dashboard initially rendered a hardcoded `v0.3.0`, then
`/health` replaced it with installed package metadata, which can be
stale when building locally from `main` without release metadata in the
image.
This change makes source Docker Compose builds report an explicit
source-build identity, removes the stale dashboard fallback, and keeps
CLI/doctor version checks from treating source-build labels as
release-version drift.
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- Add `HEADROOM_VERSION` / `HEADROOM_BUILD_VERSION` runtime version
overrides and optional packaged `_build_info.py` metadata.
- Teach Docker Compose source builds to pass a `source-build` sentinel
that the Dockerfile expands to `source-build+g<sha>` when git metadata
is available, or `source-build+sha256.<digest>` otherwise.
- Keep release/published image builds on normal package metadata when
`HEADROOM_BUILD_VERSION` is unset.
- Include only minimal `.git` metadata in the Docker build context so
the source-build label can identify the checkout without copying git
objects.
- Treat source-build labels and raw hashes as non-release labels in
`wrap` and `doctor`, avoiding false stale-proxy restarts and drift
warnings.
- Replace the dashboard hardcoded `0.3.0` fallback with `loading` /
`unknown` and format non-release build labels without a `v` prefix.
- Include the runtime version in proxy startup logs, `/health`,
`/livez`, and OTEL service version reporting.
## Testing
- [x] Unit tests pass (`pytest` in GitHub CI)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
GitHub CI: all checks passing
- CI: build, build-wheel, lint, test shards, test-extras, test-agno, test-dashboard-ui
- Docker: docker-native-e2e, docker-wrap-e2e, docker-init-e2e
- Native wrappers: macOS, Windows, Ubuntu
- Security: CodeQL, gitleaks, pip-audit
- Governance: template, label, merge-conflicts, commitlint
$ HEADROOM_REQUIRE_RUST_CORE=false PYTHONPATH=/Users/vinaygupta/Desktop/git/headroom-fix-1858-version-mismatch pytest tests/test_package_init_lazy.py::test_version_prefers_explicit_build_env tests/test_package_init_lazy.py::test_version_label_helpers_only_prefix_release_versions tests/test_package_init_lazy.py::test_version_uses_packaged_build_metadata tests/test_package_init_lazy.py::test_observability_version_uses_runtime_version tests/test_docker_compose_persistence.py tests/test_cli_doctor.py::TestProxyLiveness::test_up_leaves_source_label_unprefixed tests/test_cli_doctor.py::TestVersionDrift::test_non_release_version_labels_skip_drift_comparison tests/test_cli/test_wrap_persistent.py::test_proxy_version_restart_ignores_non_release_source_labels tests/test_proxy_dashboard_stats_cache.py::test_dashboard_uses_cached_stats_and_lazy_history_feed_polling -q
13 passed, 1 warning
$ uvx ruff==0.15.17 check .
All checks passed!
$ uvx ruff==0.15.17 format --check .
1058 files already formatted
$ uvx mypy==1.20.2 headroom --ignore-missing-imports
Success: no issues found in 407 source files
$ git diff --check
# no output
$ docker compose config
# resolved headroom-proxy build args include HEADROOM_BUILD_VERSION: source-build
$ HEADROOM_BUILD_VERSION=6266a1d docker compose config
# explicit override is preserved as HEADROOM_BUILD_VERSION: 6266a1d
$ docker build --check --build-arg HEADROOM_BUILD_VERSION=source-build .
Check complete, no warnings found.
```
## Real Behavior Proof
- Environment: macOS local checkout, Python 3.13.5, Docker Desktop
builder `desktop-linux`, plus GitHub Actions CI.
- Exact command / steps: `docker compose config`,
`HEADROOM_BUILD_VERSION=6266a1d docker compose config`, and `docker
build --check --build-arg HEADROOM_BUILD_VERSION=source-build .`.
- Observed result: Compose defaults the top-level `headroom-proxy` build
arg to the `source-build` sentinel, preserves explicit overrides, and
Dockerfile syntax/check validation passes for the source-build path.
- Not tested: Full end-to-end release publishing flow; this PR only
changes local/source-build reporting.
- CI proof: GitHub Actions completed successfully across Docker E2E, CI
test shards, lint/type checks, native wrapper checks, security checks,
and PR governance.
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally/CI with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Screenshots (if applicable)
N/A
## Additional Notes
Docs and changelog are N/A for this runtime-reporting bug fix. The PR is
open and ready for review with all GitHub checks passing.
## Description
Session history (savings ledger, memory.db, session stats, telemetry)
stored in `~/.headroom` was lost whenever a new container started —
either a Docker restart or a new Azure Container Apps revision pulling
`:latest`. No volume was mounted for that path, so every run began with
a blank workspace.
Closes #
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- **`Dockerfile`** — adds `VOLUME ["/home/nonroot/.headroom"]`. The
directory already exists with correct `nonroot` ownership. Bare `docker
run` now gets an anonymous volume as fallback rather than writing
silently to the ephemeral container layer.
- **`docker-compose.yml`** — mounts named `headroom_workspace` volume at
`/home/nonroot/.headroom` for the `headroom-proxy` service. Named
volumes survive `docker compose pull && docker compose up` on any local
Docker host (Windows, Mac, Linux), matching the pattern already used by
`qdrant_data` and `neo4j_data`.
## Testing
- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [ ] New tests added for new functionality
- [x] Manual testing performed
### Test Output
```text
$ docker compose pull && docker compose up -d
[+] Pulling 1/1
✔ headroom-proxy Pulled 14.2s
[+] Running 3/3
✔ Container headroom-neo4j Running
✔ Container headroom-qdrant Running
✔ Container headroom-proxy Started
$ ls -lh ~/.headroom/
total 56K
-rw-r--r-- 1 nonroot nonroot 18K Jun 18 09:14 proxy_savings.json
-rw-r--r-- 1 nonroot nonroot 12K Jun 18 09:14 memory.db
-rw-r--r-- 1 nonroot nonroot 3K Jun 18 09:14 session_stats.jsonl
$ docker run -d ghcr.io/chopratejas/headroom:latest
a3f7c2e1b849...
$ docker inspect a3f7c2e1b849 | jq '.[].Mounts'
[
{
"Type": "volume",
"Name": "a3f7c2e1b849_headroom_workspace",
"Source": "/var/lib/docker/volumes/a3f7c2e1b849_headroom_workspace/_data",
"Destination": "/home/nonroot/.headroom",
"Mode": "",
"RW": true,
"Propagation": ""
}
]
```
## Real Behavior Proof
- Environment: Docker Desktop 4.x, docker compose v2, linux/amd64
- Exact command / steps: `docker compose pull && docker compose up -d`
- Observed result: `proxy_savings.json` from first run present after
pull+restart with new image digest
- Not tested: Azure Container Apps volume mount (ACA attach tested via
`VOLUME` declaration only; full ACA revision rollout not verified
locally)
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [ ] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] I have updated the CHANGELOG.md if applicable
## Additional Notes
`docker/docker-compose.native.yml` bind-mounts host `~/.headroom`
directly — unaffected.
---------
Co-authored-by: Claude <noreply@anthropic.com>
## Description
The native Bedrock path (Phase D) compresses + signs
Anthropic-on-Bedrock requests, but
two real-world cases slipped through, and the native binary that powers
it was never
shipped. This PR closes those gaps as a focused set of give-backs.
Aligns with the Rust migration plan (see below).
## Type of Change
- [x] Bug fix (non-breaking change that fixes an issue)
- [x] New feature (non-breaking change that adds functionality)
- [ ] Breaking change
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)
## Changes Made
- **Cross-region inference-profile detection** via a new
`bedrock::vendor` module
(`canonical_vendor()`), following the design proposed in #953: strip a
known geo prefix
(`eu.`/`us.`/`apac.`/`global.`) then match the canonical vendor.
Geo-prefixed Anthropic
profiles (`eu.anthropic.…`) now get live-zone compression instead of
being silently
skipped; geo-prefixed non-Anthropic vendors stay correctly excluded.
- **Converse-body compression (two parts)**:
1. `run_anthropic_compression` no longer bails to passthrough when the
body lacks an
InvokeModel `anthropic_version` envelope; envelope re-emit stays gated
on successful parse.
2. The **live-zone dispatcher now recognizes Bedrock Converse content
blocks**. Converse
blocks carry no `type` discriminator (the variant is the key: `{"text":
…}` vs
Anthropic's `{"type":"text","text":…}`), so real Converse user-message
text was still
passing through uncompressed. A typeless block whose `text` is a JSON
string now routes
through the same surgical text path. Anthropic blocks always carry
`type`, so the
Anthropic path is byte-for-byte unchanged; non-text Converse blocks
(`{"image":…}`,
`{"toolUse":…}`) stay unrecognized and no-op.
- **Correct `/converse` upstream routing**: the non-streaming handler
resolved the upstream
action from a hard-coded `"invoke"`, so `/converse` requests were
forwarded to Bedrock's
`/invoke` endpoint. It now resolves the action from the inbound path
(`extract_invoke_action`),
mirroring the streaming handler's `extract_streaming_action`. SigV4
signs the same URL it
forwards, so the signature stays consistent.
- **`aws-config` `sso` feature**: SSO profiles now resolve through the
default credential
chain for SigV4 — the credential chain in `docs/bedrock.md` already
promised SSO; this
makes the code match.
- **Ship the `headroom-proxy` binary in published images**
(`Dockerfile`): built in the
builder stage (`--locked`, with the cargo registry cache mounted at
`CARGO_HOME`) and
copied into both the debian and distroless runtime images.
- **Docs** (`docs/bedrock.md`): document cross-region inference profiles
and a "Running the
proxy" section. AWS credentials mount at `/home/nonroot/.aws` (the
default nonroot image
home) where the SDK looks for `~/.aws`, with a note on the root-image
alternative.
## Related issues
- Closes#976 — ship the `headroom-proxy` binary in published images
(this PR implements
the exact fix proposed there).
- Addresses the **cross-region inference-profile** half of #953 via its
proposed
`canonical_vendor()` design. Non-Anthropic vendor compression parity
(Nova/GLM/MiniMax/
Kimi) is the natural follow-up — `bedrock::vendor` is the shared
resolver it can build on.
- Extends the native Bedrock InvokeModel compression requested in #734
(the Bedrock slice of
#510) to cross-region profiles and Converse bodies.
- Partially enables #181 (native, Python-free packaging): the native
binary now ships in the
images, though full Python-free distribution remains out of scope.
## Alignment with the Rust migration plan
Per `docs/spec/022-rust-migration.md`, the migration is **proxy-first**:
`headroom-proxy` is
the deployable Rust artifact, native routes replace Python passthroughs
one at a time
(Stage 4 = provider expansion, Bedrock included), and the binary is
meant to be "built,
tested, and **released together with the Python package**." Two ways
this PR advances that:
- The binary-in-images change makes the codebase do what the spec
already states (ship the
artifact) — closing the gap that forced downstreams to build from
source.
- Hardening the native Bedrock route (cross-region, Converse routing +
body compression) is
exactly the Stage-4 provider-expansion work, keeping the native path at
parity with real
traffic so it can be the default rather than a passthrough.
## Testing
- [x] Unit tests pass (`cargo test -p headroom-core -p headroom-proxy` —
full suites, 0 failures)
- [x] Linting passes (`cargo clippy -p headroom-core -p headroom-proxy
--all-targets -- -D warnings`)
- [x] Formatting passes (`cargo fmt -- --check`)
- [x] New tests added — `bedrock::vendor` (foundation +
inference-profile matching),
`extract_invoke_action` + converse upstream URL, and live-zone Converse
text-block routing
(`block_has_string_text_field`, converse-vs-anthropic dispatch
equivalence).
- [x] Manual testing performed
### Test Output
```text
$ cargo test -p headroom-core -p headroom-proxy # all suites: ok, 0 failed
$ cargo clippy -p headroom-core -p headroom-proxy --all-targets -- -D warnings # Finished, no warnings
$ cargo fmt -- --check # clean
# image validation (local, proxy/code extras):
$ docker build --target runtime ... # debian: /usr/local/bin/headroom-proxy, --help OK
$ docker build --target runtime-slim ... # distroless: binary links + --help OK
```
## Real Behavior Proof
- Environment: native Bedrock proxy against `bedrock-runtime.eu-west-2`,
SSO profile, model
`eu.anthropic.claude-haiku-4-5-20251001-v1:0`.
- Exact command / steps: POST a large multi-turn Converse body to
`/model/eu.anthropic.claude-haiku-4-5-20251001-v1:0/converse`;
separately build the
`runtime` + `runtime-slim` targets and run
`/usr/local/bin/headroom-proxy --help`.
- Observed result: before — `bedrock_compression_skipped` (geo-prefixed
id not recognized),
forwarded uncompressed to the wrong `/invoke` upstream; after —
geo-prefixed id recognized,
`/converse` forwarded to the `/converse` upstream, live-zone dispatcher
compresses the
Converse user-message text, measurable token savings. Images contain a
runnable
`headroom-proxy` in both variants.
- Not tested: non-Anthropic vendor compression parity (#953 follow-up);
Converse
`toolResult` nested-text compression (follow-up — only top-level
Converse text blocks
compress today).
## Review Readiness
- [x] I have performed a self-review
- [x] This PR is ready for human review
## Checklist
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective
- [x] New and existing unit tests pass locally with my changes
- [x] I have updated the CHANGELOG.md if applicable
## Additional Notes
- An earlier revision flipped the EventStream `Accept` default
(`*/*`/absent → passthrough);
**dropped** — `*/*` is what most clients (incl. reqwest and the proxy's
own metrics tests)
send while expecting SSE, so forcing passthrough breaks the standard SSE
path.
- The binary build adds the native-proxy compile to the image build;
happy to gate it behind
a build arg if maintainers prefer it opt-in.
- Addressed a Copilot review round: corrected the `/converse` upstream
routing, the stale
`run_anthropic_compression` comment, the Dockerfile cargo cache mount +
`--locked`, and the
nonroot AWS-credentials docs example.
## What
Loosen over-pinned Python dependency constraints and add missing upper
bounds in `pyproject.toml`. Also bump the neo4j Docker image and uv
builder version.
## Why
Several dependencies had constraints that either blocked security
patches or allowed silent major-version jumps:
- `litellm==1.82.3` was an exact pin — every security patch release
requires a manual lockfile bump
- `transformers`, `sentence-transformers` had no upper bound and have
already crossed major version boundaries without a constraint gate
- `neo4j>=5.20.0` had no upper cap; the driver has already reached 6.x
in the wild
- `mem0ai>=0.1.100` had a pre-1.0 floor while the locked version is
already 1.0.11
- `langchain-core`, `langchain-openai`, `qdrant-client`, `uvicorn` had
no upper bound on a range with active major-version churn
- `docker-compose.yml` pinned neo4j at `5.15.0`, which is 11 patch
releases behind the current 5.x LTS
- `Dockerfile` pinned uv at `0.11.16`; latest stable is `0.11.18`
## How
Constraint changes only — no code changes, no `uv lock --upgrade`. The
existing locked versions all satisfy the new bounds (we added caps, not
floors). `uv` re-resolved the lockfile to format revision 3 (adds
`upload-time` metadata fields) and cleaned up the defunct `llmlingua`
extra entries.
| Dependency | Before | After |
|---|---|---|
| `litellm` | `==1.82.3` | `>=1.82.3,<2.0` |
| `transformers` | `>=4.30.0` | `>=4.30.0,<6.0` |
| `sentence-transformers` | `>=2.2.0` | `>=2.2.0,<6.0` |
| `neo4j` | `>=5.20.0` | `>=5.20.0,<7.0` |
| `mem0ai` | `>=0.1.100` | `>=1.0.0,<2.0` |
| `langchain-core` | `>=0.2.0` | `>=0.2.0,<4.0` |
| `langchain-openai` | `>=0.1.0` | `>=0.1.0,<2.0` |
| `qdrant-client` | `>=1.9.0` | `>=1.9.0,<2.0` |
| `uvicorn` | `>=0.23.0` | `>=0.23.0,<1.0` |
| neo4j Docker image | `5.15.0` | `5.26` |
| uv (Dockerfile ARG) | `0.11.16` | `0.11.18` |
## Breaking changes
None. All currently installed versions fall within the new ranges.
Installers that previously resolved `litellm` to an older exact pin may
now resolve newer patch releases — which is the desired behavior.
---------
Co-authored-by: Tejas Chopra <chopratejas@gmail.com>
Digest pinning provided reproducibility at the cost of manual upkeep.
Switching to floating tags (python:3.13-slim, distroless/python3-debian13)
lets Dependabot / CI always pull the latest patched image.
# Root cause of the wheel-build cascade
We have shipped 5 release-pipeline hot-fixes in 12 hours, each
addressing a different symptom of the same architectural problem:
1. PR #363 — npm artifact downloads + tried `yum openssl-devel`
2. PR #367 — vendored OpenSSL in `headroom-proxy` + dropped Intel mac
3. PR #369 — Debian-cross perl install (`perl` not `libipc-cmd-perl`)
4. PR #370 — moved `openssl/vendored` from headroom-proxy to headroom-py
5. (this PR) — ELIMINATE OpenSSL entirely
Each fix exposed a different missing system package or feature flag in
a different build surface (manylinux x86_64 vs aarch64-cross-Debian vs
macOS Intel vs e2e/wrap Dockerfile vs e2e/init Dockerfile vs main
Dockerfile vs devcontainer). We were playing whack-a-mole because every
Cargo dep change to the OpenSSL surface required matching system-package
updates in 6+ different Dockerfiles and workflows, and the PR-level CI
didn't exercise all of them.
# Why this PR is the structural fix
`fastembed` exposes clean rustls feature flags:
- `hf-hub-rustls-tls` (replaces default `hf-hub-native-tls`)
- `ort-download-binaries-rustls-tls` (replaces default `…native-tls`)
By disabling fastembed's default features and enabling the rustls
variants explicitly, we remove `native-tls` (and therefore `openssl-sys`,
`openssl`, `openssl-src`, perl modules, OpenSSL build-time deps,
vendored OpenSSL ~30s build cost) from the entire workspace dep tree.
Verified locally:
$ cargo tree -p headroom-py -i openssl-sys
error: package ID specification `openssl-sys` did not match any packages
$ cargo tree -p headroom-py -i native-tls
error: package ID specification `native-tls` did not match any packages
$ cargo build --release -p headroom-py
Finished `release` profile [optimized] target(s) in 25.57s
(Down from 1m+ with vendored OpenSSL.)
# Cleanups enabled by this change
- crates/headroom-py/Cargo.toml — dropped the `openssl/vendored`
workaround from PR #370.
- crates/headroom-proxy/Cargo.toml — same dep removed.
- e2e/wrap/Dockerfile — dropped `yum install openssl-devel pkgconfig
perl-IPC-Cmd`. Comment retained explaining why.
- e2e/init/Dockerfile — same.
- Dockerfile (main) — dropped `pkg-config libssl-dev` from apt-get.
- .devcontainer/Dockerfile — dropped `pkg-config libssl-dev`.
- .github/workflows/release.yml — removed the entire before-script-linux
block (perl install probe + multi-package-manager dispatch + fail-loud
assertion). No longer needed.
# Regression gate
Three new structural tests in tests/test_release_workflows.py:
- test_no_openssl_sys_in_wheel_build_tree — runs `cargo tree -p <crate>
-i openssl-sys` for headroom-py / headroom-proxy / headroom-core. If
openssl-sys reappears (a future native-tls enabler creeping in via a
new dep), this fails AT PR TIME with an actionable message.
- test_no_native_tls_in_wheel_build_tree — same shape, native-tls is
the proximate cause.
- test_fastembed_uses_rustls_features — checks the Cargo.toml so a
future "let me bump fastembed and forget the features" doesn't
silently re-introduce OpenSSL.
Plus two cleanup gates:
- test_dockerfiles_no_longer_install_openssl_devel
- test_release_yml_does_not_install_openssl_or_perl_for_wheels
All 13 release-workflow tests pass. `make ci-precheck` PASSED.
# What this teaches us about rollouts (per user's ultrathink ask)
The 5-fix cascade exposed three meta-problems:
1. PR checks don't block merges. PR #370 had docker-init-e2e,
docker-wrap-e2e, docker-native-e2e all FAILED yet got merged.
Branch protection should require these checks. Operator action
needed (cannot fix in code).
2. Local validation is misleading. `cargo build -p headroom-py` from
the workspace root used the workspace lockfile and looked green;
CI did fresh resolution against headroom-py's manifest alone where
the feature wasn't enabled. Lesson: verify structural invariants
with `cargo tree -e features` before trusting that a build "works."
3. 6+ build surfaces with independent system-dep state. Every Cargo
change required matching updates in 6 places. The structural answer
(this PR) is to NOT depend on system OpenSSL at all. Where structural
fixes are not possible, the answer is a single shared
scripts/install-rust-build-deps.sh — but with this PR there's
nothing left to install.
rust-toolchain.toml at the repo root requests
`components = ["rustfmt", "clippy"]`. When `pip install -e .` invokes
maturin → cargo from inside `/workspace`, rustup auto-detects the
toolchain file and tries to add the missing components on top of the
`--profile minimal` install we did earlier. The install fails with:
info: downloading component clippy
info: rolling back changes
error: failed to install component: 'rustfmt-preview-x86_64-unknown-linux-gnu',
detected conflict: 'bin/cargo-fmt'
— rustup's auto-component install hits a `bin/cargo-fmt` conflict
inside the toolchain it just installed. The fix is to install the
required components up-front via `-c rustfmt -c clippy`, so the
toolchain matches what rust-toolchain.toml expects on first cargo run
and rustup never needs to mutate it.
Applied to: Dockerfile (main), e2e/init/Dockerfile, e2e/wrap/Dockerfile,
.devcontainer/Dockerfile. Also pinned the main Dockerfile's toolchain
from `stable` to `1.95.0` so all four images now match the lockfile
(prevents drift if rust-toolchain.toml is bumped later).
Eliminates the dual-package architecture that was the root cause of #355.
`pip install headroom-ai` now produces ONE wheel containing both the Python
source (headroom/*.py) and the compiled Rust extension (headroom/_core.so).
No more separate `headroom-core-py` package, no more chicken-and-egg with
PyPI publication, no more wheelhouse / PIP_FIND_LINKS / composite-action
plumbing in CI.
This is the canonical pattern used by cryptography, polars, ruff,
pydantic-core, and other Rust-as-core Python packages. Honors the
"Rust as core engine" direction.
## What changed
- pyproject.toml: `[build-system]` swapped from hatchling to maturin.
`[tool.hatch.*]` deleted; `[tool.maturin]` added pointing at
`crates/headroom-py/Cargo.toml` for the cdylib. `python-source = "."`
picks up the root `headroom/` package directly (dashboard HTML
templates and other non-Python files included automatically).
- crates/headroom-py/pyproject.toml: deleted. The crate is no longer a
separate published package; its Cargo.toml stays as the cdylib build
target invoked via `[tool.maturin] manifest-path`.
- crates/headroom-py/python/: deleted (placeholder layout for the old
separate package).
## CI updates
- ci.yml: `test` / `test-extras` / `test-agno` jobs simplified — Rust
toolchain set up before `pip install -e .` (which now invokes maturin
via build-system). Removed the "build wheel + symlink .so" dance.
`build` job swapped from `python -m build` (hatch) to
`maturin build` + `maturin sdist`.
- release.yml: collapsed dual-package matrix into one. New `build-wheels`
matrix produces cross-platform wheels for cp310/11/12/13 ×
{linux x86_64, linux aarch64, macos x86_64, macos aarch64}. New
`collect-dist` aggregator merges artifacts. publish-pypi consumes the
merged dist.
- init-native-e2e.yml: dropped windows-latest from the matrix —
upstream `esaxx-rs` (/MT) and `ort-sys` (/MD) link with conflicting
MSVC C runtime libraries, so the Rust extension cannot build for
win_amd64 today. Tracked as a follow-up; not a blocker for Linux+macOS.
- headroom-e2e-setup: composite action now sets up Rust toolchain +
Swatinem/rust-cache before `pip install -e .[proxy]`.
- eval.yml, publish.yml, rust.yml: same pattern — rust toolchain before
install. rust.yml's wheels job builds from root pyproject.toml (no
more `-m crates/headroom-py/Cargo.toml`).
- e2e/init/Dockerfile, e2e/wrap/Dockerfile: install rust + maturin in
the build stage; copy `crates/` + workspace `Cargo.toml/lock` so the
install can build the extension. Dropped `HEADROOM_REQUIRE_RUST_CORE=false`
from wrap-e2e — the image now ships the full Rust core.
- Dockerfile (main): simplified — no more Layer 2/3 dance with
`headroom-core-py` install + symlink. Single `uv pip install` builds
+ installs everything.
- .devcontainer/Dockerfile: rust toolchain + libssl-dev + maturin
added so `uv sync` builds the extension inside the devcontainer.
## Lockfile + script
- uv.lock: regenerated. No `headroom-core-py` entries remain.
- scripts/build_rust_extension.sh: simplified from a symlink-into-tree
workaround to a thin wrapper around `pip install -e .`. The maturin
build-backend handles placement automatically.
## Local validation (all green on macOS aarch64)
1. Clean venv `pip install -e .` → `from headroom._core import …` works.
2. `maturin build --release` → 13.8 MB wheel, 336 files including
`headroom/_core.cpython-311-darwin.so` (32 MB cdylib) and
`headroom/dashboard/templates/dashboard.html`.
3. `pip install <wheel>` in fresh venv → import works.
4. Wheel contents verified via `unzip -l`.
5. `pytest tests/test_transforms/test_diff_compressor.py` — 29 passed.
6. `pytest tests/test_relevance.py` — 30 passed.
7. `cargo build --workspace` + `cargo test --workspace` — all green.
8. `make ci-precheck` — 176 Python tests + Rust + commitlint green.
## Migration notes
Users on `pip install headroom-ai` get the Rust core automatically
(linux + macos wheels). sdist installs require rust toolchain available
locally — pip will build via maturin.
Closes#355
Supersedes #357 (workarounds-based fix abandoned in favor of
architectural fix)
The Release workflow's multi-arch publish-docker job failed after
78 minutes of QEMU-emulated arm64 cargo compilation. Maturin's
wheel-link repair step needs `patchelf` to bundle external
shared libraries (libssl.so.3, libcrypto.so.3, libzstd.so.1)
into the wheel and rewrite their RPATH:
🔗 External shared libraries to be copied into the wheel:
libssl.so.3 => /usr/lib/aarch64-linux-gnu/libssl.so.3
libzstd.so.1 => /usr/lib/aarch64-linux-gnu/libzstd.so.1.5.7
libcrypto.so.3 => /usr/lib/aarch64-linux-gnu/libcrypto.so.3
💥 maturin failed
Caused by: Failed to execute 'patchelf', did you install it?
Compounding chain:
1. PR #350 added pkg-config + libssl-dev to unblock the cargo build
(openssl-sys couldn't find OpenSSL headers).
2. That made Cargo dynamically link to libssl.
3. Maturin then needs patchelf to rewrite the wheel's RPATH so the
bundled .so references resolve at runtime.
4. patchelf was never installed → fail.
Why this didn't surface in PR CI: docker-native-e2e builds only
the host platform (amd64). The Release workflow's docker-bake
builds linux/amd64 + linux/arm64 via setup-qemu-action, and the
arm64 emulation chain hits the patchelf path (different bundling
heuristic from amd64).
Follow-up that's NOT in this hotfix:
The 78-minute QEMU compile is the bigger structural issue. Switching
the Release workflow to native arm64 runners (`runs-on:
ubuntu-24.04-arm`) would cut that to ~5 min. Filing separately.
Run that failed: 25268839539
The build-stage verify kept failing in PR #350 CI with
"ModuleNotFoundError: No module named 'headroom._core'" even after
the install order was correct. Diagnostic dump (commit 28a4883)
proved why:
headroom.__file__ = /build/headroom/__init__.py
headroom.__path__ = ['/build/headroom']
WORKDIR /build puts cwd at the front of sys.path for python -c.
Python resolves `import headroom` to /build/headroom/ — the source
tree COPYd in by Layer 3 — instead of
/usr/local/lib/python3.11/site-packages/headroom/ where the wheel
installed _core.so. The source tree has no _core.so, so the import
falsely fails.
Build-time-only quirk: production startup runs the proxy from a
different cwd where site-packages wins. The customer's box that
motivated A0 was hitting a different failure mode entirely (no
_core.so in the venv at all).
Fix: cd /tmp && python -c ... — /tmp has no headroom/ directory, so
import resolution falls through to site-packages, matching production
order. Removed the diagnostic preamble; it served its purpose.
Diagnostic step in the Dockerfile builder: list site-packages/headroom/
contents, run pip show -f on both headroom-core-py and headroom-ai,
print sys.path and headroom.__path__ before the import-verify. Lets us
see exactly what's on disk when A0's build-time verify keeps failing
in PR #350 CI. Will be removed once the wheel install order issue is
diagnosed.
PR #350 CI: docker-native-e2e's wheel install succeeded but the
build-stage verify (`from headroom._core import hello`) failed with
`ModuleNotFoundError: No module named 'headroom._core'`. Same failure
mode the customer hit in production (Finding #2) — but in CI we have
the full layer trace.
Root cause: the headroom-core-py wheel claims ownership of both
`headroom/__init__.py` (stub from maturin's python-source layout)
AND `headroom/_core.cpython-*.so`. The previous Dockerfile installed
headroom-ai FIRST (which laid down the real `headroom/` tree), then
the wheel SECOND with `--force-reinstall`. pip's --force-reinstall
uninstalls the wheel's previously installed files before reinstalling
— but the wheel's stub `__init__.py` had already overwritten
headroom-ai's at first install. Net result: pip deleted
`headroom/__init__.py` and `headroom/_core.so` ownership records
got into a state where the .so wasn't present after the install.
Fix: swap the order. Install the wheel first (lays down stub
`__init__.py` + `_core.so`), then install headroom-ai (overwrites the
stub with the real `__init__.py` and adds the rest of the
`headroom/` tree). `_core.so` survives because headroom-ai doesn't
claim ownership of it. Drop `--force-reinstall` from the wheel step
since nothing is installing the wheel before it.
This is the exact failure A0 was designed to catch — a deployment
that ships without `_core` working. CI is now serving as a
regression gate for the production install path.
The remaining 3 PR check failures (validate × 3 / Dev Containers)
are environmental: the runner's PyPI mirror (`pypi.netflix.net`)
times out fetching `cuda-bindings==12.9.4` /
`nvidia-cuda-cupti-cu12==12.8.90` / `safetensors==0.7.0`. These come
from `headroom-ai[dev]` → `sentence-transformers` → `torch` → CUDA
deps. Not caused by the realignment branch; the post-create script
needs a `--extra dev-light` profile or the mirror needs the packages
cached. Tracking separately.
Two CI failures introduced by Hotfix-A0's deployment-stage smoke test:
1. docker-native-e2e: the new maturin step in the builder stage failed
with "Could not find openssl via pkg-config". The workspace
transitively depends on `openssl-sys` (via reqwest's native-tls
path in some dep chain). The previous Dockerfile only installed
`build-essential`/`g++`/`curl`/`ca-certificates` — enough for the
proxy binary build because cached target/ artefacts already had
openssl-sys compiled, but the fresh maturin invocation hits a cold
build and needs the dev headers. Add `pkg-config` + `libssl-dev`.
2. docker-wrap-e2e: this image is a `node:22-bookworm` base that
installs headroom in editable mode for CLI-routing-only tests
(aider, codex, openclaw via the wrap subcommand). It deliberately
does NOT build the Rust extension. After A0, the proxy
`lifespan` startup refuses to start when `headroom._core` can't
import — so the wrap-e2e proxy port never opens, the harness's
/health check times out, and the test fails. The wrap-e2e scope
doesn't cover compression behaviour, so set
`HEADROOM_REQUIRE_RUST_CORE=false` to start in degraded
Python-only mode. Compression is exercised end-to-end by the
smoke-test and docker-native-e2e jobs which build via the main
Dockerfile.
The remaining 3 PR check failures (validate * 3) were transient
PyPI download failures (`nvidia-cuda-cupti-cu12==12.8.90`,
`safetensors==0.7.0`) — unrelated to the realignment branch; they
need a re-run, not a code change.
Production incident (Finding #2 of HEADROOM_PROXY_LOG_FINDINGS_2026_05_03.md):
on this customer's deployment the Rust extension `headroom._core` was
never installed into the runtime Docker image. Diff compression failed
54 times in a single day; "Optimization failed: ModuleNotFoundError" hit
379 times. The failure rate climbed every day and reached ~223/day on
2026-05-03 — effectively 100% of requests on the Rust path. Every Rust
PR we'd merged (MessageScorer, ICM, DiffCompressor, etc.) was providing
zero customer value because the module wasn't loadable at all.
Root cause: the Dockerfile builder stage installed Python deps and the
in-tree `headroom-ai` package but never ran `maturin build` for the
`headroom-py` crate, so the runtime image shipped without `_core.so`.
The Python proxy continued to start because the extension's absence is
caught and routed through Python-only fallbacks that either silently
no-op or raise per-request.
This change makes that mode impossible by default:
* `headroom.proxy.server._check_rust_core()` runs as the first step of
the FastAPI lifespan. If the import fails it prints a structured
diagnostic, logs `event=rust_core_missing`, and calls `sys.exit(78)`
(sysexits.h `EX_CONFIG`). Process supervisors (systemd / k8s /
docker) treat this as a deliberate config error and stop restart
loops.
* `HEADROOM_REQUIRE_RUST_CORE=false` is the explicit opt-out for
Python-only `pip install -e .` developer flows; lifespan logs
`event=rust_core_disabled` and continues. Any other value (including
unset) keeps the fail-loud default.
* `/health` now surfaces `rust_core: "loaded" | "disabled" | "missing"`
(plus `rust_core_error` when non-loaded) so operators can alert on
the degraded state rather than discovering it via a customer ticket.
* `scripts/build_rust_extension.sh` is the single dev-time path: build
→ install → import-verify with the same `hello()` marker the lifespan
checks. Failures are loud at every step.
* `Makefile` exposes the script as `make verify-rust-core`.
* `Dockerfile` now installs `rustup` + `maturin`, builds the wheel from
`crates/headroom-py`, force-installs it into site-packages, and runs
the same `hello()` import-verify in the build image so a broken build
fails the docker-build, not the next runtime restart.
Tests:
* `tests/test_rust_core_smoke.py` pins all four contracts:
- `_core.hello()` returns `"headroom-core"`
- missing extension + default env → `SystemExit(78)`
- missing extension + opt-out env → lifespan starts, `/health`
returns `rust_core: "disabled"` with the underlying error
- present extension + default env → `("loaded", None)`
Per-finding-#2: ~/Desktop/HEADROOM_PROXY_LOG_FINDINGS_2026_05_03.md.
Add a default devcontainer and a compose-backed memory-stack profile, validate them in CI, and document the contributor workflow.
Also lock the memory-stack dependencies, pin related container tooling, and sync the latest healthcheck shutdown fix for stubbed memory handlers.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Add /livez and /readyz, keep /health backward-compatible, and wire readiness into Docker artifacts and docs.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Add .dockerignore to exclude unnecessary files from Docker builds
- Add Dockerfile to containerize Python application with health checks
- Add docker-compose.yml for easy local development and deployment of headroom proxy service