Skip to content

Latest commit

 

History

History
1453 lines (1044 loc) · 78.8 KB

File metadata and controls

1453 lines (1044 loc) · 78.8 KB

Relay Cloud Architecture

Date: 2026-04-12

§1. Executive Summary

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-код с десктопа.


§2. Key Requirements

Фундаментальные требования (неизменные)

  1. Прозрачность локальных и удалённых сессий — пользователь работает одинаково с локальной и удалённой сессией. Нет понятия "раннер" в UI — сервер прозрачно оркестрирует всё.

  2. Переключение между устройствами — начать работу на десктопе, продолжить с мобилки, вернуться на десктоп. Сессия живёт на сервере независимо от клиентов.

  3. Изоляция через контейнеры — каждая сессия в отдельном Docker-контейнере. Сессии не видят друг друга. Агенты работают безопасно.

  4. Extensibility / pluggable architecture — каждый значимый компонент заменяем. Новые функции добавляются без переписывания core. Но: абстракция создаётся только при наличии реальной альтернативы, не "на всякий случай".

  5. Performance-first UX — нативный UI. Минимальная latency: keystroke-to-screen <100ms LAN, <200ms internet. Никаких компромиссов в пользу универсальности.

  6. Self-hosted, single binary — простой деплой: один бинарник, один конфиг, relay-runner init && relay-runner start. Без внешних зависимостей кроме Docker.

  7. AI-agent first — основной сценарий: работа с Claude Code, Aider и другими AI-агентами. Профили и образы заточены под это. Shell — secondary use case.

  8. Микросервисная расширяемость — серверная архитектура строится так, чтобы потом отдельные части (push-уведомления, CI/CD интеграция, тестирование) выносились в отдельные сервисы.

Требования к пользовательскому опыту

  1. Нативные клиенты — MVP: macOS на Swift/SwiftUI/TCA. Позже: iOS/iPadOS (переиспользование Swift-кода с десктопа), Android (Kotlin/Compose). UI всегда нативный.

  2. Проект как центральная сущность — всё отталкивается от проекта. Проект привязан к git repo. Внутри проекта — сессии. Сессия = worktree + контейнер + терминал.

  3. In-app уведомления (MVP) — события о статусах сессий по gRPC stream / SSE. Push-уведомления (APNs/FCM), Telegram bot, email — позже, через pluggable notification system.

  4. Конфигурируемые профили — предустановленные (claude, shell) + кастомные. Выбор Docker-образа при создании сессии.

Требования к серверу

  1. Сервер = source of truth — remote state (проекты, сессии) хранится на сервере. Доступен с любого устройства. Локальные сессии — только на клиенте.

  2. Docker exec с idle entrypoint — контейнер запускается с sleep infinity, сессии через docker exec. Контейнер живёт пока сессия активна, PTY перезапускается.

  3. gRPC = data plane, REST = management plane — терминальный I/O через gRPC bidi streaming (latency-critical). CRUD проектов/сессий, events — через REST/JSON.

  4. SQLite для метаданных — zero-config, один файл. sqlx (async). WAL mode.

  5. Persistence при рестарте — контейнеры переживают рестарт сервера. PTY нет. claude_session_id сохраняется для resume.

  6. 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

§3. Key Decisions Log

# Решение Альтернативы Почему выбрано Когда пересмотреть
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

Review History

  1. Architecture Expert — 3 раунда (PASS WITH CONCERNS → PASS WITH CONCERNS → PASS)
  2. PoLL Cycle 1 — Security + Performance + DevOps (CONDITIONAL → fixes → PASS)
  3. PoLL Cycle 2 — Architecture + Business Analyst + Security + Performance + DevOps (CONDITIONAL → fixes → PASS)

Всего найдено и исправлено: ~45 issues (5 critical, 15 major, 25 minor/suggestions).


§4. User Scenarios

MVP Scenarios

S1: Первый запуск — от нуля до работающей сессии с Claude Code

  1. Скачивает и устанавливает relay-runner на сервер
  2. relay-runner init — генерируется config, TLS, token, собирается Docker-образ
  3. relay-runner start — сервер запущен, token показан один раз
  4. На маке открывает Relay → Add Server → вводит IP + token
  5. Создаёт проект: указывает имя + git URL публичного репо
  6. Ждёт клонирования (видит статус "Cloning...")
  7. Создаёт сессию: выбирает профиль "claude", ветку, образ
  8. Открывается терминал — внутри Claude Code CLI
  9. Выполняет claude login (OAuth flow через терминал)
  10. Работает с Claude Code как на локальной машине

