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

205 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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. Порядок реализации (внутри среза)
Снизу вверх, вертикальный срез сначала:
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 — агент жив в демоне; открыл — экран восстановился.