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/.env.example ADDED
@@ -0,0 +1,34 @@
1
+ # Animastor Private Worker — environment (copy to .env next to worker.cjs)
2
+ # worker.cjs (and start-worker.sh) load this file automatically; you can also
3
+ # export these variables directly. NEVER commit a real credential.
4
+
5
+ # ── REQUIRED ──────────────────────────────────────────────────────────────
6
+ # GPU Hub base URL — the same origin you use in the browser, plus /gpu.
7
+ HUB_URL=https://animastor.in/gpu
8
+
9
+ # Private Worker credential — shown ONCE in Settings → Private Workers at
10
+ # creation/rotation. Fail-closed: without it the worker refuses to start.
11
+ ANIMASTOR_WORKER_TOKEN=<your-worker-key>
12
+
13
+ # Worker type: image | audio | video (must match the type chosen in the UI).
14
+ WORKER_TYPE=image
15
+
16
+ # Worker label (any short id; with a token, identity comes from the token).
17
+ WORKER_ID=my-private-worker
18
+
19
+ # ── OPTIONAL (defaults shown) ─────────────────────────────────────────────
20
+ # ComfyUI HTTP API port (auto-detected from a running ComfyUI if unset).
21
+ #COMFY_PORT=8188
22
+ # ComfyUI input directory (default: $HOME/ComfyUI/input).
23
+ #COMFY_INPUT_DIR=/home/you/ComfyUI/input
24
+ # Worker-local cleanup journal dir (crash-safe recovery; default: next to worker.cjs).
25
+ #WORKER_JOURNAL_DIR=/home/you/animastor/cleanup-journal
26
+ # Reverse-proxy base path in front of ComfyUI (e.g. notebook deployments).
27
+ #NOTEBOOK_PATH=
28
+ #
29
+ # ── Hugging Face (for gated/private models) ────────────────────────────────
30
+ # System HF token — used by the installer to download gated models.
31
+ # Set via environment variable; never commit the token to a file.
32
+ # The installer uses HF_TOKEN or HUGGINGFACE_HUB_TOKEN (either works).
33
+ # HF_TOKEN=
34
+ # HUGGINGFACE_HUB_TOKEN=
@@ -0,0 +1,291 @@
1
+ // ======================================================
2
+ // GENERATED FILE — DO NOT EDIT BY HAND
3
+ // ======================================================
4
+ // Generated copy of the canonical Job Protocol v2 implementation:
5
+ // package: @animastor/contracts
6
+ // source: contracts/src/job-protocol-v2.js
7
+ // sha256: b005fafc01614e643e325b3433f657c6bd197ee6f76dca4d36eb1bf7de2b5a84
8
+ // generated: 0.1.0 snapshot
9
+ // Generator: worker/tools/sync-protocol.cjs (Phase 9D — blocker B2, option B)
10
+ //
11
+ // The worker bundle ships with zero runtime npm dependencies (Phase 9B
12
+ // freeze) and is delivered to GPU machines without an npm registry, so it
13
+ // cannot require @animastor/contracts at runtime. This file is a byte-exact
14
+ // copy of the canonical source below the marker line — it is NOT a second
15
+ // implementation. Any edit here diverges the wire contract and will be
16
+ // caught by the parity guards:
17
+ // worker/tests/job-protocol.test.cjs
18
+ // backend/tests/architecture/phase9d-worker-package.test.js
19
+ //
20
+ // Regenerate after any change to the canonical source:
21
+ // node worker/tools/sync-protocol.cjs
22
+
23
+ // ===8<=== canonical source (verbatim, do not edit) ====================
24
+ // ======================================================
25
+ // @animastor/contracts — Job Protocol v2 (canonical implementation)
26
+ // ======================================================
27
+ // CANONICAL SOURCE OF TRUTH for the frozen Job Protocol v2 wire contract
28
+ // (docs/architecture/JOB_PROTOCOL_V2.md — NORMATIVE, FROZEN, Phase 9A).
29
+ //
30
+ // Extracted verbatim from backend/src/runtime/job-schema.js (Phase 9C).
31
+ // Consumers: backend (via the job-schema.js facade), gpu-hub, worker.
32
+ //
33
+ // NO business logic lives here: only protocol constants, job_id grammar,
34
+ // stage mapping and envelope/error contract helpers — exactly the surface
35
+ // the protocol defines, no more.
36
+ //
37
+ // Versioning: PROTOCOL_VERSION changes only via the versioning policy
38
+ // (JOB_PROTOCOL_V2.md §4) — coordinated bump in backend + gpu-hub + worker
39
+ // after old workers stop receiving tasks. Mixed-version rollout is NOT
40
+ // supported. ======================================================
41
+
42
+ // Job Protocol version backend ↔ gpu-hub ↔ worker. Sent in the task and
43
+ // callback payloads. All three components reject a mismatching version:
44
+ // mixed-version rollout is allowed only after old workers stop receiving
45
+ // tasks. [NORMATIVE — FROZEN: value 2 for the entire Phase 9]
46
+ const PROTOCOL_VERSION = 2; // T4: dispatch_id added
47
+
48
+ // Asset-type family carried by the job_id suffix (`${assetId}:${type}`).
49
+ // `iu_image` is an asset type only: on the transport it travels as
50
+ // job_type = 'image', stage = 'image' (JOB_PROTOCOL_V2.md §3.4).
51
+ const JOB_TYPES = ['audio', 'image', 'iu_image', 'video'];
52
+
53
+ // Transport-type family of the hub scan queues / queue keys
54
+ // (`queue:{type}...`). `iu_image` never appears here as a type: it is
55
+ // routed with job_type 'image'. Mirrors gpu-hub SYSTEM_JOB_TYPES.
56
+ const SYSTEM_JOB_TYPES = ['audio', 'image', 'video'];
57
+
58
+ // kind (parse result) → stage (envelope / result-key segment).
59
+ const STAGE_BY_KIND = {
60
+ audio_chunk: 'audio',
61
+ iu_image: 'image',
62
+ scene_image: 'image',
63
+ scene_video: 'video',
64
+ };
65
+
66
+ // job_id grammar regexes [NORMATIVE — FROZEN]:
67
+ // audio chunk: {bookId}_{chapterId}_{sceneId}_{NNNN}:audio (NNNN = pad(4))
68
+ // IU image: {bookId}_{chapterId}_{sceneId}_{iuId}:iu_image
69
+ // scene image: {bookId}_{chapterId}_{sceneId}:image (legacy; assetId
70
+ // containing '_iu' = old-format IU image)
71
+ // video: {bookId}_{chapterId}_{sceneId}[_gN]:video (_gN = group)
72
+ // bookId may contain '_'; chapterId/sceneId/chunkIndex/iuId may not, so
73
+ // parsing always goes from the end.
74
+ const CHUNK_INDEX_RE = /^\d{4}$/;
75
+ const GROUP_SUFFIX_RE = /^(.+?)(_g\d+)$/;
76
+ // Worker-side input-file naming split family (worker.cjs inline literals).
77
+ const JOB_ID_SPLIT_RE = /:(iu_image|image|audio|video)$/;
78
+
79
+ const JOB_TYPES_SET = new Set(JOB_TYPES);
80
+
81
+ function buildJobId(assetId, type) {
82
+ if (!assetId || typeof assetId !== 'string') {
83
+ throw new Error(`buildJobId: invalid assetId: ${assetId}`);
84
+ }
85
+ if (!JOB_TYPES.includes(type)) {
86
+ throw new Error(`buildJobId: unknown job type: ${type}`);
87
+ }
88
+ return `${assetId}:${type}`;
89
+ }
90
+
91
+ // Strips the type suffix. Returns { assetId, type } or null.
92
+ function splitJobId(jobId) {
93
+ if (!jobId || typeof jobId !== 'string') return null;
94
+ const idx = jobId.lastIndexOf(':');
95
+ if (idx === -1) return null;
96
+ const type = jobId.slice(idx + 1);
97
+ if (!JOB_TYPES_SET.has(type)) return null;
98
+ return { assetId: jobId.slice(0, idx), type };
99
+ }
100
+
101
+ // Full parse. Returns null for unrecognizable ids (never throws).
102
+ // kind: 'audio_chunk' | 'iu_image' | 'scene_image' | 'scene_video'
103
+ function parseJobId(jobId) {
104
+ const split = splitJobId(jobId);
105
+ if (!split) return null;
106
+ const { assetId, type } = split;
107
+
108
+ if (type === 'audio') {
109
+ const parts = assetId.split('_');
110
+ if (parts.length < 4) return null;
111
+ const chunkIndex = parts.pop();
112
+ if (!CHUNK_INDEX_RE.test(chunkIndex)) return null;
113
+ const sceneId = parts.pop();
114
+ const chapterId = parts.pop();
115
+ return {
116
+ kind: 'audio_chunk', type, assetId,
117
+ bookId: parts.join('_'), chapterId, sceneId, chunkIndex,
118
+ };
119
+ }
120
+
121
+ if (type === 'iu_image' || (type === 'image' && assetId.includes('_iu'))) {
122
+ const parts = assetId.split('_');
123
+ if (parts.length < 4) return null;
124
+ const iuId = parts.pop();
125
+ const sceneId = parts.pop();
126
+ const chapterId = parts.pop();
127
+ return {
128
+ kind: 'iu_image', type, assetId,
129
+ bookId: parts.join('_'), chapterId, sceneId, iuId,
130
+ };
131
+ }
132
+
133
+ if (type === 'image') {
134
+ const parts = assetId.split('_');
135
+ if (parts.length < 3) return null;
136
+ const sceneId = parts.pop();
137
+ const chapterId = parts.pop();
138
+ return {
139
+ kind: 'scene_image', type, assetId,
140
+ bookId: parts.join('_'), chapterId, sceneId,
141
+ };
142
+ }
143
+
144
+ if (type === 'video') {
145
+ let base = assetId;
146
+ let groupSuffix = '';
147
+ const groupMatch = base.match(GROUP_SUFFIX_RE);
148
+ if (groupMatch) {
149
+ base = groupMatch[1];
150
+ groupSuffix = groupMatch[2];
151
+ }
152
+ const parts = base.split('_');
153
+ if (parts.length < 3) return null;
154
+ const sceneId = parts.pop();
155
+ const chapterId = parts.pop();
156
+ return {
157
+ kind: 'scene_video', type, assetId,
158
+ bookId: parts.join('_'), chapterId, sceneId, groupSuffix,
159
+ };
160
+ }
161
+
162
+ return null;
163
+ }
164
+
165
+ function getStageForJobId(jobId) {
166
+ const parsed = parseJobId(jobId);
167
+ if (!parsed) return null;
168
+ return STAGE_BY_KIND[parsed.kind] || null;
169
+ }
170
+
171
+ // ------------------------------------------------------
172
+ // Envelope contract helpers (JOB_PROTOCOL_V2.md §3.2, §3.10, §3.11, §3.12)
173
+ // ------------------------------------------------------
174
+ // Field-sets copied from the frozen envelope tables. They document the
175
+ // contract and let consumers cross-check their payloads without importing
176
+ // business logic. The hub's runtime validation order/behavior is NOT
177
+ // reproduced here — helpers are advisory (unknown fields must be ignored,
178
+ // JOB_PROTOCOL_V2.md §3.20).
179
+
180
+ // Task envelope (backend → hub → worker). Identity fields below are the
181
+ // hub's formal required set; the hub does not validate job_id/job_type/
182
+ // params (completeness is guaranteed by the backend dispatcher).
183
+ const TASK_ENVELOPE_REQUIRED_FIELDS = [
184
+ 'dispatch_id', 'build_id', 'book_id', 'chapter_id', 'scene_id',
185
+ 'stage', 'protocol_version',
186
+ ];
187
+ const TASK_ENVELOPE_FIELDS = [
188
+ 'job_id', 'params', 'job_type', 'assets', 'build_id',
189
+ 'protocol_version', 'book_id', 'chapter_id', 'scene_id', 'stage',
190
+ 'dispatch_id', 'workspace_id', 'policy_id', 'timeout_ms',
191
+ ];
192
+
193
+ // Result envelope (worker → hub → backend), required field set.
194
+ const RESULT_ENVELOPE_REQUIRED_FIELDS = [
195
+ 'job_id', 'build_id', 'dispatch_id', 'protocol_version', 'result_base64',
196
+ ];
197
+
198
+ // Error envelope (worker → hub → backend), required field set.
199
+ const ERROR_ENVELOPE_REQUIRED_FIELDS = [
200
+ 'job_id', 'build_id', 'dispatch_id', 'protocol_version',
201
+ ];
202
+
203
+ // Beacon envelope (worker → hub), required field set.
204
+ const BEACON_ENVELOPE_REQUIRED_FIELDS = ['protocol_version'];
205
+
206
+ // Canonical error tokens of the wire contract (JOB_PROTOCOL_V2.md §3.13).
207
+ const ERROR_TOKENS = {
208
+ PROTOCOL_VERSION_MISMATCH: 'protocol_version_mismatch',
209
+ INCOMPLETE_DISPATCH_IDENTITY: 'incomplete_dispatch_identity',
210
+ INVALID_WORKSPACE_ID: 'invalid_workspace_id',
211
+ INVALID_POLICY_ID: 'invalid_policy_id',
212
+ INVALID_POLICY_ROUTING: 'invalid_policy_routing',
213
+ INVALID: 'invalid',
214
+ NOT_TASK_CLAIMER: 'not_task_claimer',
215
+ STALE_OR_UNKNOWN_DISPATCH: 'stale_or_unknown_dispatch',
216
+ WORKER_PROTOCOL_MISMATCH: 'worker_protocol_mismatch',
217
+ WORKER_TYPE_MISMATCH: 'worker_type_mismatch',
218
+ WORKER_IDENTITY_REQUIRED: 'worker_identity_required',
219
+ HUB_API_KEY_NOT_CONFIGURED: 'hub_api_key_not_configured',
220
+ };
221
+
222
+ /**
223
+ * Validates the identity block of a task envelope. Advisory helper that
224
+ * mirrors the hub's required-set check (gpu-hub.js /task):
225
+ * dispatch_id, build_id, book_id, chapter_id, scene_id, stage must be
226
+ * truthy and protocol_version must equal PROTOCOL_VERSION. Returns an
227
+ * error-token string or null. job_id / job_type / params are NOT checked
228
+ * here (hub does not validate them — JOB_PROTOCOL_V2.md §3.2).
229
+ */
230
+ function validateTaskEnvelopeIdentity(task) {
231
+ if (!task || typeof task !== 'object') {
232
+ return ERROR_TOKENS.INCOMPLETE_DISPATCH_IDENTITY;
233
+ }
234
+ if (task.protocol_version !== PROTOCOL_VERSION) {
235
+ return ERROR_TOKENS.PROTOCOL_VERSION_MISMATCH;
236
+ }
237
+ for (const field of ['dispatch_id', 'build_id', 'book_id', 'chapter_id', 'scene_id', 'stage']) {
238
+ if (!task[field]) {
239
+ return ERROR_TOKENS.INCOMPLETE_DISPATCH_IDENTITY;
240
+ }
241
+ }
242
+ return null;
243
+ }
244
+
245
+ /**
246
+ * Validates the identity block of a result/error envelope (worker → hub):
247
+ * job_id, build_id, dispatch_id present (for results also result_base64)
248
+ * and protocol_version === PROTOCOL_VERSION. Mirrors the hub's
249
+ * /task/result and /task/error guard. Returns an error token or null.
250
+ */
251
+ function validateResultEnvelopeIdentity(payload, { requireResultBase64 = false } = {}) {
252
+ if (!payload || typeof payload !== 'object') {
253
+ return ERROR_TOKENS.INVALID;
254
+ }
255
+ if (payload.protocol_version !== PROTOCOL_VERSION) {
256
+ return ERROR_TOKENS.INVALID;
257
+ }
258
+ const required = ['job_id', 'build_id', 'dispatch_id'];
259
+ if (requireResultBase64) required.push('result_base64');
260
+ for (const field of required) {
261
+ if (!payload[field]) {
262
+ return ERROR_TOKENS.INVALID;
263
+ }
264
+ }
265
+ return null;
266
+ }
267
+
268
+ module.exports = {
269
+ // protocol constants
270
+ PROTOCOL_VERSION,
271
+ JOB_TYPES,
272
+ SYSTEM_JOB_TYPES,
273
+ STAGE_BY_KIND,
274
+ // job_id grammar
275
+ CHUNK_INDEX_RE,
276
+ GROUP_SUFFIX_RE,
277
+ JOB_ID_SPLIT_RE,
278
+ buildJobId,
279
+ splitJobId,
280
+ parseJobId,
281
+ getStageForJobId,
282
+ // envelope contract
283
+ TASK_ENVELOPE_REQUIRED_FIELDS,
284
+ TASK_ENVELOPE_FIELDS,
285
+ RESULT_ENVELOPE_REQUIRED_FIELDS,
286
+ ERROR_ENVELOPE_REQUIRED_FIELDS,
287
+ BEACON_ENVELOPE_REQUIRED_FIELDS,
288
+ ERROR_TOKENS,
289
+ validateTaskEnvelopeIdentity,
290
+ validateResultEnvelopeIdentity,
291
+ };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "animastor-worker",
3
+ "version": "2.1.0",
4
+ "description": "Animastor GPU Worker runtime bundle. The version field is the canonical worker bundle version: the hub publishes it with the bundle artifact (GET /worker-bundle) and worker.cjs reports it in beacons when WORKER_VERSION is not set.",
5
+ "main": "worker.cjs",
6
+ "files": [
7
+ "worker.cjs",
8
+ "worker-env.cjs",
9
+ "worker-cleanup.cjs",
10
+ "worker-cleanup-journal.cjs",
11
+ "job-protocol-v2.cjs",
12
+ ".env.example"
13
+ ],
14
+ "scripts": {
15
+ "start": "node worker.cjs",
16
+ "test": "node ../tests/run-all.cjs",
17
+ "sync:protocol": "node ../tools/sync-protocol.cjs",
18
+ "check:protocol": "node ../tools/sync-protocol.cjs --check"
19
+ },
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "https://github.com/Animastor/animastor.git",
26
+ "directory": "worker/worker"
27
+ },
28
+ "bugs": {
29
+ "url": "https://github.com/Animastor/animastor/issues"
30
+ },
31
+ "homepage": "https://animastor.in",
32
+ "keywords": [],
33
+ "author": "",
34
+ "license": "MIT",
35
+ "type": "module"
36
+ }
@@ -0,0 +1,239 @@
1
+ // ======================================================
2
+ // Worker Cleanup Journal — crash-safe recovery временных файлов ComfyUI
3
+ // ======================================================
4
+ // Worker-local persistent journal lifecycle одной job:
5
+ // CREATED → GENERATED → DELIVERED → (CLEANED удаляет запись)
6
+ //
7
+ // Хранит ТОЛЬКО конкретные absolute paths (никаких glob/prefix). Записи
8
+ // пишутся атомарно (tmp → fsync → rename), поэтому crash не повреждает JSON.
9
+ // Живёт на persistent-диске worker'а (рядом с runtime data), переживает и
10
+ // worker-restart, и Redis-restart (не зависит от Redis/PG).
11
+ //
12
+ // ВАЖНЫЙ ПРИНЦИП recovery: если нет доказательства DELIVERED — output не
13
+ // удаляется (защита единственной копии результата). input_files можно
14
+ // удалять всегда — они пересоздаются из task.assets при re-dispatch.
15
+ //
16
+ // Повреждённый journal НЕ расшифровывается и НЕ приводит к удалению файлов:
17
+ // записывается warning, файл остаётся для диагностики.
18
+ // Orphan-файлы без journal не трогаются (нет доказательства принадлежности).
19
+
20
+ const fs = require("fs");
21
+ const fsp = require("fs").promises;
22
+ const path = require("path");
23
+ const { cleanupJobArtifacts } = require("./worker-cleanup.cjs");
24
+
25
+ const PHASES = ["created", "generated", "delivered"];
26
+
27
+ function defaultLog(level, msg) {
28
+ console.log(`[${new Date().toISOString()}] [${level}] ${msg}`);
29
+ }
30
+
31
+ function resolveJournalDir(journalDir) {
32
+ return journalDir || process.env.WORKER_JOURNAL_DIR || path.join(__dirname, "cleanup-journal");
33
+ }
34
+
35
+ function sanitizeFilePart(str) {
36
+ return String(str).replace(/[^A-Za-z0-9._-]/g, "_");
37
+ }
38
+
39
+ function journalFilePath(journalDir, jobId, dispatchId) {
40
+ return path.join(journalDir, `${sanitizeFilePart(jobId)}__${sanitizeFilePart(dispatchId)}.json`);
41
+ }
42
+
43
+ // ── Atomic JSON write: tmp file → fsync → rename ──
44
+ async function atomicWriteJson(filePath, data) {
45
+ const tmpPath = `${filePath}.tmp`;
46
+ const fh = await fsp.open(tmpPath, "w");
47
+ try {
48
+ await fh.writeFile(JSON.stringify(data, null, 2));
49
+ await fh.sync();
50
+ } finally {
51
+ await fh.close();
52
+ }
53
+ await fsp.rename(tmpPath, filePath);
54
+ }
55
+
56
+ // ── Read + validate a journal record. Returns null on any corruption. ──
57
+ function readRecord(filePath) {
58
+ try {
59
+ const raw = fs.readFileSync(filePath, "utf8");
60
+ const record = JSON.parse(raw);
61
+ if (!record || typeof record !== "object") return null;
62
+ if (!record.job_id || !record.dispatch_id) return null;
63
+ if (!PHASES.includes(record.phase)) return null;
64
+ if (!Array.isArray(record.input_files)) record.input_files = [];
65
+ return record;
66
+ } catch (_) {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ async function ensureDir(dir) {
72
+ await fsp.mkdir(dir, { recursive: true });
73
+ }
74
+
75
+ /**
76
+ * Создать journal-запись job (phase=created). ДОЛЖЕН вызываться ДО создания
77
+ * первого временного input-файла. Best-effort: возвращает record или null.
78
+ */
79
+ async function createJob({ journalDir, jobId, dispatchId, log } = {}) {
80
+ const logFn = log || defaultLog;
81
+ if (!jobId || !dispatchId) return null;
82
+ try {
83
+ const dir = resolveJournalDir(journalDir);
84
+ await ensureDir(dir);
85
+ const record = {
86
+ job_id: jobId,
87
+ dispatch_id: dispatchId,
88
+ phase: "created",
89
+ input_files: [],
90
+ output_file: null,
91
+ created_at: new Date().toISOString(),
92
+ updated_at: new Date().toISOString(),
93
+ };
94
+ await atomicWriteJson(journalFilePath(dir, jobId, dispatchId), record);
95
+ return record;
96
+ } catch (err) {
97
+ logFn("warn", `Journal create failed for ${jobId}: ${err.message}`);
98
+ return null;
99
+ }
100
+ }
101
+
102
+ async function mutate({ journalDir, jobId, dispatchId, log } = {}, mutateFn) {
103
+ const logFn = log || defaultLog;
104
+ if (!jobId || !dispatchId) return null;
105
+ try {
106
+ const dir = resolveJournalDir(journalDir);
107
+ const filePath = journalFilePath(dir, jobId, dispatchId);
108
+ const record = readRecord(filePath);
109
+ if (!record) {
110
+ logFn("warn", `Journal record missing for ${jobId} (${filePath})`);
111
+ return null;
112
+ }
113
+ const changed = await mutateFn(record);
114
+ if (changed === false) return record;
115
+ record.updated_at = new Date().toISOString();
116
+ await atomicWriteJson(filePath, record);
117
+ return record;
118
+ } catch (err) {
119
+ logFn("warn", `Journal update failed for ${jobId}: ${err.message}`);
120
+ return null;
121
+ }
122
+ }
123
+
124
+ /** Добавить фактически созданный input path в journal. */
125
+ async function addInputFile(opts, filePath) {
126
+ return mutate(opts, (record) => {
127
+ if (!filePath) return false;
128
+ if (!record.input_files.includes(filePath)) record.input_files.push(filePath);
129
+ });
130
+ }
131
+
132
+ /** После waitResult: зафиксировать конкретный output-файл → phase=generated. */
133
+ async function setOutputAndGenerated(opts, outputFile) {
134
+ return mutate(opts, (record) => {
135
+ if (!outputFile) return false;
136
+ record.output_file = outputFile;
137
+ record.phase = "generated";
138
+ });
139
+ }
140
+
141
+ /** После успешного sendResult (HTTP 200) → phase=delivered. */
142
+ async function setDelivered(opts) {
143
+ return mutate(opts, (record) => {
144
+ record.phase = "delivered";
145
+ });
146
+ }
147
+
148
+ /** Удалить journal-запись (после полного CLEANED). Идемпотентно. */
149
+ async function removeJob({ journalDir, jobId, dispatchId, log } = {}) {
150
+ try {
151
+ const dir = resolveJournalDir(journalDir);
152
+ await fsp.unlink(journalFilePath(dir, jobId, dispatchId)).catch(() => {});
153
+ return true;
154
+ } catch (_) {
155
+ return false;
156
+ }
157
+ }
158
+
159
+ function listJournalFiles(journalDir) {
160
+ let files = [];
161
+ try {
162
+ files = fs.readdirSync(resolveJournalDir(journalDir));
163
+ } catch (_) {}
164
+ return files.filter((f) => f.endsWith(".json") && !f.endsWith(".tmp.json"));
165
+ }
166
+
167
+ /**
168
+ * Crash-safe startup recovery. Для каждой записи:
169
+ * delivered → удалить input_files + output_file;
170
+ * created/generated → удалить ТОЛЬКО input_files (output сохраняется).
171
+ * Запись удаляется только когда ВСЕ удаления успешны; при частичном cleanup
172
+ * запись остаётся — следующий прогон дочистит. Повреждённые записи
173
+ * пропускаются (warning) и не ломают startup.
174
+ *
175
+ * @returns {Promise<{found:number, cleaned:number, kept:number, corrupt:number}>}
176
+ */
177
+ async function recoverCleanupJournal({ journalDir, log } = {}) {
178
+ const logFn = log || defaultLog;
179
+ const dir = resolveJournalDir(journalDir);
180
+ const files = listJournalFiles(journalDir);
181
+ const result = { found: files.length, cleaned: 0, kept: 0, corrupt: 0 };
182
+
183
+ if (files.length > 0) logFn("info", `Cleanup recovery: found ${files.length} journal(s)`);
184
+
185
+ for (const file of files) {
186
+ const filePath = path.join(dir, file);
187
+ const record = readRecord(filePath);
188
+ if (!record) {
189
+ result.corrupt++;
190
+ logFn("warn", `Cleanup recovery: corrupt journal skipped ${file} (kept for diagnostics)`);
191
+ continue;
192
+ }
193
+ const { job_id, dispatch_id } = record;
194
+ logFn("info", `Cleanup recovery: job_id=${job_id} phase=${record.phase}`);
195
+
196
+ // Без proof DELIVERED output не трогаем (единственная копия результата).
197
+ const outputFile = record.phase === "delivered" ? record.output_file : null;
198
+ let cleanupResult;
199
+ try {
200
+ cleanupResult = await cleanupJobArtifacts({
201
+ inputFiles: record.input_files,
202
+ outputFile,
203
+ });
204
+ } catch (err) {
205
+ result.kept++;
206
+ logFn("warn", `Cleanup recovery: job_id=${job_id} failed path=(${err.message})`);
207
+ continue;
208
+ }
209
+
210
+ result.cleaned += cleanupResult.cleaned;
211
+ for (const fail of cleanupResult.failed) {
212
+ logFn("warn", `Cleanup recovery: failed path=${fail.path} reason=${fail.reason}`);
213
+ }
214
+
215
+ if (cleanupResult.failed.length === 0) {
216
+ await removeJob({ journalDir: dir, jobId: job_id, dispatchId: dispatch_id });
217
+ logFn("info", `Cleanup recovery: job_id=${job_id} cleaned ${cleanupResult.cleaned} artifact(s)`);
218
+ } else {
219
+ result.kept++;
220
+ logFn("info", `Cleanup recovery: job_id=${job_id} partial cleanup, journal kept`);
221
+ }
222
+ }
223
+
224
+ return result;
225
+ }
226
+
227
+ module.exports = {
228
+ createJob,
229
+ addInputFile,
230
+ setOutputAndGenerated,
231
+ setDelivered,
232
+ removeJob,
233
+ recoverCleanupJournal,
234
+ readRecord,
235
+ atomicWriteJson,
236
+ journalFilePath,
237
+ resolveJournalDir,
238
+ sanitizeFilePart,
239
+ };
@@ -0,0 +1,60 @@
1
+ // ======================================================
2
+ // Worker Cleanup — точечная уборка временных файлов ComfyUI
3
+ // ======================================================
4
+ // Удаляет ТОЛЬКО файлы конкретной job (input-файлы, созданные worker'ом, и
5
+ // output-файл, реально прочитанный как результат). Никогда не бросает:
6
+ // - отсутствующий файл (ENOENT) считается успехом;
7
+ // - ошибка удаления одного файла не останавливает уборку остальных.
8
+ // Это НЕ глобальная чистка input/output — только явно переданные пути.
9
+
10
+ const fsp = require("fs").promises;
11
+
12
+ /**
13
+ * Safe unlink — никогда не бросает.
14
+ * @param {string} filePath абсолютный путь
15
+ * @returns {Promise<{ok:boolean, path:string, missing?:boolean, error?:string}>}
16
+ */
17
+ async function safeUnlink(filePath) {
18
+ try {
19
+ await fsp.unlink(filePath);
20
+ return { ok: true, path: filePath };
21
+ } catch (err) {
22
+ if (err && err.code === "ENOENT") {
23
+ return { ok: true, path: filePath, missing: true };
24
+ }
25
+ return { ok: false, path: filePath, error: (err && err.message) || String(err) };
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Cleanup артефактов одной job: input-файлы (всегда) + output-файл
31
+ * (только когда он передан — т.е. результат уже доставлен).
32
+ * @param {{inputFiles?: string[], outputFile?: string|null}} opts
33
+ * @returns {Promise<{cleaned:number, failed:Array<{path:string, reason:string}>, outputFile:string|null}>}
34
+ */
35
+ async function cleanupJobArtifacts({ inputFiles = [], outputFile = null } = {}) {
36
+ const failed = [];
37
+ let cleaned = 0;
38
+
39
+ for (const filePath of inputFiles) {
40
+ const res = await safeUnlink(filePath);
41
+ if (res.ok) {
42
+ cleaned++;
43
+ } else {
44
+ failed.push({ path: filePath, reason: res.error });
45
+ }
46
+ }
47
+
48
+ if (outputFile) {
49
+ const res = await safeUnlink(outputFile);
50
+ if (res.ok) {
51
+ cleaned++;
52
+ } else {
53
+ failed.push({ path: outputFile, reason: res.error });
54
+ }
55
+ }
56
+
57
+ return { cleaned, failed, outputFile: outputFile || null };
58
+ }
59
+
60
+ module.exports = { safeUnlink, cleanupJobArtifacts };