feat(deploy): finalize MVP deployment and file transfer approach

This commit is contained in:
Mikhail Putilovskij 2026-05-02 23:45:52 +03:00
parent 6369721876
commit 0f79494fbe
43 changed files with 3078 additions and 645 deletions

View file

@ -211,7 +211,7 @@ async def test_invite_event_is_idempotent_per_user():
assert client.join.await_count == 2
assert client.room_create.await_count == 2
client.room_send.assert_awaited_once()
assert client.room_send.await_count == 2
async def test_bot_ignores_its_own_messages():
@ -348,7 +348,8 @@ async def test_bot_downloads_matrix_file_to_configured_agent_workspace(tmp_path,
base_url="http://lambda.coredump.ru:7000/agent_17/",
workspace_path=str(tmp_path / "agents" / "17"),
)
]
],
user_agents={"@alice:example.org": "agent-17"},
)
await set_room_meta(
runtime.store,
@ -381,7 +382,7 @@ async def test_bot_downloads_matrix_file_to_configured_agent_workspace(tmp_path,
staged = await get_staged_attachments(
runtime.store, "!chat17:example.org", "@alice:example.org"
)
assert staged[0]["workspace_path"].startswith("incoming/")
assert staged[0]["workspace_path"] == "report.pdf"
assert (
tmp_path / "agents" / "17" / staged[0]["workspace_path"]
).read_bytes() == b"%PDF-1.7"
@ -389,7 +390,7 @@ async def test_bot_downloads_matrix_file_to_configured_agent_workspace(tmp_path,
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 = tmp_path / "agents" / "17" / "result.txt"
output_file.parent.mkdir(parents=True)
output_file.write_text("ready", encoding="utf-8")
runtime = build_runtime(platform=MockPlatformClient())
@ -401,7 +402,8 @@ async def test_bot_uploads_agent_output_from_configured_agent_workspace(tmp_path
base_url="http://lambda.coredump.ru:7000/agent_17/",
workspace_path=str(tmp_path / "agents" / "17"),
)
]
],
user_agents={"@alice:example.org": "agent-17"},
)
await set_room_meta(
runtime.store,
@ -429,7 +431,7 @@ async def test_bot_uploads_agent_output_from_configured_agent_workspace(tmp_path
type="document",
filename="result.txt",
mime_type="text/plain",
workspace_path="output/result.txt",
workspace_path="result.txt",
)
],
)

View file

