Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
16 KiB
spacesh M0+M1 — design spec
Срез реализации: M0 (живой терминал через socket) + M1 (переживаемость и reattach). Базовая спецификация:
DOCS/MAIN.md(v0.1). Этот документ сужает её до первого реализуемого среза. Дата: 2026-06-09.
1. Скоуп среза
Входит:
- Cargo workspace, крейты
spacesh-proto,spacesh-core,spacesh-pty,spaceshd. - Tauri 2 приложение
app/с одной панельюTerminalView(xterm.js) и переключением между surfaces. - Шина команд/событий поверх UDS (
~/.spacesh/sock), субсет протокола (см. §4). - Реальный PTY, батчированный поток вывода, ввод с клавиатуры.
- Грид в демоне (
alacritty_terminal), снапшот→ANSI,attach/detach, репейнт после reattach. - launchd user-agent с
KeepAlive; ленивый старт; single-instance.
Зафиксированные решения (из брейншторма):
new_surfaceзапускает произвольную команду; дефолт = login shell ($SHELL).$SPACESH_SURFACE_IDинжектируется в env панели.- Переживаемость = только live-reattach (демон переживает GUI). Диск-персист раскладки и дерево сплитов — отложены в M2.
- GUI = одна панель + переключение между surfaces (несколько surfaces живут в демоне, GUI делает
attachк одной). LayoutEngine/сплиты — M2. - Мост GUI↔демон = схема B: Tauri
Channelдляoutput,invokeдля команд,emitдля редких событий.
Не входит (явно):
- Статусы /
set_state/ хуки агентов / OSC 133 / fallback-паттерны — M3. - Дерево сплитов,
move_surface,apply_preset,close_workspace, диск-персист раскладки (state.json) — M2. spacesh-cli— M4. Нотификации Telegram/MAX, зум, поиск, diff-view — M5. Remote — M6.- Поле
split?уnew_surface, скроллбэк в снапшоте.
2. Архитектура и крейты
spacesh/
├── Cargo.toml # workspace
├── crates/
│ ├── spacesh-proto/ # типы Envelope/Cmd/Evt, serde, length-prefix framing (u32 BE + payload)
│ ├── spacesh-core/ # SurfaceId/WorkspaceId, грид (alacritty_terminal), снапшот→ANSI. NO I/O.
│ ├── spacesh-pty/ # spawn PTY (portable-pty), reader-loop, батчинг, resize, backpressure
│ └── spaceshd/ # демон: socket-сервер, реестр сессий, fan-out, attach/snapshot, launchd
└── app/
├── src-tauri/ # UDS-клиент + мост (Channel/invoke/emit), bridge.rs
└── src/ # React/TS: TerminalView, SurfaceList, socketBridge, App
| Крейт | Ответственность | Ключевые зависимости | Тестируемость |
|---|---|---|---|
spacesh-proto |
Протокол: типы + кадрирование. Чистый, шарится всеми. | serde, serde_json, tokio (codec), bytes | юнит: round-trip encode/decode, кадрирование |
spacesh-core |
Доменные id, грид-обёртка, снапшот→ANSI-дамп. Без I/O. | alacritty_terminal | юнит: feed bytes → детерминированный snapshot |
spacesh-pty |
PTY io, reader-loop, коалесцирование (4–8мс/16КБ), resize, backpressure | portable-pty, tokio, bytes | интеграц: spawn echo, читаем вывод |
spaceshd |
Реестр Surface, socket accept, корреляция req/res, fan-out evt, attach=snapshot+stream, launchd | tokio, spacesh-* | интеграц: connect→new_surface→input→output |
app/src-tauri |
Мост UDS↔webview по схеме B | tauri 2, spacesh-proto, tokio | ручная проверка |
spacesh-core намеренно без I/O — грид и снапшот юнит-тестируемы фидом байтов.
3. Мост GUI↔демон (схема B)
src-tauri держит UDS-коннект к демону и разводит трафик по частоте:
output(высокочастотный) →tauri::ipc::Channel<OutputChunk>на surface. Типизированный стрим, дешевле общей IPC-шины, естественный backpressure на канал. JS получает чанки и пишет вxterm.write().- Команды (
open/new_surface/input/resize/attach/detach/focus/close/status/shutdown) →#[tauri::command]+invokeиз JS. Rust кадрирует req, ждёт res поid, возвращает в JS. - Редкие события (
exit/surface_created/surface_closed) →app.emit(...), JS слушает.
Прямой WebSocket JS→демон отклонён: ломает «один socket/UDS», добавляет транспорт и кадрирование в JS.
4. Субсет протокола (M0+M1)
Конверт из DOCS/MAIN.md §5.2 без изменений: req/res (корреляция по id) + evt (push, без id). Кадрирование: u32 BE длина + JSON payload.
Команды
| Команда | Args | Res | Назначение |
|---|---|---|---|
open |
{ path } |
{ workspace_id } |
открыть папку воркспейсом; повтор не плодит дубль |
new_surface |
{ workspace_id, cmd?, args?, cols, rows } |
{ surface_id } |
поднять PTY; пусто→$SHELL; cwd=папка воркспейса; $SPACESH_SURFACE_ID в env |
input |
{ surface_id, bytes } |
{} |
ввод (base64 в JSON) → PTY master |
resize |
{ surface_id, cols, rows } |
{} |
ресайз PTY → SIGWINCH |
attach |
{ surface_id } |
{ snapshot, cols, rows, cursor } |
подписка на поток + ANSI-снапшот для репейнта |
detach |
{ surface_id } |
{} |
отписка от потока |
focus |
{ surface_id } |
{} |
пометить активной (паритет с CLI позже) |
close |
{ surface_id } |
{} |
убить PTY, закрыть панель |
status |
{} |
{ workspaces[] } |
структура при старте GUI |
shutdown |
{} |
{} |
остановить демон |
События (push)
| Событие | Data | Назначение |
|---|---|---|
output |
{ surface_id, bytes } |
живой батчированный поток вывода PTY (едет каналом, схема B) |
exit |
{ surface_id, code } |
процесс панели завершился |
surface_created |
{ surface_id, workspace_id } |
появилась панель |
surface_closed |
{ surface_id } |
панель закрыта |
attach возвращает снапшот в res; дальше живой output приходит потоком. Порядок гарантирован актором (см. §5).
5. Модель владения и потоки данных
Actor-per-surface. Каждый Surface — отдельная tokio-задача, единолично владеющая PTY master, дочерним процессом, alacritty-гридом, набором подписчиков. Команды к surface — через mpsc; вывод — через broadcast. Single-task = единственный писатель в грид: гонок нет by design, и это же гарантирует корректный порядок attach. Общий HashMap под RwLock отклонён (конкуренция за грид, гонки на attach).
spaceshd
├── accept-loop # на коннект → client-task
├── client-task (на клиента) # читает кадры, корреляция id, маршрут команд
├── registry # SurfaceId → mpsc::Sender<SurfaceMsg>, WorkspaceId → meta
└── surface-actor (на surface) # select! { команды-mpsc | PTY-reader-rx | flush-таймер }
├── owns: PtyMaster, Child, Term(grid), broadcast::Sender
└── PTY-reader-task → сырьё в актор
Input: client-task → input → registry → surface mpsc → write в PTY master.
Output: PTY-reader читает → актор коалесцирует (флаш по таймеру 4–8мс или при накоплении 16КБ) → на флаше: (1) term.feed(bytes) — авторитетный грид; (2) broadcast.send(bytes) подписчикам → каждый подписчик в своей client-task кадрирует output evt → socket → src-tauri reader → Tauri Channel → xterm.write().
Backpressure: broadcast ограниченной ёмкости; отстающий подписчик получает Lagged и теряет промежуточные кадры, но грид всегда целый — следующий кадр сводит экран. Демон не растёт в памяти неограниченно.
Attach / reattach (ядро M1):
client → attach{surface_id}
surface-actor обрабатывает СИНХРОННО в своём loop:
1. rx = broadcast.subscribe() // подписка ПЕРВОЙ
2. snap = term.snapshot_ansi() // снимок грида в этой же точке
3. res { snapshot: snap, cols, rows, cursor }
→ rx далее получает ТОЛЬКО output, эмитнутый после snapshot
Актор обрабатывает одно сообщение за раз → между subscribe и snapshot не вклинивается флаш вывода → нет двойной отрисовки и нет дыры. GUI: новый xterm-инстанс → пишет snapshot → дальше живой поток из канала.
detach / разрыв коннекта: client-task падает → её broadcast::Receiver дропается → подписка чистится сама. PTY и грид живут в акторе дальше — демон переживает GUI.
6. Снапшот → ANSI (spacesh-core)
Из alacritty Term обходим видимую сетку построчно:
- Старт дампа:
ESC[2J ESC[H(очистка + home) — свежий xterm-инстанс встаёт чисто. - Для каждой ячейки эмитим SGR-атрибуты (цвет fg/bg, жирный, курсив, подчёркивание) только при изменении от предыдущей ячейки, затем символ.
- Конец строки →
\r\n. - В конце — позиционирование курсора
ESC[{row};{col}H. - Скроллбэк не дампим (только видимая область — бюджет репейнта <100мс).
Детерминированно → юнит-тест: feed известных байтов → фиксированный снапшот; проверка курсора и SGR.
7. Lifecycle демона
- Ленивый старт: первый клиент (GUI), не найдя socket, форкает
spaceshdи ждёт готовности (поллинг connect с таймаутом). - Single-instance: лок-файл
~/.spacesh/daemon.lock(flock) + проверка живости socket. Кейс «socket есть, демон мёртв»: connect фейлится → удаляем stale socket → стартуем. - launchd: user-agent
~/Library/LaunchAgents/xyz.spacesh.daemon.plistсKeepAlive(автоперезапуск при краше).RunAtLoad(автостарт при логине) — опционально, по умолчанию выключен в этом срезе. - shutdown: закрыть все surface-акторы (kill child), снять socket + lock, выйти.
Транспорт изолирован за трейтом (задел на remote, DOCS/MAIN.md §4.3), но в срезе только UDS.
8. Обработка ошибок
- Протокол: невалидный кадр/JSON →
res ok:false {code:"BAD_REQUEST"}, коннект живёт. Неизвестныйsurface_id→{code:"NOT_FOUND"}. - PTY: спавн упал →
res ok:false {code:"SPAWN_FAILED", msg}. Процесс умер →exit{code}evt; surface остаётся видимым (закрытие — отдельнойclose). - Изоляция: сбой одной client-task не роняет демон и другие surfaces (best-effort на каждого клиента).
9. Тесты
| Уровень | Что проверяем |
|---|---|
spacesh-proto (юнит) |
round-trip encode/decode каждого Cmd/Evt; кадрирование (частичный/склеенный кадр) |
spacesh-core (юнит) |
feed байтов → детерминированный снапшот; корректность курсора и SGR |
spacesh-pty (интеграц) |
spawn echo hello → читаем вывод; resize не падает |
spaceshd (интеграц) |
temp-socket: open→new_surface(printf)→attach→снапшот с выводом; reattach после дропа коннекта возвращает тот же экран; close→exit evt |
app (ручной) |
байты летают GUI↔демон↔PTY; kill GUI → reopen → экран восстановлен из снапшота |
10. Бюджеты производительности (из DOCS/MAIN.md §2)
- Keypress → echo: < 16 мс при нормальной нагрузке.
- Батчинг
output: коалесцировать ~4–8 мс или до ~16 КБ, что раньше; не эмитить по байту. - Backpressure: ограниченный канал на подписчика; грид авторитетный, не растём в памяти.
- Репейнт при reattach: снапшот < 100 мс на типовом гриде.
11. Порядок реализации (внутри среза)
Снизу вверх, вертикальный срез сначала:
spacesh-proto— типы + кадрирование + тесты.spacesh-pty— spawn/reader/батчинг/resize + тест.spaceshd(M0-ядро) — socket-сервер, registry, surface-actor,open/new_surface/input/output/close/status/shutdown.appминимальный — src-tauri мост (схема B) + одинTerminalView+SurfaceList. Байты летают (конец M0).spacesh-core— грид (alacritty) + снапшот→ANSI + тесты.spaceshd(M1) —term.feedв акторе,attach/detachсо снапшотом,broadcast-fan-out.app(M1) — репейнт из снапшота на attach, reattach-флоу.- launchd-обвязка + ленивый старт + single-instance.
Конец среза: убил GUI — агент жив в демоне; открыл — экран восстановился.