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
97 changes: 97 additions & 0 deletions .github/workflows/build-deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
name: build and deploy

# Self-hosted runners only. GitHub-hosted runners are not permitted in this
# org and `runs-on: ubuntu-latest` is rejected -- see ops docs/CI-RUNNER-GATES.md.
# The `docker`/`publish` labels are advertised only by the two
# Docker-in-Docker-backed workers, which this job needs to build and push.
#
# The four repository gates run inside the image itself, as the `test` build
# target, rather than through setup-uv on the runner. Two reasons: the gates
# then run against the exact Python and toolchain the deployed image ships,
# and `ci.yml` keeps its own faster path for pull requests.
#
# What this workflow deliberately does NOT do: run the Stage 2 live execution
# tests. Those need a daemon that shares a filesystem with the process asking
# for the bind mount, and the publish runners talk to a separate DinD container
# over `DOCKER_HOST` with no shared volume -- a bind mount there would resolve
# to an empty directory on the daemon's side and prove nothing. The live suite
# runs against the deployed stack's own daemon instead, through Komodo's
# RunStackService. See docs/DEPLOYMENT.md.

on:
push:
branches: [main]
workflow_dispatch:

concurrency:
group: build-deploy-${{ github.ref }}
cancel-in-progress: false

env:
IMAGE_NAME: repo.indexarr.net/indexarr/agent-harness
# A separate repository, not another tag on IMAGE_NAME. `komodo-deploy.sh`
# rewrites every `image: ${IMAGE_NAME}:*` line in the stack, so a test image
# sharing that name would be silently rewritten to the runtime tag and the
# acceptance service would quietly run a container with no pytest in it.
TEST_IMAGE_NAME: repo.indexarr.net/indexarr/agent-harness-test
STACK_NAME: personal-agent-harness
STACK_DIR: personal/agent-harness

jobs:
test-build-push:
runs-on: [self-hosted, node-b, linux, x64, docker, publish]
steps:
- uses: actions/checkout@v5

- name: Gates, in the image that will be deployed
run: docker build --target test -t agent-harness-test .

- name: Log in to the Forgejo registry
run: |
echo "${{ secrets.FORGEJO_TOKEN }}" \
| docker login repo.indexarr.net -u "${{ secrets.FORGEJO_USER }}" --password-stdin

- name: Build and push
run: |
docker build --target runtime \
-t "${IMAGE_NAME}:latest" \
-t "${IMAGE_NAME}:${GITHUB_SHA}" \
.
docker push "${IMAGE_NAME}:latest"
docker push "${IMAGE_NAME}:${GITHUB_SHA}"
# The test target is published too: Stage 2's acceptance runs the
# live suite on the deployed host, so the tests must be a deployable
# artefact rather than something only CI ever holds. Same commit as
# the runtime image above, from the same build.
docker tag agent-harness-test "${TEST_IMAGE_NAME}:latest"
docker tag agent-harness-test "${TEST_IMAGE_NAME}:${GITHUB_SHA}"
docker push "${TEST_IMAGE_NAME}:latest"
docker push "${TEST_IMAGE_NAME}:${GITHUB_SHA}"

- name: Log out
if: always()
run: docker logout repo.indexarr.net || true

deploy:
needs: test-build-push
runs-on: [self-hosted, node-b, linux, x64]
steps:
- name: Bump the ops compose tag and trigger Komodo
env:
STACK_NAME: ${{ env.STACK_NAME }}
STACK_DIR: ${{ env.STACK_DIR }}
IMAGE_NAME: ${{ env.IMAGE_NAME }}
IMAGE_TAG: ${{ github.sha }}
GIT_AUTH_TOKEN: ${{ secrets.GIT_AUTH_TOKEN }}
KOMODO_API_KEY: ${{ secrets.KOMODO_API_KEY }}
KOMODO_API_SECRET: ${{ secrets.KOMODO_API_SECRET }}
KOMODO_GIT_ACCOUNT: sprooty
KOMODO_URL: http://192.168.1.75:3011
run: |
# Reuse the shared deploy step body from the ops repo rather than
# reimplementing the tag bump and DeployStack poll here.
curl -fsSL \
-H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \
"https://repo.indexarr.net/api/v1/repos/indexarr/ops/raw/scripts/komodo-deploy.sh" \
-o komodo-deploy.sh
bash komodo-deploy.sh
26 changes: 18 additions & 8 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ trade and it is rejected.
| Telemetry export | `src/agent_harness/adapters/otlp.py` — opt-in, lazily loaded, **export only**; the event store stays the source of truth |
| Queue schema migration | `docs/MIGRATION-graph.md` — backup, export, rebuild, rollback |
| Log readers | `src/agent_harness/ingest.py` |
| JSON API (no GUI) | `src/agent_harness/api.py` |
| JSON API and in-process browser GUI | `src/agent_harness/api.py`; `src/agent_harness/ui.py` |
| Session host client | `src/agent_harness/session_host.py` |
| Agent loop | `src/agent_harness/session_executor.py` |
| The worker and its 13 gates | `swack-tools/oxidex` — `scripts/model_fix_loop.py` |
Expand Down Expand Up @@ -151,14 +151,24 @@ with Swagger UI. Treat it as a contract:
Tests assert these properties, not just status codes — see
`tests/test_api.py`.

## Do not add a GUI here
## The GUI belongs here

