daemon: golang WS hub, REST, gitlab, docker spawner, sqlite (18/18 tests)

This commit is contained in:
Raphael Westphal
2026-08-18 13:42:34 +02:00
parent 7d5f866527
commit c62900e613
17 changed files with 3100 additions and 0 deletions
+100
View File
@@ -0,0 +1,100 @@
# lvmh daemon
Go daemon binding the lvmh wire protocol (`../PROTOCOL.md`): agent websocket
(`/agent/ws`), REST API (`/api/*`), web websocket (`/ws`), GitLab PAT
management and worker-container spawning over the Docker API.
## Layout
| file | role |
| --- | --- |
| `main.go` | flags, env wiring, HTTP server lifecycle |
| `hub.go` | agent WS hub (`/agent/ws`) + web WS hub (`/ws`), prompt/abort routing, online state |
| `api.go` | REST handlers, bearer auth, recover middleware, embedded web UI |
| `gitlab.go` | PAT connect/validate/list member projects |
| `docker.go` | spawn pipeline: clone → ensure image → create/start container, job status |
| `store.go` | SQLite (modernc, CGO-free): sessions, events, settings, containers |
| `webdist/` | embedded web UI placeholder (replace with `web/dist` at build time) |
## Build & run
```sh
cd daemon
go build ./...
LVMH_TOKEN=secret LVMH_DB=/tmp/lvmh.db ./lvmh-daemon --addr :8686
```
Flags: `--addr` (default `:8686`), `--db` (default `$LVMH_DB` or `/data/lvmh.db`),
`--webdist` (default `/app/web-dist` when that directory exists, else the
embedded `webdist/`).
Docker image (multi-stage, `CGO_ENABLED=0`):
```sh
docker build -f daemon/Dockerfile -t lvmh-daemon:latest .
```
To bake the real web UI into the binary, copy the built frontend over the
placeholder before building: `cp -r web/dist daemon/webdist/`. Otherwise mount
it at `/app/web-dist` (the default `--webdist` path) or pass `--webdist`.
## Environment
| var | default | meaning |
| --- | --- | --- |
| `LVMH_TOKEN` | — (required) | bearer token for REST + WS auth |
| `LVMH_DB` | `/data/lvmh.db` | sqlite path (WAL mode) |
| `GITLAB_BASE_URL` | `https://git.westphal.fr` | GitLab instance |
| `LVMH_WORKER_DOCKERFILE` | `/app/build/docker/worker.Dockerfile` | image build recipe |
| `LVMH_WORKER_CONTEXT` | parent of dockerfile dir | docker build context |
| `LVMH_REPO_DIR` | `/data/repos` | host-side clone cache (`<slug>` subdirs) |
| `LVMH_CONTAINER_LVMH_URL` | `ws://lvmh:8686/agent/ws` | daemon URL handed to spawned containers |
| `LVMH_CONTAINER_NETWORK` | `lvmh-net` | network for spawned containers |
| `LVMH_WORKER_MODELS` | `/app/build/docker/worker-models.json` | mounted read-only as `models.json` when present |
| `ZAI_RENAUD_API_KEY` | — | injected into spawned containers |
| `DOCKER_HOST` | — | honored by the docker client |
## Spawn pipeline
`POST /api/spawn {repo, branch?}` returns `{sessionId, containerId:""}`
immediately (async). The job walks `cloning → building → creating → running`
(or `error` + message) in `GET /api/spawn/status` and on the web WS.
1. Per-repo-slug mutex; `git clone`/`git pull --ff-only` into `$LVMH_REPO_DIR/<slug>`.
2. Ensure image `lvmh-worker:latest` — built from `LVMH_WORKER_DOCKERFILE`
when missing (502-style clear error from `POST /api/spawn` if neither image
nor Dockerfile exist).
3. Named volume `lvmh-repo-<slug>` (seeded from the clone on first use),
`lvmh-sessions` volume, `lvmh.session=<sessionId>` label, network
`lvmh-net`, env `ZAI_RENAUD_API_KEY`, `LVMH_TOKEN`, `LVMH_URL`,
`LVMH_SESSION_ID`.
4. `DELETE /api/sessions/:id/container` stops and removes the container.
## Tests
```sh
go vet ./... && staticcheck ./... && go test ./... -count=1
```
Covered: store persistence + replay-after-seq, agent handshake
(hello→welcome.lastSeq), reconnect replacement, live `message_update` fan-out
(no persistence), prompt routing + 409 offline, bearer 401s, GitLab user/
projects mapping against an httptest upstream.
## Protocol ambiguities (daemon-side resolutions)
- **Envelope payload shape**: payload fields are flattened into the envelope
object (as the `hello` example shows). The daemon persists the full frame
verbatim; `GET /api/sessions/:id/events` replays it as-is.
- **`events` frame `after`**: exclusive lower bound = `seq` of the first event
in the batch minus 1, matching the REST `?after=` semantics.
- **`POST /api/spawn` response**: returns immediately with `{sessionId,
containerId: ""}` — `containerId` appears later via `/api/spawn/status`
(async spawn per spec; the protocol table's synchronous shape is not
achievable for clones that take minutes).
- **Events persistence**: everything except `message_update`; both live and
replayed `message_update` content is reconstructed from `message_end`.
- **Repo workspace**: clone cache on the daemon filesystem under
`LVMH_REPO_DIR`; the container mounts named volume `lvmh-repo-<slug>` seeded
from that clone on first spawn (protocol: "clone into volume").