comfyui-node 1.4.2 → 1.4.4

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.
Files changed (42) hide show
  1. package/README.md +16 -3
  2. package/dist/.tsbuildinfo +1 -1
  3. package/dist/call-wrapper.js +567 -567
  4. package/dist/call-wrapper.js.map +1 -1
  5. package/dist/client.d.ts +290 -290
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +2 -0
  8. package/dist/client.js.map +1 -1
  9. package/dist/index.d.ts +1 -1
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/pool/WorkflowPool.d.ts +80 -0
  13. package/dist/pool/WorkflowPool.d.ts.map +1 -1
  14. package/dist/pool/WorkflowPool.js +608 -455
  15. package/dist/pool/WorkflowPool.js.map +1 -1
  16. package/dist/pool/client/ClientManager.d.ts +15 -1
  17. package/dist/pool/client/ClientManager.d.ts.map +1 -1
  18. package/dist/pool/client/ClientManager.js +35 -6
  19. package/dist/pool/client/ClientManager.js.map +1 -1
  20. package/dist/pool/failover/SmartFailoverStrategy.js +1 -1
  21. package/dist/pool/failover/SmartFailoverStrategy.js.map +1 -1
  22. package/dist/pool/index.d.ts +1 -0
  23. package/dist/pool/index.d.ts.map +1 -1
  24. package/dist/pool/profiling/JobProfiler.d.ts +130 -0
  25. package/dist/pool/profiling/JobProfiler.d.ts.map +1 -0
  26. package/dist/pool/profiling/JobProfiler.js +225 -0
  27. package/dist/pool/profiling/JobProfiler.js.map +1 -0
  28. package/dist/pool/queue/QueueAdapter.d.ts +30 -30
  29. package/dist/pool/queue/adapters/memory.d.ts +20 -20
  30. package/dist/pool/types/job.d.ts +3 -0
  31. package/dist/pool/types/job.d.ts.map +1 -1
  32. package/dist/pool/utils/hash.d.ts +13 -1
  33. package/dist/pool/utils/hash.d.ts.map +1 -1
  34. package/dist/pool/utils/hash.js +14 -1
  35. package/dist/pool/utils/hash.js.map +1 -1
  36. package/dist/pool.d.ts +180 -180
  37. package/dist/workflow.d.ts +27 -4
  38. package/dist/workflow.d.ts.map +1 -1
  39. package/dist/workflow.js +30 -7
  40. package/dist/workflow.js.map +1 -1
  41. package/package.json +2 -2
  42. package/README.OLD.md +0 -1395