Критерий: от скачивания до работающего Claude Code < 15 минут.

S2: Ежедневная работа — переключение между проектами и сессиями

Открывает Relay → список проектов → подключается к активной сессии (VT snapshot) → работает в Claude Code → переключается на другой проект → закрывает Relay, сессии продолжают работать на сервере.

Критерий: переключение между сессиями < 2 секунды.

S3: Продолжение работы после перерыва

Видит список сессий (Running / Stopped) → подключается к Running-сессии → продолжает диалог → для Stopped нажимает Resume → claude --resume подхватывает контекст.

Критерий: при attach к Running-сессии — полный терминал за < 1 секунду.

S4: Переключение между устройствами

(API-level в MVP, полный UX — при появлении мобильного клиента)

Работает на десктопе → закрывает крышку → сессия продолжает работать → открывает Relay на телефоне → подключается к той же сессии (десктоп автоматически отключается) → приходит домой → возобновляет на десктопе.

Критерий: attach с нового устройства < 3 секунды.

S5: Работа с несколькими ветками одновременно

Создаёт сессию для feature/auth → создаёт вторую для fix/bug-123 → переключается между сессиями (полностью изолированы) → завершает работу → удаляет сессию (worktree + контейнер удаляются).

Критерий: создание второй сессии в том же проекте < 30 секунд.

S6: Восстановление после рестарта сервера

Сервер рестартует → runner reconcile → сессии в Stopped → клиент переподключается → Resume → claude --resume подхватывает контекст.

Критерий: восстановление runner < 30 секунд. Данные не потеряны.

S7: Управление Docker-образами

Пишет Dockerfile → docker build -t my-env:v1 . → добавляет в allowed_images → при создании сессии выбирает кастомный образ.

S8: Мониторинг и диагностика

GET /api/v1/health → uptime, sessions, disk, memory → GET /api/v1/metrics → latency, throughput → логи runner'а (structured JSON).

Критерий: полная видимость состояния сервера из REST API без SSH доступа.

S9: Диагностика и восстановление при ошибке

Видит сессию в Failed → нажимает → видит причину: "Container killed: out of memory (2GB limit exceeded)" → предложенные действия → выбирает "Пересоздать" → если claude_session_id сохранён — предлагается resume.

Критерий: error message содержит: (a) что произошло, (b) почему, (c) что делать. Время до понимания причины < 10 секунд.

Future Scenarios (закладываются архитектурно)

  • 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 работает без участия пользователя)

§5. Architectural Principles

Extensibility First

Каждый значимый компонент системы проектируется как заменяемый модуль за абстракцией (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 позже без рефакторинга потребителей. Не создавать абстракцию "на всякий случай".

Performance-First UX

  • 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.


§6. System Architecture

+----------------------------+
|     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

§7. Data Model

Сущности

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

Инвариант: Project без сессий не существует

Проект без хотя бы одной сессии — невалидное состояние на обеих платформах.

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_imageconfig.docker.default_imageubuntu:24.04), первый сконфигурированный profile или "default". Если первый clone упал — повторная попытка при переходе в Ready также создаёт первую сессию. DELETE /api/v1/sessions/:id возвращает 400, если удаляется последняя non-deleted сессия проекта. Все сессии равноправны — нет понятия "default".

Изоляция сессий: clone (cloud) vs worktree (local)

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-конфликты не проблема.

SessionState (enum)

Starting | Running | Stopped | Failed

TerminalState (enum)

Starting | Running | Stopped | Failed

ProjectState (enum)

Cloning | Ready | Failed

§8. Server Architecture

Компоненты

   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 для sessions
  • SessionCoordinator — оркестрация: координирует Docker + Git + lifecycle при создании/удалении сессии

В MVP все три struct в одном модуле (session_manager.rs). Разделение на файлы/модули — при росте кодовой базы.

Project Managergit 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 Storesqlx с SQLite backend (async). Выбор зафиксирован: rusqlite (sync) блокирует tokio worker threads при конкурентных записях. sqlx использует spawn_blocking внутри.

