animastor-comfyui-workflow-connector 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +298 -0
- package/package.json +41 -0
- package/src/connector-loader.js +978 -0
- package/src/entity-schema.js +340 -0
- package/src/index.js +265 -0
- package/src/workflow-loader.js +138 -0
|
@@ -0,0 +1,978 @@
|
|
|
1
|
+
// ======================================================
|
|
2
|
+
// Connector Loader — v1.0.0
|
|
3
|
+
// animastor-comfyui-workflow-connector package
|
|
4
|
+
// ======================================================
|
|
5
|
+
// Bridges ComfyUI workflows with host code via
|
|
6
|
+
// declarative connector JSON files.
|
|
7
|
+
//
|
|
8
|
+
// A connector is a configuration file that maps
|
|
9
|
+
// Animastor data entities (prompts, images, audio, etc.)
|
|
10
|
+
// to specific node IDs and fields within a ComfyUI workflow.
|
|
11
|
+
//
|
|
12
|
+
// Key responsibilities:
|
|
13
|
+
// 1. Load connectors from the HOST-INJECTED directory
|
|
14
|
+
// 2. Validate connector structure and field completeness
|
|
15
|
+
// 3. Validate workflow ↔ connector compatibility via hash
|
|
16
|
+
// 4. Provide lookup API for host code
|
|
17
|
+
// 5. Apply values to workflow JSON nodes
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const path = require('path');
|
|
21
|
+
const crypto = require('crypto');
|
|
22
|
+
const entitySchema = require('./entity-schema');
|
|
23
|
+
|
|
24
|
+
// In-memory registry
|
|
25
|
+
const connectors = {}; // workflow_name → connector
|
|
26
|
+
const connectorsByName = {}; // connector_name → connector
|
|
27
|
+
const connectorEnabled = {}; // connector_name → boolean (default: true)
|
|
28
|
+
|
|
29
|
+
// Directory resolution order: explicit injection (configure) → env
|
|
30
|
+
// (CONNECTOR_DIR) → no default. The default is a HOST concern
|
|
31
|
+
// (extraction §1.3): the host passes its asset directory at boot via
|
|
32
|
+
// configure() / the package API.
|
|
33
|
+
let CONNECTOR_DIR = process.env.CONNECTOR_DIR || null;
|
|
34
|
+
let loggerRef = console;
|
|
35
|
+
|
|
36
|
+
const logPrefix = '[CONNECTOR]';
|
|
37
|
+
|
|
38
|
+
function log(msg) { loggerRef.log(`${logPrefix} ${msg}`); }
|
|
39
|
+
function warn(msg) { loggerRef.warn(`${logPrefix} ⚠️ ${msg}`); }
|
|
40
|
+
function error(msg) { loggerRef.error(`${logPrefix} ❌ ${msg}`); }
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Dependency-injected configuration (extraction readiness): override the
|
|
44
|
+
* connectors directory and/or the logger. Env vars and defaults still apply
|
|
45
|
+
* when nothing is injected.
|
|
46
|
+
*
|
|
47
|
+
* @param {{ connectorsDir?: string, logger?: object }} [options]
|
|
48
|
+
*/
|
|
49
|
+
function configure({ connectorsDir, logger } = {}) {
|
|
50
|
+
if (connectorsDir) {
|
|
51
|
+
CONNECTOR_DIR = connectorsDir;
|
|
52
|
+
loggerRef = logger || loggerRef;
|
|
53
|
+
}
|
|
54
|
+
if (logger) loggerRef = logger;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ─── Hashing ────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Deterministic JSON serialization: object keys are sorted at EVERY level
|
|
61
|
+
* (array order preserved). JSON.stringify's key-array replacer only sorts
|
|
62
|
+
* the top level, which would drop nested content from the hash.
|
|
63
|
+
* @param {*} value
|
|
64
|
+
* @returns {string}
|
|
65
|
+
*/
|
|
66
|
+
function stableStringify(value) {
|
|
67
|
+
if (Array.isArray(value)) {
|
|
68
|
+
return `[${value.map(stableStringify).join(',')}]`;
|
|
69
|
+
}
|
|
70
|
+
if (value && typeof value === 'object') {
|
|
71
|
+
const keys = Object.keys(value).sort();
|
|
72
|
+
return `{${keys.map(k => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
|
|
73
|
+
}
|
|
74
|
+
return JSON.stringify(value);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Compute SHA-256 hash of a workflow JSON's canonical (fully key-sorted)
|
|
79
|
+
* string form. Content-sensitive: any change inside any node changes the hash.
|
|
80
|
+
* @param {object} workflowJson — loaded workflow JSON
|
|
81
|
+
* @returns {string} hex digest
|
|
82
|
+
*/
|
|
83
|
+
function computeWorkflowHash(workflowJson) {
|
|
84
|
+
return crypto.createHash('sha256').update(stableStringify(workflowJson)).digest('hex');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── Validation ─────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Validate a single binding entry.
|
|
91
|
+
*/
|
|
92
|
+
function validateBinding(binding, context) {
|
|
93
|
+
const errors = [];
|
|
94
|
+
|
|
95
|
+
if (!binding.nodeId) {
|
|
96
|
+
errors.push(`binding ${context}: missing nodeId`);
|
|
97
|
+
}
|
|
98
|
+
if (!binding.field) {
|
|
99
|
+
errors.push(`binding ${context} (node ${binding.nodeId}): missing field`);
|
|
100
|
+
}
|
|
101
|
+
if (binding.entityType && !entitySchema.getEntity(binding.entityType)) {
|
|
102
|
+
errors.push(`binding ${context} (node ${binding.nodeId}): unknown entityType "${binding.entityType}"`);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
return errors;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Validate the full connector structure.
|
|
110
|
+
* Returns array of error messages (empty = valid).
|
|
111
|
+
*/
|
|
112
|
+
function validateConnector(connector, connectorName) {
|
|
113
|
+
const errors = [];
|
|
114
|
+
|
|
115
|
+
// Required top-level fields
|
|
116
|
+
if (!connector.connectorVersion) {
|
|
117
|
+
errors.push('missing connectorVersion');
|
|
118
|
+
}
|
|
119
|
+
if (!connector.workflow) {
|
|
120
|
+
errors.push('missing workflow reference');
|
|
121
|
+
}
|
|
122
|
+
if (!connector.type) {
|
|
123
|
+
errors.push('missing type (image/audio/video)');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Validate inputs
|
|
127
|
+
if (connector.inputs && typeof connector.inputs === 'object') {
|
|
128
|
+
for (const [key, binding] of Object.entries(connector.inputs)) {
|
|
129
|
+
if (binding.type === 'multi' && binding.bindings) {
|
|
130
|
+
binding.bindings.forEach((b, i) => {
|
|
131
|
+
errors.push(...validateBinding(b, `inputs.${key}.bindings[${i}]`));
|
|
132
|
+
});
|
|
133
|
+
} else {
|
|
134
|
+
errors.push(...validateBinding(binding, `inputs.${key}`));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Validate outputs
|
|
140
|
+
if (connector.outputs && typeof connector.outputs === 'object') {
|
|
141
|
+
for (const [key, binding] of Object.entries(connector.outputs)) {
|
|
142
|
+
errors.push(...validateBinding(binding, `outputs.${key}`));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Validate parameters
|
|
147
|
+
if (connector.parameters && typeof connector.parameters === 'object') {
|
|
148
|
+
for (const [key, binding] of Object.entries(connector.parameters)) {
|
|
149
|
+
errors.push(...validateBinding(binding, `parameters.${key}`));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Validate guideNodes
|
|
154
|
+
if (connector.guideNodes) {
|
|
155
|
+
if (!Array.isArray(connector.guideNodes.bindings)) {
|
|
156
|
+
errors.push('guideNodes.bindings must be an array');
|
|
157
|
+
} else {
|
|
158
|
+
connector.guideNodes.bindings.forEach((b, i) => {
|
|
159
|
+
if (!b.nodeId) errors.push(`guideNodes.bindings[${i}]: missing nodeId`);
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
return errors;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── Loading ────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Load all connectors from disk and validate them.
|
|
171
|
+
* Returns { connectors, warnings, errors }.
|
|
172
|
+
*/
|
|
173
|
+
/**
|
|
174
|
+
* Resolve the active connectors directory (for diagnostics/messages).
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
function getConnectorsDir() {
|
|
178
|
+
return CONNECTOR_DIR;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function loadConnectors() {
|
|
182
|
+
const loaded = {};
|
|
183
|
+
const warnings = [];
|
|
184
|
+
const loadErrors = [];
|
|
185
|
+
|
|
186
|
+
if (!fs.existsSync(CONNECTOR_DIR)) {
|
|
187
|
+
warn(`Connector directory not found: ${CONNECTOR_DIR}`);
|
|
188
|
+
return { connectors: loaded, warnings, errors: loadErrors };
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const files = fs.readdirSync(CONNECTOR_DIR).filter(f => f.endsWith('.json') && f.startsWith('conn-'));
|
|
192
|
+
|
|
193
|
+
for (const file of files) {
|
|
194
|
+
const name = file.replace('.json', '');
|
|
195
|
+
const filePath = path.join(CONNECTOR_DIR, file);
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const raw = fs.readFileSync(filePath, 'utf8');
|
|
199
|
+
const connector = JSON.parse(raw);
|
|
200
|
+
connector._sourceFile = file;
|
|
201
|
+
|
|
202
|
+
const validationErrors = validateConnector(connector, name);
|
|
203
|
+
if (validationErrors.length > 0) {
|
|
204
|
+
warn(`Connector "${name}" has validation errors:\n - ${validationErrors.join('\n - ')}`);
|
|
205
|
+
loadErrors.push(...validationErrors.map(e => `${name}: ${e}`));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
loaded[name] = connector;
|
|
209
|
+
log(`Loaded connector: ${name} → workflow "${connector.workflow}"`);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
error(`Failed to load connector "${name}": ${err.message}`);
|
|
212
|
+
loadErrors.push(`${name}: ${err.message}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
log(`Loaded ${Object.keys(loaded).length} connectors from ${CONNECTOR_DIR}`);
|
|
217
|
+
return { connectors: loaded, warnings, errors: loadErrors };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Register connectors in the in-memory registry.
|
|
222
|
+
* All connectors start as enabled.
|
|
223
|
+
* @param {object} connectorMap — { connectorName: connector }
|
|
224
|
+
*/
|
|
225
|
+
function registerConnectors(connectorMap) {
|
|
226
|
+
for (const [name, connector] of Object.entries(connectorMap)) {
|
|
227
|
+
connectorsByName[name] = connector;
|
|
228
|
+
connectors[connector.workflow] = connector;
|
|
229
|
+
// Default to enabled
|
|
230
|
+
if (connectorEnabled[name] === undefined) {
|
|
231
|
+
connectorEnabled[name] = true;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── Compatibility Check ────────────────────────────
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Check if a connector is compatible with a workflow JSON.
|
|
240
|
+
* @param {object} connector
|
|
241
|
+
* @param {object} workflowJson — loaded ComfyUI workflow JSON
|
|
242
|
+
* @returns {{ compatible: boolean, warnings: string[] }}
|
|
243
|
+
*/
|
|
244
|
+
function checkCompatibility(connector, workflowJson) {
|
|
245
|
+
const compatResult = { compatible: true, warnings: [] };
|
|
246
|
+
|
|
247
|
+
// 1. Hash check
|
|
248
|
+
if (connector.workflowHash) {
|
|
249
|
+
const actualHash = computeWorkflowHash(workflowJson);
|
|
250
|
+
if (actualHash !== connector.workflowHash) {
|
|
251
|
+
compatResult.warnings.push(
|
|
252
|
+
`Workflow hash mismatch for "${connector.workflow}": ` +
|
|
253
|
+
`connector expects ${connector.workflowHash}, got ${actualHash}. ` +
|
|
254
|
+
`The workflow may have been modified.`
|
|
255
|
+
);
|
|
256
|
+
compatResult.compatible = false;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// 2. Node class check
|
|
261
|
+
const nodeClasses = connector.compatibility?.nodeClasses;
|
|
262
|
+
if (nodeClasses) {
|
|
263
|
+
for (const [nodeId, expectedClass] of Object.entries(nodeClasses)) {
|
|
264
|
+
const actualNode = workflowJson[nodeId];
|
|
265
|
+
if (!actualNode) {
|
|
266
|
+
compatResult.warnings.push(
|
|
267
|
+
`Node ${nodeId} (expected ${expectedClass}) not found in workflow "${connector.workflow}". ` +
|
|
268
|
+
`The workflow structure may have changed.`
|
|
269
|
+
);
|
|
270
|
+
compatResult.compatible = false;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
if (actualNode.class_type !== expectedClass) {
|
|
274
|
+
compatResult.warnings.push(
|
|
275
|
+
`Node ${nodeId} class mismatch: connector expects "${expectedClass}", ` +
|
|
276
|
+
`workflow has "${actualNode.class_type}".`
|
|
277
|
+
);
|
|
278
|
+
compatResult.compatible = false;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 3. Collect all bindings with their section context
|
|
284
|
+
const allBindings = []; // { binding, section, entityKey }
|
|
285
|
+
|
|
286
|
+
function collectBindings(section, sectionObj) {
|
|
287
|
+
if (!sectionObj) return;
|
|
288
|
+
for (const [entityKey, binding] of Object.entries(sectionObj)) {
|
|
289
|
+
if (binding.type === 'multi' && binding.bindings) {
|
|
290
|
+
for (const subBinding of binding.bindings) {
|
|
291
|
+
allBindings.push({ binding: subBinding, section, entityKey });
|
|
292
|
+
}
|
|
293
|
+
} else {
|
|
294
|
+
allBindings.push({ binding, section, entityKey });
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
collectBindings('inputs', connector.inputs);
|
|
300
|
+
collectBindings('outputs', connector.outputs);
|
|
301
|
+
collectBindings('parameters', connector.parameters);
|
|
302
|
+
|
|
303
|
+
// Check each binding's nodeId exists in workflow
|
|
304
|
+
for (const { binding, section, entityKey } of allBindings) {
|
|
305
|
+
if (!binding.nodeId) {
|
|
306
|
+
// Required binding without nodeId — this is a problem
|
|
307
|
+
if (binding.required) {
|
|
308
|
+
compatResult.warnings.push(
|
|
309
|
+
`Required ${section} binding "${entityKey}" has no nodeId assigned. ` +
|
|
310
|
+
`This port must be connected for the workflow to function.`
|
|
311
|
+
);
|
|
312
|
+
compatResult.compatible = false;
|
|
313
|
+
}
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const workflowNode = workflowJson[binding.nodeId];
|
|
318
|
+
if (!workflowNode) {
|
|
319
|
+
compatResult.warnings.push(
|
|
320
|
+
`Binding references node ${binding.nodeId} (entity: ${entityKey}) ` +
|
|
321
|
+
`but it was not found in workflow "${connector.workflow}".`
|
|
322
|
+
);
|
|
323
|
+
compatResult.compatible = false;
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// 3b. Check class_type match per binding (optional — if expectedClass is set)
|
|
328
|
+
if (binding.expectedClass && workflowNode.class_type !== binding.expectedClass) {
|
|
329
|
+
compatResult.warnings.push(
|
|
330
|
+
`Binding "${entityKey}" (${section}) points to node ${binding.nodeId} ` +
|
|
331
|
+
`of class "${workflowNode.class_type}", but connector expects "${binding.expectedClass}". ` +
|
|
332
|
+
`This may indicate a workflow structure change.`
|
|
333
|
+
);
|
|
334
|
+
compatResult.compatible = false;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// 4. Verify guideNodes
|
|
339
|
+
if (connector.guideNodes?.bindings) {
|
|
340
|
+
for (const gb of connector.guideNodes.bindings) {
|
|
341
|
+
if (gb.nodeId && !workflowJson[gb.nodeId]) {
|
|
342
|
+
compatResult.warnings.push(
|
|
343
|
+
`Guide node ${gb.nodeId} not found in workflow "${connector.workflow}".`
|
|
344
|
+
);
|
|
345
|
+
compatResult.compatible = false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
return compatResult;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// ─── Value Application API ──────────────────────────
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Set a value on a workflow JSON at the path described by a binding.
|
|
357
|
+
*
|
|
358
|
+
* @param {object} workflowJson — the workflow JSON (mutated in-place)
|
|
359
|
+
* @param {object} binding — a binding descriptor { nodeId, field }
|
|
360
|
+
* @param {*} value — value to set
|
|
361
|
+
*/
|
|
362
|
+
function applyBinding(workflowJson, binding, value) {
|
|
363
|
+
if (!binding || !binding.nodeId) return;
|
|
364
|
+
const node = workflowJson[binding.nodeId];
|
|
365
|
+
if (!node) {
|
|
366
|
+
warn(`applyBinding: node ${binding.nodeId} not found in workflow`);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
const fieldParts = binding.field.split('.');
|
|
371
|
+
let target = node;
|
|
372
|
+
for (let i = 0; i < fieldParts.length - 1; i++) {
|
|
373
|
+
const part = fieldParts[i];
|
|
374
|
+
if (target[part] === undefined || typeof target[part] !== 'object') {
|
|
375
|
+
target[part] = {};
|
|
376
|
+
}
|
|
377
|
+
target = target[part];
|
|
378
|
+
}
|
|
379
|
+
target[fieldParts[fieldParts.length - 1]] = value;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Look up a binding by entity key from a connector's inputs/outputs/parameters.
|
|
384
|
+
*
|
|
385
|
+
* @param {object} connector
|
|
386
|
+
* @param {string} entityKey — e.g. "positivePrompt", "sourceImages"
|
|
387
|
+
* @returns {object|null} — binding descriptor or null
|
|
388
|
+
*/
|
|
389
|
+
function getBinding(connector, entityKey) {
|
|
390
|
+
if (!connector) return null;
|
|
391
|
+
|
|
392
|
+
// Check inputs
|
|
393
|
+
if (connector.inputs?.[entityKey]) return connector.inputs[entityKey];
|
|
394
|
+
|
|
395
|
+
// Check outputs
|
|
396
|
+
if (connector.outputs?.[entityKey]) return connector.outputs[entityKey];
|
|
397
|
+
|
|
398
|
+
// Check parameters
|
|
399
|
+
if (connector.parameters?.[entityKey]) return connector.parameters[entityKey];
|
|
400
|
+
|
|
401
|
+
return null;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Get the node ID for a binding by entity key.
|
|
406
|
+
* For multi-bindings (sourceImages), returns an array of nodeIds.
|
|
407
|
+
*
|
|
408
|
+
* @param {object} connector
|
|
409
|
+
* @param {string} entityKey
|
|
410
|
+
* @returns {string|string[]|null}
|
|
411
|
+
*/
|
|
412
|
+
function getNodeId(connector, entityKey) {
|
|
413
|
+
const binding = getBinding(connector, entityKey);
|
|
414
|
+
if (!binding) return null;
|
|
415
|
+
|
|
416
|
+
if (binding.type === 'multi' && binding.bindings) {
|
|
417
|
+
return binding.bindings.map(b => b.nodeId);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
return binding.nodeId;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Get the guide node bindings for video workflows.
|
|
425
|
+
* @param {object} connector
|
|
426
|
+
* @returns {Array<{nodeId: string, fieldFrameIdx: string, fieldStrength: string, imageSource: string}>}
|
|
427
|
+
*/
|
|
428
|
+
function getGuideBindings(connector) {
|
|
429
|
+
return connector?.guideNodes?.bindings || [];
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Apply a value to a workflow using a connector binding.
|
|
434
|
+
*
|
|
435
|
+
* @param {object} workflowJson — workflow JSON to modify
|
|
436
|
+
* @param {object} connector — connector object
|
|
437
|
+
* @param {string} entityKey — entity key to look up binding for
|
|
438
|
+
* @param {*} value — value to set
|
|
439
|
+
* @returns {boolean} — true if applied successfully
|
|
440
|
+
*/
|
|
441
|
+
function setValue(workflowJson, connector, entityKey, value) {
|
|
442
|
+
const binding = getBinding(connector, entityKey);
|
|
443
|
+
if (!binding) {
|
|
444
|
+
warn(`setValue: no binding found for entity "${entityKey}"`);
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (binding.type === 'multi' && binding.bindings) {
|
|
449
|
+
// Multi-binding: expect value to be an array
|
|
450
|
+
if (!Array.isArray(value)) {
|
|
451
|
+
warn(`setValue: expected array for multi-binding "${entityKey}"`);
|
|
452
|
+
return false;
|
|
453
|
+
}
|
|
454
|
+
for (let i = 0; i < Math.min(value.length, binding.bindings.length); i++) {
|
|
455
|
+
applyBinding(workflowJson, binding.bindings[i], value[i]);
|
|
456
|
+
}
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
applyBinding(workflowJson, binding, value);
|
|
461
|
+
return true;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Update a connector parameter's default value with validation.
|
|
466
|
+
*
|
|
467
|
+
* @param {string} connectorName — e.g. "conn-image-generation"
|
|
468
|
+
* @param {string} paramKey — e.g. "steps"
|
|
469
|
+
* @param {*} value — new value to set
|
|
470
|
+
* @returns {{ ok: boolean, error?: string, warnings?: string[] }}
|
|
471
|
+
*/
|
|
472
|
+
function updateConnectorParameter(connectorName, paramKey, value) {
|
|
473
|
+
const connector = connectorsByName[connectorName];
|
|
474
|
+
if (!connector) {
|
|
475
|
+
return { ok: false, error: `Connector "${connectorName}" not found` };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
const param = connector.parameters?.[paramKey];
|
|
479
|
+
if (!param) {
|
|
480
|
+
return { ok: false, error: `Parameter "${paramKey}" not found on connector "${connectorName}"` };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const warnings = [];
|
|
484
|
+
|
|
485
|
+
// Determine expected type from binding metadata or entity schema
|
|
486
|
+
const entity = entitySchema.getEntity(param.entityType || paramKey);
|
|
487
|
+
const expectedType = param.type || entity?.type || typeof param.default;
|
|
488
|
+
|
|
489
|
+
// Validate type
|
|
490
|
+
let parsedValue = value;
|
|
491
|
+
if (expectedType === 'int' || expectedType === 'number') {
|
|
492
|
+
if (typeof value === 'string') {
|
|
493
|
+
parsedValue = Number(value);
|
|
494
|
+
if (isNaN(parsedValue)) {
|
|
495
|
+
return { ok: false, error: `Invalid numeric value "${value}" for parameter "${paramKey}"` };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
if (typeof parsedValue !== 'number' || isNaN(parsedValue)) {
|
|
499
|
+
return { ok: false, error: `Expected numeric value for parameter "${paramKey}", got ${typeof value}` };
|
|
500
|
+
}
|
|
501
|
+
if (expectedType === 'int') {
|
|
502
|
+
parsedValue = Math.round(parsedValue);
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
// Validate min/max
|
|
506
|
+
if (param.min !== undefined && parsedValue < param.min) {
|
|
507
|
+
warnings.push(`Value ${parsedValue} is below minimum ${param.min}, clamping`);
|
|
508
|
+
parsedValue = param.min;
|
|
509
|
+
}
|
|
510
|
+
if (param.max !== undefined && parsedValue > param.max) {
|
|
511
|
+
warnings.push(`Value ${parsedValue} exceeds maximum ${param.max}, clamping`);
|
|
512
|
+
parsedValue = param.max;
|
|
513
|
+
}
|
|
514
|
+
} else if (expectedType === 'float') {
|
|
515
|
+
if (typeof value === 'string') {
|
|
516
|
+
parsedValue = parseFloat(value);
|
|
517
|
+
if (isNaN(parsedValue)) {
|
|
518
|
+
return { ok: false, error: `Invalid float value "${value}" for parameter "${paramKey}"` };
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
if (typeof parsedValue !== 'number' || isNaN(parsedValue)) {
|
|
522
|
+
return { ok: false, error: `Expected float value for parameter "${paramKey}", got ${typeof value}` };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
if (param.min !== undefined && parsedValue < param.min) {
|
|
526
|
+
warnings.push(`Value ${parsedValue} is below minimum ${param.min}, clamping`);
|
|
527
|
+
parsedValue = param.min;
|
|
528
|
+
}
|
|
529
|
+
if (param.max !== undefined && parsedValue > param.max) {
|
|
530
|
+
warnings.push(`Value ${parsedValue} exceeds maximum ${param.max}, clamping`);
|
|
531
|
+
parsedValue = param.max;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Update the parameter's default value
|
|
536
|
+
const oldValue = param.default;
|
|
537
|
+
param.default = parsedValue;
|
|
538
|
+
|
|
539
|
+
log(`Parameter "${connectorName}.${paramKey}" updated: ${JSON.stringify(oldValue)} → ${JSON.stringify(parsedValue)}`);
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
ok: true,
|
|
543
|
+
warnings: warnings.length > 0 ? warnings : undefined,
|
|
544
|
+
previousValue: oldValue,
|
|
545
|
+
currentValue: parsedValue
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* Reset a connector parameter to its original default value (from disk).
|
|
551
|
+
* @param {string} connectorName
|
|
552
|
+
* @param {string} paramKey
|
|
553
|
+
* @returns {{ ok: boolean, error?: string, currentValue?: * }}
|
|
554
|
+
*/
|
|
555
|
+
function resetConnectorParameter(connectorName, paramKey) {
|
|
556
|
+
const connector = connectorsByName[connectorName];
|
|
557
|
+
if (!connector) {
|
|
558
|
+
return { ok: false, error: `Connector "${connectorName}" not found` };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const param = connector.parameters?.[paramKey];
|
|
562
|
+
if (!param) {
|
|
563
|
+
return { ok: false, error: `Parameter "${paramKey}" not found` };
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// The original default is stored on the connector JSON and can be reloaded
|
|
567
|
+
// For now, the in-memory value IS the current value
|
|
568
|
+
log(`Parameter "${connectorName}.${paramKey}" current value: ${JSON.stringify(param.default)}`);
|
|
569
|
+
|
|
570
|
+
return {
|
|
571
|
+
ok: true,
|
|
572
|
+
currentValue: param.default
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Update workflow hash on a connector after it was loaded (e.g. at startup).
|
|
578
|
+
*/
|
|
579
|
+
function updateWorkflowHash(connector, workflowJson) {
|
|
580
|
+
connector.workflowHash = computeWorkflowHash(workflowJson);
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
// ─── Initialization ─────────────────────────────────
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Full initialization: load connectors, register them, and
|
|
587
|
+
* validate against provided workflow map.
|
|
588
|
+
* Auto-populates workflowHash for connectors that have empty hashes.
|
|
589
|
+
*
|
|
590
|
+
* @param {object} workflowMap — { workflowName: workflowJson }
|
|
591
|
+
* @returns {{ connectors: object[], warnings: string[], errors: string[] }}
|
|
592
|
+
*/
|
|
593
|
+
function initialize(workflowMap = {}) {
|
|
594
|
+
const result = loadConnectors();
|
|
595
|
+
registerConnectors(result.connectors);
|
|
596
|
+
|
|
597
|
+
const allWarnings = [...result.warnings];
|
|
598
|
+
const allErrors = [...result.errors];
|
|
599
|
+
|
|
600
|
+
// Auto-populate empty workflow hashes
|
|
601
|
+
for (const [name, connector] of Object.entries(result.connectors)) {
|
|
602
|
+
const wfName = connector.workflow;
|
|
603
|
+
const wf = workflowMap[wfName];
|
|
604
|
+
if (wf && !connector.workflowHash) {
|
|
605
|
+
updateWorkflowHash(connector, wf);
|
|
606
|
+
log(`Auto-populated workflowHash for connector "${name}": ${connector.workflowHash.slice(0, 16)}...`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Validate each connector against its workflow
|
|
611
|
+
for (const [name, connector] of Object.entries(result.connectors)) {
|
|
612
|
+
const wfName = connector.workflow;
|
|
613
|
+
const wf = workflowMap[wfName];
|
|
614
|
+
|
|
615
|
+
if (wf) {
|
|
616
|
+
const compat = checkCompatibility(connector, wf);
|
|
617
|
+
if (!compat.compatible) {
|
|
618
|
+
for (const w of compat.warnings) {
|
|
619
|
+
warn(w);
|
|
620
|
+
allWarnings.push(w);
|
|
621
|
+
}
|
|
622
|
+
} else {
|
|
623
|
+
log(`Connector "${name}" is compatible with workflow "${wfName}"`);
|
|
624
|
+
}
|
|
625
|
+
} else {
|
|
626
|
+
const msg = `Workflow "${wfName}" (referenced by connector "${name}") not found in workflow map`;
|
|
627
|
+
warn(msg);
|
|
628
|
+
allWarnings.push(msg);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
return { connectors: result.connectors, warnings: allWarnings, errors: allErrors };
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Get a connector by workflow name.
|
|
637
|
+
* @param {string} workflowName — e.g. "img-qwen-image"
|
|
638
|
+
* @returns {object|null}
|
|
639
|
+
*/
|
|
640
|
+
function getConnector(workflowName) {
|
|
641
|
+
return connectors[workflowName] || null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Get a connector by its file name (without extension).
|
|
646
|
+
* @param {string} connectorName — e.g. "conn-image-generation"
|
|
647
|
+
* @returns {object|null}
|
|
648
|
+
*/
|
|
649
|
+
function getConnectorByName(connectorName) {
|
|
650
|
+
return connectorsByName[connectorName] || null;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Get all registered connectors.
|
|
655
|
+
* @returns {object[]}
|
|
656
|
+
*/
|
|
657
|
+
function getAllConnectors() {
|
|
658
|
+
return Object.values(connectors);
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* Get all registered connectors grouped by type.
|
|
663
|
+
* @returns {{ audio: object[], image: object[], video: object[], unknown: object[] }}
|
|
664
|
+
*/
|
|
665
|
+
function getConnectorsByType() {
|
|
666
|
+
const grouped = { audio: [], image: [], video: [], unknown: [] };
|
|
667
|
+
for (const conn of Object.values(connectors)) {
|
|
668
|
+
const type = conn.type || 'unknown';
|
|
669
|
+
if (grouped[type]) {
|
|
670
|
+
grouped[type].push(conn);
|
|
671
|
+
} else {
|
|
672
|
+
grouped.unknown.push(conn);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
return grouped;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Get status summary for all connectors.
|
|
680
|
+
* @param {object} workflowMap — optional workflow map for on-demand compatibility check
|
|
681
|
+
* @returns {Array<{name: string, label: string, type: string, workflow: string, status: string, version: string}>}
|
|
682
|
+
*/
|
|
683
|
+
function getConnectorStatuses(workflowMap) {
|
|
684
|
+
const statuses = [];
|
|
685
|
+
for (const [name, connector] of Object.entries(connectorsByName)) {
|
|
686
|
+
const wfName = connector.workflow;
|
|
687
|
+
let status = 'unknown';
|
|
688
|
+
|
|
689
|
+
if (workflowMap && workflowMap[wfName]) {
|
|
690
|
+
const compat = checkCompatibility(connector, workflowMap[wfName]);
|
|
691
|
+
status = compat.compatible ? 'compatible' : 'incompatible';
|
|
692
|
+
} else if (connector.workflowHash) {
|
|
693
|
+
status = 'registered';
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
statuses.push({
|
|
697
|
+
name,
|
|
698
|
+
label: connector.label || connector.workflow,
|
|
699
|
+
type: connector.type || 'unknown',
|
|
700
|
+
workflow: connector.workflow,
|
|
701
|
+
status,
|
|
702
|
+
version: connector.connectorVersion || '1.0.0',
|
|
703
|
+
description: connector.description || '',
|
|
704
|
+
enabled: isConnectorEnabled(name)
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
return statuses;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
/**
|
|
711
|
+
* Register a single connector in the in-memory registry.
|
|
712
|
+
* @param {string} name — connector name (e.g. "conn-image-generation")
|
|
713
|
+
* @param {object} connector — connector object
|
|
714
|
+
*/
|
|
715
|
+
function registerConnector(name, connector) {
|
|
716
|
+
connectorsByName[name] = connector;
|
|
717
|
+
connectors[connector.workflow] = connector;
|
|
718
|
+
connectorEnabled[name] = true;
|
|
719
|
+
log(`Registered connector: ${name} → workflow "${connector.workflow}"`);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* Unregister a connector from the in-memory registry.
|
|
724
|
+
* @param {string} name — connector name (e.g. "conn-image-generation")
|
|
725
|
+
* @returns {boolean} — true if connector was found and removed
|
|
726
|
+
*/
|
|
727
|
+
function unregisterConnector(name) {
|
|
728
|
+
const connector = connectorsByName[name];
|
|
729
|
+
if (!connector) {
|
|
730
|
+
warn(`Cannot unregister connector "${name}" — not found`);
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
// Remove from both indices
|
|
735
|
+
delete connectorsByName[name];
|
|
736
|
+
if (connectors[connector.workflow] === connector) {
|
|
737
|
+
delete connectors[connector.workflow];
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
log(`Unregistered connector: ${name} (was → workflow "${connector.workflow}")`);
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Reload all connectors from disk, preserving the ones that fail.
|
|
746
|
+
* Clears registries, re-loads, re-registers, and re-validates.
|
|
747
|
+
* Preserves enabled/disabled state across reload.
|
|
748
|
+
*
|
|
749
|
+
* @param {object} workflowMap — { workflowName: workflowJson }
|
|
750
|
+
* @returns {{ connectors: object[], warnings: string[], errors: string[] }}
|
|
751
|
+
*/
|
|
752
|
+
function reload(workflowMap) {
|
|
753
|
+
log('Reloading connectors from disk...');
|
|
754
|
+
|
|
755
|
+
// Preserve enabled/disabled state across reload
|
|
756
|
+
const previousEnabled = { ...connectorEnabled };
|
|
757
|
+
|
|
758
|
+
// Clear registries
|
|
759
|
+
for (const key of Object.keys(connectors)) {
|
|
760
|
+
delete connectors[key];
|
|
761
|
+
}
|
|
762
|
+
for (const key of Object.keys(connectorsByName)) {
|
|
763
|
+
delete connectorsByName[key];
|
|
764
|
+
}
|
|
765
|
+
for (const key of Object.keys(connectorEnabled)) {
|
|
766
|
+
delete connectorEnabled[key];
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Re-initialize
|
|
770
|
+
const result = initialize(workflowMap);
|
|
771
|
+
|
|
772
|
+
// Restore previous enabled/disabled state for connectors that still exist
|
|
773
|
+
for (const name of Object.keys(result.connectors)) {
|
|
774
|
+
if (previousEnabled[name] !== undefined) {
|
|
775
|
+
connectorEnabled[name] = previousEnabled[name];
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
return result;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// ─── Add Connector ────────────────────────────────
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Add a new connector: save to disk, validate, and register.
|
|
786
|
+
* @param {string} name — connector name (without .json)
|
|
787
|
+
* @param {object} connectorJson — connector object
|
|
788
|
+
* @param {object} [workflowMap] — optional workflow map for validation
|
|
789
|
+
* @returns {{ ok: boolean, error?: string, warnings?: string[] }}
|
|
790
|
+
*/
|
|
791
|
+
function addConnector(name, connectorJson, workflowMap) {
|
|
792
|
+
// Validate the connector structure first
|
|
793
|
+
const validationErrors = validateConnector(connectorJson, name);
|
|
794
|
+
const warnings = [];
|
|
795
|
+
|
|
796
|
+
if (validationErrors.length > 0) {
|
|
797
|
+
return { ok: false, error: `Validation errors:\n - ${validationErrors.join('\n - ')}` };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
// Check referenced workflow exists
|
|
801
|
+
const wfName = connectorJson.workflow;
|
|
802
|
+
if (workflowMap && wfName && !workflowMap[wfName]) {
|
|
803
|
+
const available = Object.keys(workflowMap).join(', ');
|
|
804
|
+
warnings.push(`Referenced workflow "${wfName}" not loaded. Available workflows: ${available}`);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Write to disk
|
|
808
|
+
const filePath = path.join(CONNECTOR_DIR, `${name}.json`);
|
|
809
|
+
try {
|
|
810
|
+
// Ensure directory exists
|
|
811
|
+
if (!fs.existsSync(CONNECTOR_DIR)) {
|
|
812
|
+
fs.mkdirSync(CONNECTOR_DIR, { recursive: true });
|
|
813
|
+
}
|
|
814
|
+
// Merge with any existing file content to preserve source
|
|
815
|
+
fs.writeFileSync(filePath, JSON.stringify(connectorJson, null, 2) + '\n', 'utf8');
|
|
816
|
+
log(`Connector saved to disk: ${filePath}`);
|
|
817
|
+
} catch (err) {
|
|
818
|
+
return { ok: false, error: `Failed to write connector to disk: ${err.message}` };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
// Register in-memory
|
|
822
|
+
registerConnector(name, connectorJson);
|
|
823
|
+
|
|
824
|
+
// Auto-populate workflow hash if possible
|
|
825
|
+
if (workflowMap && wfName && workflowMap[wfName]) {
|
|
826
|
+
updateWorkflowHash(connectorJson, workflowMap[wfName]);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
return { ok: true, warnings: warnings.length > 0 ? warnings : undefined };
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ─── Enable/Disable ────────────────────────────────
|
|
833
|
+
|
|
834
|
+
/**
|
|
835
|
+
* Set a connector's enabled/disabled status.
|
|
836
|
+
* @param {string} connectorName — e.g. "conn-image-generation"
|
|
837
|
+
* @param {boolean} enabled
|
|
838
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
839
|
+
*/
|
|
840
|
+
function setConnectorStatus(connectorName, enabled) {
|
|
841
|
+
if (!connectorsByName[connectorName]) {
|
|
842
|
+
return { ok: false, error: `Connector "${connectorName}" not found` };
|
|
843
|
+
}
|
|
844
|
+
connectorEnabled[connectorName] = !!enabled;
|
|
845
|
+
log(`Connector "${connectorName}" ${enabled ? 'enabled' : 'disabled'}`);
|
|
846
|
+
return { ok: true, enabled: !!enabled };
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Check if a connector is enabled.
|
|
851
|
+
* @param {string} connectorName
|
|
852
|
+
* @returns {boolean} — true if enabled (default), false if disabled
|
|
853
|
+
*/
|
|
854
|
+
function isConnectorEnabled(connectorName) {
|
|
855
|
+
// Default to enabled if not explicitly set
|
|
856
|
+
return connectorEnabled[connectorName] !== false;
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// ─── Update Binding ────────────────────────────────
|
|
860
|
+
|
|
861
|
+
/**
|
|
862
|
+
* Update an input or output binding's nodeId and/or field.
|
|
863
|
+
* @param {string} connectorName — e.g. "conn-image-generation"
|
|
864
|
+
* @param {string} section — "inputs" or "outputs"
|
|
865
|
+
* @param {string} entityKey — e.g. "positivePrompt"
|
|
866
|
+
* @param {object} updates — { nodeId?: string, field?: string }
|
|
867
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
868
|
+
*/
|
|
869
|
+
function updateConnectorBinding(connectorName, section, entityKey, updates) {
|
|
870
|
+
const connector = connectorsByName[connectorName];
|
|
871
|
+
if (!connector) {
|
|
872
|
+
return { ok: false, error: `Connector "${connectorName}" not found` };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
if (section !== 'inputs' && section !== 'outputs' && section !== 'guideNodes') {
|
|
876
|
+
return { ok: false, error: `Invalid section "${section}". Must be "inputs", "outputs", or "guideNodes"` };
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Handle guideNodes section (array-based, not key-based)
|
|
880
|
+
if (section === 'guideNodes') {
|
|
881
|
+
const guideBindings = connector.guideNodes?.bindings;
|
|
882
|
+
const index = parseInt(entityKey, 10);
|
|
883
|
+
if (!guideBindings || !Array.isArray(guideBindings)) {
|
|
884
|
+
return { ok: false, error: `Connector "${connectorName}" has no guideNodes` };
|
|
885
|
+
}
|
|
886
|
+
if (isNaN(index) || index < 0 || index >= guideBindings.length) {
|
|
887
|
+
return { ok: false, error: `Invalid guide node index ${entityKey}. Must be 0-${guideBindings.length - 1}` };
|
|
888
|
+
}
|
|
889
|
+
if (updates.nodeId !== undefined) {
|
|
890
|
+
guideBindings[index].nodeId = updates.nodeId;
|
|
891
|
+
}
|
|
892
|
+
log(`Guide node binding updated: ${connectorName}.guideNodes[${index}] → nodeId=${guideBindings[index].nodeId}`);
|
|
893
|
+
return { ok: true };
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
const sectionObj = connector[section];
|
|
897
|
+
if (!sectionObj || !sectionObj[entityKey]) {
|
|
898
|
+
return { ok: false, error: `Binding "${entityKey}" not found in ${section} of connector "${connectorName}"` };
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const binding = sectionObj[entityKey];
|
|
902
|
+
|
|
903
|
+
// Handle multi-bindings (e.g. sourceImages with array of sub-bindings)
|
|
904
|
+
if (binding.type === 'multi' && binding.bindings) {
|
|
905
|
+
if (updates.bindings && Array.isArray(updates.bindings)) {
|
|
906
|
+
for (let i = 0; i < Math.min(updates.bindings.length, binding.bindings.length); i++) {
|
|
907
|
+
const subUpdate = updates.bindings[i];
|
|
908
|
+
if (subUpdate.nodeId) binding.bindings[i].nodeId = subUpdate.nodeId;
|
|
909
|
+
if (subUpdate.field) binding.bindings[i].field = subUpdate.field;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
return { ok: true };
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
// Update fields
|
|
916
|
+
if (updates.nodeId !== undefined) {
|
|
917
|
+
binding.nodeId = updates.nodeId;
|
|
918
|
+
}
|
|
919
|
+
if (updates.field !== undefined) {
|
|
920
|
+
binding.field = updates.field;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
log(`Binding updated: ${connectorName}.${section}.${entityKey} → ${JSON.stringify({ nodeId: binding.nodeId, field: binding.field })}`);
|
|
924
|
+
return { ok: true };
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// ─── Exports ────────────────────────────────────────
|
|
928
|
+
|
|
929
|
+
module.exports = {
|
|
930
|
+
// Lifecycle
|
|
931
|
+
loadConnectors,
|
|
932
|
+
registerConnectors,
|
|
933
|
+
initialize,
|
|
934
|
+
reload,
|
|
935
|
+
registerConnector,
|
|
936
|
+
unregisterConnector,
|
|
937
|
+
configure,
|
|
938
|
+
|
|
939
|
+
// Validation
|
|
940
|
+
validateConnector,
|
|
941
|
+
|
|
942
|
+
// Compatibility
|
|
943
|
+
checkCompatibility,
|
|
944
|
+
computeWorkflowHash,
|
|
945
|
+
|
|
946
|
+
// Lookup
|
|
947
|
+
getConnector,
|
|
948
|
+
getConnectorByName,
|
|
949
|
+
getConnectorsDir,
|
|
950
|
+
getAllConnectors,
|
|
951
|
+
getConnectorsByType,
|
|
952
|
+
getConnectorStatuses,
|
|
953
|
+
getBinding,
|
|
954
|
+
getNodeId,
|
|
955
|
+
getGuideBindings,
|
|
956
|
+
|
|
957
|
+
// Value manipulation
|
|
958
|
+
setValue,
|
|
959
|
+
applyBinding,
|
|
960
|
+
updateWorkflowHash,
|
|
961
|
+
updateConnectorParameter,
|
|
962
|
+
resetConnectorParameter,
|
|
963
|
+
|
|
964
|
+
// Registry (inspectable)
|
|
965
|
+
connectors,
|
|
966
|
+
connectorsByName,
|
|
967
|
+
connectorEnabled,
|
|
968
|
+
|
|
969
|
+
// Enable/Disable
|
|
970
|
+
setConnectorStatus,
|
|
971
|
+
isConnectorEnabled,
|
|
972
|
+
|
|
973
|
+
// Add connector
|
|
974
|
+
addConnector,
|
|
975
|
+
|
|
976
|
+
// Update binding
|
|
977
|
+
updateConnectorBinding
|
|
978
|
+
};
|