Date: 2026-04-12
Relay Cloud — это self-hosted платформа для запуска полноценных dev-сред в облаке с доступом через тонкий нативный клиент. Основной сценарий — интерактивные сессии с AI-агентами (Claude Code, Aider и др.) и shell-доступ к удалённым проектам. Архитектурная аналогия — GitLab CI runner, но для интерактивных терминальных сессий, а не для batch-задач. Сервер — один Rust-бинарник, оркестрирующий Docker-контейнеры; клиент MVP — нативное macOS-приложение на Swift/SwiftUI/TCA. Каждая сессия изолирована в отдельном контейнере, а серверное состояние (проекты, сессии, workspaces) является source of truth, доступным с любого устройства. Android (Kotlin/Compose) и iOS/iPad планируются позже; iOS/iPad переиспользуют Swift-код с десктопа.
-
Прозрачность локальных и удалённых сессий — пользователь работает одинаково с локальной и удалённой сессией. Нет понятия "раннер" в UI — сервер прозрачно оркестрирует всё.
-
Переключение между устройствами — начать работу на десктопе, продолжить с мобилки, вернуться на десктоп. Сессия живёт на сервере независимо от клиентов.
-
Изоляция через контейнеры — каждая сессия в отдельном Docker-контейнере. Сессии не видят друг друга. Агенты работают безопасно.
-
Extensibility / pluggable architecture — каждый значимый компонент заменяем. Новые функции добавляются без переписывания core. Но: абстракция создаётся только при наличии реальной альтернативы, не "на всякий случай".
-
Performance-first UX — нативный UI. Минимальная latency: keystroke-to-screen <100ms LAN, <200ms internet. Никаких компромиссов в пользу универсальности.
-
Self-hosted, single binary — простой деплой: один бинарник, один конфиг,
relay-runner init && relay-runner start. Без внешних зависимостей кроме Docker. -
AI-agent first — основной сценарий: работа с Claude Code, Aider и другими AI-агентами. Профили и образы заточены под это. Shell — secondary use case.
-
Микросервисная расширяемость — серверная архитектура строится так, чтобы потом отдельные части (push-уведомления, CI/CD интеграция, тестирование) выносились в отдельные сервисы.
-
Нативные клиенты — MVP: macOS на Swift/SwiftUI/TCA. Позже: iOS/iPadOS (переиспользование Swift-кода с десктопа), Android (Kotlin/Compose). UI всегда нативный.
-
Проект как центральная сущность — всё отталкивается от проекта. Проект привязан к git repo. Внутри проекта — сессии. Сессия = worktree + контейнер + терминал.
-
In-app уведомления (MVP) — события о статусах сессий по gRPC stream / SSE. Push-уведомления (APNs/FCM), Telegram bot, email — позже, через pluggable notification system.
-
Конфигурируемые профили — предустановленные (claude, shell) + кастомные. Выбор Docker-образа при создании сессии.
-
Сервер = source of truth — remote state (проекты, сессии) хранится на сервере. Доступен с любого устройства. Локальные сессии — только на клиенте.
-
Docker exec с idle entrypoint — контейнер запускается с
sleep infinity, сессии черезdocker exec. Контейнер живёт пока сессия активна, PTY перезапускается. -
gRPC = data plane, REST = management plane — терминальный I/O через gRPC bidi streaming (latency-critical). CRUD проектов/сессий, events — через REST/JSON.
-
SQLite для метаданных — zero-config, один файл.
sqlx(async). WAL mode. -
Persistence при рестарте — контейнеры переживают рестарт сервера. PTY нет.
claude_session_idсохраняется для resume. -
OAuth login per session (MVP) — не API key.
claude loginв каждой сессии. Шаринг токена — позже.
| Решение | Выбор | Почему |
|---|---|---|
| Серверный стек | Rust (axum + tonic), один бинарник | Минимальный footprint, один деплой |
| Container runtime | Docker через bollard crate | Зрелый API, широкая поддержка |
| Метаданные | SQLite через sqlx (async) | Zero-config, WAL mode для конкурентности |
| Terminal streaming | gRPC bidi (tonic) | Binary framing, backpressure, минимальный overhead |
| Management API | REST/JSON (axum) | Проще итерировать, дебажить, мобильные клиенты |
| macOS клиент (MVP) | Swift/SwiftUI/TCA | Нативный UX, существующая кодовая база Relay |
| Android клиент (позже) | Kotlin/Compose | Нативный UX |
| iOS/iPad клиент (позже) | Swift/SwiftUI | Переиспользование Swift-кода с macOS десктопа |
| Auth (MVP) | Bearer token (argon2 hash) | Простота, уже реализовано |
| Discovery | mDNS (Bonjour) + ручной ввод | Уже реализовано в runner v2 |
| VT parsing | Server-side (vte) | Snapshot при reconnect |
| Git operations | tokio::process::Command (git CLI) | Async, не блокирует tokio runtime |
| Concurrency | tokio::sync::Semaphore per-project | Async-safe, не блокирует runtime |
| # | Решение | Альтернативы | Почему выбрано | Когда пересмотреть |
|---|---|---|---|---|
| D1 | Один бинарник (axum + tonic) | Отдельные сервисы | Простота деплоя, один auth layer | При горизонтальном масштабировании |
| D2 | REST для management, gRPC для streaming | Всё gRPC / всё REST | gRPC оптимален для bidi streaming; REST проще для CRUD | Если REST станет bottleneck |
| D3 | Docker exec, не attach | docker attach (entrypoint) | exec позволяет перезапускать PTY без пересоздания контейнера | Если latency overhead exec неприемлем |
| D4 | Cloud: 1 project = 1 git clone (метаданные проекта); 1 session = 1 отдельный session clone (projects/<id>/sessions/<uuid>) + 1 container. Local: default session (checkout dir) + worktree sessions |
Git worktree на сессию (shared .git) — вызывал lock-конфликты при конкурентных сессиях |
Session clone имеет собственный .git → нет lock-конфликтов, простой lifecycle (rm -rf удаляет сессию). Local: worktree эффективнее по диску, single-user — конфликты не проблема |
Никогда — фундаментальное решение |
| D5 | Сервер = source of truth | Клиент хранит state | Multi-device continuity требует серверного state | Никогда — фундаментальное решение |
| D6 | SQLite, не Postgres | PostgreSQL | Zero-config, один файл, достаточно для single-user | При multi-user или >1000 сессий |
| D7 | sqlx (async), не rusqlite (sync) | rusqlite + spawn_blocking | sqlx не блокирует tokio worker threads | Если sqlx SQLite backend нестабилен (fallback: rusqlite + spawn_blocking) |
| D8 | Git CLI через tokio::process для мутирующих операций; git2 для read-only через spawn_blocking | git2 полностью | Async, не блокирует runtime, стримит прогресс | Если нужны тонкие git операции без shell |
| D9 | Bearer token (MVP), не JWT | JWT, OAuth2 | Простота, один пользователь | При multi-user (Wave 3) |
| D10 | Shared bridge + --icc=false (MVP) |
Per-session networks | Достаточная изоляция при single-user; per-session networks при multi-user | При добавлении multi-user (Wave 3) |
| D11 | 2 traits для MVP (ContainerOrchestrator, NotificationTransport) | 7 traits | Остальные не имеют реальной альтернативы в roadmap | При появлении альтернативных реализаций |
| D12 | Native binary для production | Docker-in-Docker | Docker socket access безопаснее с хоста | Если нужен cloud-managed deployment |
| D13 | CORS отключён в MVP | AllowOrigin::any() | Web-клиента нет, wildcard создаёт attack vector без пользы | При добавлении web-клиента |
| D14 | Публичные git repos в MVP | SSH key / OAuth | Достаточно для dogfooding, приватные repos — Wave 1 | Wave 1 (GitHub OAuth) |
| D15 | MVP разбит на MVP-0 + MVP-1 | Один большой MVP | Реалистично для solo developer, быстрый time-to-value | После завершения MVP-1 |
| D16 | KMP отклонён. MVP = Swift macOS + Rust server | KMP shared layer для всех платформ | Фокус MVP на macOS desktop; KMP усложняет без пользы на одной платформе. Android нативный позже (Kotlin/Compose). iOS/iPad share Swift-код с десктопа | Если потребуется шаринг бизнес-логики между Android и iOS |
- Architecture Expert — 3 раунда (PASS WITH CONCERNS → PASS WITH CONCERNS → PASS)
- PoLL Cycle 1 — Security + Performance + DevOps (CONDITIONAL → fixes → PASS)
- PoLL Cycle 2 — Architecture + Business Analyst + Security + Performance + DevOps (CONDITIONAL → fixes → PASS)
Всего найдено и исправлено: ~45 issues (5 critical, 15 major, 25 minor/suggestions).
- Скачивает и устанавливает
relay-runnerна сервер relay-runner init— генерируется config, TLS, token, собирается Docker-образrelay-runner start— сервер запущен, token показан один раз- На маке открывает Relay → Add Server → вводит IP + token
- Создаёт проект: указывает имя + git URL публичного репо
- Ждёт клонирования (видит статус "Cloning...")
- Создаёт сессию: выбирает профиль "claude", ветку, образ
- Открывается терминал — внутри Claude Code CLI
- Выполняет
claude login(OAuth flow через терминал) - Работает с Claude Code как на локальной машине
Критерий: от скачивания до работающего Claude Code < 15 минут.
Открывает Relay → список проектов → подключается к активной сессии (VT snapshot) → работает в Claude Code → переключается на другой проект → закрывает Relay, сессии продолжают работать на сервере.
Критерий: переключение между сессиями < 2 секунды.
Видит список сессий (Running / Stopped) → подключается к Running-сессии → продолжает диалог → для Stopped нажимает Resume → claude --resume подхватывает контекст.
Критерий: при attach к Running-сессии — полный терминал за < 1 секунду.
(API-level в MVP, полный UX — при появлении мобильного клиента)
Работает на десктопе → закрывает крышку → сессия продолжает работать → открывает Relay на телефоне → подключается к той же сессии (десктоп автоматически отключается) → приходит домой → возобновляет на десктопе.
Критерий: attach с нового устройства < 3 секунды.
Создаёт сессию для feature/auth → создаёт вторую для fix/bug-123 → переключается между сессиями (полностью изолированы) → завершает работу → удаляет сессию (worktree + контейнер удаляются).
Критерий: создание второй сессии в том же проекте < 30 секунд.
Сервер рестартует → runner reconcile → сессии в Stopped → клиент переподключается → Resume → claude --resume подхватывает контекст.
Критерий: восстановление runner < 30 секунд. Данные не потеряны.
Пишет Dockerfile → docker build -t my-env:v1 . → добавляет в allowed_images → при создании сессии выбирает кастомный образ.
GET /api/v1/health → uptime, sessions, disk, memory → GET /api/v1/metrics → latency, throughput → логи runner'а (structured JSON).
Критерий: полная видимость состояния сервера из REST API без SSH доступа.
Видит сессию в Failed → нажимает → видит причину: "Container killed: out of memory (2GB limit exceeded)" → предложенные действия → выбирает "Пересоздать" → если claude_session_id сохранён — предлагается resume.
Критерий: error message содержит: (a) что произошло, (b) почему, (c) что делать. Время до понимания причины < 10 секунд.
- F1: Приватные репозитории через GitHub OAuth
- F2: Push-уведомления на мобилке (APNs/FCM)
- F3: Управление через Telegram-бот
- F4: Chat mode (диалоговый UI, как Aider web UI)
- F5: CI/CD интеграция (GitHub Actions)
- F6: Тестирование на устройствах (ADB, Remote Mac)
- F7: Multi-user (командная работа, RBAC)
- F8: Web-клиент (терминал в браузере)
- F9: Автономные задачи (claude работает без участия пользователя)
Каждый значимый компонент системы проектируется как заменяемый модуль за абстракцией (trait в Rust, protocol в Swift, interface в Kotlin).
| Компонент | Trait/Interface | MVP реализация | MVP статус | Возможные замены |
|---|---|---|---|---|
| Storage | MetadataStore |
SQLite | concrete struct | PostgreSQL, embedded KV |
| Container Runtime | ContainerOrchestrator |
Docker (bollard) | trait | Podman, containerd, Firecracker |
| Notification | NotificationTransport |
SSE (in-app) | trait | APNs, FCM, Telegram, Email |
| Auth | AuthProvider |
Bearer token | concrete struct | JWT, OAuth2, OIDC |
| Discovery | ServiceDiscovery |
mDNS (Bonjour) | concrete struct | Cloud registry, DNS-SD |
| Git Operations | GitProvider |
local git CLI | concrete struct | GitHub API, GitLab API |
| Session Profile | ProfileResolver |
config.toml | concrete struct | REST API, database |
MVP Policy: Только компоненты с реалистичной альтернативой в ближайшем roadmap получают trait-абстракцию в MVP. Остальные — конкретные реализации с чистым public API, что позволяет извлечь trait позже без рефакторинга потребителей. Не создавать абстракцию "на всякий случай".
- Terminal I/O через gRPC bidi streaming — binary framing, минимальный overhead, backpressure.
- Нативный UI — SwiftUI (macOS MVP). Android (Compose) и iOS/iPad (SwiftUI) — позже.
- Server-side VT parsing — snapshot при reconnect, мгновенное восстановление терминала.
- Async everywhere (Rust) — tokio runtime, zero-copy где возможно, bounded channels с backpressure.
- Connection resilience — exponential backoff с jitter при reconnect.
- TCP_NODELAY обязателен для gRPC listener — Nagle algorithm буферизирует маленькие пакеты до 40ms, что напрямую нарушает <100ms latency budget для keystroke I/O. Tonic server:
TcpListenerсset_nodelay(true). Клиентская сторона: аналогично для gRPC channel.
Метрика: keystroke-to-screen latency <100ms в LAN, <200ms через интернет. Это включает: клиент → gRPC → runner → docker exec stdin → PTY → stdout → docker exec → gRPC → клиент → render.
+----------------------------+
| macOS Client (MVP) |
| SwiftUI / TCA (Native) |
| Swift Packages |
| (networking, models, |
| terminal, state) |
+-------------|------------+
| mDNS discovery / manual URL
+------------------+
|
TLS + Bearer Token
|
+------------------+-------------------+
| gRPC (port 50051) + REST (port 8080) |
+------------------+-------------------+
| |
| RELAY RUNNER (Rust) |
| |
| TerminalSvc (gRPC/tonic) |
| ManagementSvc (REST/axum) |
| Auth Middleware |
| Session Manager |
| Project Manager |
| Docker Orchestrator (bollard) |
| SQLite Store (sqlx async) |
| |
+--------|-----------------------------+
|
Docker Engine: containers + volumes
Host Filesystem: {data_dir}/projects/ + relay.db
Project
id: UUID (PK)
name: String
git_url: String
local_path: String ← on-disk clone directory
default_image: Option<String> ← image resolution chain level 2
state: ProjectState
clone_error: Option<String>
created_at, updated_at: Timestamp
deleted_at: Option<Timestamp>
Session (1:N from Project)
id: UUID (PK)
project_id: FK
branch: String
image: String
container_id: Option<String>
profile: String
state: SessionState
work_dir: String ← isolated session workspace (session clone directory)
error_reason: Option<String>
created_at, updated_at: Timestamp
stopped_at: Option<Timestamp>
deleted_at: Option<Timestamp>
Terminal (1:N from Session)
id: UUID (PK)
session_id: FK
profile: String
state: TerminalState
created_at, updated_at: Timestamp
SessionProfile (in-memory / config.toml)
name, init_command, env_vars
Проект без хотя бы одной сессии — невалидное состояние на обеих платформах.
Local (клиент): проект всегда имеет default session — shell в checkout-директории (main worktree). Default session создаётся при добавлении проекта и удаляется только вместе с ним. Дополнительные сессии — git worktree от любых веток.
Cloud (сервер): POST /api/v1/projects создаёт Project и запускает git clone в фоне. После успешного клона (state → Ready) первая сессия создаётся автоматически: default branch репозитория, default image (из project.default_image → config.docker.default_image → ubuntu:24.04), первый сконфигурированный profile или "default". Если первый clone упал — повторная попытка при переходе в Ready также создаёт первую сессию. DELETE /api/v1/sessions/:id возвращает 400, если удаляется последняя non-deleted сессия проекта. Все сессии равноправны — нет понятия "default".
Cloud: каждая сессия = отдельный git clone проекта в собственной директории projects/<project_id>/sessions/<uuid> + отдельный Docker-контейнер. У каждого session clone собственный .git, поэтому нет lock-конфликтов между конкурентными сессиями и жизненный цикл сессии простой — rm -rf удаляет всё. Путь session clone передаётся в контейнер через RELAY_WORKSPACE_DIR.
Local: дополнительные сессии = git worktree add. Эффективнее по диску за счёт общего .git, приемлемо при single-user доступе, где lock-конфликты не проблема.
Starting | Running | Stopped | Failed
Starting | Running | Stopped | Failed
Cloning | Ready | Failed
gRPC :50051 TerminalService (tonic)
REST :8080 ManagementService (axum)
|
Auth Middleware (Bearer token)
|
Session Manager Project Manager
| |
Docker Orchestrator SQLite Store (sqlx async)
(bollard)
|
Docker API (/var/run/docker.sock)
TerminalService (gRPC) — latency-critical bidi-streaming PTY I/O (AttachSession), scrollback. Существующий сервис из runner v2.
ManagementService (REST) — CRUD проектов, сессий, events (SSE). JSON API. Отдельный порт (8080) в том же tokio runtime.
Auth Middleware — единый слой. MVP: Bearer token (один на сервер). AuthInterceptor для gRPC + middleware для REST. Общий token_hash в конфигурации.
Session Manager — управляет lifecycle сессий. In-memory карта активных сессий. Персистирует в SQLite.
SRP decomposition (в MVP — один файл, разные structs):
SessionLifecycle— state machine (Starting -> Running -> Stopped -> Failed), валидация переходовSessionRepository— SQLite CRUD для sessionsSessionCoordinator— оркестрация: координирует Docker + Git + lifecycle при создании/удалении сессииВ MVP все три struct в одном модуле (
session_manager.rs). Разделение на файлы/модули — при росте кодовой базы.
Project Manager — git clone через tokio::process::Command, управление worktrees, валидация git URL. Async requirement: все Git операции (clone, worktree add/remove, fetch) через tokio::process::Command (git CLI), не через git2. Нативно async, не блокирует runtime, стримит прогресс для SSE events. git2 допускается только для read-only запросов через spawn_blocking.
Docker Orchestrator (bollard) — создание/запуск/остановка/удаление контейнеров. Pull образов on demand. Volume mounts, resource limits, network policy.
PTY Manager — в cloud-режиме PTY создаётся внутри контейнера через docker exec. Контейнер запускается с idle entrypoint (sleep infinity), runner выполняет docker exec -it <container> <command>. Runner подключается к stdin/stdout exec-инстанса через bollard exec_start с attach и проксирует в gRPC stream.
SQLite Store — sqlx с SQLite backend (async). Выбор зафиксирован: rusqlite (sync) блокирует tokio worker threads при конкурентных записях. sqlx использует spawn_blocking внутри.
Docker exec stdout ──→ [mpsc channel, cap=512] ──→ gRPC stream sender
4KB frames, ~2MB max buffer
- Bounded
tokio::sync::mpscchannel между bollard reader task и gRPC sender task - Capacity: 512 frames × ~4KB = ~2MB max memory per session
- При заполнении канала: bollard reader task блокируется (async backpressure)
- Обратное направление (stdin): capacity 64 frames
MVP Scope: Для MVP реализуется только macOS клиент (Swift/SwiftUI/TCA). Мобильные клиенты планируются позже: iOS/iPad переиспользуют Swift-код с десктопа, Android — нативный на Kotlin/Compose. Все REST/gRPC API проектируются platform-agnostic.
| Слой | Технология | MVP |
|---|---|---|
| macOS Client | Swift/SwiftUI/TCA — проекты, сессии, терминал, gRPC/REST networking, Keychain | Да |
| iOS/iPadOS Client | Swift/SwiftUI — переиспользование Swift packages с macOS | Нет (позже) |
| Android Client | Kotlin/Compose + ViewModel | Нет (позже) |
Swift Packages: SharedModels, TerminalAbstraction, TerminalSwiftTerm, TerminalFeature, RemoteTerminal, PaneManager, WorktreeManager, AgentOrchestrator.
RemoteTerminal адаптируется под новый REST API, добавляются модули для project management.
Аутентификация: Authorization: Bearer <token>. Все endpoints возвращают JSON.
POST /api/v1/projects
Body: { "name": "my-project", "git_url": "https://github.com/user/repo.git" }
201: { "id": "uuid", "state": "cloning", ... }
GET /api/v1/projects?limit=50&offset=0
200: { "projects": [...] }
GET /api/v1/projects/{project_id}
200: { "id": ..., "state": "ready", "sessions": [...] }
DELETE /api/v1/projects/{project_id}
204 (останавливает все сессии, удаляет worktrees, удаляет clone)
POST /api/v1/projects/{project_id}/retry
200: { "id": ..., "state": "cloning" }
(повторяет git clone для проекта в состоянии Failed)
Git URL Validation (SSRF prevention):
- Whitelist протоколов: только
https://.file://,git://,http://— запрещены. - DNS resolution до clone. IP проверяется против blocked ranges:
10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,127.0.0.0/8,::1/128,fc00::/7. - DNS rebinding protection: resolved IP pinning при clone на хосте.
- Timeout:
git.clone_timeout_s = 600(10 минут). - Redirect следование: отключено (
GIT_TERMINAL_PROMPT=0).
Private repos: отложены до Wave 1. MVP — только публичные репозитории. GitHub OAuth — в roadmap.
POST /api/v1/projects/{project_id}/sessions
Body: { "branch": "feature/foo", "docker_image": "relay-dev:latest", "profile": "claude" }
201: { "id": "uuid", "state": "starting", "project_id": ..., "branch": ..., "image": ..., ... }
GET /api/v1/projects/{project_id}/sessions
GET /api/v1/sessions?limit=50&offset=0
GET /api/v1/sessions/{session_id}
POST /api/v1/sessions/{session_id}/stop
200: { "id": ..., "state": "stopped" }
(останавливает контейнер; PTY умирает; контейнер и volume сохраняются)
POST /api/v1/sessions/{session_id}/resume
200: { "id": ..., "state": "running", "claude_session_id": "..." }
(перезапускает контейнер из Stopped; клиент может `claude --resume <id>`)
POST /api/v1/sessions/{session_id}/retry
200: { "id": ..., "state": "starting" }
(пересоздаёт контейнер в существующем worktree; только из Failed)
DELETE /api/v1/sessions/{session_id}
204 (удаляет контейнер, volume, worktree)
retry vs resume:
- resume: перезапускает существующий контейнер + новый
docker exec. Только изStopped. - retry: пересоздаёт контейнер с нуля в том же worktree. Только из
Failed. Worktree иclaude_session_idсохраняются.
GET /api/v1/events?project_id=...&session_id=...
Content-Type: text/event-stream
SSE Heartbeat: event: heartbeat\ndata: {}\n\n каждые 15 секунд (named event, не comment).
MVP-0 contract: в MVP-0 сервер отправляет только события из таблицы ниже. Клиенты MVP-0 не должны ожидать другие события.
| Event | Data | Описание | Status |
|---|---|---|---|
project_state_changed |
{ project_id, old_state, new_state, error? } |
Clone started/completed/failed | MVP-0 |
session_state_changed |
{ session_id, old_state, new_state, error?, exit_code? } |
Starting/Running/Stopped/Failed | MVP-0 |
heartbeat |
{} |
Keep-alive каждые 15s | MVP-0 |
sync_required |
{ reason } |
Replay невозможен — клиент должен сделать full resync | MVP-0 |
Post-MVP (зарезервированы, не эмитируются в MVP-0):
| Event | Data | Описание |
|---|---|---|
image_pull_progress |
{ session_id, percent, downloaded_mb, total_mb } |
Во время Starting |
system_warning |
{ type, message, details } |
Low disk, high load |
system_error |
{ type, message } |
Docker daemon lost |
server_maintenance |
{ action, message } |
Shutdown, upgrade |
session_output |
{ session_id, stream, chunk, seq } |
Streaming terminal output |
Все SSE event names в snake_case (event: session_state_changed). Wire format совпадает с именами в таблицах выше.
GET /api/v1/health (Bearer required) → { version, api_version, uptime_seconds,
sessions, docker, disk, memory }
GET /healthz (без auth) → `{"status":"ok"}` / 503 (для load balancer, без version/details)
GET /api/v1/metrics (Bearer required) → JSON snapshot метрик
GET /api/v1/openapi.json (без auth) → OpenAPI 3.x spec
Единственный источник правды для REST-контракта —
GET /api/v1/openapi.json. Spec генерируется из кода черезutoipa; ручное поддержание отдельного YAML-файла не требуется. При изменении хендлеров spec обновляется автоматически.
GET /api/v1/images → { "images": [{ "name": "relay-dev:latest", "size": "..." }] }
GET /api/v1/profiles → { "profiles": [{ "name": "claude", "description": "...", "command": "..." }] }
Пакет relay.terminal.v1.
В cloud-режиме data plane только:
AttachSession(bidi stream) — подключение к терминалу сессии, созданной через REST. При attach отправляет VT snapshot (последниеsnapshot_max_lines = 500строк). Полный scrollback — черезGetScrollbackGetScrollback— буфер прокруткиRefreshToken— обновление токена
CreateSession, ListSessions, TerminateSession — deprecated в cloud-режиме. Cloud-режим включается явно: cloud_mode: true в config или RELAY_CLOUD_MODE=true в окружении; наличие [docker] секции само по себе его не включает. В этом режиме методы возвращают FAILED_PRECONDITION: "Use REST API for session management in cloud mode". Сохраняются для backward compat с v2 клиентом.
Клиент ДОЛЖЕН игнорировать неизвестные JSON-поля в ответах сервера (forward-compatible). Сервер может добавлять новые поля в существующие ответы без версионирования API. Клиент НЕ ДОЛЖЕН отправлять неизвестные поля — сервер возвращает 400 при strict parsing.
Клиент по умолчанию ТРЕБУЕТ TLS. Plaintext допускается только при явном allowInsecure: true. При plaintext клиент отображает предупреждение: "Connection is not encrypted. Bearer token transmitted in plaintext."
Post-MVP. Не реализуется в MVP. Клиент повторяет запрос при неудаче без гарантии идемпотентности.
400— невалидный запрос (плохой git URL, несуществующий profile)401— невалидный или отсутствующий токен403— образ не из whitelist404— проект/сессия не найдены409— конфликт (лимит сессий, ветка уже занята)500— внутренняя ошибка507— недостаточно места на диске
POST /sessions
|
v
+-----------+
| Starting |
+-----------+
|
+-----------+ +-----------+
| Running | | Failed |
+-----------+ +-----------+
attach/detach |
(gRPC) retry → Starting
stop (REST) delete → Deleted
|
+-----------+ +-----------+
| Stopped | | Deleted |
+-----------+ +-----------+
resume → Running
delete → Deleted
Global creation timeout: Starting ограничен session_creation_timeout_s = 1200 (20 минут). При timeout → Failed.
Recovery из Failed:
retry: пересоздаёт контейнер (Failed → Starting → Running). Worktree иclaude_session_idсохраняются.delete: полное удаление.
POST /projects → 201 { "state": "cloning" }
|
+-----------+ success +-------+
| Cloning | ─────────────→| Ready |
+-----------+ +-------+
| failure
+-----------+
| Failed | (retry available)
+-----------+
Клиент подписывается на SSE. Создание сессий доступно только из Ready.
- Docker-контейнеры сохраняются; PTY-процессы мертвы
- Runner читает SQLite → reconcile
- Running сессии → Stopped (PTY dead)
claude_session_idсохранён → resume доступен
Максимум одно устройство на сессию. При новом подключении — старое соединение закрывается (SessionEvent::Detached).
Три механизма обнаружения завершения exec-процесса:
- Primary: Bollard reader task обнаруживает EOF на stdout exec stream → session →
Stopped - Watchdog: Background task делает
exec_inspectкаждые 5s для exec instances без активного reader (например, после сетевого обрыва) - Reconciliation: При расхождении (exec dead, stream alive) — принудительное закрытие stream, session →
Stopped
Watchdog запускается как отдельный tokio task при создании сессии, завершается при удалении.
exit code 0 → Stopped; non-zero → Stopped с error field. Контейнер продолжает работать (sleep infinity). SSE: session_state_changed: stopped. Клиент показывает: "Сессия завершена" + "Перезапустить".
Runner обнаруживает через Docker events API. Session → Failed, error: "Container killed: out of memory". Клиент показывает: "Сессия завершена: недостаточно памяти (лимит 2GB)". Кнопки: "Пересоздать" / "Удалить".
Session → Failed, error: "Failed to pull image: {reason}". Проект остаётся Ready. Клиент показывает: "Не удалось загрузить образ: {reason}". Кнопки: "Повторить" / "Выбрать другой образ" / "Удалить сессию".
Project → Failed, error: "Clone failed: {reason}". Клиент показывает: "Клонирование не удалось: {reason}". Кнопки: "Повторить" / "Удалить проект".
< 1 GB: warning + SSE system_warning: low_disk_space. < 100 MB: блокировка создания новых проектов/сессий, 507. Клиент показывает: "Мало места на сервере ({available} свободно)".
Pre-check перед disk-intensive ops:
- Перед
git clone: ≥ 1GB свободно - Перед
docker pull: ≥ 2GB свободно - Перед
git worktree add: ≥ 500MB свободно
Session Manager проверяет перед git worktree add. При конфликте → 409 Conflict: branch 'feature/x' already has active session. Клиент показывает: "Ветка {branch} уже используется в другой сессии".
Сериализуется через DashMap<ProjectId, Arc<tokio::sync::Semaphore(1)>> (не std::sync::Mutex — нельзя удерживать через .await). При одной ветке → 409. При разных → оба успешно (если лимит позволяет).
Cleanup: При удалении проекта (DELETE /projects/{id}) — entry удаляется из DashMap. Periodic cleanup (каждые 10 минут) удаляет entries для проектов, которых нет в SQLite (защита от утечки памяти при crash).
Все Running-сессии → Failed, error: "Docker daemon connection lost". SSE: system_error: docker_daemon_unavailable. Runner входит в reconnect loop (exponential backoff). При восстановлении — полный reconcile. Клиент показывает: "Сервер потерял связь с Docker. Все сессии остановлены."
{data_dir}/ # Default: ~/.local/share/relay/
relay.db # SQLite (permissions: 0600)
projects/
{project-id}/
repo/ # git bare clone
worktrees/
{session-id}/ # git worktree
backups/ # SQLite backups
config.toml
{data_dir}/projects/{project-id}/worktrees/{session-id}
↓ bind mount ↓
/workspace (внутри контейнера, рабочая директория)
Полная спецификация — в §13. Минимальный пример для быстрого старта:
port = 50051
rest_port = 8080
data_dir = "/var/lib/relay"
[docker]
default_image = "relay-dev:latest"
[auth]
token_hash = "..." # relay-runner hash-token <token>Server config.toml (global defaults)
↓ overridden by
Project config (per-project, SQLite)
↓ overridden by
Session creation request (per-session)
| Параметр | Default | Описание |
|---|---|---|
data_dir |
~/.local/share/relay/ |
Корень хранения проектов и SQLite |
port |
50051 |
gRPC порт |
rest_port |
8080 |
REST API порт |
max_sessions_total |
20 |
Максимум параллельных сессий |
max_sessions_per_project |
5 |
Максимум сессий на один проект |
scrollback_lines |
10000 |
Размер VT scrollback буфера |
snapshot_max_lines |
500 |
Максимум строк в VT snapshot при attach (отдельно от scrollback). Полный scrollback — через GetScrollback |
session_creation_timeout_s |
1200 |
Общий timeout на создание сессии (pull + create + start) |
| Параметр | Default | Описание |
|---|---|---|
docker.socket |
unix:///var/run/docker.sock |
Docker daemon socket |
docker.default_image |
relay-dev:latest |
Образ по умолчанию |
docker.allowed_images |
["relay-dev:latest", "ubuntu:24.04"] |
Whitelist образов. Пустой = все разрешены (warning) |
docker.cpu_limit |
2.0 |
CPU лимит на контейнер |
docker.memory_limit |
"2g" |
RAM лимит |
docker.memory_swap |
"2g" |
RAM+swap лимит (= memory_limit → swap выключен) |
docker.pids_limit |
256 |
Максимум процессов |
docker.network_mode |
"shared" |
shared: общая bridge-сеть с --icc=false. "per-session" — отдельная сеть на сессию (при multi-user) |
docker.icc |
false |
Inter-container communication. Только при network_mode = "shared" |
docker.log_max_size |
"50m" |
Максимальный размер лог-файла контейнера |
docker.log_max_files |
3 |
Количество ротируемых лог-файлов (3 × 50m = 150m) |
docker.pull_timeout_s |
900 |
Timeout на docker pull |
[[profiles]]
name = "claude"
command = "/usr/bin/claude" # Absolute path, direct exec (не sh -c)
args = []
env = {}
docker_image = "" # Override (пусто = docker.default_image)
description = "Claude Code AI agent"
[[profiles]]
name = "shell"
command = "/bin/zsh"
description = "Interactive shell"Команда в command — только absolute path. Relative path отклоняется при validation на старте runner. Выполняется через bollard::exec::CreateExecOptions { cmd: vec![command, ...args] } — shell interpretation отсутствует.
[tls]
cert_path = "/etc/relay/server.crt"
key_path = "/etc/relay/server.key"
ca_cert_path = "/etc/relay/ca.crt"
require_mtls = false
[auth]
token_hash = "..." # argon2 hash
[mdns]
enabled = true
instance_name = "My Relay Runner"| Параметр | Default | Описание |
|---|---|---|
git.clone_timeout_s |
600 |
Timeout на git clone |
git.allowed_protocols |
["https"] |
SSRF prevention. ssh — опционально |
git.min_free_disk_gb |
1.0 |
Минимум свободного места для clone |
| Endpoint | Лимит | Окно |
|---|---|---|
POST /projects |
10 req | 1 min |
POST /sessions |
20 req | 1 min |
POST /sessions/*/stop,resume,retry |
30 req | 1 min |
GET /* |
100 req | 1 min |
GET /api/v1/events (SSE) |
10 connections | per token |
Реализация: tower middleware, in-memory sliding window per Bearer token.
MVP: CORS middleware отключён — web-клиента нет, wildcard создаёт attack vector без пользы.
При добавлении web-клиента:
[cors]
allowed_origins = ["https://relay.example.com"] # явный whitelist, не wildcard| Параметр | Default | Описание |
|---|---|---|
backup.enabled |
true |
Автоматический backup SQLite |
backup.dir |
{data_dir}/backups/ |
Директория для backup'ов |
backup.retention_days |
7 |
Retention |
Создаётся при POST /projects, изменяется через PATCH /projects/{id} (будущее).
| Параметр | Описание |
|---|---|
name |
Имя проекта |
git_url |
URL репозитория |
default_branch |
Ветка по умолчанию |
default_image |
Docker-образ по умолчанию (override server) |
default_profile |
Профиль по умолчанию (override server) |
| Параметр | Хранилище | Описание |
|---|---|---|
| Server list | Keychain | Список серверов |
| Bearer tokens | Keychain | Токены авторизации per server |
| TLS certificates | Keychain | CA certs, client certs для mTLS |
| Last active project | UserDefaults | Восстановление контекста |
| UI preferences | UserDefaults | Тема, шрифт |
allowInsecure per server |
Keychain | Разрешить plaintext gRPC |
| Файл | Описание |
|---|---|
config.toml |
Конфигурация с defaults, token hash, TLS paths |
server.crt + server.key |
TLS сертификат (self-signed CA) |
ca.crt + ca.key |
Certificate Authority для mTLS |
relay.db |
SQLite база (пустая, с миграциями) |
data_dir/projects/, backups/ |
Директории |
| stdout: Bearer token | Отображается один раз, хешируется в config |
| Изменение | Требует рестарта? |
|---|---|
| Порты, TLS, token_hash | Да |
| Docker limits, profiles, allowed_images | Нет (применяется к новым) |
| Rate limits | Да (in-memory) |
| mDNS | Да |
| Метод | MVP | Будущее |
|---|---|---|
| Bearer token (argon2 hash) | Да | JWT с expiration |
| mTLS (клиентский сертификат) | Да (опционально) | Сохраняется |
| OAuth (Claude login per session) | Да | Shared refresh token |
relay-runner rotate-token
# Генерирует новый Bearer token, обновляет argon2 hash в config.toml
# Показывает новый token один раз в stdout
# Требует рестарт сервера для применения (hot reload — TODO)Команда для ротации токена без переинициализации всего конфига. Рабочий процесс: rotate-token -> скопировать новый token -> systemctl restart relay-runner -> обновить token в клиенте.
CRITICAL: mTLS client identity ДОЛЖНА извлекаться из TLS peer certificate (tonic::transport::server::TlsConnectInfo::peer_certs()), НЕ из gRPC metadata headers. Headers x-client-cert-cn, x-client-cert-fingerprint могут быть подделаны клиентом. Auth middleware ОБЯЗАН strip все x-client-cert-* headers из входящих запросов. Tonic interceptor удаляет metadata keys с префиксом x-client-cert- на самом раннем этапе.
- TLS обязателен для production.
relay-runner initгенерирует CA + server cert. - TOFU — клиент верифицирует fingerprint сервера при первом подключении.
- No
--privileged, no Docker socket inside container - Resource limits: CPU, RAM, PIDs (настраиваемые)
- Network isolation (MVP): shared bridge network с
--icc=false(запрет inter-container traffic). Достаточно при single-user. При multi-user — переход на per-session Docker networks (docker network create relay-session-{id}, удаляется вместе с сессией). - Seccomp profile (
runner/seccomp-profile.json) - Filesystem: только worktree смонтирован read-write
- Whitelist
allowed_imagesв config.toml - POST /sessions с образом не из whitelist →
403 Forbidden - Пустой whitelist = все разрешены (warning при старте: "Docker image whitelist is empty")
commandв profile — только absolute path (validated при старте runner)- Выполняется через direct exec, не через
sh -c - Profile name из API — strict exact match против config.toml
- OAuth tokens Claude — внутри контейнера
- Серверный Bearer token не передаётся в контейнер
claude_session_idхранится на сервере (SQLite)
Native binary (рекомендуемый): systemd service, пользователь relay (не root), группа docker.
/etc/systemd/system/relay-runner.service:
[Unit]
Description=Relay Runner
After=docker.service
Requires=docker.service
[Service]
Type=simple
User=relay
Group=docker
ExecStart=/usr/local/bin/relay-runner serve
Restart=on-failure
RestartSec=5
Environment=RUST_LOG=info
# Hardening
NoNewPrivileges=yes
ProtectSystem=strict
ReadWritePaths=/var/lib/relay
PrivateTmp=yes
[Install]
WantedBy=multi-user.targetАктивация: systemctl enable --now relay-runner
Docker Compose (dev/demo): Docker socket mount — runner управляет sibling-контейнерами. Менее изолировано, приемлемо для single-user.
Docker socket security: Прямой mount
/var/run/docker.sockдаёт runner полный доступ к Docker API (эквивалент root на хосте). Риски: escape из runner-процесса даёт контроль над всеми контейнерами и хост-файловой системой.Рекомендация для Docker Compose deployment: использовать socket proxy (tecnativa/docker-socket-proxy) с API whitelist:
docker-socket-proxy: image: tecnativa/docker-socket-proxy:0.2 environment: CONTAINERS: 1 EXEC: 1 IMAGES: 1 NETWORKS: 1 VOLUMES: 1 POST: 1 # Всё остальное отключено (SWARM, NODES, etc.) volumes: - /var/run/docker.sock:/var/run/docker.sock:roRunner подключается к
tcp://docker-socket-proxy:2375вместо прямого socket mount.
[Client] →TLS→ [Runner (non-root)] →Docker API→ [Session Container]
- no Docker socket
- no privileged
- seccomp, resource limits
- dedicated network
При plaintext gRPC клиент отображает: "Connection is not encrypted. Bearer token transmitted in plaintext."
Server Events
|
v
Event Bus (tokio broadcast channel)
|
+---> SSE Handler (in-app, MVP)
+---> APNs Notifier (Wave 3)
+---> FCM Notifier (Wave 3)
+---> Telegram Bot (Wave 3)
+---> Email Sender (Wave 3)
+---> WebSocket Handler (Wave 3)
Per-connection buffer: Каждое SSE-соединение получает bounded mpsc channel (capacity = 256). Event bus broadcaster отправляет в channel; SSE handler читает из channel и пишет в HTTP stream.
Обработка lagged subscribers: Основной путь — replay по Last-Event-ID; sync_required — fallback только когда replay невозможен.
При реконнекте с Last-Event-ID:
- Сервер воспроизводит пропущенные события из ring buffer (best-effort, в порядке возрастания id)
- После replay клиент продолжает получать новые события из stream
Если Last-Event-ID отсутствует или вышел за окно ring buffer:
- Сервер отправляет
event: sync_required\ndata: {"reason":"replay_window_exceeded"} - Клиент восстанавливает состояние через
GET /api/v1/projects,GET /api/v1/sessionsи detail-вызовы для открытых сущностей - После full resync клиент продолжает получать новые events из stream
При заполнении per-connection buffer (channel full) без реконнекта:
- Текущее SSE-соединение завершается сервером
- Клиент переподключается с последним полученным
Last-Event-ID→ replay path выше
| Internal Event | SSE Wire Event | Данные |
|---|---|---|
session_created |
session_state_changed |
new_state: "starting" |
session_running |
session_state_changed |
new_state: "running" |
session_stopped |
session_state_changed |
new_state: "stopped" |
session_failed |
session_state_changed |
new_state: "failed", error: "..." |
session_completed |
session_state_changed |
new_state: "stopped", exit_code: 0 |
SSE каталог в §10 — каноническое описание wire format. Notification Architecture описывает internal domain semantics.
GET /api/v1/events?project_id=...&session_id=...
MVP: конкретная реализация SSE без trait-абстракции. Trait NotificationTransport извлекается при добавлении второго транспорта (Wave 3).
| Параметр | Минимум | Рекомендуемый |
|---|---|---|
| CPU | 4 cores | 8 cores |
| RAM | 8 GB | 16 GB |
| Disk | 50 GB SSD | 100 GB NVMe SSD |
| Network | 100 Mbps | 1 Gbps |
| OS | Ubuntu 24.04 LTS | Ubuntu 24.04 LTS |
| Docker | 24.0+ | 27.0+ |
При 8 GB RAM: 3-4 параллельных сессии. При 16 GB RAM: 6-8.
- Git clone: 100-500 MB per project
- Worktree: ~50 MB (hard links)
- Docker image: ~1-2 GB
- Container layer: ~100-500 MB per session
- 10 проектов × 2 worktrees: ~15-20 GB
FROM node:20.18.1-bookworm AS node-base
FROM ubuntu:24.04
# Pin versions for reproducibility
ARG CLAUDE_CODE_VERSION=2.1.104
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl zsh openssh-client build-essential ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# Node.js — copy from official image (no curl|sh)
COPY --from=node-base /usr/local/bin/node /usr/local/bin/node
COPY --from=node-base /usr/local/lib/node_modules /usr/local/lib/node_modules
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm \
&& ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
# Claude Code CLI — pinned version
RUN npm install -g @anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}
# Non-root user
RUN useradd -m -u 1000 -s /bin/zsh relay
USER relay
ENV SHELL=/bin/zsh
WORKDIR /workspace
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD sh -c "pgrep -fx 'sleep infinity' > /dev/null"
# Idle entrypoint — runner uses docker exec for sessions
CMD ["sleep", "infinity"]Security notes: Образ использует multi-stage copy из official Node.js image вместо
curl | sh. Версии пакетов зафиксированы (не@latest). При обновлении — менятьCLAUDE_CODE_VERSIONиnode-basetag явно.
Интеграция с внешними сервисами (CI/CD, mobile testing, browser testing, Remote Mac) запланирована в Wave 4. Детальная архитектура (trait ExternalService, ServiceRegistry, MCP интеграция) описана в relay-cloud-external-services-vision.md.
MVP: внешние сервисы не поддерживаются. Контейнеры имеют outbound internet access для npm/pip/cargo/git/Anthropic API.
Уже реализовано: tracing + tracing-subscriber с JSON formatter. Каждый log entry содержит session_id, project_id, container_id (где применимо). Docker container logs через bollard LogsOptions.
| Метрика | Тип | Описание |
|---|---|---|
relay_sessions_active |
gauge | Активных сессий |
relay_sessions_total |
counter | Всего создано |
relay_terminal_latency_ms |
histogram | Keystroke-to-screen |
relay_grpc_stream_bytes |
counter | Объём через gRPC streams |
relay_docker_container_cpu |
gauge | CPU per container |
relay_docker_container_memory |
gauge | RAM per container |
relay_rest_request_duration_ms |
histogram | Latency REST endpoints |
relay_git_clone_duration_s |
histogram | Время git clone |
relay_docker_pull_duration_s |
histogram | Время docker pull |
// MVP: concrete struct, не trait
struct InMemoryMetrics { ... }
impl InMemoryMetrics {
fn record(&self, metric: MetricEvent) { ... }
fn snapshot(&self) -> MetricsSnapshot { ... }
}
// Trait извлекается при появлении Prometheus/Datadog exporterMVP: метрики через GET /api/v1/metrics (JSON). Позже: Prometheus exporter (/metrics).
Client → [1] → gRPC → [2] → Runner → [3] → Docker stdin → [4] → PTY → stdout
→ [5] → Docker stdout → [6] → Runner → [7] → gRPC → [8] → Client render
Target: [1]–[8] < 100ms LAN, < 200ms internet.
tracing-opentelemetry → Jaeger, Tempo, Datadog, Honeycomb. Trace ID пробрасывается клиент → REST/gRPC → session manager → Docker → container.
1. Parse config.toml → validate all fields
2. Check data_dir exists and writable
3. Connect to Docker daemon → verify version ≥ 24.0
4. Run SQLite migrations (create/upgrade schema)
5. Reconcile state: SQLite sessions ↔ Docker containers
- Container running, session "Running" → mark "Stopped" (PTY dead)
- Container exited/paused/dead, session "Running" → mark "Stopped"
- Container restarting → wait 30s, then apply same logic
- Reconciliation запускается параллельно для всех сессий (не блокирует startup остальных)
- Session in DB, no container → mark "Failed"
- Container with label relay.session_id, no session in DB → remove orphan
5b. Cleanup orphans: Docker networks relay-session-* без matching session → delete (при `network_mode = "per-session"`)
6. Start gRPC listener (TerminalService)
7. Start REST listener (ManagementService)
8. Start mDNS advertiser (if enabled)
9. Log: "Relay Runner started, gRPC :50051, REST :8080"
1. Stop accepting new connections
2. Send SSE event: server_maintenance: shutting_down
3. Wait for active REST requests (timeout: 5s)
4. Detach all gRPC terminal streams
5. Stop mDNS advertiser
6. Flush metrics / logs
7. Close SQLite connection
8. Exit 0
Контейнеры НЕ останавливаются.
| Проверка | Критичность | При ошибке |
|---|---|---|
data_dir exists & writable |
Critical | Exit |
| Docker socket accessible | Critical | Exit |
| Docker version ≥ 24.0 | Critical | Exit |
| gRPC/REST ports available | Critical | Exit |
| TLS cert/key readable (if enabled) | Critical | Exit |
token_hash present |
Critical | Exit |
| Default docker image pullable | Warning | Continue |
| mDNS interface available | Warning | Disable mDNS |
| Disk space > 1 GB free | Warning | Continue |
allowed_images пуст |
Warning | "Docker image whitelist is empty" |
Docker log driver не json-file/local |
Warning | "Docker log driver {driver} may not support log reading via API" |
Обязательные PRAGMA при первом создании БД (и при каждом подключении):
PRAGMA journal_mode=WAL; -- конкурентные reads не блокируются writes
PRAGMA synchronous=NORMAL; -- баланс durability/performance
PRAGMA busy_timeout=5000; -- 5s retry вместо немедленного SQLITE_BUSY
PRAGMA foreign_keys=ON; -- referential integritySchema versioning через PRAGMA user_version. Каждая миграция — в транзакции. Миграции встроены в бинарник.
Rollback protection: При startup runner проверяет PRAGMA user_version против максимальной поддерживаемой версии. Если БД имеет более высокую версию (downgrade scenario) — exit с ошибкой. Автоматический backup SQLite перед каждым upgrade (relay.db.pre-migration-{version}).
Writer pool: max_connections = 1 для write pool (WAL позволяет только одного writer), max_connections = N (по умолчанию 4) для read pool. Это предотвращает SQLITE_BUSY при конкурентных записях.
Tower middleware, in-memory sliding window per Bearer token.
REST: { "error": { "code": "SESSION_LIMIT_EXCEEDED", "message": "..." } }. Внутренние ошибки — sanitized message клиенту, полный контекст в логах.
MVP: отключён (см. §13). При web-клиенте — явный whitelist из config.
SQLite: SQLite Online Backup API (корректно при активных транзакциях). Реализация: systemd timer.
Project volumes ({data_dir}/projects/): Содержат git bare clones и worktrees. Включать в backup при необходимости сохранения uncommitted work. Альтернатива: не бэкапировать worktrees, пользователь должен git push для сохранения работы. Bare clones восстанавливаются через git clone при наличии remote.
Рекомендация: документировать в user guide что worktrees не являются резервной копией —
git pushобязателен для сохранения работы.
/etc/systemd/system/relay-runner-backup.service:
[Unit]
Description=Relay Runner SQLite Backup
[Service]
Type=oneshot
ExecStart=/bin/sh -c '/usr/local/bin/relay-runner backup --output /var/lib/relay/backups/relay-$(date +%%Y%%m%%d).db'
User=relay/etc/systemd/system/relay-runner-backup.timer:
[Unit]
Description=Daily Relay Runner Backup
[Timer]
OnCalendar=daily
Persistent=true
[Install]
WantedBy=timers.targetАктивация: systemctl enable --now relay-runner-backup.timer
Восстановление: relay-runner restore --input /var/lib/relay/backups/relay-20260412.db
GitHub Actions, trigger: push tag v*. Matrix: x86_64-unknown-linux-musl, aarch64-unknown-linux-musl (через cross). Static musl binary. Артефакты + SHA-256 checksums прикрепляются к GitHub Release.
# Prerequisite: Docker Engine 24.0+ установлен и запущен
# Проверка: docker --version && docker info
curl -fsSL https://github.com/user/relay/releases/latest/download/install.sh | sh
# Верификация SHA-256 checksum перед выполнением
relay-runner init # config, TLS certs, token, Docker image
relay-runner start # "Relay Runner started, gRPC :50051, REST :8080, token: <once>"3 шага, ~10-15 минут (основное время — docker build/pull образа).
Ускорение: docker pull relay-dev:latest из registry → ~5 минут.
- Проверка новой версии: поле
versionвGET /api/v1/health cp /usr/local/bin/relay-runner /usr/local/bin/relay-runner.baksystemctl stop relay-runner— graceful shutdown- Замена бинарника
systemctl start relay-runner— startup + SQLite migration + Docker reconcile- Контейнеры НЕ затрагиваются. Сессии → Stopped. Клиенты могут resume.
Rollback: systemctl stop relay-runner && cp relay-runner.bak relay-runner && systemctl start relay-runner. Backup SQLite перед upgrade (нет down-migration).
Semver: MAJOR.MINOR.PATCH. SQLite user_version монотонно растёт. REST API: /api/v1/.... gRPC: relay.terminal.v1.
| Компонент | Что именно |
|---|---|
| Runner: REST API | axum: CRUD projects (create, list, delete), CRUD sessions (create, list, stop, delete), profiles list, images list |
| Runner: SSE Events (базовые) | GET /api/v1/events — project_state_changed, session_state_changed, heartbeat |
| Runner: Docker Orchestrator | Создание/запуск/остановка/удаление контейнеров; pull images; resource limits |
| Runner: Project Manager | git clone по URL (публичные repos); git worktree add/remove |
| Runner: Session Manager | Создание и остановка сессий; in-memory state |
| Runner: SQLite Store | Таблицы projects, sessions; базовые миграции |
| Runner: gRPC | AttachSession проксирует I/O в Docker exec |
| Клиент: Connect | Подключение к серверу (ручной ввод URL + token) |
| Клиент: Project list | Список проектов с сервера |
| Клиент: Create session | Выбор проекта, ветки, образа, profile |
| Клиент: Terminal | gRPC AttachSession |
| Клиент: SSE подписка (базовая) | project_state_changed и session_state_changed для обновления списков |
| Reference Docker image | Dockerfile.claude: Claude Code CLI + git + zsh |
| Auth | Bearer token (один на сервер) |
| Компонент | Что именно |
|---|---|
| Runner: Resume | POST /sessions/{id}/resume с claude --resume |
| Runner: Recovery | Reconcile сессий при рестарте runner |
| Runner: Session Profiles | Конфигурируемые профили в config.toml |
| Клиент: Session control | Stop / Resume / Delete |
| Клиент: Events | In-app уведомления через SSE |
| Клиент: mDNS discovery | Автоматическое обнаружение в LAN |
| Multi-device | Одно устройство на сессию; переключение |
MVP готов когда ВСЕ выполнены:
relay-runner init && relay-runner startзапускает сервер за <30 секунд- Через macOS клиент можно создать проект из публичного git URL
- Можно запустить сессию с Claude Code в Docker-контейнере и получить интерактивный терминал
claude loginвнутри контейнера работает через PTY proxy (interactive OAuth flow)- Keystroke-to-screen latency <100ms в LAN (измеряется через
relay_terminal_latency_ms) - Stop и resume сессии работает с сохранением
claude_session_id - После рестарта runner сессии восстанавливаются в корректном статусе (Stopped, не потеряны)
- Reference Docker image собирается и содержит Claude Code CLI + git + zsh
relay-runner backupсоздаёт валидный SQLite файл;relay-runner restoreвосстанавливает проекты и сессии; после restore сервер стартует без ошибок- Concurrent session creation (5 параллельных) без
SQLITE_BUSY>1% запросов
# Prerequisite: Docker Engine 24.0+
# 1. Установка
curl -fsSL https://github.com/user/relay/releases/latest/download/install.sh | sh
relay-runner init
# 2. Запуск
relay-runner start
# 3. Подключение (macOS)
# Relay → Add Server → IP + token → Create project → git URL → Create session → Claude Code- Приватные git repos (GitHub OAuth, Wave 1)
- Chat mode, файловый браузер, git diff view
- Push-уведомления (APNs/FCM)
- Multi-user, JWT auth
- Шаринг OAuth token между сессиями
- Aux terminals, CI/CD интеграция
- Тестирование на устройствах/эмуляторах
- Конфигурация агента из UI (CLAUDE.md, settings, MCP)
- JWT auth — замена Bearer token, подготовка к multi-user
- GitHub OAuth для приватных repos — GitHub/GitLab OAuth
- Шаринг OAuth token между сессиями — единый
claude loginна проект - Aux terminals — несколько терминалов в одном контейнере
- Chat mode — диалоговый UI (как Aider web UI)
- Файловый браузер — read-only
GET /api/v1/sessions/{id}/files/{path} - Git diff view —
GET /api/v1/sessions/{id}/diff - Конфигурация агента из UI — CLAUDE.md, settings, MCP-конфиги
- Push-уведомления — APNs (Apple), FCM (Android)
- Telegram bot — уведомления и управление сессиями
- Email notifications — через pluggable transport
- Multi-user — RBAC, user management, per-user projects
- Multi-device attach — несколько устройств наблюдают одну сессию (read-only)
- CI/CD интеграция — GitHub Actions, запуск pipeline из сессии
- Mobile testing — ADB over network, Remote Mac для iOS
- Browser testing — Playwright grid
- Observability stack — Prometheus + Grafana, distributed tracing (Jaeger/Tempo)
-
gRPC + REST на одном порту? — Мультиплексирование (tonic + axum через tower) снижает complexity для клиента, но усложняет серверный код. Рекомендация: начать с двух портов, объединить позже.
-
Bare clone vs full clone? Рекомендация: bare clone + worktrees (стандартный подход).
-
Docker image management — кто собирает базовые образы? Рекомендация: поставлять Dockerfile.claude + документацию требований к образу.
-
Session output summarization для уведомлений — кто генерирует summary для
session_outputevent? В MVP ограничиться state changes без content summary. -
Миграция существующего TerminalService
[RESOLVED]— REST = management plane, gRPC = data plane. gRPCCreateSessionсохраняется для backward compat с v2 локальным клиентом. -
KMP shared layer: gRPC client
[RESOLVED]— KMP отклонён. MVP = Swift macOS + Rust server. Android нативный позже (Kotlin/Compose). iOS/iPad переиспользуют Swift-код с десктопа. gRPC-клиент реализуется нативно на каждой платформе. -
Telegram bot — self-hosted или hosted? Self-hosted проще для self-hosted архитектуры, но требует управления bot token.
-
Docker container stdout/stderr overhead
[RESOLVED]— Overhead exec однократный при создании сессии, не на каждый keystroke. Latency benchmark включён в MVP acceptance criteria. -
MCP vs custom integration — для внешних сервисов (ADB, Playwright) использовать MCP protocol (уже поддерживается Claude Code) или собственный integration layer? MCP даёт бесплатную интеграцию с AI-агентом, но ограничен его capability model.