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

301
public/js/jobs.js Normal file
View File

@@ -0,0 +1,301 @@
/**
* jobs.js — Очередь заданий, SSE-подписка, фолбэк-поллинг и выполнение edit/upscale
*/
import { apiFetch } from "./api.js";
import { getState, setBusy, setCurrentVersionId } from "./state.js";
import { setJobStatus, resetResult, updateDownloadCurrent, versionFileName } from "./ui.js";
import { refreshHistory } from "./history-tree.js";
import { getReferenceState } from "./reference.js";
let eventSource = null;
let currentTrackingJob = null;
let fallbackPollingTimer = null;
export function initJobsEvents() {
if (window.EventSource) {
connectSSE();
} else {
startFallbackPolling();
}
}
function connectSSE() {
if (eventSource) {
try {
eventSource.close();
} catch {
// ignore
}
}
eventSource = new EventSource("/api/events");
eventSource.addEventListener("snapshot", (event) => {
try {
const activeJobs = JSON.parse(event.data);
updateActiveBadge(activeJobs);
} catch {
// ignore
}
});
eventSource.addEventListener("job", (event) => {
try {
const jobData = JSON.parse(event.data);
handleJobEvent(jobData);
} catch {
// ignore
}
});
eventSource.onerror = () => {
if (eventSource) {
eventSource.close();
eventSource = null;
}
startFallbackPolling();
// Try reconnecting SSE after 15s
setTimeout(connectSSE, 15000);
};
}
function startFallbackPolling() {
if (fallbackPollingTimer) return;
fallbackPollingTimer = setInterval(async () => {
try {
const data = await apiFetch("/api/jobs");
if (Array.isArray(data?.jobs)) {
updateActiveBadge(data.jobs);
}
} catch {
// ignore
}
}, 3000);
}
function updateActiveBadge(jobs) {
const active = jobs.filter((j) => j.status === "queued" || j.status === "running");
// Could update badge in topbar if needed
}
function handleJobEvent(jobData) {
if (!currentTrackingJob || currentTrackingJob.id !== jobData.jobId) {
return;
}
const { isEdit, onFinish } = currentTrackingJob;
if (jobData.status === "queued") {
const pos = jobData.queuePosition ? ` (позиция ${jobData.queuePosition})` : "";
setJobStatus("progress", `Задание ожидает в очереди${pos}…`);
} else if (jobData.status === "running") {
setJobStatus(
"progress",
isEdit
? "Codex редактирует фотографию… Это обычно занимает 1–3 минуты."
: "ComfyUI увеличивает разрешение… Это займёт несколько секунд."
);
} else if (jobData.status === "done") {
currentTrackingJob = null;
onFinish(null, jobData);
} else if (jobData.status === "interrupted") {
currentTrackingJob = null;
const err = new Error(
jobData.error || "Задание прервано перезапуском сервера. Запустите заново."
);
err.interrupted = true;
onFinish(err);
} else if (jobData.status === "error") {
currentTrackingJob = null;
onFinish(new Error(jobData.error || "Ошибка при выполнении задания."));
}
}
export async function runJob(kind) {
const state = getState();
const isEdit = kind === "edit";
if (!state.currentVersionId || state.isBusy) {
return;
}
const promptInput = document.querySelector("#prompt");
const prompt = String(promptInput?.value || "").trim();
if (isEdit && !prompt) {
setJobStatus("error", "Введите текст промпта.");
return;
}
setBusy(true);
resetResult();
setJobStatus("progress", "Отправка задания на сервер…");
let lastJobParams = null;
try {
let res;
if (isEdit) {
const formData = new FormData();
formData.append("sourceId", state.currentVersionId);
formData.append("prompt", prompt);
const refState = getReferenceState();
if (refState?.source?.type === "file" && refState.source.file) {
formData.append("reference", refState.source.file);
} else if (refState?.source?.type === "history" && refState.source.versionId) {
formData.append("referenceId", refState.source.versionId);
}
if (refState?.region) {
formData.append("region", JSON.stringify(refState.region));
}
lastJobParams = { kind, sourceId: state.currentVersionId, prompt, refState };
res = await apiFetch("/api/edit", {
method: "POST",
body: formData,
});
} else {
lastJobParams = { kind, sourceId: state.currentVersionId };
res = await apiFetch("/api/upscale", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceId: state.currentVersionId }),
});
}
const jobId = res.jobId;
if (res.status === "queued") {
const pos = res.queuePosition ? ` (позиция ${res.queuePosition})` : "";
setJobStatus("progress", `Задание добавлено в очередь${pos}…`);
} else {
setJobStatus(
"progress",
isEdit
? "Codex редактирует фотографию… Это обычно занимает 1–3 минуты."
: "ComfyUI увеличивает разрешение… Это займёт несколько секунд."
);
}
// Wait for completion via SSE or fallback polling
await new Promise((resolve, reject) => {
currentTrackingJob = {
id: jobId,
isEdit,
onFinish: (err, resultData) => {
if (err) reject(err);
else resolve(resultData);
},
};
// Fallback polling loop in case SSE is blocked or slow
pollJobStatus(jobId, isEdit);
});
// Fetch full job status to get version and details
const finalJob = await apiFetch(`/api/jobs/${jobId}/status`);
renderJobSuccess(finalJob, isEdit);
} catch (err) {
if (err.interrupted) {
setJobStatus(
"error",
"Задание прервано перезапуском сервера. Нажмите кнопку, чтобы повторить попытку."
);
} else {
setJobStatus("error", err.message || "Ошибка при выполнении задания.");
}
} finally {
setBusy(false);
}
}
async function pollJobStatus(jobId, isEdit) {
let attempts = 0;
const poll = async () => {
if (!currentTrackingJob || currentTrackingJob.id !== jobId) {
return;
}
try {
const data = await apiFetch(`/api/jobs/${jobId}/status`);
if (!currentTrackingJob || currentTrackingJob.id !== jobId) return;
if (data.status === "queued") {
const pos = data.queuePosition ? ` (позиция ${data.queuePosition})` : "";
setJobStatus("progress", `Задание ожидает в очереди${pos}…`);
setTimeout(poll, 2000);
} else if (data.status === "running") {
setJobStatus(
"progress",
isEdit
? "Codex редактирует фотографию… Это обычно занимает 1–3 минуты."
: "ComfyUI увеличивает разрешение… Это займёт несколько секунд."
);
setTimeout(poll, 2000);
} else if (data.status === "done") {
const cb = currentTrackingJob?.onFinish;
currentTrackingJob = null;
if (cb) cb(null, data);
} else if (data.status === "interrupted") {
const cb = currentTrackingJob?.onFinish;
currentTrackingJob = null;
const err = new Error(
data.error || "Задание прервано перезапуском сервера. Запустите заново."
);
err.interrupted = true;
if (cb) cb(err);
} else if (data.status === "error") {
const cb = currentTrackingJob?.onFinish;
currentTrackingJob = null;
if (cb) cb(new Error(data.error || "Ошибка при выполнении задания."));
} else {
setTimeout(poll, 2000);
}
} catch {
attempts++;
if (attempts < 30) {
setTimeout(poll, 2500);
}
}
};
setTimeout(poll, 2000);
}
export function renderJobSuccess(data, isEdit) {
const version = data.result?.version;
const resultImage = document.querySelector("#result-image");
const resultPlaceholder = document.querySelector("#result-placeholder");
const resultLabel = document.querySelector("#result-label");
const downloadLink = document.querySelector("#download-link");
if (!version) {
setJobStatus("error", "Задание завершено, но версия не создана.");
return;
}
setJobStatus("idle", "");
if (resultImage) {
resultImage.src = version.url;
resultImage.hidden = false;
}
if (resultPlaceholder) resultPlaceholder.hidden = true;
if (resultLabel) {
resultLabel.textContent = isEdit ? "Codex" : "4× Upscale";
}
if (downloadLink) {
downloadLink.href = version.url;
downloadLink.download = versionFileName(version);
downloadLink.hidden = false;
}
// Refresh history tree and select newly created version
refreshHistory(true);
}