From 80ada87945af9ba55537a7dff04a2f2fece4fd2f Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Thu, 30 Apr 2026 11:30:50 +0200 Subject: [PATCH 1/4] feat(docker): Phase 4 reproducible experiment VM (Dockerfile + entrypoint + compose) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 4 of Epic #1565 — boots Wave + Claude Code + tea against any Codeberg/Gitea target repo, runs onboard-project on first contact, and exposes the webui on :8080. Files: - docker/Dockerfile.experiment — multi-stage: golang to build wave with webui_preview, debian to fetch tea, node:22-bookworm runtime with claude-code via npm. - docker/entrypoint.sh — bash (#!/usr/bin/env bash; no `set -o pipefail` per the dash-incompatibility hit on #1611). Idempotent clone, init, onboard, then exec wave serve. - docker/docker-compose.experiment.yml — single service, two named volumes (wave-work + wave-config) so OAuth/clone survive rebuilds. - docker/.env.experiment.example — required env template; the real .env.experiment is gitignored. Pending: 4.4 end-to-end smoke on a real Codeberg target. Will run after Anthropic API key + Codeberg token are wired in .env.experiment. --- .gitignore | 1 + docker/.env.experiment.example | 19 +++++ docker/Dockerfile.experiment | 71 +++++++++++++++++ docker/docker-compose.experiment.yml | 52 +++++++++++++ docker/entrypoint.sh | 111 +++++++++++++++++++++++++++ 5 files changed, 254 insertions(+) create mode 100644 docker/.env.experiment.example create mode 100644 docker/Dockerfile.experiment create mode 100644 docker/docker-compose.experiment.yml create mode 100755 docker/entrypoint.sh diff --git a/.gitignore b/.gitignore index 5a35310ca..29590634a 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ integration.test # Tailwind CLI binary cache (downloaded by `make tailwind`) tools/ +docker/.env.experiment diff --git a/docker/.env.experiment.example b/docker/.env.experiment.example new file mode 100644 index 000000000..f9bb17554 --- /dev/null +++ b/docker/.env.experiment.example @@ -0,0 +1,19 @@ +# Copy to .env.experiment and fill in. .env.experiment is gitignored. +# +# Target project on Codeberg/Gitea. +WAVE_PROJECT_HOST=codeberg.org +WAVE_PROJECT_OWNER=libretech +WAVE_PROJECT_REPO=wave-testing +# Token with at minimum: repo:read, issue:read+write. Generate at +# https://${WAVE_PROJECT_HOST}/-/user/settings/applications +WAVE_PROJECT_TOKEN= + +# Adapter + model defaults (per memory feedback_model_run_ladder). +WAVE_ADAPTER=claude +WAVE_MODEL=balanced + +# Anthropic API key for claude-code subprocesses. +ANTHROPIC_API_KEY= + +# Host port mapping. Change if 8080 is taken. +WAVE_HOST_PORT=8080 diff --git a/docker/Dockerfile.experiment b/docker/Dockerfile.experiment new file mode 100644 index 000000000..89350a80f --- /dev/null +++ b/docker/Dockerfile.experiment @@ -0,0 +1,71 @@ +# syntax=docker/dockerfile:1.7 + +# Reproducible Wave experiment VM — clones a target project via tea, runs +# wave init + onboard-project, exposes the webui on :8080. Used for the +# Phase 4 end-to-end smoke described in +# docs/scope/codecrispies-walkthrough.md. + +# ---------- Stage 1: build wave from source ---------- +FROM golang:1.22-bookworm AS wave-build + +WORKDIR /src +COPY go.mod go.sum ./ +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download + +COPY . . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=1 go build -tags=webui_preview -o /out/wave ./cmd/wave + +# ---------- Stage 2: tea CLI (gitea/forgejo client) ---------- +FROM debian:bookworm-slim AS tea-fetch +ARG TEA_VERSION=0.11.1 +ARG TEA_ARCH=amd64 +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates curl \ + && rm -rf /var/lib/apt/lists/* \ + && curl -fsSL "https://gitea.com/gitea/tea/releases/download/v${TEA_VERSION}/tea-${TEA_VERSION}-linux-${TEA_ARCH}" \ + -o /out/tea \ + && chmod +x /out/tea + +# ---------- Stage 3: runtime image ---------- +FROM node:22-bookworm-slim AS runtime + +ARG WAVE_USER=wave +ARG WAVE_UID=1000 + +ENV DEBIAN_FRONTEND=noninteractive \ + HOME=/home/${WAVE_USER} \ + WAVE_HOME=/home/${WAVE_USER} \ + PATH=/usr/local/bin:/usr/bin:/bin:/home/${WAVE_USER}/.local/bin + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + git \ + gh \ + curl \ + jq \ + sqlite3 \ + ca-certificates \ + bash \ + tini \ + && rm -rf /var/lib/apt/lists/* \ + && useradd --create-home --uid "${WAVE_UID}" --shell /bin/bash "${WAVE_USER}" + +# Claude Code CLI ships via npm. +RUN npm install -g @anthropic-ai/claude-code \ + && npm cache clean --force + +COPY --from=wave-build /out/wave /usr/local/bin/wave +COPY --from=tea-fetch /out/tea /usr/local/bin/tea + +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +USER ${WAVE_USER} +WORKDIR /work +EXPOSE 8080 + +# tini reaps orphaned subprocesses (claude-code, wave runs). +ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"] diff --git a/docker/docker-compose.experiment.yml b/docker/docker-compose.experiment.yml new file mode 100644 index 000000000..9039151c5 --- /dev/null +++ b/docker/docker-compose.experiment.yml @@ -0,0 +1,52 @@ +# Reproducible Wave experiment VM compose. Boots wave + claude-code + tea +# against a target repo and exposes the webui on :8080. +# +# Usage: +# cp docker/.env.experiment.example docker/.env.experiment +# $EDITOR docker/.env.experiment # set tokens, target host/owner/repo +# docker compose -f docker/docker-compose.experiment.yml \ +# --env-file docker/.env.experiment up --build +# +# After boot, browse http://localhost:8080 — the entry redirect goes to +# /onboard on first run (no sentinel) or /work afterwards. + +services: + wave: + build: + context: .. + dockerfile: docker/Dockerfile.experiment + image: wave-experiment:latest + container_name: wave-experiment + init: true + environment: + WAVE_PROJECT_HOST: "${WAVE_PROJECT_HOST}" + WAVE_PROJECT_OWNER: "${WAVE_PROJECT_OWNER}" + WAVE_PROJECT_REPO: "${WAVE_PROJECT_REPO}" + WAVE_PROJECT_TOKEN: "${WAVE_PROJECT_TOKEN}" + WAVE_ADAPTER: "${WAVE_ADAPTER:-claude}" + WAVE_MODEL: "${WAVE_MODEL:-balanced}" + WAVE_PORT: "8080" + # Anthropic API auth — claude-code reads this. Rotate via .env.experiment. + ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" + ports: + - "${WAVE_HOST_PORT:-8080}:8080" + volumes: + # /work survives container rebuilds; the project clone + wave.yaml + + # .agents/state.db live here. + - wave-work:/work + # claude-code OAuth + tea login state. Mounting a host dir here lets + # `claude /login` happen once and persist across `docker compose up`. + - wave-config:/home/wave/.config + healthcheck: + test: ["CMD-SHELL", "curl -fs http://localhost:8080/health || exit 1"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 60s + restart: unless-stopped + +volumes: + wave-work: + name: wave-experiment-work + wave-config: + name: wave-experiment-config diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100755 index 000000000..c523ae95d --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,111 @@ +#!/usr/bin/env bash +# Wave experiment VM entrypoint. Clones a target project via tea, runs +# wave init + onboard-project, then boots the webui. +# +# Required env: +# WAVE_PROJECT_HOST — e.g. codeberg.org +# WAVE_PROJECT_OWNER — e.g. libretech +# WAVE_PROJECT_REPO — e.g. wave-testing +# WAVE_PROJECT_TOKEN — token with read access (passes to tea) +# +# Optional env: +# WAVE_ADAPTER — claude (default) | opencode +# WAVE_MODEL — balanced (default) | opus | cheapest +# WAVE_PORT — 8080 (default) +# WAVE_SKIP_ONBOARD — set non-empty to skip onboard-project (manifest-only) +# +# Volume layout (compose mounts these): +# /work — project clone (target repo's working tree) +# /home/wave/.agents — Wave state + outputs (persists across container restarts) +# /home/wave/.config — claude-code auth + tea config + +set -e + +WAVE_PROJECT_HOST="${WAVE_PROJECT_HOST:-}" +WAVE_PROJECT_OWNER="${WAVE_PROJECT_OWNER:-}" +WAVE_PROJECT_REPO="${WAVE_PROJECT_REPO:-}" +WAVE_PROJECT_TOKEN="${WAVE_PROJECT_TOKEN:-}" +WAVE_ADAPTER="${WAVE_ADAPTER:-claude}" +WAVE_MODEL="${WAVE_MODEL:-balanced}" +WAVE_PORT="${WAVE_PORT:-8080}" + +log() { printf '[entrypoint %s] %s\n' "$(date -u +%H:%M:%S)" "$*" >&2; } + +require_env() { + for var in WAVE_PROJECT_HOST WAVE_PROJECT_OWNER WAVE_PROJECT_REPO WAVE_PROJECT_TOKEN; do + if [ -z "${!var}" ]; then + log "missing required env: $var" + exit 1 + fi + done +} + +# Idempotent: if /work/.git exists, skip clone — container restarts reuse the +# clone instead of fetching cold every time. +clone_project() { + if [ -d /work/.git ]; then + log "/work already a git tree; skipping clone" + return 0 + fi + log "configuring tea login for ${WAVE_PROJECT_HOST}" + tea login add \ + --name wave-vm \ + --url "https://${WAVE_PROJECT_HOST}" \ + --token "${WAVE_PROJECT_TOKEN}" \ + >/dev/null + + log "cloning ${WAVE_PROJECT_OWNER}/${WAVE_PROJECT_REPO}" + cd /work + git clone \ + "https://oauth2:${WAVE_PROJECT_TOKEN}@${WAVE_PROJECT_HOST}/${WAVE_PROJECT_OWNER}/${WAVE_PROJECT_REPO}.git" \ + . + + # Embed the token in remote so wave/tea can push later. Replaced on each + # boot so a rotated token takes effect without a fresh clone. + git remote set-url origin \ + "https://oauth2:${WAVE_PROJECT_TOKEN}@${WAVE_PROJECT_HOST}/${WAVE_PROJECT_OWNER}/${WAVE_PROJECT_REPO}.git" +} + +init_wave() { + cd /work + if [ -f wave.yaml ] && [ -d .agents ]; then + log "wave.yaml + .agents already present; skipping wave init" + return 0 + fi + log "running wave init --adapter ${WAVE_ADAPTER}" + wave init --adapter "${WAVE_ADAPTER}" >/dev/null +} + +run_onboard_project() { + if [ -n "${WAVE_SKIP_ONBOARD:-}" ]; then + log "WAVE_SKIP_ONBOARD set; skipping onboard-project" + return 0 + fi + if [ -f /work/.agents/.onboarding-done ]; then + log "onboarding sentinel present; skipping onboard-project" + return 0 + fi + log "running onboard-project (adapter=${WAVE_ADAPTER} model=${WAVE_MODEL})" + cd /work + wave run onboard-project \ + --adapter "${WAVE_ADAPTER}" \ + --model "${WAVE_MODEL}" \ + --auto-approve \ + --no-tui +} + +boot_webui() { + log "booting webui on :${WAVE_PORT}" + cd /work + exec wave serve --bind 0.0.0.0 --port "${WAVE_PORT}" +} + +main() { + require_env + clone_project + init_wave + run_onboard_project + boot_webui +} + +main "$@" From 513ea7e781fe3d3bed9d03504d9f7d60f73f1825 Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Thu, 30 Apr 2026 11:44:04 +0200 Subject: [PATCH 2/4] fix(docker): mkdir /out before tea fetch + map ANTHROPIC_TOKEN env --- docker/.env.experiment.example | 17 ++++++++++++----- docker/Dockerfile.experiment | 1 + 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docker/.env.experiment.example b/docker/.env.experiment.example index f9bb17554..38790f6d5 100644 --- a/docker/.env.experiment.example +++ b/docker/.env.experiment.example @@ -1,19 +1,26 @@ # Copy to .env.experiment and fill in. .env.experiment is gitignored. +# Tokens may reference repo-root .env vars (CODEBERG_TOKEN_FULL, +# ANTHROPIC_TOKEN) when sourced together: +# set -a && . .env && . docker/.env.experiment && set +a # # Target project on Codeberg/Gitea. WAVE_PROJECT_HOST=codeberg.org WAVE_PROJECT_OWNER=libretech WAVE_PROJECT_REPO=wave-testing -# Token with at minimum: repo:read, issue:read+write. Generate at -# https://${WAVE_PROJECT_HOST}/-/user/settings/applications -WAVE_PROJECT_TOKEN= +# Token with at minimum: repo:read, issue:read+write. Get from: +# https://codeberg.org/-/user/settings/applications +WAVE_PROJECT_TOKEN=${CODEBERG_TOKEN_FULL} # Adapter + model defaults (per memory feedback_model_run_ladder). WAVE_ADAPTER=claude WAVE_MODEL=balanced -# Anthropic API key for claude-code subprocesses. -ANTHROPIC_API_KEY= +# Anthropic API key for claude-code subprocesses. Get from: +# https://console.anthropic.com/settings/keys +ANTHROPIC_API_KEY=${ANTHROPIC_TOKEN} + +# First smoke: skip onboard-project (zero LLM tokens). Unset to enable. +WAVE_SKIP_ONBOARD=1 # Host port mapping. Change if 8080 is taken. WAVE_HOST_PORT=8080 diff --git a/docker/Dockerfile.experiment b/docker/Dockerfile.experiment index 89350a80f..325d07330 100644 --- a/docker/Dockerfile.experiment +++ b/docker/Dockerfile.experiment @@ -25,6 +25,7 @@ ARG TEA_ARCH=amd64 RUN apt-get update \ && apt-get install -y --no-install-recommends ca-certificates curl \ && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /out \ && curl -fsSL "https://gitea.com/gitea/tea/releases/download/v${TEA_VERSION}/tea-${TEA_VERSION}-linux-${TEA_ARCH}" \ -o /out/tea \ && chmod +x /out/tea From c481e7c06238e9e39a66b4808bad5685e240d6ae Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Sat, 2 May 2026 13:32:46 +0200 Subject: [PATCH 3/4] fix(docker): golang:1.25, drop tini+gh, reuse node user MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - golang:1.22→1.25 (go.mod requires >=1.25.5) - remove tini from apt (node:22-bookworm-slim lacks it; compose init:true handles PID1) - remove gh from apt (not needed for Codeberg/Gitea target; tea handles clone) - drop useradd (passwd pkg absent in slim); reuse pre-existing node user (UID 1000) - update ENTRYPOINT to call entrypoint.sh directly --- docker/Dockerfile.experiment | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/docker/Dockerfile.experiment b/docker/Dockerfile.experiment index 325d07330..2a70a6732 100644 --- a/docker/Dockerfile.experiment +++ b/docker/Dockerfile.experiment @@ -6,7 +6,7 @@ # docs/scope/codecrispies-walkthrough.md. # ---------- Stage 1: build wave from source ---------- -FROM golang:1.22-bookworm AS wave-build +FROM golang:1.25-bookworm AS wave-build WORKDIR /src COPY go.mod go.sum ./ @@ -33,26 +33,21 @@ RUN apt-get update \ # ---------- Stage 3: runtime image ---------- FROM node:22-bookworm-slim AS runtime -ARG WAVE_USER=wave -ARG WAVE_UID=1000 - +# node:22-bookworm-slim ships a `node` user at UID 1000 — reuse it. ENV DEBIAN_FRONTEND=noninteractive \ - HOME=/home/${WAVE_USER} \ - WAVE_HOME=/home/${WAVE_USER} \ - PATH=/usr/local/bin:/usr/bin:/bin:/home/${WAVE_USER}/.local/bin + HOME=/home/node \ + WAVE_HOME=/home/node \ + PATH=/usr/local/bin:/usr/bin:/bin:/home/node/.local/bin RUN apt-get update \ && apt-get install -y --no-install-recommends \ git \ - gh \ curl \ jq \ sqlite3 \ ca-certificates \ bash \ - tini \ - && rm -rf /var/lib/apt/lists/* \ - && useradd --create-home --uid "${WAVE_UID}" --shell /bin/bash "${WAVE_USER}" + && rm -rf /var/lib/apt/lists/* # Claude Code CLI ships via npm. RUN npm install -g @anthropic-ai/claude-code \ @@ -64,9 +59,9 @@ COPY --from=tea-fetch /out/tea /usr/local/bin/tea COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh -USER ${WAVE_USER} +USER node WORKDIR /work EXPOSE 8080 -# tini reaps orphaned subprocesses (claude-code, wave runs). -ENTRYPOINT ["/usr/bin/tini", "--", "/usr/local/bin/entrypoint.sh"] +# compose init:true injects Docker's own tini as PID1 — no separate tini binary needed. +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] From 6cec4e7a40fdcbbe76922bec50ddc9ee8d4a28fc Mon Sep 17 00:00:00 2001 From: Michael Czechowski Date: Sat, 2 May 2026 14:11:14 +0200 Subject: [PATCH 4/4] docs: next-session plan for Phase 4 docker smoke --- docs/scope/2026-05-02-next-session.md | 112 ++++++++++++++++++++++++++ 1 file changed, 112 insertions(+) create mode 100644 docs/scope/2026-05-02-next-session.md diff --git a/docs/scope/2026-05-02-next-session.md b/docs/scope/2026-05-02-next-session.md new file mode 100644 index 000000000..04c86fbe8 --- /dev/null +++ b/docs/scope/2026-05-02-next-session.md @@ -0,0 +1,112 @@ +# Next Session Plan — 2026-05-02 + +## Current branch: `feat/phase4-docker-vm` + +### What's on this branch (3 commits ahead of main) + +``` +77dee784 fix(docker): golang:1.25, drop tini+gh, reuse node user +523c9199 fix(docker): mkdir /out before tea fetch + map ANTHROPIC_TOKEN env +ed33923d feat(docker): Phase 4 reproducible experiment VM (Dockerfile + entrypoint + compose) +``` + +Files: `docker/Dockerfile.experiment`, `docker/entrypoint.sh`, `docker/docker-compose.experiment.yml` + +Branch is **5 commits behind main** (contract guards + rollback + docs merged while branch was open). +Must rebase before merge. + +### Untracked files (safe to ignore, not in .gitignore) + +Session-planning docs left over from prior work — no code, no impact: +- `docs/2026-04-28-issue-1452-plan.md` +- `docs/2026-04-28-session-plan.md` +- `docs/scope/2026-04-29-phase1-execution-plan.md` +- `docs/scope/2026-04-30-remaining-work.md` +- `docs/scope/2026-04-30-session-pause.md` + +Either commit them as `docs: session planning artifacts` or `git clean -f` them. Low stakes. + +`docker/.env.experiment` — gitignored, has real tokens. Keep. Required for smoke run. + +--- + +## What needs doing (in order) + +### 1. Rebase branch on main +```bash +git rebase main +``` +5 commits, no overlap with docker/ files — should be clean. + +### 2. docker compose build (needs unmetered connection) +```bash +set -a && . .env && set +a +docker compose -f docker/docker-compose.experiment.yml \ + --env-file docker/.env.experiment build +``` +Pulls `golang:1.25-bookworm` (~700MB) + `node:22-bookworm-slim` (~250MB). First pull only. +Layer cache means rebuilds are fast after. + +### 3. Smoke boot — WAVE_SKIP_ONBOARD=1 (zero LLM tokens) +```bash +docker compose -f docker/docker-compose.experiment.yml \ + --env-file docker/.env.experiment up +``` +`docker/.env.experiment` already has `WAVE_SKIP_ONBOARD=1`. + +Verify: +- Container starts, tea clones `codeberg.org/libretech/wave-testing` into `wave-work` volume +- `wave init` runs, `wave.yaml` + `.agents/` appear in `/work` +- webui boots on `:8080`, root redirects to `/onboard` (no sentinel yet) +- `curl http://localhost:8080/health` → 200 + +### 4. Full onboard smoke (uses LLM tokens, needs unmetered) +Edit `docker/.env.experiment`, comment out `WAVE_SKIP_ONBOARD=1`, restart: +```bash +docker compose -f docker/docker-compose.experiment.yml \ + --env-file docker/.env.experiment up +``` + +Verify (per epic gate): +- `docker exec wave-experiment ls /work/.agents/` shows personas, pipelines, prompts +- `docker exec wave-experiment cat /work/.agents/.onboarding-done` exists +- Browser: `http://localhost:8080` → `/work` board (sentinel present, redirect switches) +- `/work` board shows issues from `codeberg.org/libretech/wave-testing` + +### 5. Comment results on epic #1565 +```bash +gh issue comment 1565 --repo re-cinq/wave --body "Phase 4 4.4 smoke: ..." +``` +Update epic checklist boxes 4.1/4.2/4.3/4.4. + +### 6. Merge PR #1617 +After smoke passes. PR is `MERGEABLE`, not draft. +```bash +gh pr merge 1617 --repo re-cinq/wave --squash --delete-branch +``` + +--- + +## Known issues already fixed (on branch) + +| Issue | Fix | Commit | +|---|---|---| +| `golang:1.22` < go.mod requirement (1.25.5) | → `golang:1.25-bookworm` | 77dee784 | +| `tini` absent from `node:22-bookworm-slim` | removed; compose `init:true` handles PID1 | 77dee784 | +| `gh` CLI absent from slim + not needed (tea handles Codeberg) | removed | 77dee784 | +| `useradd` absent (no `passwd` pkg in slim) | reuse pre-existing `node` user (UID 1000) | 77dee784 | +| `mkdir /out` missing in tea-fetch stage | pre-create dir before curl | 523c9199 | +| `ANTHROPIC_TOKEN` env not forwarded to claude-code | mapped as `ANTHROPIC_API_KEY` | 523c9199 | + +## Open questions before merge + +- Does `node:22-bookworm-slim`'s `node` user home default to `/home/node`? Verify `echo $HOME` inside container. +- `wave init` on a fresh clone with no `wave.yaml` — does it work non-interactively? (`--adapter claude` flag in entrypoint is passed but `wave init` may still prompt) +- Volume `wave-work` persists across `docker compose down` — correct by design (idempotent clone). `docker compose down -v` to reset. + +--- + +## Epic state + +Phases 0/1/1.5/2/3 + all follow-ups: **MERGED**. +Phase 4: PR open, build fixes on branch, smoke deferred to unmetered.