v2: модульный монолит — очередь, персистентность, безопасность, SSE, тесты
Some checks failed
test / test (push) Failing after 24s

Реализация целевого дизайна (docs/architecture/target-design.md):
- src/: модульная структура (config, logger, errors, http, core: auth/history/jobs/engines/comfy/codex/images)
- Очередь заданий: FIFO, кониурентность codex=1 / comfy-upscale=2, MAX_QUEUED_JOBS=5, JSONL-журнал и восстановление после рестарта (running → interrupted, upscale requeue)
- Персистентные сессии (sha256-хеши токенов), scrypt-хеш пароля, rate-limit входа, Origin-проверка, magic-byte валидация аплоадов
- Атомарная запись манифеста истории, каскадное удаление, URL с фактическим расширением + 302-алиас /image.jpg, миграция history/ → data/history
- Даунскейл sharp до 1024px перед Codex + масштабирование области референса
- SSE /api/events с фолбэком на polling, фронтенд переведён на ES-модули (public/js/)
- Тесты node:test + supertest (27), CI workflow test.yml, Docker/деплой: том kadr-data
- Документация: README, docs/architecture/
This commit is contained in:
2026-08-27 12:52:27 +07:00
parent 1c36a21e0c
commit 401969cfdb
68 changed files with 9368 additions and 4085 deletions

View File

@@ -0,0 +1,97 @@
# System Design Baseline
**Вердикт:** READY
**Артефакт:** `docs/architecture/system-design-baseline.md`
**Статус:** 1.0, подтверждён по рабочей копии `main@1c36a21` (рабочее дерево чистое), дата наблюдения 2026-08-26.
**Язык:** русский (проект и владелец русскоязычные).
Этот документ — источник истины по **архитектуро-формирующим требованиям и ограничениям** проекта «Кадр». Он не описывает решение (см. `docs/architecture/target-design.md`). Каждый материал параметр имеет владельца, источник, дату и триггер пересмотра.
---
## 1. Идентичность и бизнес-контекст
| Параметр | Значение | Источник |
|---|---|---|
| Продукт | «Кадр» — локальный AI-фоторедактор: редактирование фотографии по текстовому промпту (Codex CLI) + 4× апскейл (ComfyUI RealESRGAN) | `README.md` |
| Владелец/оператор | Один человек («владелец»), доступ только ему | `README.md` разд. «Авторизация» |
| Развёртывание | Один контейнер Docker на хосте Portainer (`kadr-photo-editor`, порт 3000), LAN | `deploy/docker-compose.yml`, `README.md` |
| Окружение | Node.js 20+, Express 4, Codex CLI (OpenAI), ComfyUI `http://192.168.31.240:8188`, прокси `http://192.168.31.240:4067` для исходящего к OpenAI | `package.json`, `server.js`, `deploy/docker-compose.yml` |
| Читатели документа | Владелец; ИИ-агент, реализующий целевую архитектуру | — |
Критические сценарии (джорни):
1. Владелец загружает фото → видит превью → редактирует промптом (13 мин, фон) → результат в истории.
2. Владелец увеличивает разрешение версии (несколько секунд) → результат в истории.
3. Владелец возвращается к любой версии истории (дерево), продолжает правки с неё.
4. Владелец удаляет версию каскадно (с потомками).
5. Владелец получает подсказки промптов (из истории / от Codex).
Горизонт решений: **один пользователь, LAN, один хост, один контейнер**, горизонт планирования 612 месяцев. Выход за эти рамки — триггер пересмотра (см. реестр).
---
## 2. Область и не-цели
**В области:** редактирование по промпту, апскейл 4×, история версий с деревом и каскадным удалением, авторизация одного владельца, референс с областью, подсказки промптов, мультизагрузка, деплой Docker/Portainer, CI на Gitea Actions.
**Вне области (не-цели):** многопользовательность; публичный интернет-доступ; мобильные приложения; пакетная обработка; генерация изображений «с нуля»; онлайн-редактор с кистями/слоями; платёжные интеграции; облачный хостинг. Всё перечисленное — кандидаты на отдельное решение, не для текущего горизонта.
---
## 3. Драйверы и ограничения (реестр)
Легенда: **Крит.** — DRIVER / SUPPORTING / INFORMATIONAL; **Статус** — CONFIRMED (из кода/документов), ASSUMED, UNKNOWN.
| # | Тема | Параметр | Крит. | Статус | Значение/мера | Источник и владелец | Триггер пересмотра |
|---|---|---|---|---|---|---|---|
| D1 | Бизнес | Один владелец, закрытый доступ | DRIVER | CONFIRMED | Ровно один логин (`APP_USER`/`APP_PASSWORD`), cookie-сессия `kadr_session`, 7 суток, sliding | `server.js:119-218`, `README.md` | Появление второго пользователя |
| D2 | Бизнес | Редактирование по промпту — ядро продукта | DRIVER | CONFIRMED | Обработка 6082 с на 512×512, 13 мин на больших; узкое место — вызов image-модели | `README.md` разд. «Тест скорости» | Смена движка редактирования |
| D3 | Бизнес | История версий — непрерывная ветка правок | DRIVER | CONFIRMED | Дерево по `parentId`, до 50 версий, каскадное удаление, переживает перезапуск | `server.js:820-900, 1765-1826`, `README.md` | Рост лимита истории |
| D4 | Demand | Нагрузка | SUPPORTING | CONFIRMED | Один пользователь, 1 редактирование + 1 апскейл одновременно; опрос статуса каждые 3 с; лимит аплоада 25 МБ, промпт ≤ 1000 симв. | `server.js:24, 1876`, `README.md` | Параллельные правки разных фотографий |
| D5 | Demand | Объём данных | SUPPORTING | CONFIRMED | ≤ 50 версий × ≤ 25 МБ исходник; реальные файлы 16 МБ | `history/history.json`, `server.js:34` | Лимит 50 перестаёт устраивать |
| D6 | Качество | Время ответа API | SUPPORTING | CONFIRMED | `POST /api/edit`, `/api/upscale`, `/api/suggest` отвечают мгновенно (< 1 с) с `jobId`; тяжёлая работа фоном | `README.md` стр. 25-29 | — |
| D7 | Качество | Завершение редактирования | DRIVER | CONFIRMED | Codex: таймаут 420 с; ComfyUI: таймаут 300 с; сбои отображаются в UI, задание помечается `error` | `server.js:22, 29, 465-541, 1489-1534` | — |
| D8 | Данные | Переживание перезапуска | DRIVER | CONFIRMED | История переживает перезапуск; **сессии и задания — нет** (память, `sessions`/`jobs` Map); `.codex-jobs` очищается при старте | `server.js:141-153, 2259`, `README.md` стр. 98 | Цель перепроектирования: переживание заданий |
| D9 | Данные | Согласованность истории | DRIVER | CONFIRMED | Один писатель (`historyWriteChain`), полная перезапись `history.json` при каждом изменении, файлы `history/<uuid>.<ext>` | `server.js:830-868` | Переход на SQLite при > 500 версий |
| D10 | Безопасность | Аутентификация | DRIVER | CONFIRMED | Сравнение пароля — `constantTimeEqual` по **открытому** значению env-переменной; без хеширования, без rate-limit на вход | `server.js:155-159, 1641` | Редизайн: хешировать (scrypt) |
| D11 | Безопасность | Уязвимости | SUPPORTING | CONFIRMED | Только UUID в путях (`JOB_ID_PATTERN`); MIME+расширение проверяются, но без проверки сигнатуры файла; SameSite=Lax; без Secure (HTTP LAN); без Origin-проверки | `server.js:234-267, 2116` | Появление внешнего доступа |
| D12 | Операции | Деплой | SUPPORTING | CONFIRMED | Образ `node:22-bookworm-slim` (glibc обязателен для Codex); том `kadr-history`; секреты `APP_USER`/`APP_PASSWORD` из Gitea; Codex через прокси `:4067` | `Dockerfile`, `deploy/docker-compose.yml`, `.gitea/workflows/*.yml` | Смена хостинга |
| D13 | Операции | Наблюдаемость | SUPPORTING | CONFIRMED | Только лог в stdout (уровни debug/info/warn/error), без requestId, без метрик | `server.js:50-79`, `README.md` разд. «Логирование» | — |
| D14 | Экономика | Стоимость | INFORMATIONAL | CONFIRMED | Стоимость = токены Codex (ChatGPT API), редактирование ~1552k токенов/операция | `README.md` разд. «Тест скорости» | Смена тарифа |
| D15 | Эволюция | Скорость редактирования | SUPPORTING | CONFIRMED | Время растёт с разрешением исходника (1200×800 медленнее 512×512); замерено: `CODEX_REASONING_EFFORT=low` даёт ~17% | `README.md` разд. «Тест скорости» | Внедрение предварительного даунскейла |
| D16 | Ограничение | Внешние сервисы | DRIVER | CONFIRMED | ComfyUI API (upload/workflow/history/view), модель `sdxl_turbo.safetensors`, upscale `RealESRGAN_x4plus.safetensors`; Codex CLI `codex exec` с флагами `-i`, `-o last_message.txt`, `--ephemeral`, bypass sandbox | `server.js:15-17, 397-411`, `README.md` | Обновление ComfyUI/Codex |
| D17 | Ограничение | Исходящий egress | DRIVER | CONFIRMED | Прямой выход к OpenAI блокируется (403 cf-ray HEL); обязателен прокси `http://192.168.31.240:4067`, `NO_PROXY` для внутренних адресов | `deploy/docker-compose.yml:20-27`, `README.md` п.5 разд. Portainer | Смена сети/провайдера |
---
## 4. Ключевые сценарии (source/stimulus/environment/artifact/response/measure)
1. **Редактирование с сохранением в историю.** Источник: владелец. Стимул: `POST /api/edit` с фото/`sourceId` + промпт. Окружение: один контейнер, Codex доступен. Артефакт: сервис `/api/edit`. Реакция: ответ < 1 с с `jobId`; ≤ 420 с — новая версия `kind=edit` в истории; мера: версия видна в `GET /api/history` и по `job.status=done`.
2. **Апскейл.** Стимул: `POST /api/upscale`. Реакция: ≤ 300 с — версия `kind=upscale` (PNG), исходник масштабируется до ≤ 2048 px до подачи в ComfyUI. Мера: `job.status=done`.
3. **Переживание перезапуска (цель).** Стимул: перезапуск контейнера во время `running`-задания. Реакция (целевая): задание помечается прерванным с понятной ошибкой; очередь-задания и сессии переживают рестарт; история не теряется. Мера: после рестарта `GET /api/jobs/:id/status` возвращает `interrupted`/`error`, а не 404; сессия владельца жива.
4. **Безопасный вход.** Стимул: 10 неудачных попыток логина за 15 мин с одного IP. Реакция: блокировка с `Retry-After`; пароль не хранится в открытом виде. Мера: rate-limit срабатывает, в логах нет паролей.
---
## 5. Допущения и неизвестные
- **ASSUMED:** ComfyUI и Codex доступны из сети владельца (рабочее состояние подтверждается историей и `.bench/`).
- **ASSUMED:** целевой читатель (ИИ-агент) реализует сервер на Node.js/Express и фронтенд без сборки (vanilla ES-модули) — см. решения в target-design.md.
- **UNKNOWN:** точные объёмы будущего использования (нет метрик) — не влияет: горизонтальный запас огромен для одного пользователя.
- **UNKNOWN:** SLA внешних сервисов (Codex/ComfyUI) — обрабатываются таймаутами и понятными ошибками.
## 6. Триггеры пересмотра baseline
- Второй пользователь или внешний доступ (D1, D11).
- Лимит истории 50 или объём > 500 версий (D3, D5, D9).
- Параллельные правки разных фотографий (D4).
- Смена движка редактирования (D2, D16).
- Смена хостинга/сети (D12, D17).
- Переход на SQLite или другое хранилище (D9).
---
# System Design Baseline
**Checklist: 5/5 complete**<br>
**Incomplete: None** — все пункты чек-листа (объём и назначение; реестр доказательств; драйверы; написание; валидация) выполнены; изменены только документы `docs/architecture/*.md`, код не трогался.