@@ -1,456 +1,609 @@
1
- import { randomUUID } from "node:crypto";
2
- import { TypedEventTarget } from "../typed-event-target.js";
3
- import { Workflow } from "../workflow.js";
4
- import { PromptBuilder } from "../prompt-builder.js";
5
- import { CallWrapper } from "../call-wrapper.js";
6
- import { MemoryQueueAdapter } from "./queue/adapters/memory.js";
7
- import { SmartFailoverStrategy } from "./failover/SmartFailoverStrategy.js";
8
- import { ClientManager } from "./client/ClientManager.js";
9
- import { hashWorkflow } from "./utils/hash.js";
10
- import { cloneDeep } from "./utils/clone.js";
11
- const DEFAULT_MAX_ATTEMPTS = 3;
12
- const DEFAULT_RETRY_DELAY = 1000;
13
- export class WorkflowPool extends TypedEventTarget {
14
- queue;
15
- strategy;
16
- clientManager;
17
- opts;
18
- jobStore = new Map();
19
- initPromise;
20
- processing = false;
21
- activeJobs = new Map();
22
- constructor(clients, opts) {
23
- super();
24
- this.strategy = opts?.failoverStrategy ?? new SmartFailoverStrategy();
25
- this.queue = opts?.queueAdapter ?? new MemoryQueueAdapter();
26
- this.clientManager = new ClientManager(this.strategy, {
27
- healthCheckIntervalMs: opts?.healthCheckIntervalMs ?? 30000
28
- });
29
- this.opts = opts ?? {};
30
- this.clientManager.on("client:state", (ev) => {
31
- this.dispatchEvent(new CustomEvent("client:state", { detail: ev.detail }));
32
- });
33
- this.clientManager.on("client:blocked_workflow", (ev) => {
34
- this.dispatchEvent(new CustomEvent("client:blocked_workflow", { detail: ev.detail }));
35
- });
36
- this.clientManager.on("client:unblocked_workflow", (ev) => {
37
- this.dispatchEvent(new CustomEvent("client:unblocked_workflow", { detail: ev.detail }));
38
- });
39
- this.initPromise = this.clientManager
40
- .initialize(clients)
41
- .then(() => {
42
- this.dispatchEvent(new CustomEvent("pool:ready", {
43
- detail: { clientIds: this.clientManager.list().map((c) => c.id) }
44
- }));
45
- })
46
- .catch((error) => {
47
- this.dispatchEvent(new CustomEvent("pool:error", { detail: { error } }));
48
- });
49
- }
50
- async ready() {
51
- await this.initPromise;
52
- }
53
- async enqueue(workflowInput, options) {
54
- await this.ready();
55
- const workflowJson = this.normalizeWorkflow(workflowInput);
56
- const workflowHash = hashWorkflow(workflowJson);
57
- const jobId = options?.jobId ?? this.generateJobId();
58
- // Extract workflow metadata (outputAliases, outputNodeIds, etc.) if input is a Workflow instance
59
- let workflowMeta;
60
- if (workflowInput instanceof Workflow) {
61
- workflowMeta = {
62
- outputNodeIds: workflowInput.outputNodeIds ?? [],
63
- outputAliases: workflowInput.outputAliases ?? {}
64
- };
65
- }
66
- const payload = {
67
- jobId,
68
- workflow: workflowJson,
69
- workflowHash,
70
- attempts: 0,
71
- enqueuedAt: Date.now(),
72
- workflowMeta,
73
- options: {
74
- maxAttempts: options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
75
- retryDelayMs: options?.retryDelayMs ?? DEFAULT_RETRY_DELAY,
76
- priority: options?.priority ?? 0,
77
- preferredClientIds: options?.preferredClientIds ?? [],
78
- excludeClientIds: options?.excludeClientIds ?? [],
79
- metadata: options?.metadata ?? {},
80
- includeOutputs: options?.includeOutputs ?? []
81
- }
82
- };
83
- const record = {
84
- ...payload,
85
- attachments: options?.attachments,
86
- status: "queued"
87
- };
88
- this.jobStore.set(jobId, record);
89
- await this.queue.enqueue(payload, { priority: payload.options.priority });
90
- this.dispatchEvent(new CustomEvent("job:queued", { detail: { job: record } }));
91
- void this.processQueue();
92
- return jobId;
93
- }
94
- getJob(jobId) {
95
- return this.jobStore.get(jobId);
96
- }
97
- async cancel(jobId) {
98
- const record = this.jobStore.get(jobId);
99
- if (!record) {
100
- return false;
101
- }
102
- if (record.status === "queued") {
103
- const removed = await this.queue.remove(jobId);
104
- if (removed) {
105
- record.status = "cancelled";
106
- record.completedAt = Date.now();
107
- this.dispatchEvent(new CustomEvent("job:cancelled", { detail: { job: record } }));
108
- return true;
109
- }
110
- }
111
- const active = this.activeJobs.get(jobId);
112
- if (active?.cancel) {
113
- await active.cancel();
114
- record.status = "cancelled";
115
- record.completedAt = Date.now();
116
- this.dispatchEvent(new CustomEvent("job:cancelled", { detail: { job: record } }));
117
- return true;
118
- }
119
- return false;
120
- }
121
- async shutdown() {
122
- this.clientManager.destroy();
123
- await this.queue.shutdown();
124
- for (const [, ctx] of this.activeJobs) {
125
- ctx.release({ success: false });
126
- }
127
- this.activeJobs.clear();
128
- }
129
- async getQueueStats() {
130
- return this.queue.stats();
131
- }
132
- normalizeWorkflow(input) {
133
- if (typeof input === "string") {
134
- return JSON.parse(input);
135
- }
136
- if (input instanceof Workflow) {
137
- return cloneDeep(input.json ?? {});
138
- }
139
- if (typeof input?.toJSON === "function") {
140
- return cloneDeep(input.toJSON());
141
- }
142
- return cloneDeep(input);
143
- }
144
- generateJobId() {
145
- try {
146
- return randomUUID();
147
- }
148
- catch {
149
- return WorkflowPool.fallbackId();
150
- }
151
- }
152
- static fallbackId() {
153
- return (globalThis.crypto && "randomUUID" in globalThis.crypto)
154
- ? globalThis.crypto.randomUUID()
155
- : `job_${Math.random().toString(36).slice(2, 10)}`;
156
- }
157
- scheduleProcess(delayMs) {
158
- const wait = Math.max(delayMs, 10);
159
- setTimeout(() => {
160
- void this.processQueue();
161
- }, wait);
162
- }
163
- applyAutoSeed(workflow) {
164
- const autoSeeds = {};
165
- for (const [nodeId, nodeValue] of Object.entries(workflow)) {
166
- if (!nodeValue || typeof nodeValue !== "object")
167
- continue;
168
- const inputs = nodeValue.inputs;
169
- if (!inputs || typeof inputs !== "object")
170
- continue;
171
- if (typeof inputs.seed === "number" && inputs.seed === -1) {
172
- const val = Math.floor(Math.random() * 2_147_483_647);
173
- inputs.seed = val;
174
- autoSeeds[nodeId] = val;
175
- }
176
- }
177
- return autoSeeds;
178
- }
179
- async processQueue() {
180
- if (this.processing) {
181
- return;
182
- }
183
- this.processing = true;
184
- try {
185
- while (true) {
186
- const reservation = await this.queue.reserve();
187
- if (!reservation) {
188
- break;
189
- }
190
- const job = this.jobStore.get(reservation.payload.jobId);
191
- if (!job) {
192
- await this.queue.commit(reservation.reservationId);
193
- continue;
194
- }
195
- const lease = this.clientManager.claim(job);
196
- if (!lease) {
197
- await this.queue.retry(reservation.reservationId, { delayMs: job.options.retryDelayMs });
198
- this.scheduleProcess(job.options.retryDelayMs);
199
- break;
200
- }
201
- this.runJob({ reservation, job, clientId: lease.clientId, release: lease.release }).catch((error) => {
202
- console.error("[WorkflowPool] Unhandled job error", error);
203
- });
204
- }
205
- }
206
- finally {
207
- this.processing = false;
208
- }
209
- }
210
- async runJob(ctx) {
211
- const { reservation, job, clientId, release } = ctx;
212
- const managed = this.clientManager.getClient(clientId);
213
- const client = managed?.client;
214
- if (!client) {
215
- await this.queue.retry(reservation.reservationId, { delayMs: job.options.retryDelayMs });
216
- release({ success: false });
217
- return;
218
- }
219
- job.status = "running";
220
- job.clientId = clientId;
221
- job.attempts += 1;
222
- reservation.payload.attempts = job.attempts;
223
- job.startedAt = Date.now();
224
- // Don't dispatch job:started here - wait until we have promptId in onPending
225
- // this.dispatchEvent(new CustomEvent("job:started", { detail: { job } }));
226
- const workflowPayload = cloneDeep(reservation.payload.workflow);
227
- if (job.attachments?.length) {
228
- for (const attachment of job.attachments) {
229
- const filename = attachment.filename ?? `${job.jobId}-${attachment.nodeId}-${attachment.inputName}.bin`;
230
- const blob = attachment.file instanceof Buffer ? new Blob([new Uint8Array(attachment.file)]) : attachment.file;
231
- await client.ext.file.uploadImage(blob, filename, { override: true });
232
- const node = workflowPayload[attachment.nodeId];
233
- if (node?.inputs) {
234
- node.inputs[attachment.inputName] = filename;
235
- }
236
- }
237
- }
238
- const autoSeeds = this.applyAutoSeed(workflowPayload);
239
- let wfInstance = Workflow.from(workflowPayload);
240
- if (job.options.includeOutputs?.length) {
241
- for (const nodeId of job.options.includeOutputs) {
242
- if (nodeId) {
243
- wfInstance = wfInstance.output(nodeId);
244
- }
245
- }
246
- }
247
- wfInstance.inferDefaultOutputs?.();
248
- // Use stored metadata if available (from Workflow instance), otherwise extract from recreated instance
249
- const outputNodeIds = reservation.payload.workflowMeta?.outputNodeIds ??
250
- wfInstance.outputNodeIds ??
251
- job.options.includeOutputs ?? [];
252
- const outputAliases = reservation.payload.workflowMeta?.outputAliases ??
253
- wfInstance.outputAliases ?? {};
254
- let promptBuilder = new PromptBuilder(wfInstance.json, wfInstance.inputPaths ?? [], outputNodeIds);
255
- for (const nodeId of outputNodeIds) {
256
- const alias = outputAliases[nodeId] ?? nodeId;
257
- promptBuilder = promptBuilder.setOutputNode(alias, nodeId);
258
- }
259
- const wrapper = new CallWrapper(client, promptBuilder);
260
- let pendingSettled = false;
261
- let resolvePending;
262
- let rejectPending;
263
- const pendingPromise = new Promise((resolve, reject) => {
264
- resolvePending = () => {
265
- if (!pendingSettled) {
266
- pendingSettled = true;
267
- resolve();
268
- }
269
- };
270
- rejectPending = (err) => {
271
- if (!pendingSettled) {
272
- pendingSettled = true;
273
- reject(err);
274
- }
275
- };
276
- });
277
- let resolveCompletion;
278
- let rejectCompletion;
279
- const completionPromise = new Promise((resolve, reject) => {
280
- resolveCompletion = resolve;
281
- rejectCompletion = reject;
282
- });
283
- let jobStartedDispatched = false;
284
- wrapper.onProgress((progress, promptId) => {
285
- if (!job.promptId && promptId) {
286
- job.promptId = promptId;
287
- }
288
- // Dispatch job:started on first progress update with promptId
289
- if (!jobStartedDispatched && job.promptId) {
290
- jobStartedDispatched = true;
291
- this.dispatchEvent(new CustomEvent("job:started", { detail: { job } }));
292
- }
293
- this.dispatchEvent(new CustomEvent("job:progress", {
294
- detail: { jobId: job.jobId, clientId, progress }
295
- }));
296
- });
297
- wrapper.onPreview((blob, promptId) => {
298
- if (!job.promptId && promptId) {
299
- job.promptId = promptId;
300
- }
301
- // Dispatch job:started on first preview with promptId
302
- if (!jobStartedDispatched && job.promptId) {
303
- jobStartedDispatched = true;
304
- this.dispatchEvent(new CustomEvent("job:started", { detail: { job } }));
305
- }
306
- this.dispatchEvent(new CustomEvent("job:preview", {
307
- detail: { jobId: job.jobId, clientId, blob }
308
- }));
309
- });
310
- wrapper.onPreviewMeta((payload, promptId) => {
311
- if (!job.promptId && promptId) {
312
- job.promptId = promptId;
313
- }
314
- // Dispatch job:started on first preview_meta with promptId
315
- if (!jobStartedDispatched && job.promptId) {
316
- jobStartedDispatched = true;
317
- this.dispatchEvent(new CustomEvent("job:started", { detail: { job } }));
318
- }
319
- this.dispatchEvent(new CustomEvent("job:preview_meta", {
320
- detail: { jobId: job.jobId, clientId, payload }
321
- }));
322
- });
323
- wrapper.onOutput((key, data, promptId) => {
324
- if (!job.promptId && promptId) {
325
- job.promptId = promptId;
326
- }
327
- this.dispatchEvent(new CustomEvent("job:output", {
328
- detail: { jobId: job.jobId, clientId, key: String(key), data }
329
- }));
330
- });
331
- wrapper.onPending((promptId) => {
332
- if (!job.promptId && promptId) {
333
- job.promptId = promptId;
334
- }
335
- // Don't dispatch job:started here - wait for first progress/preview with promptId
336
- this.dispatchEvent(new CustomEvent("job:accepted", { detail: { job } }));
337
- resolvePending?.();
338
- });
339
- wrapper.onStart((promptId) => {
340
- if (!job.promptId && promptId) {
341
- job.promptId = promptId;
342
- }
343
- });
344
- wrapper.onFinished((data, promptId) => {
345
- if (!job.promptId && promptId) {
346
- job.promptId = promptId;
347
- }
348
- job.status = "completed";
349
- job.lastError = undefined;
350
- const resultPayload = {};
351
- for (const nodeId of outputNodeIds) {
352
- const alias = outputAliases[nodeId] ?? nodeId;
353
- // CallWrapper uses alias keys when mapOutputKeys is configured, fallback to nodeId
354
- const nodeResult = data[alias];
355
- const fallbackResult = data[nodeId];
356
- const finalResult = nodeResult !== undefined ? nodeResult : fallbackResult;
357
- resultPayload[alias] = finalResult;
358
- }
359
- resultPayload._nodes = [...outputNodeIds];
360
- resultPayload._aliases = { ...outputAliases };
361
- if (job.promptId) {
362
- resultPayload._promptId = job.promptId;
363
- }
364
- if (Object.keys(autoSeeds).length) {
365
- resultPayload._autoSeeds = { ...autoSeeds };
366
- }
367
- job.result = resultPayload;
368
- job.completedAt = Date.now();
369
- this.dispatchEvent(new CustomEvent("job:completed", { detail: { job } }));
370
- resolveCompletion?.();
371
- });
372
- wrapper.onFailed((error, promptId) => {
373
- if (!job.promptId && promptId) {
374
- job.promptId = promptId;
375
- }
376
- job.lastError = error;
377
- rejectPending?.(error);
378
- rejectCompletion?.(error);
379
- });
380
- try {
381
- const exec = wrapper.run();
382
- await pendingPromise;
383
- this.activeJobs.set(job.jobId, {
384
- reservation,
385
- job,
386
- clientId,
387
- release,
388
- cancel: async () => {
389
- try {
390
- if (job.promptId) {
391
- await client.ext.queue.interrupt(job.promptId);
392
- }
393
- }
394
- finally {
395
- this.activeJobs.delete(job.jobId);
396
- await this.queue.discard(reservation.reservationId, new Error("cancelled"));
397
- release({ success: false });
398
- }
399
- }
400
- });
401
- const result = await exec;
402
- if (result === false) {
403
- // Execution failed - try to get the error from completionPromise rejection
404
- try {
405
- await completionPromise;
406
- }
407
- catch (err) {
408
- throw err;
409
- }
410
- throw job.lastError ?? new Error("Execution failed");
411
- }
412
- await completionPromise;
413
- await this.queue.commit(reservation.reservationId);
414
- release({ success: true });
415
- }
416
- catch (error) {
417
- const latestStatus = this.jobStore.get(job.jobId)?.status;
418
- if (latestStatus === "cancelled") {
419
- release({ success: false });
420
- return;
421
- }
422
- job.lastError = error;
423
- job.status = "failed";
424
- this.clientManager.recordFailure(clientId, job, error);
425
- const remainingAttempts = job.options.maxAttempts - job.attempts;
426
- const willRetry = remainingAttempts > 0;
427
- this.dispatchEvent(new CustomEvent("job:failed", {
428
- detail: { job, willRetry }
429
- }));
430
- if (willRetry) {
431
- const delay = this.opts.retryBackoffMs ?? job.options.retryDelayMs;
432
- this.dispatchEvent(new CustomEvent("job:retrying", { detail: { job, delayMs: delay } }));
433
- job.status = "queued";
434
- job.clientId = undefined;
435
- job.promptId = undefined;
436
- job.startedAt = undefined;
437
- job.completedAt = undefined;
438
- job.result = undefined;
439
- await this.queue.retry(reservation.reservationId, { delayMs: delay });
440
- this.dispatchEvent(new CustomEvent("job:queued", { detail: { job } }));
441
- this.scheduleProcess(delay);
442
- release({ success: false });
443
- }
444
- else {
445
- job.completedAt = Date.now();
446
- await this.queue.discard(reservation.reservationId, error);
447
- release({ success: false });
448
- }
449
- }
450
- finally {
451
- this.activeJobs.delete(job.jobId);
452
- void this.processQueue();
453
- }
454
- }
455
- }
1
+ import { randomUUID } from "node:crypto";
2
+ import { TypedEventTarget } from "../typed-event-target.js";
3
+ import { Workflow } from "../workflow.js";
4
+ import { PromptBuilder } from "../prompt-builder.js";
5
+ import { CallWrapper } from "../call-wrapper.js";
6
+ import { MemoryQueueAdapter } from "./queue/adapters/memory.js";
7
+ import { SmartFailoverStrategy } from "./failover/SmartFailoverStrategy.js";
8
+ import { ClientManager } from "./client/ClientManager.js";
9
+ import { hashWorkflow } from "./utils/hash.js";
10
+ import { cloneDeep } from "./utils/clone.js";
11
+ import { JobProfiler } from "./profiling/JobProfiler.js";
12
+ const DEFAULT_MAX_ATTEMPTS = 3;
13
+ const DEFAULT_RETRY_DELAY = 1000;
14
+ export class WorkflowPool extends TypedEventTarget {
15
+ queue;
16
+ strategy;
17
+ clientManager;
18
+ opts;
19
+ jobStore = new Map();
20
+ initPromise;
21
+ processing = false;
22
+ activeJobs = new Map();
23
+ constructor(clients, opts) {
24
+ super();
25
+ this.strategy = opts?.failoverStrategy ?? new SmartFailoverStrategy();
26
+ this.queue = opts?.queueAdapter ?? new MemoryQueueAdapter();
27
+ this.clientManager = new ClientManager(this.strategy, {
28
+ healthCheckIntervalMs: opts?.healthCheckIntervalMs ?? 30000
29
+ });
30
+ this.opts = opts ?? {};
31
+ this.clientManager.on("client:state", (ev) => {
32
+ this.dispatchEvent(new CustomEvent("client:state", { detail: ev.detail }));
33
+ });
34
+ this.clientManager.on("client:blocked_workflow", (ev) => {
35
+ this.dispatchEvent(new CustomEvent("client:blocked_workflow", { detail: ev.detail }));
36
+ });
37
+ this.clientManager.on("client:unblocked_workflow", (ev) => {
38
+ this.dispatchEvent(new CustomEvent("client:unblocked_workflow", { detail: ev.detail }));
39
+ });
40
+ this.initPromise = this.clientManager
41
+ .initialize(clients)
42
+ .then(() => {
43
+ this.dispatchEvent(new CustomEvent("pool:ready", {
44
+ detail: { clientIds: this.clientManager.list().map((c) => c.id) }
45
+ }));
46
+ })
47
+ .catch((error) => {
48
+ this.dispatchEvent(new CustomEvent("pool:error", { detail: { error } }));
49
+ });
50
+ }
51
+ async ready() {
52
+ await this.initPromise;
53
+ }
54
+ async enqueue(workflowInput, options) {
55
+ await this.ready();
56
+ const workflowJson = this.normalizeWorkflow(workflowInput);
57
+ // Use the workflow's pre-computed structureHash if available (from Workflow instance)
58
+ // Otherwise compute it from the JSON
59
+ let workflowHash;
60
+ if (workflowInput instanceof Workflow) {
61
+ workflowHash = workflowInput.structureHash ?? hashWorkflow(workflowJson);
62
+ }
63
+ else {
64
+ workflowHash = hashWorkflow(workflowJson);
65
+ }
66
+ const jobId = options?.jobId ?? this.generateJobId();
67
+ // Extract workflow metadata (outputAliases, outputNodeIds, etc.) if input is a Workflow instance
68
+ let workflowMeta;
69
+ if (workflowInput instanceof Workflow) {
70
+ workflowMeta = {
71
+ outputNodeIds: workflowInput.outputNodeIds ?? [],
72
+ outputAliases: workflowInput.outputAliases ?? {}
73
+ };
74
+ }
75
+ const payload = {
76
+ jobId,
77
+ workflow: workflowJson,
78
+ workflowHash,
79
+ attempts: 0,
80
+ enqueuedAt: Date.now(),
81
+ workflowMeta,
82
+ options: {
83
+ maxAttempts: options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
84
+ retryDelayMs: options?.retryDelayMs ?? DEFAULT_RETRY_DELAY,
85
+ priority: options?.priority ?? 0,
86
+ preferredClientIds: options?.preferredClientIds ?? [],
87
+ excludeClientIds: options?.excludeClientIds ?? [],
88
+ metadata: options?.metadata ?? {},
89
+ includeOutputs: options?.includeOutputs ?? []
90
+ }
91
+ };
92
+ const record = {
93
+ ...payload,
94
+ attachments: options?.attachments,
95
+ status: "queued"
96
+ };
97
+ this.jobStore.set(jobId, record);
98
+ await this.queue.enqueue(payload, { priority: payload.options.priority });
99
+ this.dispatchEvent(new CustomEvent("job:queued", { detail: { job: record } }));
100
+ void this.processQueue();
101
+ return jobId;
102
+ }
103
+ getJob(jobId) {
104
+ return this.jobStore.get(jobId);
105
+ }
106
+ async cancel(jobId) {
107
+ const record = this.jobStore.get(jobId);
108
+ if (!record) {
109
+ return false;
110
+ }
111
+ if (record.status === "queued") {
112
+ const removed = await this.queue.remove(jobId);
113
+ if (removed) {
114
+ record.status = "cancelled";
115
+ record.completedAt = Date.now();
116
+ this.dispatchEvent(new CustomEvent("job:cancelled", { detail: { job: record } }));
117
+ return true;
118
+ }
119
+ }
120
+ const active = this.activeJobs.get(jobId);
121
+ if (active?.cancel) {
122
+ await active.cancel();
123
+ record.status = "cancelled";
124
+ record.completedAt = Date.now();
125
+ this.dispatchEvent(new CustomEvent("job:cancelled", { detail: { job: record } }));
126
+ return true;
127
+ }
128
+ return false;
129
+ }
130
+ async shutdown() {
131
+ this.clientManager.destroy();
132
+ await this.queue.shutdown();
133
+ for (const [, ctx] of Array.from(this.activeJobs)) {
134
+ ctx.release({ success: false });
135
+ }
136
+ this.activeJobs.clear();
137
+ }
138
+ async getQueueStats() {
139
+ return this.queue.stats();
140
+ }
141
+ normalizeWorkflow(input) {
142
+ if (typeof input === "string") {
143
+ return JSON.parse(input);
144
+ }
145
+ if (input instanceof Workflow) {
146
+ return cloneDeep(input.json ?? {});
147
+ }
148
+ if (typeof input?.toJSON === "function") {
149
+ return cloneDeep(input.toJSON());
150
+ }
151
+ return cloneDeep(input);
152
+ }
153
+ generateJobId() {
154
+ try {
155
+ return randomUUID();
156
+ }
157
+ catch {
158
+ return WorkflowPool.fallbackId();
159
+ }
160
+ }
161
+ static fallbackId() {
162
+ return (globalThis.crypto && "randomUUID" in globalThis.crypto)
163
+ ? globalThis.crypto.randomUUID()
164
+ : `job_${Math.random().toString(36).slice(2, 10)}`;
165
+ }
166
+ scheduleProcess(delayMs) {
167
+ const wait = Math.max(delayMs, 10);
168
+ setTimeout(() => {
169
+ void this.processQueue();
170
+ }, wait);
171
+ }
172
+ applyAutoSeed(workflow) {
173
+ const autoSeeds = {};
174
+ for (const [nodeId, nodeValue] of Object.entries(workflow)) {
175
+ if (!nodeValue || typeof nodeValue !== "object")
176
+ continue;
177
+ const inputs = nodeValue.inputs;
178
+ if (!inputs || typeof inputs !== "object")
179
+ continue;
180
+ if (typeof inputs.seed === "number" && inputs.seed === -1) {
181
+ const val = Math.floor(Math.random() * 2_147_483_647);
182
+ inputs.seed = val;
183
+ autoSeeds[nodeId] = val;
184
+ }
185
+ }
186
+ return autoSeeds;
187
+ }
188
+ async processQueue() {
189
+ if (this.processing) {
190
+ return;
191
+ }
192
+ this.processing = true;
193
+ try {
194
+ while (true) {
195
+ const reservation = await this.queue.reserve();
196
+ if (!reservation) {
197
+ break;
198
+ }
199
+ const job = this.jobStore.get(reservation.payload.jobId);
200
+ if (!job) {
201
+ await this.queue.commit(reservation.reservationId);
202
+ continue;
203
+ }
204
+ const lease = this.clientManager.claim(job);
205
+ if (!lease) {
206
+ await this.queue.retry(reservation.reservationId, { delayMs: job.options.retryDelayMs });
207
+ this.scheduleProcess(job.options.retryDelayMs);
208
+ break;
209
+ }
210
+ this.runJob({ reservation, job, clientId: lease.clientId, release: lease.release }).catch((error) => {
211
+ console.error("[WorkflowPool] Unhandled job error", error);
212
+ });
213
+ }
214
+ }
215
+ finally {
216
+ this.processing = false;
217
+ }
218
+ }
219
+ async runJob(ctx) {
220
+ const { reservation, job, clientId, release } = ctx;
221
+ const managed = this.clientManager.getClient(clientId);
222
+ const client = managed?.client;
223
+ if (!client) {
224
+ await this.queue.retry(reservation.reservationId, { delayMs: job.options.retryDelayMs });
225
+ release({ success: false });
226
+ return;
227
+ }
228
+ job.status = "running";
229
+ job.clientId = clientId;
230
+ job.attempts += 1;
231
+ reservation.payload.attempts = job.attempts;
232
+ job.startedAt = Date.now();
233
+ // Don't dispatch job:started here - wait until we have promptId in onPending
234
+ // this.dispatchEvent(new CustomEvent("job:started", { detail: { job } }));
235
+ const workflowPayload = cloneDeep(reservation.payload.workflow);
236
+ if (job.attachments?.length) {
237
+ for (const attachment of job.attachments) {
238
+ const filename = attachment.filename ?? `${job.jobId}-${attachment.nodeId}-${attachment.inputName}.bin`;
239
+ const blob = attachment.file instanceof Buffer ? new Blob([new Uint8Array(attachment.file)]) : attachment.file;
240
+ await client.ext.file.uploadImage(blob, filename, { override: true });
241
+ const node = workflowPayload[attachment.nodeId];
242
+ if (node?.inputs) {
243
+ node.inputs[attachment.inputName] = filename;
244
+ }
245
+ }
246
+ }
247
+ const autoSeeds = this.applyAutoSeed(workflowPayload);
248
+ let wfInstance = Workflow.from(workflowPayload);
249
+ if (job.options.includeOutputs?.length) {
250
+ for (const nodeId of job.options.includeOutputs) {
251
+ if (nodeId) {
252
+ wfInstance = wfInstance.output(nodeId);
253
+ }
254
+ }
255
+ }
256
+ wfInstance.inferDefaultOutputs?.();
257
+ // Use stored metadata if available (from Workflow instance), otherwise extract from recreated instance
258
+ const outputNodeIds = reservation.payload.workflowMeta?.outputNodeIds ??
259
+ wfInstance.outputNodeIds ??
260
+ job.options.includeOutputs ?? [];
261
+ const outputAliases = reservation.payload.workflowMeta?.outputAliases ??
262
+ wfInstance.outputAliases ?? {};
263
+ let promptBuilder = new PromptBuilder(wfInstance.json, wfInstance.inputPaths ?? [], outputNodeIds);
264
+ for (const nodeId of outputNodeIds) {
265
+ const alias = outputAliases[nodeId] ?? nodeId;
266
+ promptBuilder = promptBuilder.setOutputNode(alias, nodeId);
267
+ }
268
+ const wrapper = new CallWrapper(client, promptBuilder);
269
+ // Setup profiling if enabled
270
+ const profiler = this.opts.enableProfiling
271
+ ? new JobProfiler(job.enqueuedAt, workflowPayload)
272
+ : undefined;
273
+ // Setup node execution timeout tracking
274
+ const nodeExecutionTimeout = this.opts.nodeExecutionTimeoutMs ?? 300000; // 5 minutes default
275
+ let nodeTimeoutId;
276
+ let lastNodeStartTime;
277
+ let currentExecutingNode = null;
278
+ const resetNodeTimeout = (nodeName) => {
279
+ if (nodeTimeoutId) {
280
+ clearTimeout(nodeTimeoutId);
281
+ nodeTimeoutId = undefined;
282
+ }
283
+ if (nodeExecutionTimeout > 0 && nodeName !== null) {
284
+ lastNodeStartTime = Date.now();
285
+ currentExecutingNode = nodeName || null;
286
+ nodeTimeoutId = setTimeout(() => {
287
+ const elapsed = Date.now() - (lastNodeStartTime || 0);
288
+ const nodeInfo = currentExecutingNode ? ` (node: ${currentExecutingNode})` : '';
289
+ rejectCompletion?.(new Error(`Node execution timeout: took longer than ${nodeExecutionTimeout}ms${nodeInfo}. ` +
290
+ `Actual time: ${elapsed}ms. Server may be stuck or node is too slow for configured timeout.`));
291
+ }, nodeExecutionTimeout);
292
+ }
293
+ };
294
+ const clearNodeTimeout = () => {
295
+ if (nodeTimeoutId) {
296
+ clearTimeout(nodeTimeoutId);
297
+ nodeTimeoutId = undefined;
298
+ }
299
+ currentExecutingNode = null;
300
+ lastNodeStartTime = undefined;
301
+ };
302
+ // Setup profiling event listeners on the raw ComfyUI client
303
+ if (profiler) {
304
+ const onExecutionStart = (event) => {
305
+ const promptId = event.detail?.prompt_id;
306
+ if (promptId) {
307
+ profiler.onExecutionStart(promptId);
308
+ }
309
+ };
310
+ const onExecutionCached = (event) => {
311
+ const nodes = event.detail?.nodes;
312
+ if (Array.isArray(nodes)) {
313
+ profiler.onCachedNodes(nodes.map(String));
314
+ }
315
+ };
316
+ const onExecuting = (event) => {
317
+ const node = event.detail?.node;
318
+ if (node === null) {
319
+ // Workflow completed
320
+ profiler.onExecutionComplete();
321
+ }
322
+ else if (node !== undefined) {
323
+ profiler.onNodeExecuting(String(node));
324
+ }
325
+ };
326
+ const onExecutionError = (event) => {
327
+ const detail = event.detail || {};
328
+ if (detail.node !== undefined) {
329
+ profiler.onNodeError(String(detail.node), detail.exception_message || 'Execution error');
330
+ }
331
+ };
332
+ // Attach listeners to client
333
+ client.addEventListener('execution_start', onExecutionStart);
334
+ client.addEventListener('execution_cached', onExecutionCached);
335
+ client.addEventListener('executing', onExecuting);
336
+ client.addEventListener('execution_error', onExecutionError);
337
+ // Cleanup function to remove listeners
338
+ const cleanupProfiler = () => {
339
+ client.removeEventListener('execution_start', onExecutionStart);
340
+ client.removeEventListener('execution_cached', onExecutionCached);
341
+ client.removeEventListener('executing', onExecuting);
342
+ client.removeEventListener('execution_error', onExecutionError);
343
+ };
344
+ // Ensure cleanup happens when job finishes
345
+ wrapper.onFinished(() => cleanupProfiler());
346
+ wrapper.onFailed(() => cleanupProfiler());
347
+ }
348
+ // Setup node execution timeout listeners (always active if timeout > 0)
349
+ const onNodeExecuting = (event) => {
350
+ const node = event.detail?.node;
351
+ if (node === null) {
352
+ // Workflow completed - clear timeout
353
+ clearNodeTimeout();
354
+ }
355
+ else if (node !== undefined) {
356
+ // New node started - reset timeout
357
+ resetNodeTimeout(String(node));
358
+ }
359
+ };
360
+ const onNodeProgress = (event) => {
361
+ // Progress event means node is still working - reset timeout
362
+ if (event.detail?.node) {
363
+ resetNodeTimeout(String(event.detail.node));
364
+ }
365
+ };
366
+ const onExecutionStarted = (event) => {
367
+ // Execution started - reset timeout for first node
368
+ resetNodeTimeout('execution_start');
369
+ };
370
+ if (nodeExecutionTimeout > 0) {
371
+ client.addEventListener('execution_start', onExecutionStarted);
372
+ client.addEventListener('executing', onNodeExecuting);
373
+ client.addEventListener('progress', onNodeProgress);
374
+ }
375
+ const cleanupNodeTimeout = () => {
376
+ clearNodeTimeout();
377
+ if (nodeExecutionTimeout > 0) {
378
+ client.removeEventListener('execution_start', onExecutionStarted);
379
+ client.removeEventListener('executing', onNodeExecuting);
380
+ client.removeEventListener('progress', onNodeProgress);
381
+ }
382
+ };
383
+ let pendingSettled = false;
384
+ let resolvePending;
385
+ let rejectPending;
386
+ const pendingPromise = new Promise((resolve, reject) => {
387
+ resolvePending = () => {
388
+ if (!pendingSettled) {
389
+ pendingSettled = true;
390
+ resolve();
391
+ }
392
+ };
393
+ rejectPending = (err) => {
394
+ if (!pendingSettled) {
395
+ pendingSettled = true;
396
+ reject(err);
397
+ }
398
+ };
399
+ });
400
+ let resolveCompletion;
401
+ let rejectCompletion;
402
+ const completionPromise = new Promise((resolve, reject) => {
403
+ resolveCompletion = resolve;
404
+ rejectCompletion = reject;
405
+ });
406
+ let jobStartedDispatched = false;
407
+ wrapper.onProgress((progress, promptId) => {
408
+ if (!job.promptId && promptId) {
409
+ job.promptId = promptId;
410
+ }
411
+ // Dispatch job:started on first progress update with promptId
412
+ if (!jobStartedDispatched && job.promptId) {
413
+ jobStartedDispatched = true;
414
+ this.dispatchEvent(new CustomEvent("job:started", { detail: { job } }));
415
+ }
416
+ // Feed progress to profiler
417
+ if (profiler) {
418
+ profiler.onProgress(progress);
419
+ }
420
+ this.dispatchEvent(new CustomEvent("job:progress", {
421
+ detail: { jobId: job.jobId, clientId, progress }
422
+ }));
423
+ });
424
+ wrapper.onPreview((blob, promptId) => {
425
+ if (!job.promptId && promptId) {
426
+ job.promptId = promptId;
427
+ }
428
+ // Dispatch job:started on first preview with promptId
429
+ if (!jobStartedDispatched && job.promptId) {
430
+ jobStartedDispatched = true;
431
+ this.dispatchEvent(new CustomEvent("job:started", { detail: { job } }));
432
+ }
433
+ this.dispatchEvent(new CustomEvent("job:preview", {
434
+ detail: { jobId: job.jobId, clientId, blob }
435
+ }));
436
+ });
437
+ wrapper.onPreviewMeta((payload, promptId) => {
438
+ if (!job.promptId && promptId) {
439
+ job.promptId = promptId;
440
+ }
441
+ // Dispatch job:started on first preview_meta with promptId
442
+ if (!jobStartedDispatched && job.promptId) {
443
+ jobStartedDispatched = true;
444
+ this.dispatchEvent(new CustomEvent("job:started", { detail: { job } }));
445
+ }
446
+ this.dispatchEvent(new CustomEvent("job:preview_meta", {
447
+ detail: { jobId: job.jobId, clientId, payload }
448
+ }));
449
+ });
450
+ wrapper.onOutput((key, data, promptId) => {
451
+ if (!job.promptId && promptId) {
452
+ job.promptId = promptId;
453
+ }
454
+ this.dispatchEvent(new CustomEvent("job:output", {
455
+ detail: { jobId: job.jobId, clientId, key: String(key), data }
456
+ }));
457
+ });
458
+ wrapper.onPending((promptId) => {
459
+ if (!job.promptId && promptId) {
460
+ job.promptId = promptId;
461
+ }
462
+ // Don't dispatch job:started here - wait for first progress/preview with promptId
463
+ this.dispatchEvent(new CustomEvent("job:accepted", { detail: { job } }));
464
+ resolvePending?.();
465
+ });
466
+ wrapper.onStart((promptId) => {
467
+ if (!job.promptId && promptId) {
468
+ job.promptId = promptId;
469
+ }
470
+ });
471
+ wrapper.onFinished((data, promptId) => {
472
+ if (!job.promptId && promptId) {
473
+ job.promptId = promptId;
474
+ }
475
+ job.status = "completed";
476
+ job.lastError = undefined;
477
+ const resultPayload = {};
478
+ for (const nodeId of outputNodeIds) {
479
+ const alias = outputAliases[nodeId] ?? nodeId;
480
+ // CallWrapper uses alias keys when mapOutputKeys is configured, fallback to nodeId
481
+ const nodeResult = data[alias];
482
+ const fallbackResult = data[nodeId];
483
+ const finalResult = nodeResult !== undefined ? nodeResult : fallbackResult;
484
+ resultPayload[alias] = finalResult;
485
+ }
486
+ resultPayload._nodes = [...outputNodeIds];
487
+ resultPayload._aliases = { ...outputAliases };
488
+ if (job.promptId) {
489
+ resultPayload._promptId = job.promptId;
490
+ }
491
+ if (Object.keys(autoSeeds).length) {
492
+ resultPayload._autoSeeds = { ...autoSeeds };
493
+ }
494
+ job.result = resultPayload;
495
+ job.completedAt = Date.now();
496
+ // Cleanup timeouts
497
+ cleanupNodeTimeout();
498
+ // Attach profiling stats if profiling was enabled
499
+ if (profiler) {
500
+ job.profileStats = profiler.getStats();
501
+ }
502
+ this.dispatchEvent(new CustomEvent("job:completed", { detail: { job } }));
503
+ resolveCompletion?.();
504
+ });
505
+ wrapper.onFailed((error, promptId) => {
506
+ if (!job.promptId && promptId) {
507
+ job.promptId = promptId;
508
+ }
509
+ job.lastError = error;
510
+ // Cleanup timeouts
511
+ cleanupNodeTimeout();
512
+ rejectPending?.(error);
513
+ rejectCompletion?.(error);
514
+ });
515
+ try {
516
+ const exec = wrapper.run();
517
+ // Add timeout for execution start to prevent jobs getting stuck
518
+ const executionStartTimeout = this.opts.executionStartTimeoutMs ?? 5000;
519
+ let pendingTimeoutId;
520
+ if (executionStartTimeout > 0) {
521
+ const pendingWithTimeout = Promise.race([
522
+ pendingPromise,
523
+ new Promise((_, reject) => {
524
+ pendingTimeoutId = setTimeout(() => {
525
+ reject(new Error(`Execution failed to start within ${executionStartTimeout}ms. ` +
526
+ `Server may be stuck or unresponsive.`));
527
+ }, executionStartTimeout);
528
+ })
529
+ ]);
530
+ await pendingWithTimeout;
531
+ clearTimeout(pendingTimeoutId);
532
+ }
533
+ else {
534
+ await pendingPromise;
535
+ }
536
+ this.activeJobs.set(job.jobId, {
537
+ reservation,
538
+ job,
539
+ clientId,
540
+ release,
541
+ cancel: async () => {
542
+ try {
543
+ if (job.promptId) {
544
+ await client.ext.queue.interrupt(job.promptId);
545
+ }
546
+ }
547
+ finally {
548
+ this.activeJobs.delete(job.jobId);
549
+ await this.queue.discard(reservation.reservationId, new Error("cancelled"));
550
+ release({ success: false });
551
+ }
552
+ }
553
+ });
554
+ const result = await exec;
555
+ if (result === false) {
556
+ // Execution failed - try to get the error from completionPromise rejection
557
+ try {
558
+ await completionPromise;
559
+ }
560
+ catch (err) {
561
+ throw err;
562
+ }
563
+ throw job.lastError ?? new Error("Execution failed");
564
+ }
565
+ await completionPromise;
566
+ await this.queue.commit(reservation.reservationId);
567
+ release({ success: true });
568
+ }
569
+ catch (error) {
570
+ const latestStatus = this.jobStore.get(job.jobId)?.status;
571
+ if (latestStatus === "cancelled") {
572
+ release({ success: false });
573
+ return;
574
+ }
575
+ job.lastError = error;
576
+ job.status = "failed";
577
+ this.clientManager.recordFailure(clientId, job, error);
578
+ const remainingAttempts = job.options.maxAttempts - job.attempts;
579
+ const willRetry = remainingAttempts > 0;
580
+ this.dispatchEvent(new CustomEvent("job:failed", {
581
+ detail: { job, willRetry }
582
+ }));
583
+ if (willRetry) {
584
+ const delay = this.opts.retryBackoffMs ?? job.options.retryDelayMs;
585
+ this.dispatchEvent(new CustomEvent("job:retrying", { detail: { job, delayMs: delay } }));
586
+ job.status = "queued";
587
+ job.clientId = undefined;
588
+ job.promptId = undefined;
589
+ job.startedAt = undefined;
590
+ job.completedAt = undefined;
591
+ job.result = undefined;
592
+ await this.queue.retry(reservation.reservationId, { delayMs: delay });
593
+ this.dispatchEvent(new CustomEvent("job:queued", { detail: { job } }));
594
+ this.scheduleProcess(delay);
595
+ release({ success: false });
596
+ }
597
+ else {
598
+ job.completedAt = Date.now();
599
+ await this.queue.discard(reservation.reservationId, error);
600
+ release({ success: false });
601
+ }
602
+ }
603
+ finally {
604
+ this.activeJobs.delete(job.jobId);
605
+ void this.processQueue();
606
+ }
607
+ }
608
+ }
456
609
  //# sourceMappingURL=WorkflowPool.js.map