animastor-worker 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/worker.cjs ADDED
@@ -0,0 +1,750 @@
1
+ // ======================================================
2
+ // GPU Worker - v2.1.0 (fail-closed authorization, PW-4)
3
+ // ======================================================
4
+ // CJS (CommonJS) — Node 18+ with global fetch is assumed.
5
+ // Job Protocol v2 comes from the GENERATED copy of the canonical
6
+ // @animastor/contracts package (Phase 9D, blocker B2 — option B):
7
+ // worker/worker/job-protocol-v2.cjs (regenerate: node worker/tools/sync-protocol.cjs)
8
+ // It is byte-parity guarded against the canonical source — never edit it.
9
+
10
+ const { execSync } = require("child_process");
11
+ const os = require("os");
12
+ const fs = require("fs");
13
+ const fsp = require("fs").promises;
14
+ const path = require("path");
15
+ const { cleanupJobArtifacts } = require("./worker-cleanup.cjs");
16
+ const journal = require("./worker-cleanup-journal.cjs");
17
+ const { loadDotEnv } = require("./worker-env.cjs");
18
+ const { PROTOCOL_VERSION, JOB_ID_SPLIT_RE } = require("./job-protocol-v2.cjs");
19
+
20
+ // Bundle self-containment: load ./.env next to the worker (cp .env.example
21
+ // .env). Real environment variables always win over the file.
22
+ loadDotEnv(__dirname);
23
+
24
+ // ======================================================
25
+ // CONFIG
26
+ // ======================================================
27
+
28
+ const HUB_URL = process.env.HUB_URL || "https://animastor.in/gpu";
29
+ const COMFY_PORT = process.env.COMFY_PORT || 8188;
30
+ const WORKER_TYPE = process.env.WORKER_TYPE || "image";
31
+
32
+ // PW-4 (FAIL CLOSED): the worker credential (`wrk.<worker_id>.<secret>`,
33
+ // issued once at registration in Animastor) is REQUIRED. Every hub call
34
+ // carries `Authorization: Bearer <token>` and the hub derives identity,
35
+ // workspace and MODE from the registry — the worker never chooses its own
36
+ // mode. No credential → the worker refuses to start: a missing credential
37
+ // must never silently become a system/share worker.
38
+ const ANIMASTOR_WORKER_TOKEN = process.env.ANIMASTOR_WORKER_TOKEN || null;
39
+
40
+ // Backend API base for the startup credential verification. Derived from
41
+ // HUB_URL by default (…/gpu → …/api/v1); override with ANIMASTOR_API_URL.
42
+ const ANIMASTOR_API_URL = process.env.ANIMASTOR_API_URL
43
+ || HUB_URL.replace(/\/gpu\/?$/, "") + "/api/v1";
44
+
45
+ const NOTEBOOK_PATH = process.env.NOTEBOOK_PATH || "";
46
+ const WORKER_ID = process.env.WORKER_ID || "gpu-" + os.hostname();
47
+ // Canonical bundle version lives in ./package.json (the hub publishes the
48
+ // same value with the worker-bundle artifact). WORKER_VERSION env overrides.
49
+ function readBundleVersion() {
50
+ try { return require("./package.json").version || null; } catch (_) { return null; }
51
+ }
52
+ const WORKER_VERSION = process.env.WORKER_VERSION || readBundleVersion();
53
+ const WORKER_IMAGE_TAG = process.env.WORKER_IMAGE_TAG || null;
54
+ // PROTOCOL_VERSION comes from the generated copy of @animastor/contracts
55
+ // (top of file) — the frozen Job Protocol v2 value (2) lives there only.
56
+
57
+ const RESULT_TIMEOUT_MS = Number(process.env.RESULT_TIMEOUT_MS || 600000);
58
+ // Видео-генерация длинная по своей природе (LTX: 5-10 мин, на слабом GPU —
59
+ // 20-30+ мин). Дефолт для видео НЕ может быть 10 мин — иначе нормальная
60
+ // долгая генерация убивается как timeout. Приоритет: task.timeout_ms
61
+ // (приходит от backend через gpu-hub, layer-config per-type timeout);
62
+ // fallback для видео — 2 часа (реальный потолок — dispatch-lease backend'а).
63
+ const VIDEO_RESULT_TIMEOUT_MS = Number(process.env.VIDEO_RESULT_TIMEOUT_MS || 7200000);
64
+ const TASK_SLEEP_MS = Number(process.env.TASK_SLEEP_MS || 2000);
65
+ const BEACON_INTERVAL_MS = Number(process.env.BEACON_INTERVAL_MS || 10000);
66
+
67
+ const COMFY_INPUT_DIR = process.env.COMFY_INPUT_DIR || "/home/jovyan/ComfyUI/input";
68
+ const COMFY_OUTPUT_DIR = path.resolve(COMFY_INPUT_DIR, "../output");
69
+
70
+ // ======================================================
71
+ // UTILS
72
+ // ======================================================
73
+
74
+ function log(level, msg, data) {
75
+ console.log(`[${new Date().toISOString()}] [${level}] ${msg}`);
76
+ if (data !== undefined) console.log(typeof data === 'string' ? data : JSON.stringify(data, null, 2));
77
+ }
78
+
79
+ function sleep(ms) {
80
+ return new Promise(r => setTimeout(r, ms));
81
+ }
82
+
83
+ async function fetchTimeout(url, options = {}, timeout = 30000) {
84
+ const controller = new AbortController();
85
+ const id = setTimeout(() => controller.abort(), timeout);
86
+
87
+ try {
88
+ return await fetch(url, { ...options, signal: controller.signal });
89
+ } finally {
90
+ clearTimeout(id);
91
+ }
92
+ }
93
+
94
+ function comfyUrl(p) {
95
+ return `http://127.0.0.1:${COMFY_PORT}${NOTEBOOK_PATH}${p}`;
96
+ }
97
+
98
+ // PW-4: hub request headers — always Bearer-authenticated (the startup gate
99
+ // guarantees a credential is present; defense-in-depth keeps it unconditional).
100
+ function hubHeaders() {
101
+ const headers = { "Content-Type": "application/json" };
102
+ if (ANIMASTOR_WORKER_TOKEN) headers["Authorization"] = `Bearer ${ANIMASTOR_WORKER_TOKEN}`;
103
+ return headers;
104
+ }
105
+
106
+ // FAIL CLOSED: an auth rejection from the hub/backend is terminal. Retrying
107
+ // an invalid credential forever would only hide the misconfiguration — the
108
+ // operator must fix ANIMASTOR_WORKER_TOKEN.
109
+ function authFailed(source, status) {
110
+ log("error", "Worker authentication failed — check ANIMASTOR_WORKER_TOKEN");
111
+ log("error", `${source} rejected the credential (HTTP ${status}). The token may be wrong, rotated or revoked.`);
112
+ log("error", "Create/rotate a worker in Animastor (Settings → Workers) and set the new token.");
113
+ process.exit(1);
114
+ }
115
+
116
+ // ======================================================
117
+ // STARTUP CREDENTIAL VERIFICATION
118
+ // ======================================================
119
+ // The registry is the source of truth: the worker learns its identity and
120
+ // mode from the backend (POST /api/v1/worker/verify). It never decides for
121
+ // itself whether it is private/share/system — it only confirms.
122
+
123
+ async function verifyCredential() {
124
+ let res;
125
+ try {
126
+ res = await fetchTimeout(`${ANIMASTOR_API_URL}/worker/verify`, {
127
+ method: "POST",
128
+ headers: hubHeaders(),
129
+ body: JSON.stringify({})
130
+ });
131
+ } catch (err) {
132
+ // Network failure — the backend may be temporarily down. The hub still
133
+ // enforces the credential on every call, so warn and continue.
134
+ log("warn", `Credential verification unavailable (${err.message}) — continuing, the hub will enforce auth`);
135
+ return;
136
+ }
137
+ if (res.status === 401 || res.status === 403) {
138
+ authFailed("Animastor backend", res.status);
139
+ return;
140
+ }
141
+ if (!res.ok) {
142
+ log("warn", `Credential verification returned HTTP ${res.status} — continuing, the hub will enforce auth`);
143
+ return;
144
+ }
145
+ try {
146
+ const data = await res.json();
147
+ log("info", "✓ Credential accepted");
148
+ if (data.workspace_name) log("info", `✓ Workspace: ${data.workspace_name}`);
149
+ log("info", `✓ Mode: ${String(data.mode || "").toUpperCase()}`);
150
+ log("info", `✓ Worker type (registry): ${data.worker_type}`);
151
+ if (data.worker_type && data.worker_type !== WORKER_TYPE) {
152
+ log("warn", `WORKER_TYPE=${WORKER_TYPE} differs from the registry type ${data.worker_type} — the registry wins at the hub`);
153
+ }
154
+ } catch (_) { /* cosmetic only */ }
155
+ }
156
+
157
+ // ======================================================
158
+ // FIND OUTPUT NODES (Save*)
159
+ // ======================================================
160
+
161
+ function findOutputNodes(workflow) {
162
+ const result = { image: [], audio: [], video: [] };
163
+
164
+ for (const [id, node] of Object.entries(workflow || {})) {
165
+ const type = node.class_type || "";
166
+
167
+ if (type.startsWith("SaveImage")) result.image.push(id);
168
+ if (type.startsWith("SaveAudio")) result.audio.push(id);
169
+ if (type.startsWith("SaveVideo") || type.startsWith("CreateVideo")) result.video.push(id);
170
+ }
171
+
172
+ return result;
173
+ }
174
+
175
+ // ======================================================
176
+ // GPU INFO
177
+ // ======================================================
178
+
179
+ function getGPUInfo() {
180
+ try {
181
+ const gpu = execSync(
182
+ "nvidia-smi --query-gpu=name,memory.total --format=csv,noheader"
183
+ ).toString().trim();
184
+
185
+ const [name, vram] = gpu.split(",");
186
+ return { name: name.trim(), vram: vram.trim() };
187
+ } catch (err) {
188
+ log("error", "nvidia-smi failed", err.message);
189
+ return { name: "unknown", vram: "unknown" };
190
+ }
191
+ }
192
+
193
+ // ======================================================
194
+ // WAIT COMFY with exponential backoff
195
+ // ======================================================
196
+
197
+ async function waitForComfyUI() {
198
+ log("info", "Waiting for ComfyUI");
199
+ let attempts = 0;
200
+
201
+ while (true) {
202
+ try {
203
+ const res = await fetchTimeout(comfyUrl("/system_stats"));
204
+ if (res.ok) {
205
+ log("info", "ComfyUI ready");
206
+ await sleep(3000);
207
+ return;
208
+ }
209
+ } catch (err) {
210
+ log("warn", `ComfyUI not ready (attempt ${attempts + 1}): ${err.message}`);
211
+ }
212
+
213
+ attempts++;
214
+ const backoff = Math.min(1000 * (1 << Math.min(attempts, 5)), 30000);
215
+ process.stdout.write(".");
216
+ await sleep(backoff);
217
+ }
218
+ }
219
+
220
+ // ======================================================
221
+ // BEACON
222
+ // ======================================================
223
+
224
+ async function sendBeacon() {
225
+ try {
226
+ const gpu = getGPUInfo();
227
+
228
+ const res = await fetchTimeout(`${HUB_URL}/beacon`, {
229
+ method: "POST",
230
+ headers: hubHeaders(),
231
+ body: JSON.stringify({
232
+ id: WORKER_ID,
233
+ type: WORKER_TYPE,
234
+ gpu: gpu.name,
235
+ vram: gpu.vram,
236
+ version: WORKER_VERSION,
237
+ image_tag: WORKER_IMAGE_TAG,
238
+ protocol_version: PROTOCOL_VERSION
239
+ })
240
+ });
241
+ if (res.status === 401 || res.status === 403) {
242
+ authFailed("GPU hub /beacon", res.status);
243
+ return;
244
+ }
245
+ if (!res.ok) {
246
+ throw new Error(`Hub rejected beacon: HTTP ${res.status}`);
247
+ }
248
+ } catch (err) {
249
+ log("error", "Beacon failed", err.message);
250
+ }
251
+ }
252
+
253
+ // ======================================================
254
+ // GET TASK with backoff
255
+ // ======================================================
256
+
257
+ async function getTask() {
258
+ try {
259
+ const res = await fetchTimeout(
260
+ `${HUB_URL}/task/next?worker=${WORKER_ID}&type=${WORKER_TYPE}`,
261
+ { headers: hubHeaders() }
262
+ );
263
+
264
+ if (res.status === 401 || res.status === 403) {
265
+ authFailed("GPU hub /task/next", res.status);
266
+ return null;
267
+ }
268
+ if (!res.ok) return null;
269
+
270
+ const data = await res.json();
271
+ return data?.task || null;
272
+
273
+ } catch (err) {
274
+ log("warn", "getTask failed", err.message);
275
+ return null;
276
+ }
277
+ }
278
+
279
+ // ======================================================
280
+ // SAVE IMAGE (async)
281
+ // ======================================================
282
+
283
+ async function saveBase64ImageSafe(base64, filename) {
284
+ const clean = base64.includes(",") ? base64.split(",")[1] : base64;
285
+ const buffer = Buffer.from(clean, "base64");
286
+
287
+ await fsp.mkdir(COMFY_INPUT_DIR, { recursive: true }).catch(err => log("warn", "mkdir", err.message));
288
+
289
+ const filePath = path.join(COMFY_INPUT_DIR, filename);
290
+ await fsp.writeFile(filePath, buffer);
291
+
292
+ return { path: filePath, expectedSize: buffer.length };
293
+ }
294
+
295
+ // ======================================================
296
+ // WAIT FILE READY
297
+ // ======================================================
298
+
299
+ async function waitForFileReady(filePath, expectedSize, timeout = 5000) {
300
+ const start = Date.now();
301
+
302
+ while (true) {
303
+ if (Date.now() - start > timeout) {
304
+ throw new Error(`File not ready: ${filePath}`);
305
+ }
306
+
307
+ try {
308
+ await fsp.access(filePath);
309
+ const stats = await fsp.stat(filePath);
310
+ if (stats.size === expectedSize && stats.size > 0) {
311
+ await sleep(50);
312
+ return true;
313
+ }
314
+ } catch (err) {
315
+ log("warn", `waitForFileReady error`, err.message);
316
+ }
317
+
318
+ await sleep(100);
319
+ }
320
+ }
321
+
322
+ // ======================================================
323
+ // RUN WORKFLOW
324
+ // ======================================================
325
+
326
+ async function runWorkflow(workflow) {
327
+ const body = workflow?.prompt
328
+ ? { ...workflow, client_id: WORKER_ID }
329
+ : { prompt: workflow, client_id: WORKER_ID };
330
+
331
+ const res = await fetchTimeout(
332
+ comfyUrl("/prompt"),
333
+ {
334
+ method: "POST",
335
+ headers: { "Content-Type": "application/json" },
336
+ body: JSON.stringify(body)
337
+ }
338
+ );
339
+
340
+ const text = await res.text();
341
+
342
+ let data;
343
+ try {
344
+ data = JSON.parse(text);
345
+ } catch {
346
+ throw new Error("Invalid JSON from ComfyUI: " + text.slice(0, 500));
347
+ }
348
+
349
+ if (!data.prompt_id) {
350
+ log("error", "ComfyUI error", data);
351
+ throw new Error("No prompt_id");
352
+ }
353
+
354
+ return data.prompt_id;
355
+ }
356
+
357
+ // ======================================================
358
+ // WAIT RESULT (with backoff)
359
+ // ======================================================
360
+
361
+ async function waitResult(prompt_id, workflow, timeoutMs) {
362
+ const start = Date.now();
363
+ const outputsMap = findOutputNodes(workflow);
364
+ const isVideoJob = outputsMap.video.length > 0;
365
+ const effectiveTimeoutMs = timeoutMs || (isVideoJob ? VIDEO_RESULT_TIMEOUT_MS : RESULT_TIMEOUT_MS);
366
+ log("debug", `waitResult: ${isVideoJob ? 'video' : 'other'} prompt=${prompt_id} timeoutMs=${effectiveTimeoutMs} (task.timeout_ms=${timeoutMs || 'none'})`);
367
+
368
+ let videoDir, beforeFiles, videoPrefix;
369
+ if (isVideoJob) {
370
+ videoDir = path.join(COMFY_OUTPUT_DIR, 'video');
371
+ try {
372
+ beforeFiles = new Set((await fsp.readdir(videoDir)).filter(f => f.endsWith('.mp4')));
373
+ } catch (err) {
374
+ log("warn", "waitResult readdir", err && err.message || err);
375
+ beforeFiles = new Set();
376
+ }
377
+ for (const id of outputsMap.video) {
378
+ const prefix = workflow?.[id]?.inputs?.filename_prefix;
379
+ if (prefix) {
380
+ videoPrefix = path.basename(prefix);
381
+ break;
382
+ }
383
+ }
384
+ }
385
+
386
+ let pollDelay = 500;
387
+ while (true) {
388
+ if (Date.now() - start > effectiveTimeoutMs) {
389
+ try {
390
+ const res = await fetchTimeout(comfyUrl(`/history/${prompt_id}`));
391
+ const d = await res.json();
392
+ log("error", `Timeout after ${Math.round(effectiveTimeoutMs / 60000)}min: last history response`, JSON.stringify(d).slice(0, 2000));
393
+ } catch (err) {
394
+ log("error", "Timeout: failed to fetch history", err.message);
395
+ }
396
+ throw new Error(`Timeout waiting result (${Math.round(effectiveTimeoutMs / 60000)}min)`);
397
+ }
398
+
399
+ try {
400
+ const res = await fetchTimeout(comfyUrl(`/history/${prompt_id}`));
401
+ const data = await res.json();
402
+ const outputs = data?.[prompt_id]?.outputs || {};
403
+
404
+ for (const id of outputsMap.image) {
405
+ const node = outputs[id];
406
+ if (node?.images?.length > 0) return { type: "image", meta: node.images[0] };
407
+ }
408
+
409
+ for (const id of outputsMap.audio) {
410
+ const node = outputs[id];
411
+ if (node?.audio) {
412
+ const a = Array.isArray(node.audio) ? node.audio[0] : node.audio;
413
+ if (a?.filename) return { type: "audio", meta: a };
414
+ if (a?.data || typeof a === "string") return { type: "audio_base64", data: a.data || a };
415
+ }
416
+ }
417
+
418
+ if (isVideoJob) {
419
+ for (const id of outputsMap.video) {
420
+ const node = outputs[id];
421
+ for (const key of ['videos', 'video', 'gifs', 'result', 'files', 'media']) {
422
+ const arr = node?.[key];
423
+ if (Array.isArray(arr) && arr[0]?.filename) {
424
+ return { type: "video", meta: arr[0] };
425
+ }
426
+ }
427
+ }
428
+
429
+ for (const node of Object.values(outputs)) {
430
+ for (const arr of Object.values(node || {})) {
431
+ if (Array.isArray(arr) && arr[0]?.filename?.endsWith('.mp4')) {
432
+ return { type: "video", meta: arr[0] };
433
+ }
434
+ }
435
+ }
436
+
437
+ if (data?.[prompt_id]?.status?.completed && videoDir) {
438
+ const allFiles = await fsp.readdir(videoDir).catch(() => []);
439
+ const mp4Files = allFiles.filter(f => f.endsWith('.mp4'));
440
+ const newFiles = mp4Files.filter(f => !beforeFiles.has(f));
441
+ const matched = videoPrefix
442
+ ? newFiles.filter(f => f.startsWith(videoPrefix))
443
+ : newFiles;
444
+ if (matched.length > 0) {
445
+ const newest = matched.sort().pop();
446
+ log("info", `FS video: ${newest}`);
447
+ return { type: "video", meta: { filename: newest, subfolder: 'video', type: 'output' } };
448
+ }
449
+ }
450
+ }
451
+
452
+ // Reset poll delay on successful response
453
+ pollDelay = 500;
454
+ } catch (err) {
455
+ log("warn", "waitResult poll failed", err.message);
456
+ // Exponential backoff on error: 1s → 2s → 4s → 8s cap
457
+ pollDelay = Math.min(pollDelay * 2, 8000);
458
+ }
459
+
460
+ await sleep(pollDelay);
461
+ }
462
+ }
463
+
464
+ // ======================================================
465
+ // DOWNLOAD RESULT (OOM-safe: читаем с диска, не через HTTP re-download)
466
+ // ======================================================
467
+ // ComfyUI уже сохранил результат на диск в COMFY_OUTPUT_DIR.
468
+ // Вместо повторного HTTP download (который держит 2x файл в памяти:
469
+ // arrayBuffer + base64), читаем локально.
470
+ // Для файлов > 50MB логируем предупреждение — они всё равно будут
471
+ // загружены в память как base64 (protocol limitation).
472
+
473
+ const MIME_MAP = {
474
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp',
475
+ '.mp3': 'audio/mp3', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.flac': 'audio/flac',
476
+ '.mp4': 'video/mp4', '.webm': 'video/webm', '.avi': 'video/avi', '.mov': 'video/quicktime',
477
+ };
478
+
479
+ async function downloadResult(result) {
480
+ if (result.type === "audio_base64") {
481
+ return `data:audio/mp3;base64,${result.data}`;
482
+ }
483
+
484
+ const f = result.meta;
485
+ const filename = f.filename;
486
+ const subfolder = f.subfolder || "";
487
+ const ext = path.extname(filename).toLowerCase();
488
+ const mime = MIME_MAP[ext] || 'application/octet-stream';
489
+
490
+ // ── Try local filesystem first (OOM-safe, no HTTP overhead) ──
491
+ const localPath = path.resolve(COMFY_OUTPUT_DIR, subfolder, filename);
492
+ try {
493
+ const stat = await fsp.stat(localPath).catch(() => null);
494
+ if (stat && stat.isFile() && stat.size > 0) {
495
+ if (stat.size > 50 * 1024 * 1024) {
496
+ log("warn", `Large file (${(stat.size / 1024 / 1024).toFixed(1)}MB) — base64 will use significant memory: ${filename}`);
497
+ }
498
+ const buffer = await fsp.readFile(localPath);
499
+ return `data:${mime};base64,${buffer.toString('base64')}`;
500
+ }
501
+ } catch (_) {}
502
+
503
+ // ── Fallback: HTTP download from ComfyUI ──
504
+ log("info", `Falling back to HTTP download for ${filename}`);
505
+ let sf = subfolder;
506
+ if (sf.includes("/")) sf = sf.split("/")[0];
507
+
508
+ const url = comfyUrl(
509
+ `/view?filename=${encodeURIComponent(filename)}&subfolder=${encodeURIComponent(sf)}&type=output`
510
+ );
511
+
512
+ const res = await fetchTimeout(url);
513
+ if (!res.ok) throw new Error("Download failed: " + res.status);
514
+
515
+ const buffer = await res.arrayBuffer();
516
+ const raw = Buffer.from(buffer).toString("base64");
517
+
518
+ return `data:${mime};base64,${raw}`;
519
+ }
520
+
521
+ // ======================================================
522
+ // SEND RESULT
523
+ // ======================================================
524
+
525
+ async function sendResult(task, data) {
526
+ const { job_id, build_id, dispatch_id } = task;
527
+ const res = await fetchTimeout(`${HUB_URL}/task/result`, {
528
+ method: "POST",
529
+ headers: hubHeaders(),
530
+ body: JSON.stringify({
531
+ job_id,
532
+ build_id,
533
+ dispatch_id,
534
+ protocol_version: PROTOCOL_VERSION,
535
+ result_base64: data,
536
+ worker_version: WORKER_VERSION,
537
+ worker_image_tag: WORKER_IMAGE_TAG
538
+ })
539
+ });
540
+ if (!res.ok) {
541
+ throw new Error(`Hub rejected result: HTTP ${res.status}`);
542
+ }
543
+ }
544
+
545
+ async function sendTaskError(task, reason) {
546
+ const res = await fetchTimeout(`${HUB_URL}/task/error`, {
547
+ method: "POST",
548
+ headers: hubHeaders(),
549
+ body: JSON.stringify({
550
+ job_id: task.job_id,
551
+ build_id: task.build_id || null,
552
+ dispatch_id: task.dispatch_id,
553
+ protocol_version: PROTOCOL_VERSION,
554
+ reason: String(reason || "worker_error").slice(0, 500),
555
+ worker_version: WORKER_VERSION,
556
+ worker_image_tag: WORKER_IMAGE_TAG
557
+ })
558
+ });
559
+ if (!res.ok) {
560
+ throw new Error(`Hub rejected task error: HTTP ${res.status}`);
561
+ }
562
+ }
563
+
564
+ // ======================================================
565
+ // LOOP (with backoff on empty queue)
566
+ // ======================================================
567
+
568
+ let emptyQueueDelay = TASK_SLEEP_MS;
569
+
570
+ async function workerLoop() {
571
+ setInterval(sendBeacon, BEACON_INTERVAL_MS);
572
+
573
+ while (true) {
574
+ const task = await getTask();
575
+
576
+ if (!task) {
577
+ await sleep(emptyQueueDelay);
578
+ emptyQueueDelay = Math.min(emptyQueueDelay * 2, 15000);
579
+ continue;
580
+ }
581
+ emptyQueueDelay = TASK_SLEEP_MS; // reset on task received
582
+
583
+ log("info", `Task ${task.job_id} (type=${WORKER_TYPE}, timeout_ms=${task.timeout_ms || 'default'})`);
584
+
585
+ if (task.protocol_version !== PROTOCOL_VERSION || !task.dispatch_id) {
586
+ const reason = `incompatible_task_protocol:${task.protocol_version || 'missing'}`;
587
+ log("error", `Rejecting incompatible task: protocol=${task.protocol_version}, dispatch=${task.dispatch_id || 'missing'}`);
588
+ if (task.job_id && task.build_id && task.dispatch_id) {
589
+ try {
590
+ await sendTaskError(task, reason);
591
+ } catch (sendErr) {
592
+ log("error", "Failed to report incompatible task", sendErr.message);
593
+ }
594
+ }
595
+ await sleep(TASK_SLEEP_MS);
596
+ continue;
597
+ }
598
+
599
+ // ── Per-job artifact tracking (cleanup after job) ──
600
+ // Точечная уборка: удаляются ТОЛЬКО файлы этой job. Output удаляется
601
+ // только после успешной доставки результата (downloadResult + sendResult),
602
+ // чтобы при ошибке sendResult() единственный результат не потерялся.
603
+ // Journal (worker-local persistent) фиксирует lifecycle
604
+ // CREATED→GENERATED→DELIVERED→CLEANED, чтобы после crash restart мог
605
+ // дочистить файлы job, результат которой уже доставлен в hub.
606
+ const createdInputFiles = [];
607
+ let outputPath = null;
608
+ let outputDelivered = false;
609
+ const jobId = task.job_id;
610
+ const dispatchId = task.dispatch_id;
611
+
612
+ try {
613
+ // Journal: CREATED — создаётся ДО первого временного input-файла.
614
+ await journal.createJob({ jobId, dispatchId, log });
615
+
616
+ if (task.assets?.images) {
617
+ const [jobBase] = task.job_id.split(JOB_ID_SPLIT_RE);
618
+ const scenePrefix = jobBase.replace(/_g\d+$/, '');
619
+ for (const [unitId, base64] of Object.entries(task.assets.images)) {
620
+ const filename = `${scenePrefix}_${unitId}.png`;
621
+ const filePath = path.join(COMFY_INPUT_DIR, filename);
622
+ createdInputFiles.push(filePath);
623
+ const { expectedSize } = await saveBase64ImageSafe(base64, filename);
624
+ // Journal: каждый фактически созданный reference image.
625
+ await journal.addInputFile({ jobId, dispatchId, log }, filePath);
626
+ log("info", `Multi-image saved: ${filename}`);
627
+ await waitForFileReady(filePath, expectedSize);
628
+ log("info", `Multi-image ready: ${filename}`);
629
+ }
630
+ } else if (task.assets?.image) {
631
+ const [baseId] = task.job_id.split(JOB_ID_SPLIT_RE);
632
+ const filename = `${baseId}.png`;
633
+ const filePath = path.join(COMFY_INPUT_DIR, filename);
634
+ createdInputFiles.push(filePath);
635
+ const { expectedSize } = await saveBase64ImageSafe(task.assets.image, filename);
636
+ await journal.addInputFile({ jobId, dispatchId, log }, filePath);
637
+ log("info", `Image saved: ${filename}`);
638
+ await waitForFileReady(filePath, expectedSize);
639
+ log("info", `Image ready: ${filename}`);
640
+ }
641
+
642
+ const prompt_id = await runWorkflow(task.params);
643
+ // timeout_ms приходит с задачей (backend → gpu-hub → worker): per-job
644
+ // таймаут для данного типа генерации. Если нет — per-type fallback.
645
+ const result = await waitResult(prompt_id, task.params, task.timeout_ms);
646
+
647
+ // Точечный output этой job: COMFY_OUTPUT_DIR + subfolder + filename.
648
+ // Это именно тот файл, который waitResult выбрал как результат (в т.ч.
649
+ // для video — реально выбранный mp4 из history/fallback/fs-scan).
650
+ if (result.meta && result.meta.filename) {
651
+ outputPath = path.resolve(COMFY_OUTPUT_DIR, result.meta.subfolder || "", result.meta.filename);
652
+ // Journal: GENERATED — известен конкретный output-файл.
653
+ await journal.setOutputAndGenerated({ jobId, dispatchId, log }, outputPath);
654
+ }
655
+
656
+ const base64 = await downloadResult(result);
657
+ log("debug", `result for ${task.job_id}: type=${result.type} size=${Math.round(base64.length / 1024)}KB`);
658
+ await sendResult(task, base64);
659
+ // Journal: DELIVERED — HTTP 200 от hub = результат уже durable в hub
660
+ // Redis (animastor:result:* записан до ответа 200). Output можно удалять.
661
+ await journal.setDelivered({ jobId, dispatchId, log });
662
+ outputDelivered = true;
663
+ log("info", `Done ${task.job_id}`);
664
+
665
+ } catch (err) {
666
+ log("error", `Failed ${task.job_id}`, err.message);
667
+
668
+ try {
669
+ await sendTaskError(task, err && err.message || err || "worker_error");
670
+ } catch (sendErr) {
671
+ log("error", "Failed to send error to hub", sendErr.message);
672
+ }
673
+ } finally {
674
+ // Cleanup after job: только собственные временные файлы этой job.
675
+ // Output — ТОЛЬКО после успешной доставки результата; при ошибке
676
+ // downloadResult()/sendResult() output сохраняется.
677
+ try {
678
+ const toCleanOutput = outputDelivered ? outputPath : null;
679
+ const cleanupResult = await cleanupJobArtifacts({
680
+ inputFiles: createdInputFiles,
681
+ outputFile: toCleanOutput,
682
+ });
683
+
684
+ if (cleanupResult.cleaned > 0 || cleanupResult.failed.length > 0) {
685
+ log("info",
686
+ `Cleanup ${task.job_id}: removed ${cleanupResult.cleaned} artifact(s) ` +
687
+ `(${createdInputFiles.length} input, ${toCleanOutput ? "1 output" : "0 output"})`);
688
+ }
689
+ for (const f of cleanupResult.failed) {
690
+ log("warn", `Cleanup ${task.job_id}: failed to remove ${f.path}: ${f.reason}`);
691
+ }
692
+
693
+ if (cleanupResult.failed.length === 0) {
694
+ // CLEANED: все файлы job удалены → journal больше не нужен.
695
+ await journal.removeJob({ jobId, dispatchId, log });
696
+ } else {
697
+ // Частичный cleanup: journal остаётся — следующий recovery дочистит
698
+ // оставшиеся файлы (например, один input из видео-набора).
699
+ log("warn", `Cleanup ${task.job_id}: partial cleanup (${cleanupResult.failed.length} failed) — journal kept for recovery`);
700
+ }
701
+ } catch (cleanupErr) {
702
+ // Cleanup никогда не должен маскировать исходную ошибку job.
703
+ log("warn", `Cleanup ${task.job_id}: error: ${cleanupErr.message}`);
704
+ }
705
+ }
706
+ }
707
+ }
708
+
709
+ // ======================================================
710
+ // MAIN
711
+ // ======================================================
712
+
713
+ async function main() {
714
+ // PW-4 FAIL CLOSED startup gate: without a credential the worker refuses
715
+ // to run. There is no "system pool (no credential)" mode anymore — a
716
+ // missing token must never silently turn this GPU into shared capacity.
717
+ if (!ANIMASTOR_WORKER_TOKEN) {
718
+ log("error", "Worker authentication failed — check ANIMASTOR_WORKER_TOKEN");
719
+ log("error", "No worker credential configured. This worker cannot start:");
720
+ log("error", " 1. Open Animastor → Settings → Workers and create a worker");
721
+ log("error", " (choose Private for your own workspace, or Share to volunteer it).");
722
+ log("error", " 2. Copy the one-time credential (wrk.…).");
723
+ log("error", " 3. Set ANIMASTOR_WORKER_TOKEN=wrk.… in ./.env (or the environment).");
724
+ process.exit(1);
725
+ }
726
+
727
+ log("info", `Worker ${WORKER_TYPE} started`);
728
+ log("info", `Worker ID: ${WORKER_ID} (label only — identity comes from the credential)`);
729
+ log("info", `Worker version: ${WORKER_VERSION || 'unknown'}`);
730
+ log("info", `Worker image tag: ${WORKER_IMAGE_TAG || 'unknown'}`);
731
+ log("info", `Hub URL: ${HUB_URL}`);
732
+ log("info", `Protocol version: ${PROTOCOL_VERSION}`);
733
+
734
+ // Confirm identity + mode against the registry before doing any work.
735
+ await verifyCredential();
736
+
737
+ await waitForComfyUI();
738
+
739
+ // Crash-safe recovery: завершить cleanup незакрытых job упавшего worker.
740
+ // delivered → удаляем input+output; created/generated → только input
741
+ // (output без proof DELIVERED не трогаем — защита единственной копии).
742
+ await journal.recoverCleanupJournal({ log });
743
+
744
+ await workerLoop();
745
+ }
746
+
747
+ main().catch(err => {
748
+ log("error", "Worker crashed", err.message);
749
+ process.exit(1);
750
+ });