From 5b537880ae7eb46ac86bcd57b0cfb80414c46088 Mon Sep 17 00:00:00 2001 From: Mikhail Putilovskij Date: Tue, 28 Apr 2026 20:11:27 +0300 Subject: [PATCH] docs(deploy): finalize multi-agent surface image handoff --- .dockerignore | 5 + .env.example | 10 +- Dockerfile | 35 +++++-- README.md | 44 +++++++-- config/matrix-agents.example.yaml | 14 +++ docker-compose.fullstack.yml | 10 ++ docker-compose.prod.yml | 2 +- docs/deploy-architecture.md | 45 ++++++++- tests/adapter/matrix/test_dispatcher.py | 121 +++++++++++++++++++++++- tests/adapter/matrix/test_files.py | 40 +++++++- tests/test_deploy_handoff.py | 62 ++++++++++++ 11 files changed, 361 insertions(+), 27 deletions(-) create mode 100644 tests/test_deploy_handoff.py diff --git a/.dockerignore b/.dockerignore index 1996568..2d88441 100644 --- a/.dockerignore +++ b/.dockerignore @@ -6,11 +6,16 @@ __pycache__/ .ruff_cache/ .venv/ .worktrees/ +external/ +.planning/ +docs/superpowers/ +tests/ # Local runtime state must not be baked into the image. lambda_matrix.db matrix_store/ lambda_bot.db +config/matrix-agents.yaml # Local environment and editor state .env diff --git a/.env.example b/.env.example index 610314e..cc5f2e0 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,13 @@ MATRIX_PASSWORD=your_password_here # Backend: "real" connects to platform-agent via AgentApi; "mock" uses local stub (testing only) MATRIX_PLATFORM_BACKEND=real +# Published surface image used by docker-compose.prod.yml. +# Must point to a Docker Hub/registry namespace where you have push/pull access. +SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest + +# platform/agent_api ref used when building a surface image +LAMBDA_AGENT_API_REF=master + # Path to agent registry inside the container (mounted via ./config:/app/config:ro) MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml @@ -16,7 +23,8 @@ MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml # Fullstack E2E: overridden to http://platform-agent:8000 by docker-compose.fullstack.yml AGENT_BASE_URL=http://your-agent-host:8000 -# Shared volume path inside the bot container (default: /agents) +# Shared volume path inside the bot container (default: /agents). +# For multi-agent production, each agent gets a subdirectory such as /agents/0. SURFACES_WORKSPACE_DIR=/agents # Docker volume names (created automatically on first run) diff --git a/Dockerfile b/Dockerfile index 00a6e58..e83ae3b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim +FROM python:3.11-slim AS base WORKDIR /app @@ -6,8 +6,11 @@ ENV PYTHONUNBUFFERED=1 ENV PYTHONPATH=/app ENV UV_PROJECT_ENVIRONMENT=/usr/local -# Install uv for dependency management inside the container. -RUN pip install --no-cache-dir uv +# Install uv and git for reproducible platform SDK installation. +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates git \ + && rm -rf /var/lib/apt/lists/* \ + && pip install --no-cache-dir uv # Copy dependency manifests first for layer caching. COPY pyproject.toml uv.lock* ./ @@ -15,15 +18,27 @@ COPY pyproject.toml uv.lock* ./ # Install project dependencies into the system environment. RUN uv sync --no-dev --no-install-project --frozen -# Copy project source after dependency layers. -COPY . . +FROM base AS development -# Install the project itself. +# Local fullstack/dev builds can override the SDK with a checked-out agent_api +# build context, matching platform-agent's development Dockerfile pattern. +COPY --from=agent_api . /agent_api/ +RUN python -m pip install --no-cache-dir --ignore-requires-python -e /agent_api/ + +COPY . . RUN uv sync --no-dev --frozen -# Install lambda_agent_api from the vendored source tree. -# --ignore-requires-python: the package declares python<3.12 but works fine on 3.11; -# the guard exists for its own dev tooling, not the runtime API surface we use. -RUN pip install --no-cache-dir --ignore-requires-python /app/external/platform-agent_api +CMD ["python", "-m", "adapter.matrix.bot"] + +FROM base AS production + +# Production builds follow the platform-agent pattern: install the API SDK from +# the platform Git repository instead of relying on local external/ clones. +ARG LAMBDA_AGENT_API_REF=master +RUN python -m pip install --no-cache-dir --ignore-requires-python \ + "git+https://git.lambda.coredump.ru/platform/agent_api.git@${LAMBDA_AGENT_API_REF}" + +COPY . . +RUN uv sync --no-dev --frozen CMD ["python", "-m", "adapter.matrix.bot"] diff --git a/README.md b/README.md index f4833da..9a1a2fb 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Matrix-бот для взаимодействия пользователя с AI ## Интеграция для платформы -Бот — это один Docker-контейнер (`matrix-bot`), который вы добавляете в свою инфраструктуру рядом с агентами. +Бот — это один Docker-контейнер (`matrix-bot`), который вы добавляете в свою инфраструктуру рядом с агентами. Production target — один surface container на 25-30 внешних agent containers/services. ### Что бот ожидает от вас @@ -37,10 +37,11 @@ Bot container Agent containers ### Минимальный чеклист +- [ ] Взять опубликованный image `SURFACES_BOT_IMAGE` или собрать production image из этого репозитория - [ ] Заполнить `config/matrix-agents.yaml` — ID агентов, `base_url` каждого, `workspace_path`, маппинг пользователей - [ ] Задать переменные окружения (см. `.env.example`): Matrix credentials, `MATRIX_PLATFORM_BACKEND=real`, `MATRIX_AGENT_REGISTRY_PATH=/app/config/matrix-agents.yaml` - [ ] Смонтировать в бот-контейнер shared volume как `/agents` — каждый агент должен видеть свою поддиректорию как `/workspace` -- [ ] Добавить бот-сервис в свой compose (используйте `docker-compose.prod.yml` как шаблон сервиса) +- [ ] Добавить bot-only service в свой compose; агенты в этот compose не входят и управляются платформой --- @@ -94,6 +95,7 @@ cp .env.example .env | `MATRIX_USER_ID` | ✓ | `@bot:example.org` | | `MATRIX_PASSWORD` | ✓ | пароль (или `MATRIX_ACCESS_TOKEN`) | | `MATRIX_PLATFORM_BACKEND` | ✓ | `real` для продакшна | +| `SURFACES_BOT_IMAGE` | ✓ | Docker image поверхности: `mput1/surfaces-bot:latest` | | `AGENT_BASE_URL` | | Fallback URL агента если `base_url` не задан в `matrix-agents.yaml` | | `MATRIX_AGENT_REGISTRY_PATH` | ✓ | путь к реестру внутри контейнера: `/app/config/matrix-agents.yaml` | | `SURFACES_WORKSPACE_DIR` | | путь к shared volume в контейнере (по умолчанию `/agents`) | @@ -117,31 +119,57 @@ agents: label: "Agent 1" base_url: "http://lambda.coredump.ru:7000/agent_1/" workspace_path: "/agents/1" + - id: agent-2 + label: "Agent 2" + base_url: "http://lambda.coredump.ru:7000/agent_2/" + workspace_path: "/agents/2" ``` - `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент. - `base_url` — HTTP URL агент-эндпоинта (path-based routing через reverse proxy). - `workspace_path` — путь к воркспейсу агента внутри бот-контейнера на shared volume. Бот сохраняет входящие файлы в `{workspace_path}/incoming/`, агент пишет исходящие в свой `/workspace/`. +- Для 25-30 агентов продолжайте тот же паттерн: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`. Полный пример с комментариями: `config/matrix-agents.example.yaml` ### Production (bot-only) -`docker-compose.prod.yml` — шаблон сервиса `matrix-bot`. Платформа добавляет этот сервис в свой compose рядом с агент-контейнерами, монтирует shared volume и задаёт переменные окружения. +`docker-compose.prod.yml` — bot-only handoff через published image. Платформа добавляет этот сервис в свой compose рядом с agent containers, монтирует shared volume и задаёт переменные окружения. Этот compose не создаёт и не собирает агент-контейнеры. -Для изолированного запуска бота без агентов (smoke-тест): +Для запуска опубликованного image: ```bash -docker compose --env-file .env -f docker-compose.prod.yml up -d --build +export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest +docker compose --env-file .env -f docker-compose.prod.yml up -d ``` +Опубликованный image: + +```text +mput1/surfaces-bot:latest +sha256:26ba3a49290ab7c1cf0fa97f3de3fefdc70b59df7e6f1e0c2255728f8e2369be +``` + +Для сборки и публикации surface image: +```bash +docker login +export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest + +docker build --target production \ + --build-arg LAMBDA_AGENT_API_REF=master \ + -t "$SURFACES_BOT_IMAGE" . +docker push "$SURFACES_BOT_IMAGE" +``` + +Если push возвращает `insufficient_scope`, текущий Docker login не имеет доступа к namespace/repository. Создайте repository в нужном Docker Hub namespace или перетегируйте image в namespace пользователя, под которым выполнен `docker login`. + ### Fullstack E2E (bot + agent) ```bash docker compose --env-file .env -f docker-compose.fullstack.yml up --build ``` -Поднимает `matrix-bot` вместе с локальным `platform-agent`. `AGENT_BASE_URL` перекрывается на `http://platform-agent:8000`. Shared volume виден как `/agents` в боте и `/workspace` в агенте. +Поднимает `matrix-bot` вместе с локальным `platform-agent`. Это internal harness, не production topology на 25-30 агентов. `matrix-bot` собирается через Dockerfile target `development` и локальный `external/platform-agent_api` context, как в dev-сборке `platform-agent`. `AGENT_BASE_URL` перекрывается на `http://platform-agent:8000`. Shared volume виден как `/agents` в боте и `/workspace` в агенте. ### Сброс состояния (локально) @@ -159,8 +187,8 @@ Bot (/agents) Agent (/workspace = /agents/N/) /agents/0/output/ ←────────────────────────────────→ /workspace/output/ ``` -- **Входящий файл** (пользователь → агент): бот сохраняет в `{workspace_path}/incoming/{stamp}-{file}`, передаёт агенту `attachments=["incoming/{stamp}-{file}"]` -- **Исходящий файл** (агент → пользователь): агент пишет в `/workspace/output/file`, бот читает из `{workspace_path}/output/file` и отправляет пользователю как Matrix file message +- **Входящий файл** (пользователь → агент): бот сохраняет в `{workspace_path}/incoming/{stamp}-{file}`, например `/agents/17/incoming/report.pdf`, и передаёт агенту `attachments=["incoming/{stamp}-{file}"]` +- **Исходящий файл** (агент → пользователь): агент пишет в `/workspace/output/file`, бот читает из `{workspace_path}/output/file`, например `/agents/17/output/file`, и отправляет пользователю как Matrix file message - `workspace_path` для каждого агента задаётся в `config/matrix-agents.yaml` --- diff --git a/config/matrix-agents.example.yaml b/config/matrix-agents.example.yaml index 8696def..30d41a2 100644 --- a/config/matrix-agents.example.yaml +++ b/config/matrix-agents.example.yaml @@ -1,4 +1,6 @@ # Agent registry for the Matrix bot. +# Production target: one surface bot routes to 25-30 externally managed agents. +# Keep adding entries with the same base_url/workspace_path pattern. # # user_agents: maps a Matrix user ID to an agent ID. # If a user is not listed, the bot uses the first agent from the list below. @@ -17,6 +19,7 @@ user_agents: "@user0:matrix.example.org": agent-0 "@user1:matrix.example.org": agent-1 + "@user2:matrix.example.org": agent-2 agents: - id: agent-0 @@ -28,3 +31,14 @@ agents: label: "Agent 1" base_url: "http://lambda.coredump.ru:7000/agent_1/" workspace_path: "/agents/1" + + - id: agent-2 + label: "Agent 2" + base_url: "http://lambda.coredump.ru:7000/agent_2/" + workspace_path: "/agents/2" + + # Continue the same pattern through agent-29 for a 25-30 agent deployment: + # - id: agent-29 + # label: "Agent 29" + # base_url: "http://lambda.coredump.ru:7000/agent_29/" + # workspace_path: "/agents/29" diff --git a/docker-compose.fullstack.yml b/docker-compose.fullstack.yml index d412773..88ff37b 100644 --- a/docker-compose.fullstack.yml +++ b/docker-compose.fullstack.yml @@ -3,6 +3,16 @@ services: extends: file: docker-compose.prod.yml service: matrix-bot + build: + context: . + dockerfile: Dockerfile + target: development + args: + LAMBDA_AGENT_API_REF: ${LAMBDA_AGENT_API_REF:-master} + additional_contexts: + agent_api: ./external/platform-agent_api + tags: + - ${SURFACES_BOT_DEV_IMAGE:-surfaces-bot:dev} environment: AGENT_BASE_URL: http://platform-agent:8000 depends_on: diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 04f37d8..2c7e942 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,6 +1,6 @@ services: matrix-bot: - build: . + image: "${SURFACES_BOT_IMAGE:?Set SURFACES_BOT_IMAGE to the pushed image, e.g. mput1/surfaces-bot:latest}" environment: MATRIX_HOMESERVER: ${MATRIX_HOMESERVER:-} MATRIX_USER_ID: ${MATRIX_USER_ID:-} diff --git a/docs/deploy-architecture.md b/docs/deploy-architecture.md index 8f0e896..0d9a872 100644 --- a/docs/deploy-architecture.md +++ b/docs/deploy-architecture.md @@ -7,10 +7,10 @@ ## Compose Artifacts - **Production deploy:** `docker-compose.prod.yml` - Bot-only handoff. Поднимает только `matrix-bot`, монтирует shared volume в `/agents`. - Платформа предоставляет агент-контейнеры отдельно; бот подключается к ним через `base_url` из `matrix-agents.yaml`. + Bot-only handoff через published image (`SURFACES_BOT_IMAGE`). Поднимает только `matrix-bot`, монтирует shared volume в `/agents`. + Платформа предоставляет 25-30 agent containers/services отдельно; бот подключается к ним через `base_url` из `matrix-agents.yaml`. - **Internal full-stack E2E:** `docker-compose.fullstack.yml` - Внутренний harness для тестирования. Поднимает `matrix-bot` и один `platform-agent`, health-gated startup. + Внутренний harness для тестирования. Локально собирает `matrix-bot` через Dockerfile target `development`, поднимает один `platform-agent`, health-gated startup. Production operators: `docker-compose.prod.yml`. Internal E2E: `docker-compose.fullstack.yml`. @@ -33,7 +33,7 @@ lambda.coredump.ru ``` - **Один инстанс Matrix-бота** обслуживает всех пользователей. -- **Один агент-контейнер на пользователя.** Изоляция по agent_id, не через chat_id внутри одного инстанса. +- **Один агент-контейнер на пользователя.** Production scale target: 25-30 внешних агентов. Изоляция по `agent_id`, не через один общий agent instance. - **Shared volume** `/agents/` смонтирован в Matrix-бот. В internal full-stack harness тот же volume mounted as `/workspace` inside `platform-agent`, чтобы bot-side absolute paths и agent workspace относились к одному и тому же хранилищу. --- @@ -58,12 +58,49 @@ agents: label: "Agent 1" base_url: "http://lambda.coredump.ru:7000/agent_1/" workspace_path: "/agents/1" + + - id: agent-2 + label: "Agent 2" + base_url: "http://lambda.coredump.ru:7000/agent_2/" + workspace_path: "/agents/2" ``` - `user_agents` — маппинг Matrix user_id → agent_id. Если пользователь не найден — используется первый агент из списка. - `agents[].base_url` — HTTP URL агент-эндпоинта. Бот подключается через AgentApi. - `agents[].workspace_path` — абсолютный путь к воркспейсу агента **внутри контейнера бота** (т.е. на shared volume). Бот сохраняет входящие файлы в `{workspace_path}/incoming/`, читает исходящие из `{workspace_path}/`. +- Для 25-30 агентов продолжайте тот же паттерн до нужного номера: `/agent_17/` + `/agents/17`, `/agent_29/` + `/agents/29`. + +## Surface Image Build Contract + +Production image содержит только Matrix surface. Он не содержит `platform-agent` и не требует локального `external/` build context. + +```bash +docker login +export SURFACES_BOT_IMAGE=mput1/surfaces-bot:latest + +docker build --target production \ + --build-arg LAMBDA_AGENT_API_REF=master \ + -t "$SURFACES_BOT_IMAGE" . +docker push "$SURFACES_BOT_IMAGE" +``` + +Published image: + +```text +mput1/surfaces-bot:latest +sha256:26ba3a49290ab7c1cf0fa97f3de3fefdc70b59df7e6f1e0c2255728f8e2369be +``` + +`SURFACES_BOT_IMAGE` должен указывать на registry namespace, куда текущий Docker account может пушить. Ошибка `insufficient_scope` означает, что пользователь не залогинен в этот namespace, repository не создан, или у аккаунта нет push-доступа. + +Production Dockerfile ставит `platform/agent_api` из Git по тому же принципу, что и `platform-agent` production image: + +```bash +git+https://git.lambda.coredump.ru/platform/agent_api.git +``` + +Локальный `docker-compose.fullstack.yml` остаётся dev/E2E harness: он использует target `development` и `additional_contexts.agent_api=./external/platform-agent_api`, чтобы можно было тестировать surface вместе с локальным checkout SDK. --- diff --git a/tests/adapter/matrix/test_dispatcher.py b/tests/adapter/matrix/test_dispatcher.py index 338525d..1733b75 100644 --- a/tests/adapter/matrix/test_dispatcher.py +++ b/tests/adapter/matrix/test_dispatcher.py @@ -15,8 +15,10 @@ from nio import ( from nio.api import RoomVisibility from nio.responses import SyncResponse +from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry from adapter.matrix.bot import MatrixBot, build_runtime, prepare_live_sync from adapter.matrix.handlers.auth import handle_invite +from adapter.matrix.routed_platform import RoutedPlatformClient from adapter.matrix.store import ( add_staged_attachment, get_platform_chat_id, @@ -36,7 +38,6 @@ from core.protocol import ( ) from sdk.interface import PlatformError from sdk.mock import MockPlatformClient -from adapter.matrix.routed_platform import RoutedPlatformClient async def test_matrix_dispatcher_registers_custom_handlers(): @@ -107,7 +108,10 @@ async def test_new_chat_creates_real_matrix_room_when_client_available(): assert client.room_create.await_count >= 1 client.room_put_state.assert_awaited_once() put_call = client.room_put_state.call_args - assert put_call.kwargs.get("room_id") == "!space:example" or put_call.args[0] == "!space:example" + assert ( + put_call.kwargs.get("room_id") == "!space:example" + or put_call.args[0] == "!space:example" + ) chats = await runtime.chat_mgr.list_active("u1") assert [c.chat_id for c in chats] == ["C7"] assert [c.surface_ref for c in chats] == ["!r2:example"] @@ -333,6 +337,119 @@ async def test_bot_downloads_matrix_file_to_workspace_before_staging(tmp_path, m bot._send_all.assert_not_awaited() +async def test_bot_downloads_matrix_file_to_configured_agent_workspace(tmp_path, monkeypatch): + monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents")) + runtime = build_runtime(platform=MockPlatformClient()) + runtime.registry = AgentRegistry( + [ + AgentDefinition( + agent_id="agent-17", + label="Agent 17", + base_url="http://lambda.coredump.ru:7000/agent_17/", + workspace_path=str(tmp_path / "agents" / "17"), + ) + ] + ) + await set_room_meta( + runtime.store, + "!chat17:example.org", + { + "chat_id": "C17", + "matrix_user_id": "@alice:example.org", + "platform_chat_id": "17", + "agent_id": "agent-17", + }, + ) + client = SimpleNamespace( + user_id="@bot:example.org", + download=AsyncMock(return_value=SimpleNamespace(body=b"%PDF-1.7")), + ) + bot = MatrixBot(client, runtime) + runtime.dispatcher.dispatch = AsyncMock(return_value=[]) + room = SimpleNamespace(room_id="!chat17:example.org") + event = SimpleNamespace( + sender="@alice:example.org", + body="report.pdf", + msgtype="m.file", + replyto_event_id=None, + url="mxc://server/id", + mimetype="application/pdf", + ) + + await bot.on_room_message(room, event) + + staged = await get_staged_attachments( + runtime.store, "!chat17:example.org", "@alice:example.org" + ) + assert staged[0]["workspace_path"].startswith("incoming/") + assert ( + tmp_path / "agents" / "17" / staged[0]["workspace_path"] + ).read_bytes() == b"%PDF-1.7" + + +async def test_bot_uploads_agent_output_from_configured_agent_workspace(tmp_path, monkeypatch): + monkeypatch.setenv("SURFACES_WORKSPACE_DIR", str(tmp_path / "agents")) + output_file = tmp_path / "agents" / "17" / "output" / "result.txt" + output_file.parent.mkdir(parents=True) + output_file.write_text("ready", encoding="utf-8") + runtime = build_runtime(platform=MockPlatformClient()) + runtime.registry = AgentRegistry( + [ + AgentDefinition( + agent_id="agent-17", + label="Agent 17", + base_url="http://lambda.coredump.ru:7000/agent_17/", + workspace_path=str(tmp_path / "agents" / "17"), + ) + ] + ) + await set_room_meta( + runtime.store, + "!chat17:example.org", + { + "chat_id": "C17", + "matrix_user_id": "@alice:example.org", + "platform_chat_id": "17", + "agent_id": "agent-17", + }, + ) + client = SimpleNamespace( + user_id="@bot:example.org", + upload=AsyncMock(return_value=(SimpleNamespace(content_uri="mxc://server/result"), {})), + room_send=AsyncMock(), + ) + bot = MatrixBot(client, runtime) + runtime.dispatcher.dispatch = AsyncMock( + return_value=[ + OutgoingMessage( + chat_id="C17", + text="Файл готов", + attachments=[ + Attachment( + type="document", + filename="result.txt", + mime_type="text/plain", + workspace_path="output/result.txt", + ) + ], + ) + ] + ) + room = SimpleNamespace(room_id="!chat17:example.org") + event = SimpleNamespace( + sender="@alice:example.org", + body="сделай отчёт", + msgtype="m.text", + replyto_event_id=None, + ) + + await bot.on_room_message(room, event) + + uploaded_handle = client.upload.await_args.args[0] + assert uploaded_handle.name == str(output_file) + assert client.room_send.await_args_list[1].args[2]["url"] == "mxc://server/result" + + async def test_file_only_event_is_staged_and_does_not_dispatch(): runtime = build_runtime(platform=MockPlatformClient()) client = SimpleNamespace(user_id="@bot:example.org", room_send=AsyncMock()) diff --git a/tests/adapter/matrix/test_files.py b/tests/adapter/matrix/test_files.py index 71fb02f..674907d 100644 --- a/tests/adapter/matrix/test_files.py +++ b/tests/adapter/matrix/test_files.py @@ -3,7 +3,11 @@ from __future__ import annotations from pathlib import Path from types import SimpleNamespace -from adapter.matrix.files import build_workspace_attachment_path, download_matrix_attachment +from adapter.matrix.files import ( + build_agent_incoming_path, + build_workspace_attachment_path, + download_matrix_attachment, +) from core.protocol import Attachment @@ -65,3 +69,37 @@ def test_build_workspace_attachment_path_keeps_room_safe_agents_relative_contrac ) assert not Path(rel_path).is_absolute() assert abs_path == tmp_path / "agents" / "7" / rel_path + + +def test_build_agent_incoming_path_uses_agent_workspace_volume(tmp_path: Path): + rel_path, abs_path = build_agent_incoming_path( + workspace_root=tmp_path / "agents" / "17", + filename="quarterly status.pdf", + timestamp="20260428-110000", + ) + + assert rel_path == "incoming/20260428-110000-quarterly_status.pdf" + assert abs_path == tmp_path / "agents" / "17" / rel_path + + +async def test_download_matrix_attachment_uses_agent_workspace_incoming_dir(tmp_path: Path): + async def download(url: str): + assert url == "mxc://server/id" + return SimpleNamespace(body=b"%PDF-1.7") + + saved = await download_matrix_attachment( + client=SimpleNamespace(download=download), + workspace_root=tmp_path / "agents" / "17", + matrix_user_id="@alice:example.org", + room_id="!room:example.org", + attachment=Attachment( + type="document", + url="mxc://server/id", + filename="report.pdf", + mime_type="application/pdf", + ), + timestamp="20260428-110000", + ) + + assert saved.workspace_path == "incoming/20260428-110000-report.pdf" + assert (tmp_path / "agents" / "17" / saved.workspace_path).read_bytes() == b"%PDF-1.7" diff --git a/tests/test_deploy_handoff.py b/tests/test_deploy_handoff.py new file mode 100644 index 0000000..e2f3953 --- /dev/null +++ b/tests/test_deploy_handoff.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from pathlib import Path + +import yaml + +ROOT = Path(__file__).resolve().parents[1] + + +def _compose(path: str) -> dict: + return yaml.safe_load((ROOT / path).read_text(encoding="utf-8")) + + +def test_prod_compose_uses_registry_image_not_local_build(): + prod = _compose("docker-compose.prod.yml") + service = prod["services"]["matrix-bot"] + + assert "image" in service + assert "build" not in service + assert service["image"].startswith("${SURFACES_BOT_IMAGE:?") + + +def test_fullstack_compose_keeps_local_dev_build_with_agent_api_context(): + fullstack = _compose("docker-compose.fullstack.yml") + service = fullstack["services"]["matrix-bot"] + + assert service["build"]["target"] == "development" + assert service["build"]["additional_contexts"]["agent_api"] == "./external/platform-agent_api" + assert service["extends"]["file"] == "docker-compose.prod.yml" + + +def test_dockerfile_production_build_does_not_require_local_external_tree(): + dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8") + + assert "/app/external/platform-agent_api" not in dockerfile + assert "external/platform-agent_api" not in dockerfile + assert "git+https://git.lambda.coredump.ru/platform/agent_api.git" in dockerfile + assert "python -m pip install --no-cache-dir --ignore-requires-python" in dockerfile + assert "uv pip install --system --ignore-requires-python" not in dockerfile + + +def test_dockerignore_excludes_local_only_and_runtime_artifacts(): + dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8") + + assert "external/" in dockerignore + assert ".planning/" in dockerignore + assert "config/matrix-agents.yaml" in dockerignore + assert ".env" in dockerignore + + +def test_agent_registry_example_documents_multi_agent_volume_contract(): + registry = yaml.safe_load( + (ROOT / "config" / "matrix-agents.example.yaml").read_text(encoding="utf-8") + ) + agents = registry["agents"] + + assert len(agents) >= 3 + assert len({agent["id"] for agent in agents}) == len(agents) + assert len({agent["workspace_path"] for agent in agents}) == len(agents) + for index, agent in enumerate(agents): + assert agent["base_url"].endswith(f"/agent_{index}/") + assert agent["workspace_path"] == f"/agents/{index}"