# syntax=docker/dockerfile:1 # Control-layer image: the `handler` CLI (the write side — spawn/list/kill agents, # approve/reject branches, forge-init, and the CI poller). It shares the package, the # database, and the /var/lib/handler data volume with the API image (see Dockerfile), # but runs the control process instead of uvicorn. # ---- forge build stage: compile the git-forge CLI (git-pkgs/forge, Go) ---- # Built here and copied into the runtime image as a single static binary, so the runtime # stage needs no Go toolchain. `forge` gives the CI poller its cross-forge `ci list`. # `golang:1` tracks the latest stable Go (forge >= v0.6.0 needs Go 1.26+); GOTOOLCHAIN=auto # lets `go install` fetch an even newer toolchain if a future forge release requires one. FROM golang:1-bookworm AS forge-builder ENV CGO_ENABLED=0 \ GOTOOLCHAIN=auto RUN go install github.com/git-pkgs/forge/cmd/forge@latest # ---- build stage: install the package + deps into an isolated venv ---- FROM python:3.11-slim AS builder ENV PIP_DISABLE_PIP_VERSION_CHECK=1 \ PIP_NO_CACHE_DIR=1 RUN python -m venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" WORKDIR /build COPY pyproject.toml README.md ./ COPY src ./src RUN pip install . # ---- runtime stage ---- FROM python:3.11-slim # Every executable the control layer shells out to is now bundled — no bring-your-own # binaries — so the container can spawn live agents, run the verification gate, resolve CI, # and drive the claude web-login flow out of the box: # git / openssh-client — clone/push over https + ssh remotes # tmux — one detached session per agent (and per login attempt) # node + claude — the Claude Code CLI the agents *are*, and the /login flow the # dashboard drives (see control/login.py) # mise — the per-project task runner the test/build gates invoke # forge — the cross-forge CLI the CI poller reads run status from # Node comes from NodeSource (>=18 is required by Claude Code); mise from its official apt # repo; forge from the build stage above. Installed under /usr/{bin,local/bin} — outside the # /var/lib/handler VOLUME — so the volume mount never masks them at runtime. The image is # built for amd64 and arm64: NodeSource + forge detect the arch, and the mise apt source is # pinned to `dpkg --print-architecture` (the image's own arch) so the arm64 build pulls the # arm64 package, not an amd64 one. RUN apt-get update \ && apt-get install -y --no-install-recommends \ git tmux openssh-client curl ca-certificates gnupg \ && install -dm 755 /etc/apt/keyrings \ && curl -fsSL https://deb.nodesource.com/setup_20.x | bash - \ && apt-get install -y --no-install-recommends nodejs \ && npm install -g @anthropic-ai/claude-code \ && npm cache clean --force \ && curl -fsSL https://mise.jdx.dev/gpg-key.pub \ | gpg --dearmor -o /etc/apt/keyrings/mise-archive-keyring.gpg \ && echo "deb [signed-by=/etc/apt/keyrings/mise-archive-keyring.gpg arch=$(dpkg --print-architecture)] https://mise.jdx.dev/deb stable main" \ > /etc/apt/sources.list.d/mise.list \ && apt-get update \ && apt-get install -y --no-install-recommends mise \ && rm -rf /var/lib/apt/lists/* # The cross-forge CLI compiled in the build stage above (github.com/git-pkgs/forge). COPY --from=forge-builder /go/bin/forge /usr/local/bin/forge ENV PATH="/opt/venv/bin:$PATH" \ PYTHONUNBUFFERED=1 \ # SQLite fallback lives on the /var/lib/handler volume; point DATABASE_URL at the # same Postgres the API uses for a shared, real deploy (see docker-compose.yml). DATABASE_URL="sqlite:////var/lib/handler/handler.db" \ PROJECTS_ROOT="/var/lib/handler/projects" COPY --from=builder /opt/venv /opt/venv # Ship alembic alongside the package so the image can migrate standalone if asked # (RUN_MIGRATIONS=true). In the compose stack the API owns migrations and the control # service runs with RUN_MIGRATIONS=false to avoid a startup race. WORKDIR /app COPY alembic.ini ./ COPY src/handler/migrations ./src/handler/migrations COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh RUN useradd --system --home-dir /var/lib/handler --create-home handler \ && mkdir -p /var/lib/handler/projects \ && chown -R handler:handler /var/lib/handler \ && chmod +x /usr/local/bin/docker-entrypoint.sh USER handler VOLUME /var/lib/handler # Liveness: exercises the CLI end-to-end and confirms the database is reachable. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ CMD handler list >/dev/null 2>&1 || exit 1 # Default to the worker: it drains the control-command queue the API enqueues # (spawn/kill/resume/approve/…) and sweeps CI on an interval (subsuming `poll-ci --watch`). # Override for one-shot control operations, e.g. `docker compose run --rm control handler list`. ENTRYPOINT ["docker-entrypoint.sh"] CMD ["handler", "worker"]