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.
@@ -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
+ };