Files
spaceshell/DOCS/superpowers/specs/2026-06-09-spacesh-m0-m1-design.md
2026-06-09 19:50:15 +07:00

16 KiB
Raw Permalink Blame History

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-cliM4. Нотификации 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: opennew_surface(printf)→attach→снапшот с выводом; reattach после дропа коннекта возвращает тот же экран; closeexit evt
app (ручной) байты летают GUI↔демон↔PTY; kill GUI → reopen → экран восстановлен из снапшота

10. Бюджеты производительности (из DOCS/MAIN.md §2)

  • Keypress → echo: < 16 мс при нормальной нагрузке.
  • Батчинг output: коалесцировать ~4–8 мс или до ~16 КБ, что раньше; не эмитить по байту.
  • Backpressure: ограниченный канал на подписчика; грид авторитетный, не растём в памяти.
  • Репейнт при reattach: снапшот < 100 мс на типовом гриде.

11. Порядок реализации (внутри среза)

Снизу вверх, вертикальный срез сначала:

  1. spacesh-proto — типы + кадрирование + тесты.
  2. spacesh-pty — spawn/reader/батчинг/resize + тест.
  3. spaceshd (M0-ядро) — socket-сервер, registry, surface-actor, open/new_surface/input/output/close/status/shutdown.
  4. app минимальный — src-tauri мост (схема B) + один TerminalView + SurfaceList. Байты летают (конец M0).
  5. spacesh-core — грид (alacritty) + снапшот→ANSI + тесты.
  6. spaceshd (M1) — term.feed в акторе, attach/detach со снапшотом, broadcast-fan-out.
  7. app (M1) — репейнт из снапшота на attach, reattach-флоу.
  8. launchd-обвязка + ленивый старт + single-instance.

Конец среза: убил GUI — агент жив в демоне; открыл — экран восстановился.