Backpressure Strategy

Docker exec stdout ──→ [mpsc channel, cap=512] ──→ gRPC stream sender
                        4KB frames, ~2MB max buffer
  • Bounded tokio::sync::mpsc channel между bollard reader task и gRPC sender task
  • Capacity: 512 frames × ~4KB = ~2MB max memory per session
  • При заполнении канала: bollard reader task блокируется (async backpressure)
  • Обратное направление (stdin): capacity 64 frames

§9. Client Architecture

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 Нет (позже)

Существующая клиентская структура (macOS)

Swift Packages: SharedModels, TerminalAbstraction, TerminalSwiftTerm, TerminalFeature, RemoteTerminal, PaneManager, WorktreeManager, AgentOrchestrator.

RemoteTerminal адаптируется под новый REST API, добавляются модули для project management.


§10. API Contract

REST API — ManagementService (порт 8080)

Аутентификация: Authorization: Bearer <token>. Все endpoints возвращают JSON.

Projects

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.

Sessions

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 сохраняются.

Events (SSE)

GET /api/v1/events?project_id=...&session_id=...
  Content-Type: text/event-stream

SSE Heartbeat: event: heartbeat\ndata: {}\n\n каждые 15 секунд (named event, не comment).

SSE Event Types

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 совпадает с именами в таблицах выше.

Health & Metrics

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 обновляется автоматически.

Docker Images & Profiles

GET /api/v1/images    → { "images": [{ "name": "relay-dev:latest", "size": "..." }] }
GET /api/v1/profiles  → { "profiles": [{ "name": "claude", "description": "...", "command": "..." }] }

gRPC API — TerminalService (порт 50051)

Пакет relay.terminal.v1.

В cloud-режиме data plane только:

  • AttachSession (bidi stream) — подключение к терминалу сессии, созданной через REST. При attach отправляет VT snapshot (последние snapshot_max_lines = 500 строк). Полный scrollback — через GetScrollback
  • GetScrollback — буфер прокрутки
  • 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 клиентом.

Tolerant Reader

Клиент ДОЛЖЕН игнорировать неизвестные JSON-поля в ответах сервера (forward-compatible). Сервер может добавлять новые поля в существующие ответы без версионирования API. Клиент НЕ ДОЛЖЕН отправлять неизвестные поля — сервер возвращает 400 при strict parsing.

Plaintext Fallback Policy

Клиент по умолчанию ТРЕБУЕТ TLS. Plaintext допускается только при явном allowInsecure: true. При plaintext клиент отображает предупреждение: "Connection is not encrypted. Bearer token transmitted in plaintext."

Idempotency

Post-MVP. Не реализуется в MVP. Клиент повторяет запрос при неудаче без гарантии идемпотентности.

HTTP Error Codes

  • 400 — невалидный запрос (плохой git URL, несуществующий profile)
  • 401 — невалидный или отсутствующий токен
  • 403 — образ не из whitelist
  • 404 — проект/сессия не найдены
  • 409 — конфликт (лимит сессий, ветка уже занята)
  • 500 — внутренняя ошибка
  • 507 — недостаточно места на диске

§11. Session Lifecycle

State Machine

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: полное удаление.

Project Creation Lifecycle (асинхронное)

POST /projects → 201 { "state": "cloning" }
       |
  +-----------+    success    +-------+
  |  Cloning  | ─────────────→| Ready |
  +-----------+               +-------+
       |         failure
  +-----------+
  |  Failed   | (retry available)
  +-----------+

Клиент подписывается на SSE. Создание сессий доступно только из Ready.

Поведение при рестарте сервера

  1. Docker-контейнеры сохраняются; PTY-процессы мертвы
  2. Runner читает SQLite → reconcile
  3. Running сессии → Stopped (PTY dead)
  4. claude_session_id сохранён → resume доступен

Мультиустройственный доступ (MVP)

Максимум одно устройство на сессию. При новом подключении — старое соединение закрывается (SessionEvent::Detached).

Edge Cases

Docker exec lifecycle monitoring

