# Системный дизайн: «Кадр» v2 — целевая архитектура **Вердикт:** READY **Артефакт:** `docs/architecture/target-design.md` **Статус:** 1.0, готов к планированию реализации **Язык:** русский; идентификаторы кода — английские. Этот документ — полный проект перепроектирования «Кадр» (локальный AI-фоторедактор). Он написан так, чтобы **человек** мог понять решение, а **ИИ-агент** — реализовать его без дополнительных уточнений: здесь зафиксированы границы, контракты, структура модулей, данные, поведение при сбоях, решения с альтернативами, порядок реализации и критерии приёмки каждой фазы. Сопутствующие артефакты: `docs/architecture/system-design-baseline.md` (драйверы и ограничения, реестр D1–D17), `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` | Пропадает редактирование на 1–3 мин при рестарте | Персистентный 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 | | Правок в сутки | 0–20 (пик) | Оценка по `history.json` (12 версий за 2 дня активного тестирования) | | Апскейлов в сутки | 0–20 | Аналогично | | Параллельность | 1 edit/suggest + ≤ 2 upscale | Текущее узкое место — Codex (один поток); ComfyUI справляется с 1–2 воркфлоу | | Размер загрузки | ≤ 25 МБ, типично 1–6 МБ | `server.js:24`, фактические файлы | | История | ≤ 50 версий, манифест ~10 КБ | `server.js:34` | | Частота опроса статуса | 3 с (фронт) → 0 с при SSE | `README.md` | | Очередь | ≤ 5 заданий в очереди | Лимит для защиты от «накликивания»; дешевле, чем бесконечная очередь | **Формула для оценки времени:** `t_edit ≈ 60–90 с × (maxDim/512)^1.3` (эмпирика из README: 512² ≈ 60–82 с, 1200×800 заметно дольше). Даунскейл до 1024 px сокращает среднее время до ~1–1.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/.` — **важно**: каталог переносится из `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 // атомарно: tmp+rename+fsync; прунинг > limit (удаление файлов вытесненных) async removeCascade(id): Promise<{ removed: Version[] }> // BFS по parentId, удаление файлов get(id): Version | null filePath(version): string } ``` Все мутации сериализуются цепочкой (как текущий `historyWriteChain`) — **сохраняем** этот паттерн, вынося его в store. Атомарность: писать в `.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 выдерживает 1–2 воркфлоу; апскейлы быстрые | Интерфейс: ```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//` сохраняется до завершения задания; при `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.` из файла задания или из версии истории (`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, 1–1000 символов. - **`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 // почти без изменений; подключение: 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.` — сохранённые вкладки/ссылки не ломаются. 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`: `