comfyui-node 1.6.1 → 1.6.3
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/dist/.tsbuildinfo +1 -1
- package/dist/call-wrapper.js +856 -856
- package/dist/index.d.ts +7 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/index.js.map +1 -1
- package/dist/multipool/client-registry.d.ts +32 -11
- package/dist/multipool/client-registry.d.ts.map +1 -1
- package/dist/multipool/client-registry.js +135 -8
- package/dist/multipool/client-registry.js.map +1 -1
- package/dist/multipool/helpers.d.ts +5 -0
- package/dist/multipool/helpers.d.ts.map +1 -0
- package/dist/multipool/helpers.js +53 -0
- package/dist/multipool/helpers.js.map +1 -0
- package/dist/multipool/index.d.ts +2 -1
- package/dist/multipool/index.d.ts.map +1 -1
- package/dist/multipool/index.js +2 -1
- package/dist/multipool/index.js.map +1 -1
- package/dist/multipool/interfaces.d.ts +25 -0
- package/dist/multipool/interfaces.d.ts.map +1 -1
- package/dist/multipool/job-profiler.d.ts +128 -0
- package/dist/multipool/job-profiler.d.ts.map +1 -0
- package/dist/multipool/job-profiler.js +222 -0
- package/dist/multipool/job-profiler.js.map +1 -0
- package/dist/multipool/job-queue-processor.d.ts +27 -11
- package/dist/multipool/job-queue-processor.d.ts.map +1 -1
- package/dist/multipool/job-queue-processor.js +196 -9
- package/dist/multipool/job-queue-processor.js.map +1 -1
- package/dist/multipool/job-state-registry.d.ts +67 -0
- package/dist/multipool/job-state-registry.d.ts.map +1 -0
- package/dist/multipool/job-state-registry.js +283 -0
- package/dist/multipool/job-state-registry.js.map +1 -0
- package/dist/multipool/logger.d.ts +30 -0
- package/dist/multipool/logger.d.ts.map +1 -0
- package/dist/multipool/logger.js +75 -0
- package/dist/multipool/logger.js.map +1 -0
- package/dist/multipool/multi-workflow-pool.d.ts +86 -7
- package/dist/multipool/multi-workflow-pool.d.ts.map +1 -1
- package/dist/multipool/multi-workflow-pool.js +365 -13
- package/dist/multipool/multi-workflow-pool.js.map +1 -1
- package/dist/multipool/pool-event-manager.d.ts +10 -10
- package/dist/multipool/tests/client-registry-api-demo.d.ts +7 -0
- package/dist/multipool/tests/client-registry-api-demo.d.ts.map +1 -0
- package/dist/multipool/tests/client-registry-api-demo.js +136 -0
- package/dist/multipool/tests/client-registry-api-demo.js.map +1 -0
- package/dist/multipool/tests/client-registry.spec.d.ts +2 -0
- package/dist/multipool/tests/client-registry.spec.d.ts.map +1 -0
- package/dist/multipool/tests/client-registry.spec.js +191 -0
- package/dist/multipool/tests/client-registry.spec.js.map +1 -0
- package/dist/multipool/tests/error-classification-tests.d.ts +2 -0
- package/dist/multipool/tests/error-classification-tests.d.ts.map +1 -0
- package/dist/multipool/tests/error-classification-tests.js +374 -0
- package/dist/multipool/tests/error-classification-tests.js.map +1 -0
- package/dist/multipool/tests/event-forwarding-demo.d.ts +7 -0
- package/dist/multipool/tests/event-forwarding-demo.d.ts.map +1 -0
- package/dist/multipool/tests/event-forwarding-demo.js +88 -0
- package/dist/multipool/tests/event-forwarding-demo.js.map +1 -0
- package/dist/multipool/tests/helpers.spec.d.ts +2 -0
- package/dist/multipool/tests/helpers.spec.d.ts.map +1 -0
- package/dist/multipool/tests/helpers.spec.js +100 -0
- package/dist/multipool/tests/helpers.spec.js.map +1 -0
- package/dist/multipool/tests/job-queue-processor.spec.d.ts +2 -0
- package/dist/multipool/tests/job-queue-processor.spec.d.ts.map +1 -0
- package/dist/multipool/tests/job-queue-processor.spec.js +89 -0
- package/dist/multipool/tests/job-queue-processor.spec.js.map +1 -0
- package/dist/multipool/tests/job-state-registry.d.ts +16 -16
- package/dist/multipool/tests/job-state-registry.js +23 -23
- package/dist/multipool/tests/job-state-registry.spec.d.ts +2 -0
- package/dist/multipool/tests/job-state-registry.spec.d.ts.map +1 -0
- package/dist/multipool/tests/job-state-registry.spec.js +143 -0
- package/dist/multipool/tests/job-state-registry.spec.js.map +1 -0
- package/dist/multipool/tests/multipool-basic.d.ts +11 -1
- package/dist/multipool/tests/multipool-basic.d.ts.map +1 -1
- package/dist/multipool/tests/multipool-basic.js +140 -2
- package/dist/multipool/tests/multipool-basic.js.map +1 -1
- package/dist/multipool/tests/profiling-demo.d.ts +7 -0
- package/dist/multipool/tests/profiling-demo.d.ts.map +1 -0
- package/dist/multipool/tests/profiling-demo.js +88 -0
- package/dist/multipool/tests/profiling-demo.js.map +1 -0
- package/dist/multipool/tests/prompt-generator.d.ts +10 -0
- package/dist/multipool/tests/prompt-generator.d.ts.map +1 -0
- package/dist/multipool/tests/prompt-generator.js +26 -0
- package/dist/multipool/tests/prompt-generator.js.map +1 -0
- package/dist/multipool/tests/test-helpers.d.ts +4 -0
- package/dist/multipool/tests/test-helpers.d.ts.map +1 -0
- package/dist/multipool/tests/test-helpers.js +10 -0
- package/dist/multipool/tests/test-helpers.js.map +1 -0
- package/dist/multipool/tests/two-stage-edit-simulation.d.ts +32 -0
- package/dist/multipool/tests/two-stage-edit-simulation.d.ts.map +1 -0
- package/dist/multipool/tests/two-stage-edit-simulation.js +299 -0
- package/dist/multipool/tests/two-stage-edit-simulation.js.map +1 -0
- package/dist/multipool/workflow.d.ts +178 -173
- package/dist/multipool/workflow.d.ts.map +1 -1
- package/dist/multipool/workflow.js +333 -271
- package/dist/multipool/workflow.js.map +1 -1
- package/dist/pool/SmartPool.d.ts +143 -143
- package/dist/pool/SmartPool.js +676 -676
- package/dist/pool/SmartPoolV2.d.ts +119 -119
- package/dist/pool/SmartPoolV2.js +586 -586
- package/dist/pool/WorkflowPool.d.ts +202 -202
- package/dist/pool/WorkflowPool.d.ts.map +1 -1
- package/dist/pool/WorkflowPool.js +845 -840
- package/dist/pool/WorkflowPool.js.map +1 -1
- package/dist/pool/client/ClientManager.d.ts +86 -86
- package/dist/pool/client/ClientManager.js +215 -215
- package/dist/pool/index.d.ts +9 -11
- package/dist/pool/index.d.ts.map +1 -1
- package/dist/pool/index.js +3 -5
- package/dist/pool/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,841 +1,846 @@
|
|
|
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
|
-
import { analyzeWorkflowFailure } from "./utils/failure-analysis.js";
|
|
13
|
-
import { WorkflowNotSupportedError } from "../types/error.js";
|
|
14
|
-
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
15
|
-
const DEFAULT_RETRY_DELAY = 1000;
|
|
16
|
-
export class WorkflowPool extends TypedEventTarget {
|
|
17
|
-
queue;
|
|
18
|
-
strategy;
|
|
19
|
-
clientManager;
|
|
20
|
-
opts;
|
|
21
|
-
jobStore = new Map();
|
|
22
|
-
jobFailureAnalysis = new Map();
|
|
23
|
-
affinities = new Map();
|
|
24
|
-
initPromise;
|
|
25
|
-
processing = false;
|
|
26
|
-
processQueued = false;
|
|
27
|
-
activeJobs = new Map();
|
|
28
|
-
queueDebug = process.env.WORKFLOW_POOL_DEBUG === "1";
|
|
29
|
-
debugLog(...args) {
|
|
30
|
-
if (this.queueDebug) {
|
|
31
|
-
console.log(...args);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
constructor(clients, opts) {
|
|
35
|
-
super();
|
|
36
|
-
this.strategy = opts?.failoverStrategy ?? new SmartFailoverStrategy();
|
|
37
|
-
this.queue = opts?.queueAdapter ?? new MemoryQueueAdapter();
|
|
38
|
-
this.clientManager = new ClientManager(this.strategy, {
|
|
39
|
-
healthCheckIntervalMs: opts?.healthCheckIntervalMs ?? 30000
|
|
40
|
-
});
|
|
41
|
-
this.opts = opts ?? {};
|
|
42
|
-
if (opts?.workflowAffinities) {
|
|
43
|
-
for (const affinity of opts.workflowAffinities) {
|
|
44
|
-
this.affinities.set(affinity.workflowHash, affinity);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
this.clientManager.on("client:state", (ev) => {
|
|
48
|
-
this.dispatchEvent(new CustomEvent("client:state", { detail: ev.detail }));
|
|
49
|
-
});
|
|
50
|
-
this.clientManager.on("client:blocked_workflow", (ev) => {
|
|
51
|
-
this.dispatchEvent(new CustomEvent("client:blocked_workflow", { detail: ev.detail }));
|
|
52
|
-
});
|
|
53
|
-
this.clientManager.on("client:unblocked_workflow", (ev) => {
|
|
54
|
-
this.dispatchEvent(new CustomEvent("client:unblocked_workflow", { detail: ev.detail }));
|
|
55
|
-
});
|
|
56
|
-
this.initPromise = this.clientManager
|
|
57
|
-
.initialize(clients)
|
|
58
|
-
.then(() => {
|
|
59
|
-
this.dispatchEvent(new CustomEvent("pool:ready", {
|
|
60
|
-
detail: { clientIds: this.clientManager.list().map((c) => c.id) }
|
|
61
|
-
}));
|
|
62
|
-
})
|
|
63
|
-
.catch((error) => {
|
|
64
|
-
this.dispatchEvent(new CustomEvent("pool:error", { detail: { error } }));
|
|
65
|
-
});
|
|
66
|
-
}
|
|
67
|
-
async ready() {
|
|
68
|
-
await this.initPromise;
|
|
69
|
-
}
|
|
70
|
-
setAffinity(affinity) {
|
|
71
|
-
this.affinities.set(affinity.workflowHash, affinity);
|
|
72
|
-
}
|
|
73
|
-
removeAffinity(workflowHash) {
|
|
74
|
-
return this.affinities.delete(workflowHash);
|
|
75
|
-
}
|
|
76
|
-
getAffinities() {
|
|
77
|
-
return Array.from(this.affinities.values());
|
|
78
|
-
}
|
|
79
|
-
async enqueue(workflowInput, options) {
|
|
80
|
-
await this.ready();
|
|
81
|
-
const workflowJson = this.normalizeWorkflow(workflowInput);
|
|
82
|
-
// Use the workflow's pre-computed structureHash if available (from Workflow instance)
|
|
83
|
-
// Otherwise compute it from the JSON
|
|
84
|
-
let workflowHash;
|
|
85
|
-
if (workflowInput instanceof Workflow) {
|
|
86
|
-
workflowHash = workflowInput.structureHash ?? hashWorkflow(workflowJson);
|
|
87
|
-
}
|
|
88
|
-
else {
|
|
89
|
-
workflowHash = hashWorkflow(workflowJson);
|
|
90
|
-
}
|
|
91
|
-
const jobId = options?.jobId ?? this.generateJobId();
|
|
92
|
-
// Extract workflow metadata (outputAliases, outputNodeIds, etc.) if input is a Workflow instance
|
|
93
|
-
let workflowMeta;
|
|
94
|
-
if (workflowInput instanceof Workflow) {
|
|
95
|
-
workflowMeta = {
|
|
96
|
-
outputNodeIds: workflowInput.outputNodeIds ?? [],
|
|
97
|
-
outputAliases: workflowInput.outputAliases ?? {}
|
|
98
|
-
};
|
|
99
|
-
}
|
|
100
|
-
const affinity = this.affinities.get(workflowHash);
|
|
101
|
-
const preferredClientIds = options?.preferredClientIds
|
|
102
|
-
? [...options.preferredClientIds]
|
|
103
|
-
: (affinity?.preferredClientIds ? [...affinity.preferredClientIds] : []);
|
|
104
|
-
const excludeClientIds = options?.excludeClientIds
|
|
105
|
-
? [...options.excludeClientIds]
|
|
106
|
-
: (affinity?.excludeClientIds ? [...affinity.excludeClientIds] : []);
|
|
107
|
-
const payload = {
|
|
108
|
-
jobId,
|
|
109
|
-
workflow: workflowJson,
|
|
110
|
-
workflowHash,
|
|
111
|
-
attempts: 0,
|
|
112
|
-
enqueuedAt: Date.now(),
|
|
113
|
-
workflowMeta,
|
|
114
|
-
options: {
|
|
115
|
-
maxAttempts: options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
116
|
-
retryDelayMs: options?.retryDelayMs ?? DEFAULT_RETRY_DELAY,
|
|
117
|
-
priority: options?.priority ?? 0,
|
|
118
|
-
preferredClientIds: preferredClientIds,
|
|
119
|
-
excludeClientIds: excludeClientIds,
|
|
120
|
-
metadata: options?.metadata ?? {},
|
|
121
|
-
includeOutputs: options?.includeOutputs ?? []
|
|
122
|
-
}
|
|
123
|
-
};
|
|
124
|
-
const record = {
|
|
125
|
-
...payload,
|
|
126
|
-
options: {
|
|
127
|
-
...payload.options,
|
|
128
|
-
preferredClientIds: payload.options.preferredClientIds ? [...payload.options.preferredClientIds] : [],
|
|
129
|
-
excludeClientIds: payload.options.excludeClientIds ? [...payload.options.excludeClientIds] : [],
|
|
130
|
-
includeOutputs: payload.options.includeOutputs ? [...payload.options.includeOutputs] : []
|
|
131
|
-
},
|
|
132
|
-
attachments: options?.attachments,
|
|
133
|
-
status: "queued"
|
|
134
|
-
};
|
|
135
|
-
this.jobStore.set(jobId, record);
|
|
136
|
-
await this.queue.enqueue(payload, { priority: payload.options.priority });
|
|
137
|
-
this.dispatchEvent(new CustomEvent("job:queued", { detail: { job: record } }));
|
|
138
|
-
void this.processQueue();
|
|
139
|
-
return jobId;
|
|
140
|
-
}
|
|
141
|
-
getJob(jobId) {
|
|
142
|
-
return this.jobStore.get(jobId);
|
|
143
|
-
}
|
|
144
|
-
async cancel(jobId) {
|
|
145
|
-
const record = this.jobStore.get(jobId);
|
|
146
|
-
if (!record) {
|
|
147
|
-
return false;
|
|
148
|
-
}
|
|
149
|
-
if (record.status === "queued") {
|
|
150
|
-
const removed = await this.queue.remove(jobId);
|
|
151
|
-
if (removed) {
|
|
152
|
-
record.status = "cancelled";
|
|
153
|
-
record.completedAt = Date.now();
|
|
154
|
-
this.clearJobFailures(jobId);
|
|
155
|
-
this.dispatchEvent(new CustomEvent("job:cancelled", { detail: { job: record } }));
|
|
156
|
-
return true;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
const active = this.activeJobs.get(jobId);
|
|
160
|
-
if (active?.cancel) {
|
|
161
|
-
await active.cancel();
|
|
162
|
-
record.status = "cancelled";
|
|
163
|
-
record.completedAt = Date.now();
|
|
164
|
-
this.clearJobFailures(jobId);
|
|
165
|
-
this.dispatchEvent(new CustomEvent("job:cancelled", { detail: { job: record } }));
|
|
166
|
-
return true;
|
|
167
|
-
}
|
|
168
|
-
return false;
|
|
169
|
-
}
|
|
170
|
-
async shutdown() {
|
|
171
|
-
this.clientManager.destroy();
|
|
172
|
-
await this.queue.shutdown();
|
|
173
|
-
for (const [, ctx] of Array.from(this.activeJobs)) {
|
|
174
|
-
ctx.release({ success: false });
|
|
175
|
-
}
|
|
176
|
-
this.activeJobs.clear();
|
|
177
|
-
}
|
|
178
|
-
async getQueueStats() {
|
|
179
|
-
return this.queue.stats();
|
|
180
|
-
}
|
|
181
|
-
normalizeWorkflow(input) {
|
|
182
|
-
if (typeof input === "string") {
|
|
183
|
-
return JSON.parse(input);
|
|
184
|
-
}
|
|
185
|
-
if (input instanceof Workflow) {
|
|
186
|
-
return cloneDeep(input.json ?? {});
|
|
187
|
-
}
|
|
188
|
-
if (typeof input?.toJSON === "function") {
|
|
189
|
-
return cloneDeep(input.toJSON());
|
|
190
|
-
}
|
|
191
|
-
return cloneDeep(input);
|
|
192
|
-
}
|
|
193
|
-
generateJobId() {
|
|
194
|
-
try {
|
|
195
|
-
return randomUUID();
|
|
196
|
-
}
|
|
197
|
-
catch {
|
|
198
|
-
return WorkflowPool.fallbackId();
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
static fallbackId() {
|
|
202
|
-
return globalThis.crypto && "randomUUID" in globalThis.crypto
|
|
203
|
-
? globalThis.crypto.randomUUID()
|
|
204
|
-
: `job_${Math.random().toString(36).slice(2, 10)}`;
|
|
205
|
-
}
|
|
206
|
-
scheduleProcess(delayMs) {
|
|
207
|
-
const wait = Math.max(delayMs, 10);
|
|
208
|
-
setTimeout(() => {
|
|
209
|
-
void this.processQueue();
|
|
210
|
-
}, wait);
|
|
211
|
-
}
|
|
212
|
-
applyAutoSeed(workflow) {
|
|
213
|
-
const autoSeeds = {};
|
|
214
|
-
for (const [nodeId, nodeValue] of Object.entries(workflow)) {
|
|
215
|
-
if (!nodeValue || typeof nodeValue !== "object")
|
|
216
|
-
continue;
|
|
217
|
-
const inputs = nodeValue.inputs;
|
|
218
|
-
if (!inputs || typeof inputs !== "object")
|
|
219
|
-
continue;
|
|
220
|
-
if (typeof inputs.seed === "number" && inputs.seed === -1) {
|
|
221
|
-
const val = Math.floor(Math.random() * 2_147_483_647);
|
|
222
|
-
inputs.seed = val;
|
|
223
|
-
autoSeeds[nodeId] = val;
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return autoSeeds;
|
|
227
|
-
}
|
|
228
|
-
rememberJobFailure(job, clientId, analysis) {
|
|
229
|
-
let map = this.jobFailureAnalysis.get(job.jobId);
|
|
230
|
-
if (!map) {
|
|
231
|
-
map = new Map();
|
|
232
|
-
this.jobFailureAnalysis.set(job.jobId, map);
|
|
233
|
-
}
|
|
234
|
-
map.set(clientId, analysis);
|
|
235
|
-
}
|
|
236
|
-
clearJobFailures(jobId) {
|
|
237
|
-
this.jobFailureAnalysis.delete(jobId);
|
|
238
|
-
}
|
|
239
|
-
collectFailureReasons(jobId) {
|
|
240
|
-
const map = this.jobFailureAnalysis.get(jobId);
|
|
241
|
-
if (!map) {
|
|
242
|
-
return {};
|
|
243
|
-
}
|
|
244
|
-
const reasons = {};
|
|
245
|
-
for (const [clientId, analysis] of map.entries()) {
|
|
246
|
-
reasons[clientId] = analysis.reason;
|
|
247
|
-
}
|
|
248
|
-
return reasons;
|
|
249
|
-
}
|
|
250
|
-
addPermanentExclusion(job, clientId) {
|
|
251
|
-
if (!job.options.excludeClientIds) {
|
|
252
|
-
job.options.excludeClientIds = [];
|
|
253
|
-
}
|
|
254
|
-
if (!job.options.excludeClientIds.includes(clientId)) {
|
|
255
|
-
job.options.excludeClientIds.push(clientId);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
hasRetryPath(job) {
|
|
259
|
-
const map = this.jobFailureAnalysis.get(job.jobId);
|
|
260
|
-
const exclude = new Set(job.options.excludeClientIds ?? []);
|
|
261
|
-
const preferred = job.options.preferredClientIds?.length ? new Set(job.options.preferredClientIds) : null;
|
|
262
|
-
for (const client of this.clientManager.list()) {
|
|
263
|
-
if (preferred && !preferred.has(client.id)) {
|
|
264
|
-
continue;
|
|
265
|
-
}
|
|
266
|
-
if (exclude.has(client.id)) {
|
|
267
|
-
continue;
|
|
268
|
-
}
|
|
269
|
-
const analysis = map?.get(client.id);
|
|
270
|
-
if (analysis?.blockClient === "permanent") {
|
|
271
|
-
continue;
|
|
272
|
-
}
|
|
273
|
-
return true;
|
|
274
|
-
}
|
|
275
|
-
return false;
|
|
276
|
-
}
|
|
277
|
-
createWorkflowNotSupportedError(job, cause) {
|
|
278
|
-
const reasons = this.collectFailureReasons(job.jobId);
|
|
279
|
-
const message = `Workflow ${job.workflowHash} is not supported by any connected clients`;
|
|
280
|
-
return new WorkflowNotSupportedError(message, {
|
|
281
|
-
workflowHash: job.workflowHash,
|
|
282
|
-
reasons,
|
|
283
|
-
cause
|
|
284
|
-
});
|
|
285
|
-
}
|
|
286
|
-
async processQueue() {
|
|
287
|
-
this.debugLog("[processQueue] Called");
|
|
288
|
-
if (this.processing) {
|
|
289
|
-
this.debugLog("[processQueue] Already processing, returning early");
|
|
290
|
-
this.processQueued = true;
|
|
291
|
-
return;
|
|
292
|
-
}
|
|
293
|
-
this.processing = true;
|
|
294
|
-
try {
|
|
295
|
-
// Continue processing until no more jobs can be assigned
|
|
296
|
-
let iteration = 0;
|
|
297
|
-
while (true) {
|
|
298
|
-
iteration++;
|
|
299
|
-
this.debugLog(`[processQueue] Iteration ${iteration}`);
|
|
300
|
-
const idleClients = this.clientManager.list().filter(c => this.clientManager.isClientStable(c));
|
|
301
|
-
this.debugLog(`[processQueue] Idle clients: [${idleClients.map(c => c.id).join(", ")}] (${idleClients.length})`);
|
|
302
|
-
if (!idleClients.length) {
|
|
303
|
-
this.debugLog("[processQueue] No idle clients, breaking");
|
|
304
|
-
break; // No idle clients available
|
|
305
|
-
}
|
|
306
|
-
const waitingJobs = await this.queue.peek(100); // Peek at top 100 jobs
|
|
307
|
-
this.debugLog(`[processQueue] Waiting jobs in queue: ${waitingJobs.length}`);
|
|
308
|
-
if (!waitingJobs.length) {
|
|
309
|
-
this.debugLog("[processQueue] No waiting jobs, breaking");
|
|
310
|
-
break; // No jobs in queue
|
|
311
|
-
}
|
|
312
|
-
const leasedClientIds = new Set();
|
|
313
|
-
const reservedJobIds = new Set();
|
|
314
|
-
const jobMatchInfos = [];
|
|
315
|
-
for (const jobPayload of waitingJobs) {
|
|
316
|
-
const job = this.jobStore.get(jobPayload.jobId);
|
|
317
|
-
if (!job) {
|
|
318
|
-
this.debugLog(`[processQueue] Job ${jobPayload.jobId} not in jobStore, skipping`);
|
|
319
|
-
continue;
|
|
320
|
-
}
|
|
321
|
-
const compatibleClients = idleClients
|
|
322
|
-
.filter(client => {
|
|
323
|
-
const canRun = this.clientManager.canClientRunJob(client, job);
|
|
324
|
-
if (!canRun) {
|
|
325
|
-
this.debugLog(`[processQueue] Job ${job.jobId.substring(0, 8)}... NOT compatible with ${client.id}. Checking why...`);
|
|
326
|
-
this.debugLog(`[processQueue] - preferredClientIds: ${JSON.stringify(job.options.preferredClientIds)}`);
|
|
327
|
-
this.debugLog(`[processQueue] - excludeClientIds: ${JSON.stringify(job.options.excludeClientIds)}`);
|
|
328
|
-
this.debugLog(`[processQueue] - client.id: ${client.id}`);
|
|
329
|
-
}
|
|
330
|
-
return canRun;
|
|
331
|
-
})
|
|
332
|
-
.map(client => client.id);
|
|
333
|
-
this.debugLog(`[processQueue] Job ${job.jobId.substring(0, 8)}... compatible with: [${compatibleClients.join(", ")}] (selectivity=${compatibleClients.length})`);
|
|
334
|
-
if (compatibleClients.length > 0) {
|
|
335
|
-
jobMatchInfos.push({
|
|
336
|
-
jobPayload,
|
|
337
|
-
job,
|
|
338
|
-
compatibleClients,
|
|
339
|
-
selectivity: compatibleClients.length
|
|
340
|
-
});
|
|
341
|
-
}
|
|
342
|
-
}
|
|
343
|
-
this.debugLog(`[processQueue] Found ${jobMatchInfos.length} compatible job matches`);
|
|
344
|
-
if (jobMatchInfos.length === 0) {
|
|
345
|
-
this.debugLog("[processQueue] No compatible jobs for idle clients, breaking");
|
|
346
|
-
break; // No compatible jobs for idle clients
|
|
347
|
-
}
|
|
348
|
-
// Sort jobs by priority first, then selectivity, to maximize throughput
|
|
349
|
-
// 1. Higher priority jobs execute first (explicit user priority)
|
|
350
|
-
// 2. More selective jobs (fewer compatible clients) assigned first within same priority
|
|
351
|
-
// 3. Earlier queue position as final tiebreaker
|
|
352
|
-
jobMatchInfos.sort((a, b) => {
|
|
353
|
-
// Primary: priority (higher priority = higher precedence)
|
|
354
|
-
const aPriority = a.job.options.priority ?? 0;
|
|
355
|
-
const bPriority = b.job.options.priority ?? 0;
|
|
356
|
-
if (aPriority !== bPriority) {
|
|
357
|
-
return bPriority - aPriority; // Higher priority first
|
|
358
|
-
}
|
|
359
|
-
// Secondary: selectivity (fewer compatible clients = higher precedence)
|
|
360
|
-
if (a.selectivity !== b.selectivity) {
|
|
361
|
-
return a.selectivity - b.selectivity;
|
|
362
|
-
}
|
|
363
|
-
// Tertiary: maintain queue order (earlier jobs first)
|
|
364
|
-
const aIndex = waitingJobs.indexOf(a.jobPayload);
|
|
365
|
-
const bIndex = waitingJobs.indexOf(b.jobPayload);
|
|
366
|
-
return aIndex - bIndex;
|
|
367
|
-
});
|
|
368
|
-
// Assign jobs to clients using the selectivity-based ordering
|
|
369
|
-
let assignedAnyJob = false;
|
|
370
|
-
for (const matchInfo of jobMatchInfos) {
|
|
371
|
-
if (reservedJobIds.has(matchInfo.job.jobId))
|
|
372
|
-
continue;
|
|
373
|
-
// Find first available compatible client
|
|
374
|
-
const availableClient = matchInfo.compatibleClients.find(clientId => !leasedClientIds.has(clientId));
|
|
375
|
-
if (!availableClient) {
|
|
376
|
-
this.debugLog(`[processQueue] No available client for job ${matchInfo.job.jobId.substring(0, 8)}...`);
|
|
377
|
-
continue; // No available clients for this job
|
|
378
|
-
}
|
|
379
|
-
this.debugLog(`[processQueue] Reserving job ${matchInfo.job.jobId.substring(0, 8)}... for client ${availableClient}`);
|
|
380
|
-
const reservation = await this.queue.reserveById(matchInfo.job.jobId);
|
|
381
|
-
if (reservation) {
|
|
382
|
-
// Mark as leased/reserved for this cycle
|
|
383
|
-
leasedClientIds.add(availableClient);
|
|
384
|
-
reservedJobIds.add(matchInfo.job.jobId);
|
|
385
|
-
assignedAnyJob = true;
|
|
386
|
-
// Get the lease (which marks the client as busy)
|
|
387
|
-
const lease = this.clientManager.claim(matchInfo.job, availableClient);
|
|
388
|
-
if (lease) {
|
|
389
|
-
this.debugLog(`[processQueue] Starting job ${matchInfo.job.jobId.substring(0, 8)}... on client ${availableClient}`);
|
|
390
|
-
this.runJob({
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
nodeTimeoutId =
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
//
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
}
|
|
627
|
-
//
|
|
628
|
-
if (
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
job.
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
}
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
job.
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
job.
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
job.
|
|
672
|
-
}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
});
|
|
682
|
-
wrapper.
|
|
683
|
-
if (!job.promptId && promptId) {
|
|
684
|
-
job.promptId = promptId;
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
resultPayload
|
|
701
|
-
}
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
this.
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
}
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
job.status
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
this.
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
job
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
job.
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
this.
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
|
|
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
|
+
import { analyzeWorkflowFailure } from "./utils/failure-analysis.js";
|
|
13
|
+
import { WorkflowNotSupportedError } from "../types/error.js";
|
|
14
|
+
const DEFAULT_MAX_ATTEMPTS = 3;
|
|
15
|
+
const DEFAULT_RETRY_DELAY = 1000;
|
|
16
|
+
export class WorkflowPool extends TypedEventTarget {
|
|
17
|
+
queue;
|
|
18
|
+
strategy;
|
|
19
|
+
clientManager;
|
|
20
|
+
opts;
|
|
21
|
+
jobStore = new Map();
|
|
22
|
+
jobFailureAnalysis = new Map();
|
|
23
|
+
affinities = new Map();
|
|
24
|
+
initPromise;
|
|
25
|
+
processing = false;
|
|
26
|
+
processQueued = false;
|
|
27
|
+
activeJobs = new Map();
|
|
28
|
+
queueDebug = process.env.WORKFLOW_POOL_DEBUG === "1";
|
|
29
|
+
debugLog(...args) {
|
|
30
|
+
if (this.queueDebug) {
|
|
31
|
+
console.log(...args);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
constructor(clients, opts) {
|
|
35
|
+
super();
|
|
36
|
+
this.strategy = opts?.failoverStrategy ?? new SmartFailoverStrategy();
|
|
37
|
+
this.queue = opts?.queueAdapter ?? new MemoryQueueAdapter();
|
|
38
|
+
this.clientManager = new ClientManager(this.strategy, {
|
|
39
|
+
healthCheckIntervalMs: opts?.healthCheckIntervalMs ?? 30000
|
|
40
|
+
});
|
|
41
|
+
this.opts = opts ?? {};
|
|
42
|
+
if (opts?.workflowAffinities) {
|
|
43
|
+
for (const affinity of opts.workflowAffinities) {
|
|
44
|
+
this.affinities.set(affinity.workflowHash, affinity);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
this.clientManager.on("client:state", (ev) => {
|
|
48
|
+
this.dispatchEvent(new CustomEvent("client:state", { detail: ev.detail }));
|
|
49
|
+
});
|
|
50
|
+
this.clientManager.on("client:blocked_workflow", (ev) => {
|
|
51
|
+
this.dispatchEvent(new CustomEvent("client:blocked_workflow", { detail: ev.detail }));
|
|
52
|
+
});
|
|
53
|
+
this.clientManager.on("client:unblocked_workflow", (ev) => {
|
|
54
|
+
this.dispatchEvent(new CustomEvent("client:unblocked_workflow", { detail: ev.detail }));
|
|
55
|
+
});
|
|
56
|
+
this.initPromise = this.clientManager
|
|
57
|
+
.initialize(clients)
|
|
58
|
+
.then(() => {
|
|
59
|
+
this.dispatchEvent(new CustomEvent("pool:ready", {
|
|
60
|
+
detail: { clientIds: this.clientManager.list().map((c) => c.id) }
|
|
61
|
+
}));
|
|
62
|
+
})
|
|
63
|
+
.catch((error) => {
|
|
64
|
+
this.dispatchEvent(new CustomEvent("pool:error", { detail: { error } }));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
async ready() {
|
|
68
|
+
await this.initPromise;
|
|
69
|
+
}
|
|
70
|
+
setAffinity(affinity) {
|
|
71
|
+
this.affinities.set(affinity.workflowHash, affinity);
|
|
72
|
+
}
|
|
73
|
+
removeAffinity(workflowHash) {
|
|
74
|
+
return this.affinities.delete(workflowHash);
|
|
75
|
+
}
|
|
76
|
+
getAffinities() {
|
|
77
|
+
return Array.from(this.affinities.values());
|
|
78
|
+
}
|
|
79
|
+
async enqueue(workflowInput, options) {
|
|
80
|
+
await this.ready();
|
|
81
|
+
const workflowJson = this.normalizeWorkflow(workflowInput);
|
|
82
|
+
// Use the workflow's pre-computed structureHash if available (from Workflow instance)
|
|
83
|
+
// Otherwise compute it from the JSON
|
|
84
|
+
let workflowHash;
|
|
85
|
+
if (workflowInput instanceof Workflow) {
|
|
86
|
+
workflowHash = workflowInput.structureHash ?? hashWorkflow(workflowJson);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
workflowHash = hashWorkflow(workflowJson);
|
|
90
|
+
}
|
|
91
|
+
const jobId = options?.jobId ?? this.generateJobId();
|
|
92
|
+
// Extract workflow metadata (outputAliases, outputNodeIds, etc.) if input is a Workflow instance
|
|
93
|
+
let workflowMeta;
|
|
94
|
+
if (workflowInput instanceof Workflow) {
|
|
95
|
+
workflowMeta = {
|
|
96
|
+
outputNodeIds: workflowInput.outputNodeIds ?? [],
|
|
97
|
+
outputAliases: workflowInput.outputAliases ?? {}
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
const affinity = this.affinities.get(workflowHash);
|
|
101
|
+
const preferredClientIds = options?.preferredClientIds
|
|
102
|
+
? [...options.preferredClientIds]
|
|
103
|
+
: (affinity?.preferredClientIds ? [...affinity.preferredClientIds] : []);
|
|
104
|
+
const excludeClientIds = options?.excludeClientIds
|
|
105
|
+
? [...options.excludeClientIds]
|
|
106
|
+
: (affinity?.excludeClientIds ? [...affinity.excludeClientIds] : []);
|
|
107
|
+
const payload = {
|
|
108
|
+
jobId,
|
|
109
|
+
workflow: workflowJson,
|
|
110
|
+
workflowHash,
|
|
111
|
+
attempts: 0,
|
|
112
|
+
enqueuedAt: Date.now(),
|
|
113
|
+
workflowMeta,
|
|
114
|
+
options: {
|
|
115
|
+
maxAttempts: options?.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
|
|
116
|
+
retryDelayMs: options?.retryDelayMs ?? DEFAULT_RETRY_DELAY,
|
|
117
|
+
priority: options?.priority ?? 0,
|
|
118
|
+
preferredClientIds: preferredClientIds,
|
|
119
|
+
excludeClientIds: excludeClientIds,
|
|
120
|
+
metadata: options?.metadata ?? {},
|
|
121
|
+
includeOutputs: options?.includeOutputs ?? []
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
const record = {
|
|
125
|
+
...payload,
|
|
126
|
+
options: {
|
|
127
|
+
...payload.options,
|
|
128
|
+
preferredClientIds: payload.options.preferredClientIds ? [...payload.options.preferredClientIds] : [],
|
|
129
|
+
excludeClientIds: payload.options.excludeClientIds ? [...payload.options.excludeClientIds] : [],
|
|
130
|
+
includeOutputs: payload.options.includeOutputs ? [...payload.options.includeOutputs] : []
|
|
131
|
+
},
|
|
132
|
+
attachments: options?.attachments,
|
|
133
|
+
status: "queued"
|
|
134
|
+
};
|
|
135
|
+
this.jobStore.set(jobId, record);
|
|
136
|
+
await this.queue.enqueue(payload, { priority: payload.options.priority });
|
|
137
|
+
this.dispatchEvent(new CustomEvent("job:queued", { detail: { job: record } }));
|
|
138
|
+
void this.processQueue();
|
|
139
|
+
return jobId;
|
|
140
|
+
}
|
|
141
|
+
getJob(jobId) {
|
|
142
|
+
return this.jobStore.get(jobId);
|
|
143
|
+
}
|
|
144
|
+
async cancel(jobId) {
|
|
145
|
+
const record = this.jobStore.get(jobId);
|
|
146
|
+
if (!record) {
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
if (record.status === "queued") {
|
|
150
|
+
const removed = await this.queue.remove(jobId);
|
|
151
|
+
if (removed) {
|
|
152
|
+
record.status = "cancelled";
|
|
153
|
+
record.completedAt = Date.now();
|
|
154
|
+
this.clearJobFailures(jobId);
|
|
155
|
+
this.dispatchEvent(new CustomEvent("job:cancelled", { detail: { job: record } }));
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
const active = this.activeJobs.get(jobId);
|
|
160
|
+
if (active?.cancel) {
|
|
161
|
+
await active.cancel();
|
|
162
|
+
record.status = "cancelled";
|
|
163
|
+
record.completedAt = Date.now();
|
|
164
|
+
this.clearJobFailures(jobId);
|
|
165
|
+
this.dispatchEvent(new CustomEvent("job:cancelled", { detail: { job: record } }));
|
|
166
|
+
return true;
|
|
167
|
+
}
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
async shutdown() {
|
|
171
|
+
this.clientManager.destroy();
|
|
172
|
+
await this.queue.shutdown();
|
|
173
|
+
for (const [, ctx] of Array.from(this.activeJobs)) {
|
|
174
|
+
ctx.release({ success: false });
|
|
175
|
+
}
|
|
176
|
+
this.activeJobs.clear();
|
|
177
|
+
}
|
|
178
|
+
async getQueueStats() {
|
|
179
|
+
return this.queue.stats();
|
|
180
|
+
}
|
|
181
|
+
normalizeWorkflow(input) {
|
|
182
|
+
if (typeof input === "string") {
|
|
183
|
+
return JSON.parse(input);
|
|
184
|
+
}
|
|
185
|
+
if (input instanceof Workflow) {
|
|
186
|
+
return cloneDeep(input.json ?? {});
|
|
187
|
+
}
|
|
188
|
+
if (typeof input?.toJSON === "function") {
|
|
189
|
+
return cloneDeep(input.toJSON());
|
|
190
|
+
}
|
|
191
|
+
return cloneDeep(input);
|
|
192
|
+
}
|
|
193
|
+
generateJobId() {
|
|
194
|
+
try {
|
|
195
|
+
return randomUUID();
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return WorkflowPool.fallbackId();
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
static fallbackId() {
|
|
202
|
+
return globalThis.crypto && "randomUUID" in globalThis.crypto
|
|
203
|
+
? globalThis.crypto.randomUUID()
|
|
204
|
+
: `job_${Math.random().toString(36).slice(2, 10)}`;
|
|
205
|
+
}
|
|
206
|
+
scheduleProcess(delayMs) {
|
|
207
|
+
const wait = Math.max(delayMs, 10);
|
|
208
|
+
setTimeout(() => {
|
|
209
|
+
void this.processQueue();
|
|
210
|
+
}, wait);
|
|
211
|
+
}
|
|
212
|
+
applyAutoSeed(workflow) {
|
|
213
|
+
const autoSeeds = {};
|
|
214
|
+
for (const [nodeId, nodeValue] of Object.entries(workflow)) {
|
|
215
|
+
if (!nodeValue || typeof nodeValue !== "object")
|
|
216
|
+
continue;
|
|
217
|
+
const inputs = nodeValue.inputs;
|
|
218
|
+
if (!inputs || typeof inputs !== "object")
|
|
219
|
+
continue;
|
|
220
|
+
if (typeof inputs.seed === "number" && inputs.seed === -1) {
|
|
221
|
+
const val = Math.floor(Math.random() * 2_147_483_647);
|
|
222
|
+
inputs.seed = val;
|
|
223
|
+
autoSeeds[nodeId] = val;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return autoSeeds;
|
|
227
|
+
}
|
|
228
|
+
rememberJobFailure(job, clientId, analysis) {
|
|
229
|
+
let map = this.jobFailureAnalysis.get(job.jobId);
|
|
230
|
+
if (!map) {
|
|
231
|
+
map = new Map();
|
|
232
|
+
this.jobFailureAnalysis.set(job.jobId, map);
|
|
233
|
+
}
|
|
234
|
+
map.set(clientId, analysis);
|
|
235
|
+
}
|
|
236
|
+
clearJobFailures(jobId) {
|
|
237
|
+
this.jobFailureAnalysis.delete(jobId);
|
|
238
|
+
}
|
|
239
|
+
collectFailureReasons(jobId) {
|
|
240
|
+
const map = this.jobFailureAnalysis.get(jobId);
|
|
241
|
+
if (!map) {
|
|
242
|
+
return {};
|
|
243
|
+
}
|
|
244
|
+
const reasons = {};
|
|
245
|
+
for (const [clientId, analysis] of map.entries()) {
|
|
246
|
+
reasons[clientId] = analysis.reason;
|
|
247
|
+
}
|
|
248
|
+
return reasons;
|
|
249
|
+
}
|
|
250
|
+
addPermanentExclusion(job, clientId) {
|
|
251
|
+
if (!job.options.excludeClientIds) {
|
|
252
|
+
job.options.excludeClientIds = [];
|
|
253
|
+
}
|
|
254
|
+
if (!job.options.excludeClientIds.includes(clientId)) {
|
|
255
|
+
job.options.excludeClientIds.push(clientId);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
hasRetryPath(job) {
|
|
259
|
+
const map = this.jobFailureAnalysis.get(job.jobId);
|
|
260
|
+
const exclude = new Set(job.options.excludeClientIds ?? []);
|
|
261
|
+
const preferred = job.options.preferredClientIds?.length ? new Set(job.options.preferredClientIds) : null;
|
|
262
|
+
for (const client of this.clientManager.list()) {
|
|
263
|
+
if (preferred && !preferred.has(client.id)) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
266
|
+
if (exclude.has(client.id)) {
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
const analysis = map?.get(client.id);
|
|
270
|
+
if (analysis?.blockClient === "permanent") {
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
return false;
|
|
276
|
+
}
|
|
277
|
+
createWorkflowNotSupportedError(job, cause) {
|
|
278
|
+
const reasons = this.collectFailureReasons(job.jobId);
|
|
279
|
+
const message = `Workflow ${job.workflowHash} is not supported by any connected clients`;
|
|
280
|
+
return new WorkflowNotSupportedError(message, {
|
|
281
|
+
workflowHash: job.workflowHash,
|
|
282
|
+
reasons,
|
|
283
|
+
cause
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
async processQueue() {
|
|
287
|
+
this.debugLog("[processQueue] Called");
|
|
288
|
+
if (this.processing) {
|
|
289
|
+
this.debugLog("[processQueue] Already processing, returning early");
|
|
290
|
+
this.processQueued = true;
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
this.processing = true;
|
|
294
|
+
try {
|
|
295
|
+
// Continue processing until no more jobs can be assigned
|
|
296
|
+
let iteration = 0;
|
|
297
|
+
while (true) {
|
|
298
|
+
iteration++;
|
|
299
|
+
this.debugLog(`[processQueue] Iteration ${iteration}`);
|
|
300
|
+
const idleClients = this.clientManager.list().filter(c => this.clientManager.isClientStable(c));
|
|
301
|
+
this.debugLog(`[processQueue] Idle clients: [${idleClients.map(c => c.id).join(", ")}] (${idleClients.length})`);
|
|
302
|
+
if (!idleClients.length) {
|
|
303
|
+
this.debugLog("[processQueue] No idle clients, breaking");
|
|
304
|
+
break; // No idle clients available
|
|
305
|
+
}
|
|
306
|
+
const waitingJobs = await this.queue.peek(100); // Peek at top 100 jobs
|
|
307
|
+
this.debugLog(`[processQueue] Waiting jobs in queue: ${waitingJobs.length}`);
|
|
308
|
+
if (!waitingJobs.length) {
|
|
309
|
+
this.debugLog("[processQueue] No waiting jobs, breaking");
|
|
310
|
+
break; // No jobs in queue
|
|
311
|
+
}
|
|
312
|
+
const leasedClientIds = new Set();
|
|
313
|
+
const reservedJobIds = new Set();
|
|
314
|
+
const jobMatchInfos = [];
|
|
315
|
+
for (const jobPayload of waitingJobs) {
|
|
316
|
+
const job = this.jobStore.get(jobPayload.jobId);
|
|
317
|
+
if (!job) {
|
|
318
|
+
this.debugLog(`[processQueue] Job ${jobPayload.jobId} not in jobStore, skipping`);
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
const compatibleClients = idleClients
|
|
322
|
+
.filter(client => {
|
|
323
|
+
const canRun = this.clientManager.canClientRunJob(client, job);
|
|
324
|
+
if (!canRun) {
|
|
325
|
+
this.debugLog(`[processQueue] Job ${job.jobId.substring(0, 8)}... NOT compatible with ${client.id}. Checking why...`);
|
|
326
|
+
this.debugLog(`[processQueue] - preferredClientIds: ${JSON.stringify(job.options.preferredClientIds)}`);
|
|
327
|
+
this.debugLog(`[processQueue] - excludeClientIds: ${JSON.stringify(job.options.excludeClientIds)}`);
|
|
328
|
+
this.debugLog(`[processQueue] - client.id: ${client.id}`);
|
|
329
|
+
}
|
|
330
|
+
return canRun;
|
|
331
|
+
})
|
|
332
|
+
.map(client => client.id);
|
|
333
|
+
this.debugLog(`[processQueue] Job ${job.jobId.substring(0, 8)}... compatible with: [${compatibleClients.join(", ")}] (selectivity=${compatibleClients.length})`);
|
|
334
|
+
if (compatibleClients.length > 0) {
|
|
335
|
+
jobMatchInfos.push({
|
|
336
|
+
jobPayload,
|
|
337
|
+
job,
|
|
338
|
+
compatibleClients,
|
|
339
|
+
selectivity: compatibleClients.length
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
this.debugLog(`[processQueue] Found ${jobMatchInfos.length} compatible job matches`);
|
|
344
|
+
if (jobMatchInfos.length === 0) {
|
|
345
|
+
this.debugLog("[processQueue] No compatible jobs for idle clients, breaking");
|
|
346
|
+
break; // No compatible jobs for idle clients
|
|
347
|
+
}
|
|
348
|
+
// Sort jobs by priority first, then selectivity, to maximize throughput
|
|
349
|
+
// 1. Higher priority jobs execute first (explicit user priority)
|
|
350
|
+
// 2. More selective jobs (fewer compatible clients) assigned first within same priority
|
|
351
|
+
// 3. Earlier queue position as final tiebreaker
|
|
352
|
+
jobMatchInfos.sort((a, b) => {
|
|
353
|
+
// Primary: priority (higher priority = higher precedence)
|
|
354
|
+
const aPriority = a.job.options.priority ?? 0;
|
|
355
|
+
const bPriority = b.job.options.priority ?? 0;
|
|
356
|
+
if (aPriority !== bPriority) {
|
|
357
|
+
return bPriority - aPriority; // Higher priority first
|
|
358
|
+
}
|
|
359
|
+
// Secondary: selectivity (fewer compatible clients = higher precedence)
|
|
360
|
+
if (a.selectivity !== b.selectivity) {
|
|
361
|
+
return a.selectivity - b.selectivity;
|
|
362
|
+
}
|
|
363
|
+
// Tertiary: maintain queue order (earlier jobs first)
|
|
364
|
+
const aIndex = waitingJobs.indexOf(a.jobPayload);
|
|
365
|
+
const bIndex = waitingJobs.indexOf(b.jobPayload);
|
|
366
|
+
return aIndex - bIndex;
|
|
367
|
+
});
|
|
368
|
+
// Assign jobs to clients using the selectivity-based ordering
|
|
369
|
+
let assignedAnyJob = false;
|
|
370
|
+
for (const matchInfo of jobMatchInfos) {
|
|
371
|
+
if (reservedJobIds.has(matchInfo.job.jobId))
|
|
372
|
+
continue;
|
|
373
|
+
// Find first available compatible client
|
|
374
|
+
const availableClient = matchInfo.compatibleClients.find(clientId => !leasedClientIds.has(clientId));
|
|
375
|
+
if (!availableClient) {
|
|
376
|
+
this.debugLog(`[processQueue] No available client for job ${matchInfo.job.jobId.substring(0, 8)}...`);
|
|
377
|
+
continue; // No available clients for this job
|
|
378
|
+
}
|
|
379
|
+
this.debugLog(`[processQueue] Reserving job ${matchInfo.job.jobId.substring(0, 8)}... for client ${availableClient}`);
|
|
380
|
+
const reservation = await this.queue.reserveById(matchInfo.job.jobId);
|
|
381
|
+
if (reservation) {
|
|
382
|
+
// Mark as leased/reserved for this cycle
|
|
383
|
+
leasedClientIds.add(availableClient);
|
|
384
|
+
reservedJobIds.add(matchInfo.job.jobId);
|
|
385
|
+
assignedAnyJob = true;
|
|
386
|
+
// Get the lease (which marks the client as busy)
|
|
387
|
+
const lease = this.clientManager.claim(matchInfo.job, availableClient);
|
|
388
|
+
if (lease) {
|
|
389
|
+
this.debugLog(`[processQueue] Starting job ${matchInfo.job.jobId.substring(0, 8)}... on client ${availableClient}`);
|
|
390
|
+
this.runJob({
|
|
391
|
+
reservation,
|
|
392
|
+
job: matchInfo.job,
|
|
393
|
+
clientId: lease.clientId,
|
|
394
|
+
release: lease.release
|
|
395
|
+
}).catch((error) => {
|
|
396
|
+
console.error("[WorkflowPool] Unhandled job error", error);
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
else {
|
|
400
|
+
// This should not happen since we checked canClientRunJob, but handle defensively
|
|
401
|
+
console.error(`[processQueue.processQueue] CRITICAL: Failed to claim client ${availableClient} for job ${matchInfo.job.jobId} after successful check.`);
|
|
402
|
+
await this.queue.retry(reservation.reservationId, { delayMs: matchInfo.job.options.retryDelayMs });
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
this.debugLog(`[processQueue] Failed to reserve job ${matchInfo.job.jobId.substring(0, 8)}...`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
this.debugLog(`[processQueue] Assigned any job in this iteration: ${assignedAnyJob}`);
|
|
410
|
+
// If we didn't assign any jobs this iteration, no point continuing
|
|
411
|
+
if (!assignedAnyJob) {
|
|
412
|
+
this.debugLog("[processQueue] No jobs assigned, breaking");
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
finally {
|
|
418
|
+
this.debugLog("[processQueue] Exiting, setting processing = false");
|
|
419
|
+
this.processing = false;
|
|
420
|
+
if (this.processQueued) {
|
|
421
|
+
this.debugLog("[processQueue] Pending rerun detected, draining queue again");
|
|
422
|
+
this.processQueued = false;
|
|
423
|
+
void this.processQueue();
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
async runJob(ctx) {
|
|
428
|
+
const { reservation, job, clientId, release } = ctx;
|
|
429
|
+
let released = false;
|
|
430
|
+
const safeRelease = (opts) => {
|
|
431
|
+
if (released) {
|
|
432
|
+
return;
|
|
433
|
+
}
|
|
434
|
+
released = true;
|
|
435
|
+
release(opts);
|
|
436
|
+
};
|
|
437
|
+
const managed = this.clientManager.getClient(clientId);
|
|
438
|
+
const client = managed?.client;
|
|
439
|
+
if (!client) {
|
|
440
|
+
await this.queue.retry(reservation.reservationId, { delayMs: job.options.retryDelayMs });
|
|
441
|
+
safeRelease({ success: false });
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
job.status = "running";
|
|
445
|
+
job.clientId = clientId;
|
|
446
|
+
job.attempts += 1;
|
|
447
|
+
reservation.payload.attempts = job.attempts;
|
|
448
|
+
job.startedAt = Date.now();
|
|
449
|
+
// Don't dispatch job:started here - wait until we have promptId in onPending
|
|
450
|
+
// this.dispatchEvent(new CustomEvent("job:started", { detail: { job } }));
|
|
451
|
+
const workflowPayload = cloneDeep(reservation.payload.workflow);
|
|
452
|
+
if (job.attachments?.length) {
|
|
453
|
+
for (const attachment of job.attachments) {
|
|
454
|
+
const filename = attachment.filename ?? `${job.jobId}-${attachment.nodeId}-${attachment.inputName}.bin`;
|
|
455
|
+
const blob = attachment.file instanceof Buffer ? new Blob([new Uint8Array(attachment.file)]) : attachment.file;
|
|
456
|
+
await client.ext.file.uploadImage(blob, filename, { override: true });
|
|
457
|
+
const node = workflowPayload[attachment.nodeId];
|
|
458
|
+
if (node?.inputs) {
|
|
459
|
+
node.inputs[attachment.inputName] = filename;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
const autoSeeds = this.applyAutoSeed(workflowPayload);
|
|
464
|
+
let wfInstance = Workflow.from(workflowPayload);
|
|
465
|
+
if (job.options.includeOutputs?.length) {
|
|
466
|
+
for (const nodeId of job.options.includeOutputs) {
|
|
467
|
+
if (nodeId) {
|
|
468
|
+
wfInstance = wfInstance.output(nodeId);
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
wfInstance.inferDefaultOutputs?.();
|
|
473
|
+
// Use stored metadata if available (from Workflow instance), otherwise extract from recreated instance
|
|
474
|
+
const outputNodeIds = reservation.payload.workflowMeta?.outputNodeIds ??
|
|
475
|
+
wfInstance.outputNodeIds ??
|
|
476
|
+
job.options.includeOutputs ??
|
|
477
|
+
[];
|
|
478
|
+
const outputAliases = reservation.payload.workflowMeta?.outputAliases ?? wfInstance.outputAliases ?? {};
|
|
479
|
+
let promptBuilder = new PromptBuilder(wfInstance.json, wfInstance.inputPaths ?? [], outputNodeIds);
|
|
480
|
+
for (const nodeId of outputNodeIds) {
|
|
481
|
+
const alias = outputAliases[nodeId] ?? nodeId;
|
|
482
|
+
promptBuilder = promptBuilder.setOutputNode(alias, nodeId);
|
|
483
|
+
}
|
|
484
|
+
const wrapper = new CallWrapper(client, promptBuilder);
|
|
485
|
+
// Setup profiling if enabled
|
|
486
|
+
const profiler = this.opts.enableProfiling ? new JobProfiler(job.enqueuedAt, workflowPayload) : undefined;
|
|
487
|
+
// Setup node execution timeout tracking
|
|
488
|
+
const nodeExecutionTimeout = this.opts.nodeExecutionTimeoutMs ?? 300000; // 5 minutes default
|
|
489
|
+
let nodeTimeoutId;
|
|
490
|
+
let lastNodeStartTime;
|
|
491
|
+
let currentExecutingNode = null;
|
|
492
|
+
const resetNodeTimeout = (nodeName) => {
|
|
493
|
+
if (nodeTimeoutId) {
|
|
494
|
+
clearTimeout(nodeTimeoutId);
|
|
495
|
+
nodeTimeoutId = undefined;
|
|
496
|
+
}
|
|
497
|
+
if (nodeExecutionTimeout > 0 && nodeName !== null) {
|
|
498
|
+
lastNodeStartTime = Date.now();
|
|
499
|
+
currentExecutingNode = nodeName || null;
|
|
500
|
+
nodeTimeoutId = setTimeout(() => {
|
|
501
|
+
const elapsed = Date.now() - (lastNodeStartTime || 0);
|
|
502
|
+
const nodeInfo = currentExecutingNode ? ` (node: ${currentExecutingNode})` : "";
|
|
503
|
+
completionError = new Error(`Node execution timeout: took longer than ${nodeExecutionTimeout}ms${nodeInfo}. ` +
|
|
504
|
+
`Actual time: ${elapsed}ms. Server may be stuck or node is too slow for configured timeout.`);
|
|
505
|
+
resolveCompletion?.();
|
|
506
|
+
}, nodeExecutionTimeout);
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
const clearNodeTimeout = () => {
|
|
510
|
+
if (nodeTimeoutId) {
|
|
511
|
+
clearTimeout(nodeTimeoutId);
|
|
512
|
+
nodeTimeoutId = undefined;
|
|
513
|
+
}
|
|
514
|
+
currentExecutingNode = null;
|
|
515
|
+
lastNodeStartTime = undefined;
|
|
516
|
+
};
|
|
517
|
+
// Setup profiling event listeners on the raw ComfyUI client
|
|
518
|
+
if (profiler) {
|
|
519
|
+
const onExecutionStart = (event) => {
|
|
520
|
+
const promptId = event.detail?.prompt_id;
|
|
521
|
+
if (promptId) {
|
|
522
|
+
profiler.onExecutionStart(promptId);
|
|
523
|
+
}
|
|
524
|
+
};
|
|
525
|
+
const onExecutionCached = (event) => {
|
|
526
|
+
const nodes = event.detail?.nodes;
|
|
527
|
+
if (Array.isArray(nodes)) {
|
|
528
|
+
profiler.onCachedNodes(nodes.map(String));
|
|
529
|
+
}
|
|
530
|
+
};
|
|
531
|
+
const onExecuting = (event) => {
|
|
532
|
+
const node = event.detail?.node;
|
|
533
|
+
if (node === null) {
|
|
534
|
+
// Workflow completed
|
|
535
|
+
profiler.onExecutionComplete();
|
|
536
|
+
}
|
|
537
|
+
else if (node !== undefined) {
|
|
538
|
+
profiler.onNodeExecuting(String(node));
|
|
539
|
+
}
|
|
540
|
+
};
|
|
541
|
+
const onExecutionError = (event) => {
|
|
542
|
+
const detail = event.detail || {};
|
|
543
|
+
if (detail.node !== undefined) {
|
|
544
|
+
profiler.onNodeError(String(detail.node), detail.exception_message || "Execution error");
|
|
545
|
+
}
|
|
546
|
+
};
|
|
547
|
+
// Attach listeners to client
|
|
548
|
+
client.addEventListener("execution_start", onExecutionStart);
|
|
549
|
+
client.addEventListener("execution_cached", onExecutionCached);
|
|
550
|
+
client.addEventListener("executing", onExecuting);
|
|
551
|
+
client.addEventListener("execution_error", onExecutionError);
|
|
552
|
+
// Cleanup function to remove listeners
|
|
553
|
+
const cleanupProfiler = () => {
|
|
554
|
+
client.removeEventListener("execution_start", onExecutionStart);
|
|
555
|
+
client.removeEventListener("execution_cached", onExecutionCached);
|
|
556
|
+
client.removeEventListener("executing", onExecuting);
|
|
557
|
+
client.removeEventListener("execution_error", onExecutionError);
|
|
558
|
+
};
|
|
559
|
+
// Ensure cleanup happens when job finishes
|
|
560
|
+
wrapper.onFinished(() => cleanupProfiler());
|
|
561
|
+
wrapper.onFailed(() => cleanupProfiler());
|
|
562
|
+
}
|
|
563
|
+
// Setup node execution timeout listeners (always active if timeout > 0)
|
|
564
|
+
const onNodeExecuting = (event) => {
|
|
565
|
+
const node = event.detail?.node;
|
|
566
|
+
if (node === null) {
|
|
567
|
+
// Workflow completed - clear timeout
|
|
568
|
+
clearNodeTimeout();
|
|
569
|
+
}
|
|
570
|
+
else if (node !== undefined) {
|
|
571
|
+
// New node started - reset timeout
|
|
572
|
+
resetNodeTimeout(String(node));
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
const onNodeProgress = (event) => {
|
|
576
|
+
// Progress event means node is still working - reset timeout
|
|
577
|
+
if (event.detail?.node) {
|
|
578
|
+
resetNodeTimeout(String(event.detail.node));
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
const onExecutionStarted = (event) => {
|
|
582
|
+
// Execution started - reset timeout for first node
|
|
583
|
+
resetNodeTimeout("execution_start");
|
|
584
|
+
};
|
|
585
|
+
if (nodeExecutionTimeout > 0) {
|
|
586
|
+
client.addEventListener("execution_start", onExecutionStarted);
|
|
587
|
+
client.addEventListener("executing", onNodeExecuting);
|
|
588
|
+
client.addEventListener("progress", onNodeProgress);
|
|
589
|
+
}
|
|
590
|
+
const cleanupNodeTimeout = () => {
|
|
591
|
+
clearNodeTimeout();
|
|
592
|
+
if (nodeExecutionTimeout > 0) {
|
|
593
|
+
client.removeEventListener("execution_start", onExecutionStarted);
|
|
594
|
+
client.removeEventListener("executing", onNodeExecuting);
|
|
595
|
+
client.removeEventListener("progress", onNodeProgress);
|
|
596
|
+
}
|
|
597
|
+
};
|
|
598
|
+
let pendingSettled = false;
|
|
599
|
+
let resolvePending;
|
|
600
|
+
let rejectPending;
|
|
601
|
+
const pendingPromise = new Promise((resolve, reject) => {
|
|
602
|
+
resolvePending = () => {
|
|
603
|
+
if (!pendingSettled) {
|
|
604
|
+
pendingSettled = true;
|
|
605
|
+
resolve();
|
|
606
|
+
}
|
|
607
|
+
};
|
|
608
|
+
rejectPending = (err) => {
|
|
609
|
+
if (!pendingSettled) {
|
|
610
|
+
pendingSettled = true;
|
|
611
|
+
reject(err);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
});
|
|
615
|
+
let resolveCompletion;
|
|
616
|
+
let completionError;
|
|
617
|
+
// completionPromise is used to track when the wrapper completes (success or failure)
|
|
618
|
+
// It's resolved in onFinished and onFailed handlers
|
|
619
|
+
const completionPromise = new Promise((resolve) => {
|
|
620
|
+
resolveCompletion = resolve;
|
|
621
|
+
});
|
|
622
|
+
let jobStartedDispatched = false;
|
|
623
|
+
wrapper.onProgress((progress, promptId) => {
|
|
624
|
+
if (!job.promptId && promptId) {
|
|
625
|
+
job.promptId = promptId;
|
|
626
|
+
}
|
|
627
|
+
// Dispatch job:started on first progress update with promptId
|
|
628
|
+
if (!jobStartedDispatched && job.promptId) {
|
|
629
|
+
jobStartedDispatched = true;
|
|
630
|
+
this.dispatchEvent(new CustomEvent("job:started", { detail: { job } }));
|
|
631
|
+
}
|
|
632
|
+
// Feed progress to profiler
|
|
633
|
+
if (profiler) {
|
|
634
|
+
profiler.onProgress(progress);
|
|
635
|
+
}
|
|
636
|
+
this.dispatchEvent(new CustomEvent("job:progress", {
|
|
637
|
+
detail: { jobId: job.jobId, clientId, progress }
|
|
638
|
+
}));
|
|
639
|
+
});
|
|
640
|
+
wrapper.onPreview((blob, promptId) => {
|
|
641
|
+
if (!job.promptId && promptId) {
|
|
642
|
+
job.promptId = promptId;
|
|
643
|
+
}
|
|
644
|
+
// Dispatch job:started on first preview with promptId
|
|
645
|
+
if (!jobStartedDispatched && job.promptId) {
|
|
646
|
+
jobStartedDispatched = true;
|
|
647
|
+
this.dispatchEvent(new CustomEvent("job:started", { detail: { job } }));
|
|
648
|
+
}
|
|
649
|
+
this.dispatchEvent(new CustomEvent("job:preview", {
|
|
650
|
+
detail: { jobId: job.jobId, clientId, blob }
|
|
651
|
+
}));
|
|
652
|
+
});
|
|
653
|
+
wrapper.onPreviewMeta((payload, promptId) => {
|
|
654
|
+
if (!job.promptId && promptId) {
|
|
655
|
+
job.promptId = promptId;
|
|
656
|
+
}
|
|
657
|
+
// Dispatch job:started on first preview_meta with promptId
|
|
658
|
+
if (!jobStartedDispatched && job.promptId) {
|
|
659
|
+
jobStartedDispatched = true;
|
|
660
|
+
this.dispatchEvent(new CustomEvent("job:started", { detail: { job } }));
|
|
661
|
+
}
|
|
662
|
+
this.dispatchEvent(new CustomEvent("job:preview_meta", {
|
|
663
|
+
detail: { jobId: job.jobId, clientId, payload }
|
|
664
|
+
}));
|
|
665
|
+
});
|
|
666
|
+
wrapper.onOutput((key, data, promptId) => {
|
|
667
|
+
if (!job.promptId && promptId) {
|
|
668
|
+
job.promptId = promptId;
|
|
669
|
+
}
|
|
670
|
+
this.dispatchEvent(new CustomEvent("job:output", {
|
|
671
|
+
detail: { jobId: job.jobId, clientId, key: String(key), data }
|
|
672
|
+
}));
|
|
673
|
+
});
|
|
674
|
+
wrapper.onPending((promptId) => {
|
|
675
|
+
if (!job.promptId && promptId) {
|
|
676
|
+
job.promptId = promptId;
|
|
677
|
+
}
|
|
678
|
+
// Don't dispatch job:started here - wait for first progress/preview with promptId
|
|
679
|
+
this.dispatchEvent(new CustomEvent("job:accepted", { detail: { job } }));
|
|
680
|
+
resolvePending?.();
|
|
681
|
+
});
|
|
682
|
+
wrapper.onStart((promptId) => {
|
|
683
|
+
if (!job.promptId && promptId) {
|
|
684
|
+
job.promptId = promptId;
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
wrapper.onFinished((data, promptId) => {
|
|
688
|
+
if (!job.promptId && promptId) {
|
|
689
|
+
job.promptId = promptId;
|
|
690
|
+
}
|
|
691
|
+
job.status = "completed";
|
|
692
|
+
job.lastError = undefined;
|
|
693
|
+
const resultPayload = {};
|
|
694
|
+
for (const nodeId of outputNodeIds) {
|
|
695
|
+
const alias = outputAliases[nodeId] ?? nodeId;
|
|
696
|
+
// CallWrapper uses alias keys when mapOutputKeys is configured, fallback to nodeId
|
|
697
|
+
const nodeResult = data[alias];
|
|
698
|
+
const fallbackResult = data[nodeId];
|
|
699
|
+
const finalResult = nodeResult !== undefined ? nodeResult : fallbackResult;
|
|
700
|
+
resultPayload[alias] = finalResult;
|
|
701
|
+
}
|
|
702
|
+
resultPayload._nodes = [...outputNodeIds];
|
|
703
|
+
resultPayload._aliases = { ...outputAliases };
|
|
704
|
+
if (job.promptId) {
|
|
705
|
+
resultPayload._promptId = job.promptId;
|
|
706
|
+
}
|
|
707
|
+
if (Object.keys(autoSeeds).length) {
|
|
708
|
+
resultPayload._autoSeeds = { ...autoSeeds };
|
|
709
|
+
}
|
|
710
|
+
job.result = resultPayload;
|
|
711
|
+
job.completedAt = Date.now();
|
|
712
|
+
this.clearJobFailures(job.jobId);
|
|
713
|
+
// Cleanup timeouts
|
|
714
|
+
cleanupNodeTimeout();
|
|
715
|
+
// Attach profiling stats if profiling was enabled
|
|
716
|
+
if (profiler) {
|
|
717
|
+
job.profileStats = profiler.getStats();
|
|
718
|
+
}
|
|
719
|
+
completionError = undefined;
|
|
720
|
+
this.dispatchEvent(new CustomEvent("job:completed", { detail: { job } }));
|
|
721
|
+
safeRelease({ success: true });
|
|
722
|
+
resolveCompletion?.();
|
|
723
|
+
});
|
|
724
|
+
wrapper.onFailed((error, promptId) => {
|
|
725
|
+
this.debugLog("[debug] wrapper.onFailed", job.jobId, error.name);
|
|
726
|
+
if (!job.promptId && promptId) {
|
|
727
|
+
job.promptId = promptId;
|
|
728
|
+
}
|
|
729
|
+
job.lastError = error;
|
|
730
|
+
// Cleanup timeouts
|
|
731
|
+
cleanupNodeTimeout();
|
|
732
|
+
rejectPending?.(error);
|
|
733
|
+
completionError = error;
|
|
734
|
+
this.debugLog("[debug] resolveCompletion available", Boolean(resolveCompletion));
|
|
735
|
+
safeRelease({ success: false });
|
|
736
|
+
resolveCompletion?.();
|
|
737
|
+
});
|
|
738
|
+
try {
|
|
739
|
+
// Start the workflow execution
|
|
740
|
+
const exec = wrapper.run();
|
|
741
|
+
// Add timeout for execution start to prevent jobs getting stuck
|
|
742
|
+
const executionStartTimeout = this.opts.executionStartTimeoutMs ?? 5000;
|
|
743
|
+
let pendingTimeoutId;
|
|
744
|
+
if (executionStartTimeout > 0) {
|
|
745
|
+
const pendingWithTimeout = Promise.race([
|
|
746
|
+
pendingPromise,
|
|
747
|
+
new Promise((_, reject) => {
|
|
748
|
+
pendingTimeoutId = setTimeout(() => {
|
|
749
|
+
reject(new Error(`Execution failed to start within ${executionStartTimeout}ms. ` +
|
|
750
|
+
`Server may be stuck or unresponsive.`));
|
|
751
|
+
}, executionStartTimeout);
|
|
752
|
+
})
|
|
753
|
+
]);
|
|
754
|
+
await pendingWithTimeout;
|
|
755
|
+
}
|
|
756
|
+
else {
|
|
757
|
+
await pendingPromise;
|
|
758
|
+
}
|
|
759
|
+
if (executionStartTimeout > 0) {
|
|
760
|
+
clearTimeout(pendingTimeoutId);
|
|
761
|
+
}
|
|
762
|
+
this.activeJobs.set(job.jobId, {
|
|
763
|
+
reservation,
|
|
764
|
+
job,
|
|
765
|
+
clientId,
|
|
766
|
+
release: (opts) => safeRelease(opts),
|
|
767
|
+
cancel: async () => {
|
|
768
|
+
try {
|
|
769
|
+
wrapper.cancel("workflow pool cancel");
|
|
770
|
+
if (job.promptId) {
|
|
771
|
+
await client.ext.queue.interrupt(job.promptId);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
finally {
|
|
775
|
+
this.activeJobs.delete(job.jobId);
|
|
776
|
+
await this.queue.discard(reservation.reservationId, new Error("cancelled"));
|
|
777
|
+
safeRelease({ success: false });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
});
|
|
781
|
+
const result = await exec;
|
|
782
|
+
// Wait for the wrapper to complete (onFinished or onFailed callback)
|
|
783
|
+
await completionPromise;
|
|
784
|
+
if (result === false) {
|
|
785
|
+
const errorToThrow = (completionError instanceof Error ? completionError : undefined) ??
|
|
786
|
+
(job.lastError instanceof Error ? job.lastError : undefined) ??
|
|
787
|
+
new Error("Execution failed");
|
|
788
|
+
throw errorToThrow;
|
|
789
|
+
}
|
|
790
|
+
await this.queue.commit(reservation.reservationId);
|
|
791
|
+
safeRelease({ success: true });
|
|
792
|
+
}
|
|
793
|
+
catch (error) {
|
|
794
|
+
// Immediately release the client on any failure
|
|
795
|
+
safeRelease({ success: false });
|
|
796
|
+
const latestStatus = this.jobStore.get(job.jobId)?.status;
|
|
797
|
+
if (latestStatus === "cancelled") {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
job.lastError = error;
|
|
801
|
+
job.status = "failed";
|
|
802
|
+
const remainingAttempts = job.options.maxAttempts - job.attempts;
|
|
803
|
+
const failureAnalysis = analyzeWorkflowFailure(error);
|
|
804
|
+
this.rememberJobFailure(job, clientId, failureAnalysis);
|
|
805
|
+
if (failureAnalysis.blockClient === "permanent") {
|
|
806
|
+
this.addPermanentExclusion(job, clientId);
|
|
807
|
+
reservation.payload.options.excludeClientIds = [...(job.options.excludeClientIds ?? [])];
|
|
808
|
+
}
|
|
809
|
+
this.clientManager.recordFailure(clientId, job, error);
|
|
810
|
+
const hasRetryPath = this.hasRetryPath(job);
|
|
811
|
+
const willRetry = failureAnalysis.retryable && remainingAttempts > 0 && hasRetryPath;
|
|
812
|
+
this.dispatchEvent(new CustomEvent("job:failed", {
|
|
813
|
+
detail: { job, willRetry }
|
|
814
|
+
}));
|
|
815
|
+
if (willRetry) {
|
|
816
|
+
const delay = this.opts.retryBackoffMs ?? job.options.retryDelayMs;
|
|
817
|
+
this.dispatchEvent(new CustomEvent("job:retrying", { detail: { job, delayMs: delay } }));
|
|
818
|
+
job.status = "queued";
|
|
819
|
+
job.clientId = undefined;
|
|
820
|
+
job.promptId = undefined;
|
|
821
|
+
job.startedAt = undefined;
|
|
822
|
+
job.completedAt = undefined;
|
|
823
|
+
job.result = undefined;
|
|
824
|
+
reservation.payload.options.excludeClientIds = [...(job.options.excludeClientIds ?? [])];
|
|
825
|
+
await this.queue.retry(reservation.reservationId, { delayMs: delay });
|
|
826
|
+
this.dispatchEvent(new CustomEvent("job:queued", { detail: { job } }));
|
|
827
|
+
this.scheduleProcess(delay);
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
job.completedAt = Date.now();
|
|
831
|
+
const finalError = !hasRetryPath && failureAnalysis.type === "client_incompatible" && this.jobFailureAnalysis.has(job.jobId)
|
|
832
|
+
? this.createWorkflowNotSupportedError(job, error)
|
|
833
|
+
: error;
|
|
834
|
+
job.lastError = finalError;
|
|
835
|
+
await this.queue.discard(reservation.reservationId, finalError);
|
|
836
|
+
this.clearJobFailures(job.jobId);
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
finally {
|
|
840
|
+
this.activeJobs.delete(job.jobId);
|
|
841
|
+
this.debugLog(`[runJob.finally] Job ${job.jobId.substring(0, 8)}... completed, calling processQueue()`);
|
|
842
|
+
void this.processQueue();
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
841
846
|
//# sourceMappingURL=WorkflowPool.js.map
|