View File

@@ -0,0 +1,676 @@
# Системный дизайн: «Кадр» v2 — целевая архитектура
**Вердикт:** READY
**Артефакт:** `docs/architecture/target-design.md`
**Статус:** 1.0, готов к планированию реализации
**Язык:** русский; идентификаторы кода — английские.
Этот документ — полный проект перепроектирования «Кадр» (локальный AI-фоторедактор). Он написан так, чтобы **человек** мог понять решение, а **ИИ-агент** — реализовать его без дополнительных уточнений: здесь зафиксированы границы, контракты, структура модулей, данные, поведение при сбоях, решения с альтернативами, порядок реализации и критерии приёмки каждой фазы.
Сопутствующие артефакты: `docs/architecture/system-design-baseline.md` (драйверы и ограничения, реестр D1D17), `README.md` (текущая документация).
---
## 1. Контекст и цель перепроектирования
«Кадр» — веб-приложение одного владельца в LAN: редактирование фотографий текстовым промптом (Codex CLI) и 4× апскейл (ComfyUI), с деревом версий, референсом и областью, подсказками промптов. Развёрнуто одним контейнером Docker на Portainer.
Текущая реализация — рабочий прототип: один файл `server.js` на ~2290 строк и `public/app.js` на ~1523 строки. Она выполняет все функции, но имеет структурные дефекты (см. §2), которые делают её хрупкой, трудной для доработки и для безопасного изменения ИИ-агентом.
**Цель перепроектирования:** превратить прототип в **модульный монолит** с чёткими границами, устойчивый к перезапуску, безопасный, тестируемый, с теми же внешними зависимостями (Codex CLI, ComfyUI) и той же моделью развёртывания. Никаких новых сервисов, микросервисов и внешних хранилищ.
**Не-цели:** многопользовательность, интернет-доступ, смена стека фронтенда на сборку (React/Vue + bundler), SQLite, генеративные функции «с нуля». Обоснование — в §8 «Решения».
---
## 2. Текущее состояние: что ломается и что сохраняем
Оценка основана на коде (`server.js`, `public/app.js`, `history/history.json`, `Dockerfile`, `deploy/docker-compose.yml`, `.gitea/workflows/*.yml`).
### 2.1 Дефекты, которые устраняет v2
| # | Проблема | Где | Последствие | Решение в v2 |
|---|---|---|---|---|
| P1 | Монолит в одном файле: auth, история, задания, Codex, ComfyUI, парсеры изображений, роуты | `server.js` (2288 стр.) | Тяжело менять, тестировать, навигировать | Модульная структура `src/` (§4) |
| P2 | Задания в памяти: рестарт убивает `running` и теряет `queued`; `.codex-jobs` стирается при старте | `server.js:141-153, 2259` | Пропадает редактирование на 13 мин при рестарте | Персистентный job-store + журнал (§6.2), восстановление при старте |
| P3 | Глобальный single-flight `codexEditInFlight` → 429, а не очередь; suggest делит флаг с edit | `server.js:91, 1880, 2031` | Нельзя поставить в очередь две правки; UX «дождитесь завершения» | Очередь с персистентностью и кониурентностью по группам (§6.1) |
| P4 | Сессии в памяти; пароль сравнивается с открытым env-значением; нет rate-limit на вход | `server.js:141, 155-159, 1641` | Разлогин при рестарте; открытый пароль в сравнении; перебор пароля | scrypt-хеш в памяти + persistent session store + rate-limit (§7) |
| P5 | Валидация аплоада только по MIME/расширению | `server.js:244-267` | Поддельный MIME проходит | Проверка сигнатуры (magic bytes) (§7.3) |
| P6 | `history.json` переписывается целиком; запись без tmp+rename (частичная запись при крахе) | `server.js:830-868` | Риск порчи манифеста при обрыве | Атомарная запись (tmp+rename+fsync) в history-store (§5.1) |
| P7 | URL версии жёстко `/image.jpg` независимо от реального расширения | `server.js:895` | Ложный URL для PNG | URL формируется из фактического `ext` (§5.1) |
| P8 | Фронтенд: один файл, глобальные переменные, опрос каждые 3 с | `public/app.js` (1523 стр.) | Трудно поддерживать; задержка обновлений до 3 с | ES-модули (§9) + SSE с фолбэком на опрос (§6.4) |
| P9 | Нет тестов, нет CI-проверок; `package.json` только `start` | `package.json` | Регрессии незаметны | `node:test` + supertest + Gitea workflow `test` (§10) |
| P10 | Магические константы и env разбросаны по файлу | `server.js:15-47` | Ошибки конфигурации | Единый `config.js` с валидацией (§4.3) |
| P11 | Нет requestId/корреляции в логах; нет метрик | `server.js:50-79` | Трудно разбирать инциденты | requestId + структурированные логи + счётчики (§11) |
| P12 | Нет предварительного даунскейла для Codex (замер: 1200×800 медленнее 512×512) | `README.md` разд. «Тест скорости» | Дорогие и медленные правки больших фото | Даунскейл `sharp` до `EDIT_MAX_DIMENSION` (§6.5) |
### 2.2 Что сохраняем без изменений (проверено, работает)
- Внешние зависимости: Codex CLI (`codex exec`, флаги `-i/-o/--ephemeral/--skip-git-repo-check/--dangerously-bypass-approvals-and-sandbox`), ComfyUI REST (`/object_info`, `/upload/image`, `/prompt`, `/history/{id}`, `/view`), модели `sdxl_turbo.safetensors`, `RealESRGAN_x4plus.safetensors`.
- Модель развёртывания: Docker (`node:22-bookworm-slim`), Portainer stack, Gitea Actions build/deploy, прокси `:4067` для egress к OpenAI, тома.
- HTTP-контракт публичных эндпоинтов `/api/health`, `/api/login`, `/api/me`, `/api/logout` и семантика основных бизнес-эндпоинтов (см. §7.1 — изменения только аддитивные).
- Формат версии истории (поля `id, kind, label, prompt, parentId, referenceId, createdAt, url, width, height, ext`) — обратная совместимость с существующим `history/history.json` и файлами.
- UI/UX: русский интерфейс, дерево истории, референс с областью, подсказки, «до/после».
- Парсинг размеров PNG/JPEG/WebP из заголовков (без библиотек) — переносится в модуль `images/dimensions.js` как есть.
---
## 3. Оценка нагрузки (до выбора компонентов)
Расчёт для одного владельца в LAN. Никакие компоненты не выбираются «на вырост»; механизмы масштабирования перечислены как отложенные (§8, триггеры).
| Величина | Значение | Обоснование |
|---|---|---|
| Пользователей | 1 | D1 |
| Правок в сутки | 020 (пик) | Оценка по `history.json` (12 версий за 2 дня активного тестирования) |
| Апскейлов в сутки | 020 | Аналогично |
| Параллельность | 1 edit/suggest + ≤ 2 upscale | Текущее узкое место — Codex (один поток); ComfyUI справляется с 12 воркфлоу |
| Размер загрузки | ≤ 25 МБ, типично 16 МБ | `server.js:24`, фактические файлы |
| История | ≤ 50 версий, манифест ~10 КБ | `server.js:34` |
| Частота опроса статуса | 3 с (фронт) → 0 с при SSE | `README.md` |
| Очередь | ≤ 5 заданий в очереди | Лимит для защиты от «накликивания»; дешевле, чем бесконечная очередь |
**Формула для оценки времени:** `t_edit ≈ 6090 с × (maxDim/512)^1.3` (эмпирика из README: 512² ≈ 6082 с, 1200×800 заметно дольше). Даунскейл до 1024 px сокращает среднее время до ~11.5 мин на типичных фото 4000×3000.
**Первый вероятный бутылочный горлышек:** одиночный поток Codex (один `codex exec` на машине). Отложенный механизм: кониурентность > 1 для Codex при появлении второго ключа/аккаунта — не требуется сейчас.
---
## 4. Целевая структура: модульный монолит
### 4.1 Дерево сервера
```
server.js # Тонкий bootstrap-загрузчик: `require("./src/server")` (совместимость с Docker CMD и `npm start`)
src/
server.js # Реальная точка входа: loadConfig → createApp → listen (см. §13, Фаза 0)
config.js # Единый источник конфигурации (env → объект, валидация)
logger.js # Структурированный лог (JSON-lines), уровни, requestId
errors.js # Иерархия ошибок: AppError(status, message, details)
http/
app.js # createApp(deps) -> express app (DI для тестов)
middleware/
request-context.js # requestId, тайминги, лог запросов
auth.js # requireAuth (cookie-сессия, sliding TTL)
origin-check.js # CSRF-защита: Origin против Host на state-changing запросах
rate-limit.js # Окно попыток входа на IP
error-handler.js # Единый формат ошибок { ok:false, error, details, requestId }
routes/
auth.routes.js # POST /api/login, GET /api/me, POST /api/logout
health.routes.js # GET /api/health
history.routes.js # GET /api/history, POST /api/history/import, DELETE /api/history/:id, GET /api/history/:id/image.{ext}
preview.routes.js # POST /api/preview
edit.routes.js # POST /api/edit
upscale.routes.js # POST /api/upscale
suggest.routes.js # GET /api/suggest-prompts, POST /api/suggest
jobs.routes.js # GET /api/jobs, GET /api/jobs/:id/status, GET /api/jobs/:id/output.jpg
events.routes.js # GET /api/events (SSE)
result.routes.js # GET /api/result (прокси к ComfyUI /view)
core/
auth/
password.js # scrypt hash/verify (timing-safe)
session-store.js # Персистентный store сессий (JSON-файл, атомарная запись, TTL-sweep)
history/
history-store.js # Манифест + файлы изображений; атомарные операции
history-service.js # Бизнес-операции: импорт, каскадное удаление, прунинг, чтение файла
jobs/
job-store.js # Персистентный журнал заданий (JSONL) + индекс в памяти
queue.js # FIFO-очередь с кониурентностью по группам, восстановлением
job-service.js # Создание задания, статусы, эмиссия событий
engines/
engine.js # Контракт движка + реестр (тип -> фабрика)
codex-engine.js # Редактирование и подсказки через Codex CLI
comfy-upscale-engine.js # Апскейл через ComfyUI
comfy/
comfy-client.js # REST-клиент: upload, submit, poll history, download view
comfy-runtime.js # Кэш /object_info (30 с), выбор моделей, requireNodes
workflows.js # buildUpscaleWorkflow (перенос), будущие воркфлоу
codex/
cli.js # Обёртка spawn `codex exec`: запуск, ожидание output.jpg/last_message.txt, таймаут
prompts.js # buildEditPrompt, buildSuggestPrompt, parseSuggestions, scaleRegion
images/
dimensions.js # getImageDimensions (PNG/JPEG/WebP) — перенос без изменений
heic.js # convertHeicToJpeg (перенос)
resize.js # Даунскейл через sharp (только для edit-пути)
data/ # Runtime-данные (gitignored, Docker volume /app/data)
history.json # Манифест версий
sessions.json # Сессии (если SESSION_PERSIST=true)
jobs.jsonl # Журнал заданий
test/ # node:test + supertest
config.test.js logger.test.js images.test.js
history-store.test.js job-queue.test.js prompts.test.js
api.test.js
```
### 4.2 Правила зависимостей (обязательны для реализации)
1. **Слои:** `routes → core/* → (engines | comfy | codex | images)`. Роуты не содержат бизнес-логики — только парсинг запроса и вызов сервиса.
2. `core` не импортирует `express` (кроме `http/app.js` и middleware). Движки и сервисы получают зависимости через конструктор/DI.
3. Никто не читает `process.env` напрямую, кроме `config.js`. Все константы — из `config`.
4. `engines/*` не знают про HTTP. Возвращают `EngineResult` (см. §6.3).
5. `queue.js` не знает про движки — получает фабрику по типу задания из реестра.
6. Файловая система — только через выделенные модули-хранилища (`history-store`, `session-store`, `job-store`, `codex/cli`); случайные `fs.*` в роутах запрещены.
### 4.3 Конфигурация (`src/config.js`)
Функция `loadConfig(env = process.env)` → замороженный объект; бросает `ConfigError` со списком проблем при старте. Все значения — с дефолтами (совместимы с текущими):
| Ключ env | Тип | Дефолт | Комментарий |
|---|---|---|---|
| `PORT` | number | 3000 | Единый порт (убрать каскад 8080/8090? — оставить как `PORT_CANDIDATES` массив `[3000, 8080, 8090]` для совместимости, управляется `PORT` при задании) |
| `COMFY_URL` | url | `http://192.168.31.240:8188` | Валидируется как URL |
| `APP_USER` | string | `admin` | |
| `APP_PASSWORD` | string | генерируется при старте (баннер в лог, как сейчас) | Никогда не логируется |
| `LOG_LEVEL` | debug/info/warn/error | info | |
| `DATA_DIR` | path | `./data` | Хранилища сессий и заданий |
| `HISTORY_LIMIT` | number | 50 | |
| `MAX_UPLOAD_BYTES` | number | 25 МБ | |
| `EDIT_MAX_DIMENSION` | number | 1024 | Даунскейл перед Codex; `0` = отключить |
| `UPSCALE_MAX_DIMENSION` | number | 2048 | Существующий предел |
| `CODEX_CLI_PATH` | string | авто-поиск (`resolveCodexExe` переносится) | |
| `CODEX_MODEL`, `CODEX_REASONING_EFFORT` | string | из `~/.codex/config.toml` | Передаются в `codex exec` как временный config или аргументы (как сегодня) |
| `CODEX_EDIT_TIMEOUT_MS` | number | 420000 | |
| `JOB_TIMEOUT_MS` | number | 300000 | Таймаут ComfyUI |
| `REQUEST_TIMEOUT_MS` | number | 15000 | |
| `SESSION_TTL_MS` | number | 7 суток | |
| `SESSION_PERSIST` | bool | true | Переживание входа через рестарт |
| `LOGIN_RATE_LIMIT` | object | `{ windowMs: 15*60*1000, max: 5 }` | |
| `CODEX_CONCURRENCY` | number | 1 | Потоков Codex |
| `UPSCALE_CONCURRENCY` | number | 2 | Потоков ComfyUI-апскейла |
| `MAX_QUEUED_JOBS` | number | 5 | Предел очереди на тип |
| `SSE_ENABLED` | bool | true | |
| `TRUST_PROXY` | bool | false | Для rate-limit по IP за прокси |
При старте в лог пишется **редактируемый** снимок конфигурации (без `APP_PASSWORD`, без путей к токенам).
---
## 5. Данные и хранилища
### 5.1 История версий (`core/history`)
Формат манифеста — **без изменений** (обратная совместимость с текущим `history/history.json`): массив объектов
```js
{
id: string, // uuid v4
kind: 'original'|'edit'|'upscale',
label: string,
prompt: string|null,
parentId: string|null,
referenceId: string|null,
createdAt: ISO string,
url: `/api/history/${id}/image.${ext}`, // P7: фактическое расширение
width: number|null,
height: number|null,
ext: 'jpg'|'png'|'webp'
}
```
Файлы изображений: `data/history/<id>.<ext>`**важно**: каталог переносится из `history/` в `data/history/` (том `kadr-data` вместо `kadr-history`; миграция описана в §12). Пока не мигрирован — `HISTORY_DIR` конфигурируется и по умолчанию остаётся `./history`; v2 пишет в `DATA_DIR/history`. Выбираем: **`HISTORY_DIR = path.join(DATA_DIR, 'history')`**, миграция при старте: если существует старый `./history/history.json`, а нового нет — перенести файлы (см. §12).
Интерфейс `history-store`:
```js
class HistoryStore {
constructor({ dir, limit, logger })
load(): Version[] // чтение манифеста (пусто при отсутствии)
async append(version): Promise<void> // атомарно: tmp+rename+fsync; прунинг > limit (удаление файлов вытесненных)
async removeCascade(id): Promise<{ removed: Version[] }> // BFS по parentId, удаление файлов
get(id): Version | null
filePath(version): string
}
```
Все мутации сериализуются цепочкой (как текущий `historyWriteChain`) — **сохраняем** этот паттерн, вынося его в store. Атомарность: писать в `<manifest>.tmp`, `fsync`, `rename`, `fsync` директории.
### 5.2 Сессии (`core/auth/session-store`)
`SessionStore` — Map + персистентный JSON-файл `data/sessions.json`:
```js
class SessionStore {
constructor({ file, ttlMs, persist, logger })
create(user): token
get(token): { user, expiresAt } | null // sliding: обновляет expiresAt
delete(token): void
sweep(): void // периодическая чистка TTL
_persist(): void // атомарная запись (debounce 500 мс)
}
```
Токен — 32 байта hex (как сейчас). В файле хранятся хеши токенов (`sha256(token)`), чтобы кража файла не давала прямых сессий. При `SESSION_PERSIST=false` — чисто in-memory (поведение v1).
### 5.3 Задания (`core/jobs/job-store`)
Персистентность: **append-only JSONL-журнал** `data/jobs.jsonl` + индекс в памяти. Каждая строка — событие перехода:
```jsonl
{"seq":1,"jobId":"...","at":"2026-08-26T06:00:00.000Z","event":"created","job":{...полный объект...}}
{"seq":2,"jobId":"...","at":"...","event":"status","status":"running","startedAt":"..."}
{"seq":3,"jobId":"...","at":"...","event":"status","status":"done","result":{...},"finishedAt":"..."}
```
- При старте: журнал читается, для каждого `jobId` восстанавливается последнее состояние (события применяются по порядку). Схемы строк журнала фиксируются JSDoc-типами (`JobCreatedEvent`, `JobStatusEvent`) в `core/jobs/job-store.js`, чтобы валидация журнала при старте была тривиальной (невалидная строка → warn + пропуск).
- `status=running` на момент старта → `interrupted` (см. §6.1 восстановление).
- Периодическая компактизация: каждые 6 ч журнал переписывается, оставляя только задания моложе 1 ч после `finishedAt` (терминальные задания живут в индексе 1 ч, как сейчас `server.js:146-153`).
- Формат задания:
```js
{
id: string, // uuid
type: 'edit'|'upscale'|'suggest',
status: 'queued'|'running'|'done'|'error'|'interrupted',
createdAt: ISO, startedAt: ISO|null, finishedAt: ISO|null,
payload: object, // зависит от типа (§7.1)
result: object|null, // { version?, summary?, ... }
error: string|null, details: any|null,
attempts: number, // для восстановления
engine: 'codex'|'comfy-upscale' // производная от type
}
```
### 5.4 Что не храним
- Исходные файлы заданий в `.codex-jobs` **не персистим как данные** — это рабочий каталог движка; очистка: при старте удалять каталоги старше 24 ч (а не все, как сейчас).
---
## 6. Очередь, жизненный цикл заданий, восстановление, события
### 6.1 Очередь (`core/jobs/queue.js`)
FIFO по `createdAt`; кониурентность по группам движков:
| Группа | Типы заданий | Кониурентность | Обоснование |
|---|---|---|---|
| `codex` | edit, suggest | `CODEX_CONCURRENCY` (1) | Один Codex-процесс на машине; узкое место — image-модель |
| `comfy-upscale` | upscale | `UPSCALE_CONCURRENCY` (2) | ComfyUI выдерживает 12 воркфлоу; апскейлы быстрые |
Интерфейс:
```js
class Queue {
constructor({ concurrencyByGroup, jobStore, engineRegistry, logger, events })
async enqueue(job): Promise<{ job, queuePosition }> // 429, если MAX_QUEUED_JOBS достигнут
start(): void // запуск воркеров (при старте сервера)
stop(): void // graceful shutdown: дождаться running, queued остаются
status(jobId): Job | null
listActive(): Job[] // для UI-бейджа и SSE-снапшота
}
```
Жизненный цикл (state machine):
```
queued ──worker взял──▶ running ──успех──▶ done
├──ошибка──▶ error
└──рестарт──▶ interrupted (attempts+1; если attempts < 1 — снова queued, иначе error «прервано перезапуском»)
```
**Политика восстановления после рестарта (решение):**
- `queued` → остаётся в очереди, выполняется после старта (входные файлы целы, движок ещё не запускался).
- `running``interrupted`; **автоматически НЕ перезапускаем** задания Codex (риск двойного списания токенов): пользователь видит ошибку «Задание прервано перезапуском сервера. Запустите заново.» с кнопкой повтора в UI. Исключение: `upscale` — безопасно перезапустить (идемпотентно), `attempts < 1` → requeue.
- Входные файлы движка: каталог `.codex-jobs/<jobId>/` сохраняется до завершения задания; при `interrupted` кодекс-задания файлы не нужны (повтор не авто), поэтому каталог можно чистить сразу.
### 6.2 Контракт движка (`core/engines/engine.js`)
```js
/**
* @typedef {Object} EngineContext
* @property {Config} config
* @property {Logger} logger
* @property {HistoryService} history
* @property {ComfyClient} comfy
* @property {CodexCli} codex
*
* @typedef {Object} EngineResult
* @property {boolean} ok
* @property {Version} [version] // созданная версия (edit/upscale)
* @property {object} [extra] // summary, promptId, model и т.п. → в job.result
* @property {string} [error] // сообщение для пользователя
* @property {any} [details] // технические детали (в лог)
*
* Реестр: engineRegistry.register('edit', (ctx) => ({ run(job) {...} }))
*/
```
Типы движков: `codex-engine` (edit, suggest — два метода одного адаптера), `comfy-upscale-engine` (upscale).
### 6.3 Кодекс-движок (`core/engines/codex-engine.js` + `core/codex/*`)
`codex/cli.js` — перенос `runCodexEdit`/`runCodexSuggest` (spawn, ожидание `output.jpg`/`last_message.txt`, таймаут `CODEX_EDIT_TIMEOUT_MS`, обработка `exit/error`), с одним изменением: функция принимает `{ jobDir, inputNames, prompt, timeoutMs }` и возвращает `{ exitCode, timedOut, outputPath, lastMessage }`.
`codex/prompts.js` — перенос `buildCodexPrompt`/`buildSuggestPrompt`/`parseSuggestions` + **новая** функция:
```js
// Масштабирование координат области референса после даунскейла референса.
// region — {x,y,w,h} в пикселях ОРИГИНАЛА референса; factor = targetDim / max(origW, origH)
function scaleRegion(region, factor): { x,y,w,h } // Math.round каждого, минимум 1px
```
`codex-engine.js` (edit):
1. По `job.payload` собрать вход: `input.<ext>` из файла задания или из версии истории (`history-service` выдаёт buffer).
2. Даунскейл: если `EDIT_MAX_DIMENSION > 0` и `max(w,h) > EDIT_MAX_DIMENSION``images/resize.js` (`sharp`), входной buffer уменьшается, `scaledSize` сохраняется. Если есть референс с областью — референс тоже даунскейлится тем же фактором, `region = scaleRegion(region, factor)`; промпт получает размер **даунскейленного** изображения.
3. Промпт: `buildEditPrompt(...)` (см. §6.5).
4. Запуск `codex exec`, ожидание `output.jpg`.
5. Создание версии `kind=edit`, `parentId=sourceId`, `referenceId`, размеры = даунскейленные (если был даунскейл, в `job.result.extra` пишем `downscaledFrom: {width,height}`).
6. `EngineResult { ok, version, extra }`.
`suggest`: без даунскейла (текстовый вывод), перенос как есть.
### 6.4 Апскейл-движок (`core/engines/comfy-upscale-engine.js` + `core/comfy/*`)
Перенос без изменения логики: `uploadToComfy``buildUpscaleWorkflow``submitWorkflow``waitForResult``downloadComfyImage` → версия `kind=upscale`. Компоненты: `comfy-client.js` (HTTP-слой с таймаутами и AppError-обёрткой), `comfy-runtime.js` (кэш `/object_info` 30 с, `chooseUpscaleModel`, `requireNodes`), `workflows.js` (построитель графа). `GET /api/result` (прокси к `/view`) остаётся в роуте, используя `comfy-client`.
### 6.5 Промпт редактирования (обновлённый `buildEditPrompt`)
Структура совпадает с текущей (`server.js:355-395`), плюс одна строка при даунскейле:
```
Перед тобой фотография input.jpg. Примени к ней следующее редактирование: «{prompt}».
[Если референс:] Также дано вспомогательное изображение reference.jpg — тот же сюжет... Координаты области: x=..., y=..., ширина=..., высота=... (в пикселях). Примени правку именно в области...
[Если даунскейл:] Исходное фото уменьшено для скорости обработки.
Сохрани отредактированное фото в файл output.jpg в текущей директории.
Не перерисовывай фото с нуля... Сохрани исходный размер {W}×{H}. // W,H — размер фактически поданного файла
Файл output.jpg должен быть валидным JPEG. По завершении кратко опиши, что сделал.
```
### 6.6 События и SSE (`core/jobs/events` + `routes/events.routes.js`)
`job-service` эмитит события через простой `EventEmitter`:
```js
events.emit('job:updated', { jobId, status, type, payload?, error?, queuePosition? })
events.emit('job:removed', { jobId })
```
`GET /api/events` (SSE, авторизованный):
- Заголовки: `Content-Type: text/event-stream`, `Cache-Control: no-cache`, `Connection: keep-alive`, `X-Accel-Buffering: no`.
- При подключении — снапшот активных заданий (`event: snapshot`), далее `event: job` на каждое обновление; heartbeat `: ping` каждые 15 с.
- При `close` — отписка; таймаут простоя 10 мин → закрытие.
- Формат данных: `data: {"jobId":"...","status":"running","type":"edit"}` — только разрешённые поля, без внутренних объектов.
Фронтенд: `EventSource('/api/events')`; при ошибке/`onerror` — фолбэк на текущий опрос `GET /api/jobs/:id/status` каждые 3 с (поведение v1). Это делает SSE аддитивным и безопасным.
---
## 7. HTTP-контракт (целевой)
### 7.1 Эндпоинты
Все эндпоинты, кроме публичных, требуют сессию (`requireAuth`). Единый формат ошибок:
```json
{ "ok": false, "error": "Текст для пользователя", "details": {}, "requestId": "req_..." }
```
| Метод и путь | Публичный | Тело/параметры | Ответ (успех) | Изменения vs v1 |
|---|---|---|---|---|
| `GET /api/health` | да | — | `{ ok, comfyAvailable, message, selectedUpscaleModel, upscaleModels, checkpoint, checkpointAvailable, nodes, missingNodes, version }` | + `version` |
| `POST /api/login` | да | `{ username, password }` | `{ ok, user }` + cookie | + rate-limit; сравнение по scrypt-хешу |
| `GET /api/me` | да | — | `{ ok, user }` | как есть |
| `POST /api/logout` | да | — | `{ ok }` | как есть |
| `GET /api/history` | нет | — | `{ ok, versions: Version[] }` | как есть |
| `POST /api/history/import` | нет | multipart `photo` | `{ ok, version }` | + magic-byte проверка |
| `DELETE /api/history/:id` | нет | — | `{ ok, versions }` | как есть (каскад) |
| `GET /api/history/:id/image.{ext}` | нет | — | изображение | URL с фактическим ext (P7); старый `/image.jpg` оставить алиасом 302 → совместимость с сохранёнными ссылками |
| `POST /api/preview` | нет | multipart `photo` | изображение | как есть |
| `GET /api/suggest-prompts` | нет | — | `{ ok, prompts }` | как есть |
| `POST /api/suggest` | нет | multipart `photo` (или `sourceId`) | `{ ok, jobId, status:'queued' }` | очередь вместо 429 |
| `POST /api/edit` | нет | multipart: `photo`\|`sourceId`, `prompt`, `reference`, `referenceId`, `region` | `{ ok, jobId, status:'queued', queuePosition }` | очередь вместо 429; даунскейл |
| `POST /api/upscale` | нет | multipart `photo`\|`sourceId` | `{ ok, jobId, status:'queued', queuePosition }` | очередь |
| `GET /api/jobs` | нет | — | `{ ok, jobs: JobSummary[] }` | **новый** (активные + последние терминальные) |
| `GET /api/jobs/:id/status` | нет | — | `{ ok, status, type, queuePosition?, payload?, error?, details?, requestId }` | + `queuePosition` |
| `GET /api/jobs/:id/output.jpg` | нет | — | JPEG | как есть |
| `GET /api/result` | нет | `filename, subfolder, type=output` | изображение (прокси ComfyUI) | как есть |
| `GET /api/events` | нет | — | SSE-поток | **новый** |
`JobSummary = { id, type, status, createdAt, startedAt?, finishedAt?, queuePosition? }` — без payload/result (для бейджа очереди).
### 7.2 Payload'ы заданий
```js
edit: { sourceId: string|null, inputBuffer?: Buffer, inputMimetype?: string,
prompt: string, referenceId?: string|null, region?: {x,y,w,h}|null }
upscale: { sourceId: string|null, inputBuffer?: Buffer, inputMimetype?: string }
suggest: { sourceId: string|null, inputBuffer?: Buffer, inputMimetype?: string }
```
(`sourceId` предпочтителен; если его нет — в payload кладётся buffer загруженного файла; buffer НЕ пишется в журнал — только в рабочий каталог движка.)
`result`:
```js
edit: { version: Version, summary?: string, extra?: { downscaledFrom?: {width,height}, exitCode?, timedOut? } }
upscale: { version: Version, extra?: { promptId, model, scaledSize? } }
suggest: { suggestions: string[] }
```
### 7.3 Валидация входов
- **Загрузка файлов:** multer (лимиты как сейчас); `fileFilter` — по MIME **и** по сигнатуре: `sniffImageType(buffer)` определяет `jpeg|png|webp|heic` по первым байтам (JPEG `FF D8 FF`, PNG-сигнатура, `RIFF....WEBP`, HEIC `ftyp` с брендом `heic/heix/hevc/heif`). Несовпадение → 400.
- **Промпт:** trim, 11000 символов.
- **`region`:** как сейчас (`parseRegion`), дополнительно: целые ≥ 0; не больше размеров референса.
- **UUID:** `JOB_ID_PATTERN` остаётся для всех `:id` параметров.
- **Origin-проверка (CSRF):** middleware для не-GET `/api/*`: если заголовок `Origin` присутствует и не равен `Host` (с учётом схемы) → 403. Плюс `SameSite=Lax` (уже есть). Для LAN-инструмента этого достаточно (прокси не используется, кроме `/api/result` к ComfyUI).
---
## 8. Решения и альтернативы
| # | Решение | Выбрано | Альтернативы (рассмотрены) | Почему | Триггер пересмотра |
|---|---|---|---|---|---|
| R1 | Гранулярность | Модульный монолит | Микросервисы; один файл | Один владелец, один хост; микросервисы = жизненный цикл без выгоды | Второй хост/сервис, командная разработка |
| R2 | Хранилище истории | JSON-манифест + файлы, атомарная запись | SQLite (better-sqlite3) | ≤ 50 версий; совместимость с существующими данными; ноль нативных зависимостей | > 500 версий, конкурентные писатели, сложные запросы дерева |
| R3 | Хранилище заданий | JSONL-журнал + индекс в памяти | SQLite; in-memory (v1) | Транзиентные данные (1 ч); простота восстановления; нет нативных зависимостей | Нужна аналитика по истории заданий |
| R4 | Сессии | Персистентный файл (хеши токенов), TTL | In-memory (v1) | «Переживает рестарт» — явная ценность владельца; дёшево | Мультипользователь, внешний доступ |
| R5 | Пароль | scrypt (встроенный `crypto`) | bcrypt/argon2 (native); открытый compare (v1) | Ноль зависимостей, timing-safe, NIST-приемлемо | Требования комплаенса |
| R6 | Даунскейл перед Codex | `sharp` | `jimp` (чистый JS); без даунскейла (v1) | Замеры README показывают выигрыш; sharp — стандарт, есть prebuilds для bookworm-slim | Проблемы сборки native → заменить на jimp |
| R7 | Обновления статуса | SSE + фолбэк на polling | Только polling (v1); WebSocket | SSE — нулевые зависимости, работает через любой прокси; фолбэк сохраняет v1-поведение | Нужны двусторонние каналы (отмена заданий) |
| R8 | Фронтенд | Vanilla ES-модули, без сборки | React/Vue + Vite | Ноль build-шага в Docker/CI; текущий HTML/CSS сохраняется; сложность UI невелика | Рост сложности UI (много интерактивных панелей) |
| R9 | Очередь | FIFO + кониурентность по группам | Только single-flight (v1); полноценный worker pool | Покрывает реальный сценарий «поставить правку в очередь» без оверкилла | Параллельные правки |
| R10 | Восстановление заданий | `queued` выполняются; `running``interrupted` (upscale — requeue 1 раз) | Автоперезапуск всего | Избегаем двойного списания токенов Codex; честный UX | Появление идемпотентных движков |
| R11 | Тесты | `node:test` + supertest | Jest/Vitest | Встроено в Node 20, ноль зависимостей | Миграция на TS |
| R12 | TypeScript | Нет (JS + JSDoc) | TS с сборкой | Текущий стек без сборки; JSDoc даёт контракты для ИИ-агента | Кодовой базы > ~10k строк |
---
## 9. Фронтенд (target)
### 9.1 Структура
```
public/
index.html // почти без изменений; подключение: <script type="module" src="/js/main.js"></script>
style.css // без изменений (v1); допускается мелкая правка под новые блоки
js/
main.js // bootstrap: чтение DOM-элементов, инициализация модулей, подписки
state.js // единое состояние { currentVersionId, historyVersions, collapsed, reference, busy, activeJobs }
api.js // apiFetch(url, opts): JSON-ошибки, 401 → auth.show(), requestId
auth.js // showAuthOverlay/hide, checkAuth, submitLogin, logout
health.js // опрос /api/health (как сейчас, 30 с)
upload.js // drop-zone, мультизагрузка (последовательный импорт, прогресс)
reference.js // выбор референса (файл/история), канвас, область (перенос из app.js:1072-1290)
suggest.js // chips из истории, «Спросить Codex», подписка на job (перенос)
jobs.js // EventSource + фолбэк-поллинг; renderJobSuccess, ошибки
history-tree.js // рендер дерева, свёртка групп, выбор версии, удаление (перенос из app.js:532-852)
ui.js // мелкие утилиты: formatFileSize, versionFileName, toast
```
### 9.2 Ключевые изменения поведения
1. **Обновления статуса:** `jobs.js` держит один `EventSource`; на `event: job` обновляет бейдж задания и завершает текущий job-цикл мгновенно (без 3-секундной задержки). При `onerror` (сеть/прокси) — фолбэк: поллинг `GET /api/jobs` каждые 3 с.
2. **Очередь:** если ответ `POST /api/edit` = `{ status:'queued', queuePosition }` — UI показывает «В очереди, позиция N» и слушает событие до `running`/`done` (текущий код `runJob` предполагает только `running` — доработать).
3. **Ошибка `interrupted`:** после рестарта сервера задание приходит как `interrupted` → показать сообщение и предложить повторно запустить (кнопка повторяет `POST /api/edit` с теми же параметрами, которые UI хранит в памяти сессии).
4. **Скачивание:** `download-current` и `download-link` используют `version.url` (теперь с фактическим ext).
### 9.3 Состояние
`state.js` — простой объект + `subscribe(fn)`; модули не трогают DOM друг друга напрямую. Это минимально достаточное улучшение без библиотек.
---
## 10. Тестирование и CI
### 10.1 Модульные (node:test, `test/*.test.js`)
| Файл | Что проверяет |
|---|---|
| `config.test.js` | дефолты, валидация, ошибки при неверных значениях, редактирование лог-снимка |
| `logger.test.js` | уровни, формат JSON-lines, requestId-корреляция, отсутствие пароля |
| `images.test.js` | `getImageDimensions` (PNG/JPEG/WebP фикстуры), `sniffImageType` (magic bytes, включая HEIC `ftyp`), `scaleRegion` (округление, factor=1) |
| `history-store.test.js` | append + прунинг + удаление файлов; атомарность (нет tmp-файлов после успеха); каскадное удаление; переживание перезаписи |
| `job-queue.test.js` | FIFO-порядок, кониурентность по группам (fake-движок с задержками), `MAX_QUEUED_JOBS` → 429, восстановление из журнала (queued остаётся, running → interrupted, upscale requeue) |
| `prompts.test.js` | сборка промпта (с референсом/областью/даунскейлом), `parseSuggestions` |
| `api.test.js` (supertest) | login (успех/ошибка/rate-limit), auth-защита, import/история, edit/upscale/suggest с **stub-движками** (журнал), SSE-снапшот, ошибки `{ok:false,error,requestId}` |
Все тесты работают с временным `DATA_DIR` (`fs.mkdtemp`) — реальные `data/` и `history/` не трогаются.
### 10.2 Приёмочные проверки каждой фазы — в §13.
### 10.3 CI (Gitea Actions)
Новый workflow `.gitea/workflows/test.yml` (workflow_dispatch + push на `main`):
```yaml
name: test
on: { workflow_dispatch: {}, push: { branches: [main] } }
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: 20, cache: npm }
- run: npm ci
- run: npm test
```
`package.json`: добавить `"test": "node --test test/"` и devDependencies `supertest`.
---
## 11. Наблюдаемость
- **Логи:** JSON-lines, поля `{ ts, level, msg, requestId, jobId?, versionId?, type?, durationMs? }`. `requestId` генерируется в `request-context.js` (`req_` + uuid8) и прокидывается в контекст задания при его создании (журналируем `requestId` в job-событиях).
- **Метрики (лёгкие, in-memory):** счётчики `jobs.created{type}`, `jobs.done{type}`, `jobs.error{type}`, гистограмма `job.duration{type}` (бакеты 5/15/30/60/120/300/600 с), `history.size`. Экспонируются в `GET /api/health` (поле `metrics`) — отдельный `/api/metrics` не нужен для одного владельца.
- **Аудит:** отдельный уровень `audit` для входа/выхода/удаления версий (в stdout).
- **Health:** как сейчас (ComfyUI `/object_info`), плюс проверка, что очередь не заблокирована (если `running` дольше 10 мин — поле `warning`).
---
## 12. Миграция и совместимость
1. **Данные истории:** при первом старте v2, если `./history/history.json` существует, а `data/history/history.json` — нет: скопировать каталог `history/*``data/history/`, оставить `./history` нетронутым (не удалять), залогировать миграцию. Обратная совместимость формата версий — да (поля совпадают).
2. **Старый URL изображений:** `GET /api/history/:id/image.jpg` остаётся как redirect 302 на `/api/history/:id/image.<ext>` — сохранённые вкладки/ссылки не ломаются.
3. **Docker/deploy:**
- `Dockerfile`: `COPY server.js ./`, `COPY src ./src`, `COPY public ./public`; `VOLUME ["/app/history", "/app/data"]`.
- `deploy/docker-compose.yml`: заменить `kadr-history:/app/history` на `kadr-data:/app/data` (или добавить `kadr-data` рядом — при первом старте сработает миграция из старого тома только если смонтирован и старый; **рекомендация:** смонтировать оба тома на переходный период: `kadr-history:/app/history:ro`? Нет — проще: оставить `kadr-history:/app/history` и добавить `kadr-data:/app/data`; v2 пишет в `data/history`, миграция читает `./history`).
- `.gitignore`: добавить `data/` (уже есть `history/`).
4. **README:** обновить разделы «Запуск», «Авторизация» (rate-limit, scrypt), «Логирование» (requestId), добавить раздел «Архитектура» со ссылкой на эти документы и список env-переменных.
---
## 13. План реализации для ИИ-агента (по фазам)
Порядок фаз сохраняет работоспособность сервиса на каждом шаге (каждая фаза заканчивается зелёными проверками и рабочей страницей). Реализатор должен следовать правилам зависимостей §4.2 и контрактам §5§7.
### Фаза 0 — Каркас (без изменения поведения)
- Создать `src/config.js`, `src/logger.js`, `src/errors.js`; перенести существующие константы и `log()` из `server.js`.
- Создать `src/http/app.js` (`createApp(deps)`) и `src/server.js` (bootstrap: `loadConfig → createApp → listen`). Корневой `server.js` становится тонким загрузчиком (`require("./src/server")`) — Docker CMD и `npm start` не меняются.
- Перенести все роуты из `server.js` в `src/http/routes/*` **без изменения логики** (механический перенос, временно оставив в одном месте `sessions/jobs` Map).
- `package.json`: `"start": "node server.js"`, `"start:dev": "node --watch src/server.js"`.
- **Приёмка:** `npm start` поднимает сервер; все сценарии README работают как раньше (ручная проверка); `npm test` (пустой набор) зелёный.
### Фаза 1 — Хранилища данных
- `core/history/history-store.js` + `history-service.js`: атомарная запись, прунинг, каскадное удаление (перенос из `server.js:820-900, 1765-1826`), URL с фактическим ext + алиас `/image.jpg` (302).
- `core/auth/password.js` + `session-store.js`: scrypt-хеш, персистентные сессии с хешами токенов.
- Миграция `history/``data/history/` (§12.1).
- Тесты: `history-store.test.js` (модульный), `api.test.js` (импорт/удаление/история).
- **Приёмка:** `npm test` зелёный; загрузка/удаление версий работает; перезапуск сервера сохраняет историю и вход (с `SESSION_PERSIST=true`).
### Фаза 2 — Очередь и движки
- `core/jobs/job-store.js`, `queue.js`, `job-service.js`; контракт `engines/engine.js`.
- Перенос Codex-логики в `core/codex/cli.js` + `prompts.js` + `core/engines/codex-engine.js`; ComfyUI-логики в `core/comfy/*` + `comfy-upscale-engine.js`.
- Роуты edit/upscale/suggest переключаются на `job-service.enqueue` (вместо `codexEditInFlight`); `GET /api/jobs`, `queuePosition`, восстановление при старте.
- Тесты: `job-queue.test.js` (fake-движки), `prompts.test.js`, обновить `api.test.js` (stub-движки).
- **Приёмка:** две правки подряд встают в очередь; рестарт во время `running``interrupted` с понятной ошибкой в UI; `queued` переживает рестарт; апскейл работает.
### Фаза 3 — Безопасность и валидация
- `rate-limit.js` (логин), `origin-check.js`, `sniffImageType` в фильтре аплоада, валидация `region`.
- Тесты: login rate-limit, Origin-проверка, поддельный MIME отклоняется.
- **Приёмка:** 6 неудачных входов за 15 мин блокируют; curl с чужим Origin на `POST` → 403; файл с переименованным расширением отклоняется.
### Фаза 4 — Даунскейл и SSE
- `images/resize.js` (sharp), даунскейл в `codex-engine` (edit), `scaleRegion`.
- `events.routes.js` (SSE) + эмиттер в `job-service`; фронтенд: `js/jobs.js` (EventSource + фолбэк).
- Тесты: `images.test.js` (scaleRegion), SSE-снапшот в `api.test.js`.
- **Приёмка:** правка фото 4000×3000 уходит в Codex как ≤ 1024 px (проверка лога), результат корректен; статусы приходят мгновенно без поллинга (DevTools Network); при отключённом SSE работает фолбэк.
### Фаза 5 — Фронтенд-модули
- Разбить `public/app.js` на `js/*.js` (ES-модули) по §9.1 без изменения вёрстки/стилей; перенести логику 1-в-1, заменить глобальные переменные на `state.js`.
- `index.html`: `<script type="module" src="/js/main.js">`.
- **Приёмка:** все сценарии README работают; `npm start` отдаёт модули без build-шага; консоль браузера чистая.
### Фаза 6 — CI, наблюдаемость, документация
- `.gitea/workflows/test.yml`; метрики в `/api/health`; requestId в логах и ошибках; обновление README и Dockerfile/deploy (том `data`).
- **Приёмка:** Gitea Actions `test` зелёный; `docker compose up -d --build` собирается; после рестарта контейнера данные и входы на месте.
---
## 14. Валидация целевой архитектуры (приёмочные доказательства)
| Требование | Доказательство |
|---|---|
| Поведение v1 сохранено | Фаза 01 ручной прогон всех README-сценариев; `api.test.js` покрывает контракты |
| Переживание рестарта | Тест `job-queue.test.js` (восстановление из журнала) + ручной: рестарт во время edit |
| Безопасность | Тесты rate-limit/Origin/magic-bytes; в логах нет паролей и токенов |
| Скорость | Замер времени edit до/после даунскейла (лог `durationMs`, сравнение с README-таблицей) |
| Наблюдаемость | Лог с requestId по всем API; `GET /api/health` содержит metrics |
| Реализуемость | Каждая фаза имеет автотесты и ручную приёмку; контракты в §5§7 однозначны |
---
## 15. Открытые вопросы и остаточные риски
Вопросы, которые **могут** изменить планирование (на текущий момент решены дефолтами):
1. **Резкость даунскейла для Codex.** Дефолт 1024 px; если владелец заметит потерю деталей на продуктах (этикетки, текст), поднять до 1536 или отключить (`EDIT_MAX_DIMENSION=0`). Обратимо на уровне env.
2. **Судьба старого тома `kadr-history`.** После подтверждённой миграции владелец может удалить том вручную; v2 не удаляет его сам.
3. **Кониурентность апскейлов = 2.** Если ComfyUI (2× AMD Radeon Pro VII) начнёт ставить в очередь — снизить до 1; env-переменная уже предусмотрена.
Риски и их снижение:
- **sharp в node:22-bookworm-slim:** prebuilt binaries распространяются для linux-x64 glibc — ожидаемо работает; фолбэк `jimp` (R6).
- **Изменения API Codex/ComfyUI:** клиенты изолированы в `core/codex` и `core/comfy`; изменения затрагивают один модуль + тесты.
- **SSE через прокси:** в LAN прокси нет (кроме egress-прокси для OpenAI, который не стоит на пути браузера); фолбэк-поллинг покрывает остальные случаи.
- **Рост `data/history` при даунскейле:** файлы исходников хранятся как есть; лимит 50 версий действует (D3).
---
## 16. Реестр решений — сводка для быстрой навигации
| Решение | Секция |
|---|---|
| Модульный монолит, правила зависимостей | §4.14.2 |
| Единый config с валидацией | §4.3 |
| JSON-манифест истории + атомарная запись | §5.1 |
| Персистентные сессии (хеши токенов) | §5.2 |
| JSONL-журнал заданий + восстановление | §5.3, §6.1 |
| Очередь FIFO, кониурентность по группам, MAX_QUEUED_JOBS | §6.1 |
| Контракт движка EngineResult | §6.2 |
| Даунскейл sharp + scaleRegion | §6.5, Фаза 4 |
| SSE + фолбэк-поллинг | §6.6 |
| API: аддитивные изменения, requestId | §7 |
| scrypt, rate-limit, Origin-check, magic bytes | §7.3 |
| Vanilla ES-модули без сборки | §9 |
| node:test + supertest + Gitea test workflow | §10 |
| requestId-логи, лёгкие метрики | §11 |
| Миграция data/ + алиас /image.jpg | §12 |
| План по фазам с приёмкой | §13 |
---
# System Design Proposal
**Checklist: 6/6 complete**<br>
**Incomplete: None** — обрамление (§1), оценка (§3), домены/данные/контракты (§4§7), HLD/LLD (§4§6, §9§11), решения (§8), написание/валидация (§13§15) выполнены; изменены только `docs/architecture/*.md`, код не менялся.