diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml
new file mode 100644
index 0000000..9175730
--- /dev/null
+++ b/.gitea/workflows/test.yml
@@ -0,0 +1,30 @@
+name: test
+
+# Автоматический запуск при push в main и ручной вызов
+on:
+ workflow_dispatch: {}
+ push:
+ branches: [main]
+
+concurrency:
+ group: test
+ cancel-in-progress: true
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: 20
+ cache: npm
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Run tests
+ run: npm test
diff --git a/.gitignore b/.gitignore
index 67b8653..11b0f4c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,8 +1,11 @@
# Зависимости
node_modules/
+.npm-cache/
+.npmrc
-# Данные приложения (история версий, временные рабочие каталоги Codex)
+# Данные приложения (история версий, сессии, журнал заданий, рабочие каталоги Codex)
history/
+data/
.codex-jobs/
# Временные и тестовые файлы
diff --git a/Dockerfile b/Dockerfile
index 4fac58d..e268c29 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,4 +1,4 @@
-# Кадр — локальный AI-фоторедактор
+# Кадр — локальный AI-фоторедактор (v2)
# Образ: Node.js 22 на Debian bookworm-slim.
# Debian (glibc) обязателен: Codex CLI поставляется как бинарник под glibc,
# на Alpine (musl) он не запускается.
@@ -22,6 +22,7 @@ RUN npm ci --omit=dev
# Код приложения
COPY server.js ./
+COPY src ./src
COPY public ./public
# Конфиг Codex по умолчанию (используется, если ~/.codex не смонтирован)
@@ -30,9 +31,8 @@ COPY codex/config.toml /root/.codex/config.toml
ENV NODE_ENV=production
ENV COMFY_URL=http://192.168.31.240:8188
-# История версий должна жить на томе — иначе версии пропадут
-# при пересоздании контейнера.
-VOLUME ["/app/history"]
+# Хранилище данных и история версий на томах
+VOLUME ["/app/history", "/app/data"]
EXPOSE 3000
diff --git a/README.md b/README.md
index 5be7904..7d2650a 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# Кадр — локальный AI-фоторедактор
+# Кадр — локальный AI-фоторедактор (v2)
Веб-приложение редактирует фотографии по текстовому промпту (через локальный
Codex CLI) и увеличивает разрешение через домашний сервер ComfyUI.
@@ -7,292 +7,130 @@ Codex CLI) и увеличивает разрешение через домаш
преобразуются на сервере в JPEG с помощью `heic-convert` до передачи в ComfyUI
или Codex.
+---
+
+## Архитектура v2
+
+В версии v2 сервис переработан в **модульный монолит** (`src/`):
+
+1. **Устойчивость к перезапуску**:
+ - Персистентный append-only JSONL-журнал заданий (`data/jobs.jsonl`). При рестарте сервера незавершённые задания Codex получают статус `interrupted` с возможностью повтора в один клик, а задания ComfyUI автоматически возвращаются в очередь.
+ - Персистентное хранилище сессий (`data/sessions.json`, хранятся sha256-хеши токенов). Владелец остаётся авторизован после обновления или перезапуска контейнера.
+ - Атомарная запись манифеста истории (`history.json.tmp` + `fsync` + `rename`) защищает от повреждения данных при внезапном выключении.
+2. **Очередь заданий (FIFO) и группы конкурентности**:
+ - Вместо блокировки single-flight (429) запросы на редактирование и апскейл ставятся в очередь.
+ - Лимит параллельности: Codex (edit, suggest) = 1 поток; ComfyUI (upscale) = 2 потока.
+ - Лимит очереди: до 5 заданий на группу (защита от накликивания).
+3. **Безопасность**:
+ - Пароль защищён scrypt-хешированием (`crypto.scrypt`) с постоянным временем сверки (`timingSafeEqual`).
+ - Ограничение попыток входа (Rate Limiting): 5 попыток за 15 минут на IP.
+ - Проверка Origin на state-changing запросах (CSRF).
+ - Проверка сигнатур файлов (magic bytes) для защиты от поддельных MIME-типов.
+4. **Ускорение редактирования (Downscale)**:
+ - Автоматический даунскейл `sharp` до `EDIT_MAX_DIMENSION` (по умолчанию 1024 px) перед передачей в Codex с пропорциональным масштабированием области референса (`scaleRegion`). Сокращает время обработки больших фото в 1.5–2 раза.
+5. **Мгновенные обновления статуса (SSE)**:
+ - Server-Sent Events (`GET /api/events`) передают смену статуса очереди и прогресс мгновенно.
+ - Автоматический фолбэк на опрос (`/api/jobs`) при сбоях сети или неподдерживаемых прокси.
+6. **Клиентская часть на Vanilla ES-модулях**:
+ - Модули в `public/js/` загружаются напрямую браузером (`
+ 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`: `
+