Три механизма обнаружения завершения exec-процесса:

  1. Primary: Bollard reader task обнаруживает EOF на stdout exec stream → session → Stopped
  2. Watchdog: Background task делает exec_inspect каждые 5s для exec instances без активного reader (например, после сетевого обрыва)
  3. Reconciliation: При расхождении (exec dead, stream alive) — принудительное закрытие stream, session → Stopped

Watchdog запускается как отдельный tokio task при создании сессии, завершается при удалении.

Exec-процесс завершился

exit code 0 → Stopped; non-zero → Stopped с error field. Контейнер продолжает работать (sleep infinity). SSE: session_state_changed: stopped. Клиент показывает: "Сессия завершена" + "Перезапустить".

Контейнер OOMKilled

Runner обнаруживает через Docker events API. Session → Failed, error: "Container killed: out of memory". Клиент показывает: "Сессия завершена: недостаточно памяти (лимит 2GB)". Кнопки: "Пересоздать" / "Удалить".

Docker pull fails

Session → Failed, error: "Failed to pull image: {reason}". Проект остаётся Ready. Клиент показывает: "Не удалось загрузить образ: {reason}". Кнопки: "Повторить" / "Выбрать другой образ" / "Удалить сессию".

Git clone fails

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 свободно

Concurrent worktree на одну ветку

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).

Docker daemon недоступен во время работы

Все Running-сессии → Failed, error: "Docker daemon connection lost". SSE: system_error: docker_daemon_unavailable. Runner входит в reconnect loop (exponential backoff). При восстановлении — полный reconcile. Клиент показывает: "Сервер потерял связь с Docker. Все сессии остановлены."


§12. Storage Layout

Файловая структура

{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

Docker volumes

{data_dir}/projects/{project-id}/worktrees/{session-id}
  ↓ bind mount ↓
/workspace  (внутри контейнера, рабочая директория)

config.toml (минимальный пример)

Полная спецификация — в §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>

§13. Configuration Reference

Уровни конфигурации

Server config.toml (global defaults)
  ↓ overridden by
Project config (per-project, SQLite)
  ↓ overridden by
Session creation request (per-session)

Server Configuration (config.toml)

Core

Параметр 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)

Docker

Параметр 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

Session Profiles