The GUI belongs to the session host — AIDevEnv is the reference one. It
already has tabs, token auth, push
notifications, an Android PWA, and the PTY sessions the agents run in. A web
UI in this repo means a second URL, a second login, no notifications and no
phone story — worse, for the same work. This service serves JSON; the host
renders it as a Work tab.
`agent-harness serve` owns and serves the browser GUI from the same process and
origin as its public JSON API. Templates, static assets, browser authentication,
tests and documentation live in this repository and ship in its distribution.

The GUI has no dependency on MyDevEnv, AIDevEnv or another session host: it must
not import their code, consume their assets or authentication, require their
proxy, or assume their session and terminal model. An optional session host may
still execute agents through the generic `session_host` protocol; that execution
adapter is not the owner or host of the GUI.

HTML controllers delegate to the same typed query and command services as JSON
routes. They never read SQLite directly or duplicate a gate. Browser actions
require authenticated operator identity, CSRF validation and explicit review;
navigation and drag-and-drop never imply permission for a state transition.
The JSON API remains public, typed and documented, and normal GUI operation
must not require a CDN or a separately deployed frontend.

## Two invariants the store must keep

Expand Down
113 changes: 113 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# agent-harness — controller image.
#
# This image is the **controller**, not an agent sandbox. It holds the queue,
# the gates, the model client and the credentials; it creates a separate,
# disposable container per work item through the selected execution backend and
# never runs an agent's commands itself.
#
# That distinction decides two things here:
#
# * the Docker CLI is installed, because `adapters/docker.py` shells out to
# it — but no Docker socket is baked in. The daemon is supplied at runtime
# through `DOCKER_HOST`, which in the Node B stack points at a dedicated
# DinD sidecar on an internal network. The controller therefore never holds
# root-equivalent access to the deploy host (STATUS.md §2.7).
# * item worktrees live under a path that must be **identical** in this
# container and in whichever daemon creates the item containers. A bind
# mount is resolved by the daemon, not by the client, so a controller that
# mounts `/harness/work` while the daemon knows that content by another
# path would silently mount an empty directory into every agent's
# checkout. `HARNESS_WORK_ROOT` names that shared path.
#
# Targets, per STATUS.md §2.7's "publish deliberately different image targets":
#
# test the four repository gates, with dev dependencies and the tests
# runtime the service, without them
#
# Base pinned by tag; the resolved digest is recorded by preflight and in item
# evidence so a result stays explicable after a tag moves.

# ---------------------------------------------------------------- base

FROM python:3.12-slim-bookworm AS base

ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
UV_LINK_MODE=copy \
UV_COMPILE_BYTECODE=1

# git is not optional: the harness allocates a worktree per item, computes the
# candidate diff and drives plan-branch promotion. docker-cli talks to the
# daemon named by DOCKER_HOST. ca-certificates is needed to reach a gateway.
RUN apt-get update \
&& apt-get install --no-install-recommends -y \
ca-certificates \
curl \
git \
docker.io \
&& rm -rf /var/lib/apt/lists/*

COPY --from=ghcr.io/astral-sh/uv:0.5.11 /uv /usr/local/bin/uv

# A committing identity, because the harness commits: the suite builds real
# git repositories and the executor commits an item branch. Without one, git
# refuses with "Please tell me who you are" in an image where no human can.
RUN git config --system user.name "agent-harness" \
&& git config --system user.email "agent-harness@invalid" \
&& git config --system init.defaultBranch main \
&& git config --system --add safe.directory '*'

WORKDIR /app

# Dependency layer first, so a source-only change does not re-resolve.
COPY pyproject.toml uv.lock README.md ./
RUN uv sync --frozen --no-install-project --all-extras

COPY src ./src
COPY tests ./tests
COPY examples ./examples
RUN uv sync --frozen --all-extras

# ---------------------------------------------------------------- test

FROM base AS test

# The same four gates the repository runs, in the image that will be deployed.
# TMPDIR matters: the suite creates temporary git repositories heavily.
ENV TMPDIR=/tmp
RUN uv run ruff check . \
&& uv run ruff format --check . \
&& uv run mypy \
&& uv run pytest -q

# ------------------------------------------------------------- runtime

FROM base AS runtime

# Runtime carries no dev dependencies. Re-synced rather than copied from a
# clean layer so the lock file remains the single source of what is installed.
RUN uv sync --frozen --no-dev --extra agent-loop

# `/harness/work` is where a project's checkout must live. It is not a setting
# the harness reads — a project's `work_dir` is a row on the project, supplied
# when the project is registered — it is a **deployment constraint**: the
# daemon that creates item containers resolves bind mounts by its own paths, so
# a project registered outside the shared volume would hand every agent an
# empty checkout. Register projects under this path and nowhere else.
ENV HARNESS_DB=/harness/state/queue.sqlite \
HARNESS_AUDIT_DB=/harness/state/audit.sqlite \
PATH="/app/.venv/bin:$PATH"

# The controller does not need root, and an agent's commands never run here
# anyway. The Docker CLI only needs to reach DOCKER_HOST over TCP.
RUN groupadd --gid 1000 harness \
&& useradd --uid 1000 --gid 1000 --create-home harness \
&& mkdir -p /harness/work /harness/state \
&& chown -R harness:harness /harness /app
USER harness

EXPOSE 8080

# `serve` is the deployment entry point; `run` is the one-shot CLI. Neither
# claims work until a project is started through the API.
CMD ["agent-harness", "serve", "--host", "0.0.0.0", "--port", "8080"]
Loading
Loading