chore: seed repo with spec, plan, and design
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
# 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 — агент жив в демоне; открыл — экран восстановился.
|
||||
Reference in New Issue
Block a user