[[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, Auth, mDNS

[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"

Git

Параметр Default Описание
git.clone_timeout_s 600 Timeout на git clone
git.allowed_protocols ["https"] SSRF prevention. ssh — опционально
git.min_free_disk_gb 1.0 Минимум свободного места для clone

Rate Limiting

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.

CORS

MVP: CORS middleware отключён — web-клиента нет, wildcard создаёт attack vector без пользы.

При добавлении web-клиента:

[cors]
allowed_origins = ["https://relay.example.com"]  # явный whitelist, не wildcard

Backup

Параметр Default Описание
backup.enabled true Автоматический backup SQLite
backup.dir {data_dir}/backups/ Директория для backup'ов
backup.retention_days 7 Retention

Project Configuration (per-project, SQLite)

Создаётся при POST /projects, изменяется через PATCH /projects/{id} (будущее).

Параметр Описание
name Имя проекта
git_url URL репозитория
default_branch Ветка по умолчанию
default_image Docker-образ по умолчанию (override server)
default_profile Профиль по умолчанию (override server)

Client Configuration (per-device, локальная)

Параметр Хранилище Описание
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

relay-runner init — что генерирует

Файл Описание
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

Hot Reload vs Restart

Изменение Требует рестарта?
Порты, TLS, token_hash Да
Docker limits, profiles, allowed_images Нет (применяется к новым)
Rate limits Да (in-memory)
mDNS Да

§14. Security Model

Аутентификация

Метод MVP Будущее
Bearer token (argon2 hash) Да JWT с expiration
mTLS (клиентский сертификат) Да (опционально) Сохраняется
OAuth (Claude login per session) Да Shared refresh token

Bearer Token Rotation (MVP-1)

relay-runner rotate-token
# Генерирует новый Bearer token, обновляет argon2 hash в config.toml
# Показывает новый token один раз в stdout
# Требует рестарт сервера для применения (hot reload — TODO)

Команда для ротации токена без переинициализации всего конфига. Рабочий процесс: rotate-token -> скопировать новый token -> systemctl restart relay-runner -> обновить token в клиенте.

mTLS Identity Verification

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- на самом раннем этапе.

Transport Security

  • TLS обязателен для production. relay-runner init генерирует CA + server cert.
  • TOFU — клиент верифицирует fingerprint сервера при первом подключении.

Container Isolation

  • 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

Docker Image Policy

  • Whitelist allowed_images в config.toml
  • POST /sessions с образом не из whitelist → 403 Forbidden
  • Пустой whitelist = все разрешены (warning при старте: "Docker image whitelist is empty")

Command Execution Safety

  • command в profile — только absolute path (validated при старте runner)
  • Выполняется через direct exec, не через sh -c
  • Profile name из API — strict exact match против config.toml

Credential Isolation

  • OAuth tokens Claude — внутри контейнера
  • Серверный Bearer token не передаётся в контейнер
  • claude_session_id хранится на сервере (SQLite)

Deployment Model

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:ro

Runner подключается к 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 Warning

При plaintext gRPC клиент отображает: "Connection is not encrypted. Bearer token transmitted in plaintext."


§15. Notification Architecture

Архитектура

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:

  1. Сервер воспроизводит пропущенные события из ring buffer (best-effort, в порядке возрастания id)
  2. После replay клиент продолжает получать новые события из stream

Если Last-Event-ID отсутствует или вышел за окно ring buffer:

  1. Сервер отправляет event: sync_required\ndata: {"reason":"replay_window_exceeded"}
  2. Клиент восстанавливает состояние через GET /api/v1/projects, GET /api/v1/sessions и detail-вызовы для открытых сущностей
  3. После full resync клиент продолжает получать новые events из stream

При заполнении per-connection buffer (channel full) без реконнекта:

  1. Текущее SSE-соединение завершается сервером
  2. Клиент переподключается с последним полученным Last-Event-ID → replay path выше

Internal → SSE маппинг

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.

Subscription Model

GET /api/v1/events?project_id=...&session_id=...

MVP: конкретная реализация SSE без trait-абстракции. Trait NotificationTransport извлекается при добавлении второго транспорта (Wave 3).


§16. Reference Infrastructure

Минимальная серверная машина

Параметр Минимум Рекомендуемый
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.

Disk estimates

  • 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

Reference Docker Image (Dockerfile.claude)

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-base tag явно.


§17. External Services Architecture

Интеграция с внешними сервисами (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.


§18. Observability

Structured Logging (MVP)

Уже реализовано: tracing + tracing-subscriber с JSON formatter. Каждый log entry содержит session_id, project_id, container_id (где применимо). Docker container logs через bollard LogsOptions.

Metrics (MVP — in-memory)

Метрика Тип Описание
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 exporter

MVP: метрики через GET /api/v1/metrics (JSON). Позже: Prometheus exporter (/metrics).

Performance Monitoring Points

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.

Distributed Tracing (после MVP)

tracing-opentelemetry → Jaeger, Tempo, Datadog, Honeycomb. Trace ID пробрасывается клиент → REST/gRPC → session manager → Docker → container.


§19. Operational Best Practices

Startup Sequence

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"

Graceful Shutdown (SIGTERM/SIGINT)

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

Контейнеры НЕ останавливаются.

Configuration Validation

Проверка Критичность При ошибке
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"

SQLite Migrations

Обязательные 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 integrity

Schema 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 при конкурентных записях.

Rate Limiting

Tower middleware, in-memory sliding window per Bearer token.

Error Handling

REST: { "error": { "code": "SESSION_LIMIT_EXCEEDED", "message": "..." } }. Внутренние ошибки — sanitized message клиенту, полный контекст в логах.

CORS

MVP: отключён (см. §13). При web-клиенте — явный whitelist из config.

Backup & Recovery

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


§20. CI/CD и Release Pipeline

Сборка runner

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 минут.

Upgrade Procedure

  1. Проверка новой версии: поле version в GET /api/v1/health
  2. cp /usr/local/bin/relay-runner /usr/local/bin/relay-runner.bak
  3. systemctl stop relay-runner — graceful shutdown
  4. Замена бинарника
  5. systemctl start relay-runner — startup + SQLite migration + Docker reconcile
  6. Контейнеры НЕ затрагиваются. Сессии → 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.


§21. MVP Scope

MVP-0: Core (3-4 недели)

Компонент Что именно
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/eventsproject_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 (один на сервер)

MVP-1: Polish (2-3 недели)

Компонент Что именно
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 Одно устройство на сессию; переключение

Acceptance Criteria

MVP готов когда ВСЕ выполнены:

  1. relay-runner init && relay-runner start запускает сервер за <30 секунд
  2. Через macOS клиент можно создать проект из публичного git URL
  3. Можно запустить сессию с Claude Code в Docker-контейнере и получить интерактивный терминал
  4. claude login внутри контейнера работает через PTY proxy (interactive OAuth flow)
  5. Keystroke-to-screen latency <100ms в LAN (измеряется через relay_terminal_latency_ms)
  6. Stop и resume сессии работает с сохранением claude_session_id
  7. После рестарта runner сессии восстанавливаются в корректном статусе (Stopped, не потеряны)
  8. Reference Docker image собирается и содержит Claude Code CLI + git + zsh
  9. relay-runner backup создаёт валидный SQLite файл; relay-runner restore восстанавливает проекты и сессии; после restore сервер стартует без ошибок
  10. Concurrent session creation (5 параллельных) без SQLITE_BUSY >1% запросов

Golden Path: от установки до первой сессии

# 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

НЕ входит в MVP

  • Приватные 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)

§22. Roadmap

Wave 1 — Core Improvements (сразу после MVP)

  1. JWT auth — замена Bearer token, подготовка к multi-user
  2. GitHub OAuth для приватных repos — GitHub/GitLab OAuth
  3. Шаринг OAuth token между сессиями — единый claude login на проект
  4. Aux terminals — несколько терминалов в одном контейнере

Wave 2 — Rich Client Experience

  1. Chat mode — диалоговый UI (как Aider web UI)
  2. Файловый браузер — read-only GET /api/v1/sessions/{id}/files/{path}
  3. Git diff viewGET /api/v1/sessions/{id}/diff
  4. Конфигурация агента из UI — CLAUDE.md, settings, MCP-конфиги

Wave 3 — Notifications & Multi-user

  1. Push-уведомления — APNs (Apple), FCM (Android)
  2. Telegram bot — уведомления и управление сессиями
  3. Email notifications — через pluggable transport
  4. Multi-user — RBAC, user management, per-user projects
  5. Multi-device attach — несколько устройств наблюдают одну сессию (read-only)

Wave 4 — DevOps Integration

  1. CI/CD интеграция — GitHub Actions, запуск pipeline из сессии
  2. Mobile testing — ADB over network, Remote Mac для iOS
  3. Browser testing — Playwright grid
  4. Observability stack — Prometheus + Grafana, distributed tracing (Jaeger/Tempo)

§23. Open Questions

  1. gRPC + REST на одном порту? — Мультиплексирование (tonic + axum через tower) снижает complexity для клиента, но усложняет серверный код. Рекомендация: начать с двух портов, объединить позже.

  2. Bare clone vs full clone? Рекомендация: bare clone + worktrees (стандартный подход).

  3. Docker image management — кто собирает базовые образы? Рекомендация: поставлять Dockerfile.claude + документацию требований к образу.

  4. Session output summarization для уведомлений — кто генерирует summary для session_output event? В MVP ограничиться state changes без content summary.

  5. Миграция существующего TerminalService [RESOLVED] — REST = management plane, gRPC = data plane. gRPC CreateSession сохраняется для backward compat с v2 локальным клиентом.

  6. KMP shared layer: gRPC client [RESOLVED] — KMP отклонён. MVP = Swift macOS + Rust server. Android нативный позже (Kotlin/Compose). iOS/iPad переиспользуют Swift-код с десктопа. gRPC-клиент реализуется нативно на каждой платформе.

  7. Telegram bot — self-hosted или hosted? Self-hosted проще для self-hosted архитектуры, но требует управления bot token.

  8. Docker container stdout/stderr overhead [RESOLVED] — Overhead exec однократный при создании сессии, не на каждый keystroke. Latency benchmark включён в MVP acceptance criteria.

  9. MCP vs custom integration — для внешних сервисов (ADB, Playwright) использовать MCP protocol (уже поддерживается Claude Code) или собственный integration layer? MCP даёт бесплатную интеграцию с AI-агентом, но ограничен его capability model.