@ -4,29 +4,12 @@ from pathlib import Path
from types import SimpleNamespace
from adapter.matrix.files import (
build_agent_incoming_path,
build_workspace_attachment_path,
build_agent_workspace_path,
download_matrix_attachment,
)
from core.protocol import Attachment
def test_build_workspace_attachment_path_scopes_by_surface_user_and_room(tmp_path: Path):
rel_path, abs_path = build_workspace_attachment_path(
workspace_root=tmp_path,
matrix_user_id="@alice:example.org",
room_id="!room:example.org",
filename="report.pdf",
timestamp="20260420-153000",
)
assert (
rel_path
== "surfaces/matrix/alice_example.org/room_example.org/inbox/20260420-153000-report.pdf"
)
assert abs_path == tmp_path / rel_path
async def test_download_matrix_attachment_persists_file_and_returns_workspace_path(tmp_path: Path):
async def download(url: str):
assert url == "mxc://server/id"
@ -49,40 +32,46 @@ async def test_download_matrix_attachment_persists_file_and_returns_workspace_pa
timestamp="20260420-153000",
)
assert saved.workspace_path is not None
assert saved.workspace_path.endswith("20260420-153000-report.pdf")
assert (tmp_path / saved.workspace_path).read_bytes() == b"%PDF-1.7"
assert saved.workspace_path == "report.pdf"
assert (tmp_path / "report.pdf").read_bytes() == b"%PDF-1.7"
def test_build_workspace_attachment_path_keeps_room_safe_agents_relative_contract(tmp_path: Path):
rel_path, abs_path = build_workspace_attachment_path(
workspace_root=tmp_path / "agents" / "7",
matrix_user_id="@alice+bob:example.org",
room_id="!room/ops:example.org",
filename="quarterly status (final).pdf",
timestamp="20260420-153000",
)
assert rel_path == (
"surfaces/matrix/alice_bob_example.org/room_ops_example.org/inbox/"
"20260420-153000-quarterly_status_final_.pdf"
)
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(
def test_build_agent_workspace_path_uses_agent_workspace_volume(tmp_path: Path):
rel_path, abs_path = build_agent_workspace_path(
workspace_root=tmp_path / "agents" / "17",
filename="quarterly status.pdf",
timestamp="20260428-110000",
)
assert rel_path == "incoming/20260428-110000-quarterly_status.pdf"
assert rel_path == "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):
def test_build_agent_workspace_path_uses_windows_style_copy_index(tmp_path: Path):
workspace_root = tmp_path / "agents" / "17"
workspace_root.mkdir(parents=True)
(workspace_root / "report.pdf").write_bytes(b"old")
(workspace_root / "report (1).pdf").write_bytes(b"older")
rel_path, abs_path = build_agent_workspace_path(
workspace_root=workspace_root,
filename="report.pdf",
)
assert rel_path == "report (2).pdf"
assert abs_path == workspace_root / "report (2).pdf"
def test_build_agent_workspace_path_sanitizes_to_basename(tmp_path: Path):
rel_path, abs_path = build_agent_workspace_path(
workspace_root=tmp_path / "agents" / "17",
filename="../../quarterly: status?.pdf",
)
assert rel_path == "quarterly_ status_.pdf"
assert abs_path == tmp_path / "agents" / "17" / "quarterly_ status_.pdf"
async def test_download_matrix_attachment_uses_agent_workspace_root(tmp_path: Path):
async def download(url: str):
assert url == "mxc://server/id"
return SimpleNamespace(body=b"%PDF-1.7")
@ -101,5 +90,5 @@ async def test_download_matrix_attachment_uses_agent_workspace_incoming_dir(tmp_
timestamp="20260428-110000",
)
assert saved.workspace_path == "incoming/20260428-110000-report.pdf"
assert saved.workspace_path == "report.pdf"
assert (tmp_path / "agents" / "17" / saved.workspace_path).read_bytes() == b"%PDF-1.7"

View file

@ -7,7 +7,7 @@ from nio.api import RoomVisibility
from adapter.matrix.bot import build_runtime
from adapter.matrix.handlers.auth import handle_invite
from adapter.matrix.store import get_room_meta, get_user_meta, set_user_meta
from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta
from sdk.mock import MockPlatformClient
@ -100,6 +100,53 @@ async def test_mat02_invite_idempotent():
assert client.room_create.await_count == 2
async def test_existing_user_invite_reinvites_space_and_active_chats():
runtime = build_runtime(platform=MockPlatformClient())
await set_user_meta(
runtime.store,
"@alice:example.org",
{"space_id": "!space:example.org", "next_chat_index": 2},
)
await set_room_meta(
runtime.store,
"!chat1:example.org",
{
"room_type": "chat",
"chat_id": "C1",
"display_name": "Чат 1",
"matrix_user_id": "@alice:example.org",
"space_id": "!space:example.org",
"platform_chat_id": "1",
"agent_id": "agent-1",
},
)
await runtime.chat_mgr.get_or_create(
user_id="@alice:example.org",
chat_id="C1",
platform="matrix",
surface_ref="!chat1:example.org",
name="Чат 1",
)
client = _make_client()
room = SimpleNamespace(room_id="!dm:example.org", display_name="Alice")
event = SimpleNamespace(sender="@alice:example.org", membership="invite")
await handle_invite(
client,
room,
event,
runtime.platform,
runtime.store,
runtime.auth_mgr,
runtime.chat_mgr,
)
client.room_create.assert_not_awaited()
client.room_invite.assert_any_await("!space:example.org", "@alice:example.org")
client.room_invite.assert_any_await("!chat1:example.org", "@alice:example.org")
client.room_send.assert_awaited()
async def test_mat03_no_hardcoded_c1():
runtime = build_runtime(platform=MockPlatformClient())
await set_user_meta(runtime.store, "@alice:example.org", {"next_chat_index": 7})

View file

@ -4,6 +4,7 @@ import importlib
from types import SimpleNamespace
from unittest.mock import AsyncMock
from adapter.matrix.agent_registry import AgentDefinition, AgentRegistry
from adapter.matrix.bot import MatrixBot, build_runtime
from adapter.matrix.reconciliation import reconcile_startup_state
from adapter.matrix.store import get_room_meta, get_user_meta, set_room_meta, set_user_meta
@ -124,6 +125,55 @@ async def test_reconcile_startup_state_is_idempotent_with_existing_local_state()
assert chats[0].chat_id == "C3"
async def test_reconcile_updates_default_agent_assignment_after_user_is_configured():
runtime = build_runtime(platform=MockPlatformClient())
runtime.registry = AgentRegistry(
[
AgentDefinition("agent-default", "Default"),
AgentDefinition("agent-alice", "Alice"),
],
user_agents={"@alice:example.org": "agent-alice"},
)
client = SimpleNamespace(
user_id="@bot:example.org",
rooms={
"!space:example.org": _room(
"!space:example.org",
"Lambda - Alice",
["@bot:example.org", "@alice:example.org"],
),
"!chat3:example.org": _room(
"!chat3:example.org",
"Чат 3",
["@bot:example.org", "@alice:example.org"],
parents=("!space:example.org",),
),
},
)
await set_room_meta(
runtime.store,
"!chat3:example.org",
{
"room_type": "chat",
"chat_id": "C3",
"display_name": "Чат 3",
"matrix_user_id": "@alice:example.org",
"space_id": "!space:example.org",
"platform_chat_id": "42",
"agent_id": "agent-default",
"agent_assignment": "default",
},
)
await reconcile_startup_state(client, runtime)
room_meta = await get_room_meta(runtime.store, "!chat3:example.org")
assert room_meta is not None
assert room_meta["agent_id"] == "agent-alice"
assert room_meta["agent_assignment"] == "configured"
assert room_meta["platform_chat_id"] == "42"
async def test_reconciliation_prevents_lazy_bootstrap_for_existing_room():
runtime = build_runtime(platform=MockPlatformClient())
client = SimpleNamespace(

View file

@ -185,6 +185,24 @@ async def test_real_platform_client_send_message_uses_direct_agent_api_per_chat(
assert await prototype_state.get_last_tokens_used_for_context("chat-7") == 0
@pytest.mark.asyncio
async def test_real_platform_client_preserves_path_base_url_without_trailing_slash():
agent_api = FakeAgentApiFactory()
client = RealPlatformClient(
agent_id="agent-17",
agent_base_url="http://lambda.coredump.ru:7000/agent_17",
agent_api_cls=agent_api,
prototype_state=PrototypeStateStore(),
platform="matrix",
)
await client.send_message("@alice:example.org", "41", "hello")
assert agent_api.created_calls == [
("agent-17", "http://lambda.coredump.ru:7000/agent_17/", "41")
]
@pytest.mark.asyncio
async def test_real_platform_client_forwards_attachments_to_chat_api():
agent_api = FakeAgentApiFactory(chat_api_cls=AttachmentTrackingChatAgentApi)
@ -213,15 +231,15 @@ async def test_real_platform_client_forwards_attachments_to_chat_api():
def test_attachment_paths_normalize_workspace_roots_to_relative_paths():
attachments = [
Attachment(workspace_path="/workspace/output/report.pdf"),
Attachment(workspace_path="/agents/7/output/report.csv"),
Attachment(workspace_path="surfaces/matrix/alice/room/inbox/note.txt"),
Attachment(workspace_path="/workspace/report.pdf"),
Attachment(workspace_path="/agents/7/report.csv"),
Attachment(workspace_path="note.txt"),
]
assert RealPlatformClient._attachment_paths(attachments) == [
"output/report.pdf",
"output/report.csv",
"surfaces/matrix/alice/room/inbox/note.txt",
"report.pdf",
"report.csv",
"note.txt",
]
@ -257,9 +275,12 @@ async def test_real_platform_client_preserves_send_file_events_in_sync_result(mo
@pytest.mark.parametrize(
("location", "expected_workspace_path"),
[
("/workspace/output/report.pdf", "output/report.pdf"),
("/agents/7/output/report.pdf", "output/report.pdf"),
("surfaces/matrix/alice/room/inbox/report.pdf", "surfaces/matrix/alice/room/inbox/report.pdf"),
("/workspace/report.pdf", "report.pdf"),
("/agents/7/report.pdf", "report.pdf"),
(
"surfaces/matrix/alice/room/inbox/report.pdf",
"surfaces/matrix/alice/room/inbox/report.pdf",
),
],
)
def test_attachment_from_send_file_event_normalizes_shared_volume_paths(

View file

@ -0,0 +1,22 @@
from tools.check_matrix_agents import build_agent_ws_url
def test_build_agent_ws_url_preserves_path_prefix_without_trailing_slash():
assert (
build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17", "41")
== "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
)
def test_build_agent_ws_url_preserves_path_prefix_with_trailing_slash():
assert (
build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/", "41")
== "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
)
def test_build_agent_ws_url_accepts_existing_agent_ws_url():
assert (
build_agent_ws_url("http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/0/", "41")
== "http://lambda.coredump.ru:7000/agent_17/v1/agent_ws/41/"
)

View file

@ -39,6 +39,21 @@ def test_dockerfile_production_build_does_not_require_local_external_tree():
assert "uv pip install --system --ignore-requires-python" not in dockerfile
def test_dockerfile_installs_agent_api_after_final_uv_sync():
dockerfile = (ROOT / "Dockerfile").read_text(encoding="utf-8")
development = dockerfile.split("FROM base AS development", maxsplit=1)[1].split(
"FROM base AS production", maxsplit=1
)[0]
production = dockerfile.split("FROM base AS production", maxsplit=1)[1]
assert development.index("RUN uv sync --no-dev --frozen") < development.index(
"pip install --no-cache-dir --ignore-requires-python -e /agent_api/"
)
assert production.index("RUN uv sync --no-dev --frozen") < production.index(
"git+https://git.lambda.coredump.ru/platform/agent_api.git"
)
def test_dockerignore_excludes_local_only_and_runtime_artifacts():
dockerignore = (ROOT / ".dockerignore").read_text(encoding="utf-8")
@ -60,3 +75,28 @@ def test_agent_registry_example_documents_multi_agent_volume_contract():
for index, agent in enumerate(agents):
assert agent["base_url"].endswith(f"/agent_{index}/")
assert agent["workspace_path"] == f"/agents/{index}"
def test_smoke_compose_models_deploy_like_proxy_and_surface_checker():
smoke = _compose("docker-compose.smoke.yml")
assert set(smoke["services"]) >= {"surface-smoke", "agent-proxy", "agent-0", "agent-1"}
assert "tools.check_matrix_agents" in smoke["services"]["surface-smoke"]["command"]
assert smoke["services"]["agent-proxy"]["ports"] == ["${SMOKE_PROXY_PORT:-7000}:7000"]
def test_smoke_timeout_override_routes_one_agent_to_no_status_stub():
smoke_timeout = _compose("docker-compose.smoke.timeout.yml")
assert set(smoke_timeout["services"]) >= {"agent-proxy", "agent-no-status"}
def test_smoke_registry_targets_local_proxy_routes():
registry = yaml.safe_load(
(ROOT / "config" / "matrix-agents.smoke.yaml").read_text(encoding="utf-8")
)
assert [agent["base_url"] for agent in registry["agents"]] == [
"http://agent-proxy:7000/agent_0/",
"http://agent-proxy:7000/agent_1/",
]