project-graph-mcp 2.3.1 → 2.3.2
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/package.json +1 -1
- package/vendor/symbiote-node/engine/AgentUICommands.js +100 -0
- package/vendor/symbiote-node/engine/Executor.js +371 -0
- package/vendor/symbiote-node/engine/Graph.js +314 -0
- package/vendor/symbiote-node/engine/GraphServer.js +353 -0
- package/vendor/symbiote-node/engine/HandlerLoader.js +145 -0
- package/vendor/symbiote-node/engine/History.js +83 -0
- package/vendor/symbiote-node/engine/Lifecycle.js +118 -0
- package/vendor/symbiote-node/engine/Persistence.js +84 -0
- package/vendor/symbiote-node/engine/Registry.js +264 -0
- package/vendor/symbiote-node/engine/SocketTypes.js +79 -0
- package/vendor/symbiote-node/engine/cli.js +404 -0
- package/vendor/symbiote-node/engine/index.js +56 -0
- package/vendor/symbiote-node/engine/nanoid.js +28 -0
- package/vendor/symbiote-node/engine/package.json +26 -0
- package/vendor/symbiote-node/engine/packs/ai/beat-detect.handler.js +215 -0
- package/vendor/symbiote-node/engine/packs/ai/content-adapt.handler.js +238 -0
- package/vendor/symbiote-node/engine/packs/ai/face-detect.handler.js +287 -0
- package/vendor/symbiote-node/engine/packs/ai/grok-generate.handler.js +565 -0
- package/vendor/symbiote-node/engine/packs/ai/kling-lipsync.handler.js +414 -0
- package/vendor/symbiote-node/engine/packs/ai/lesson-generate.handler.js +343 -0
- package/vendor/symbiote-node/engine/packs/ai/opencode.handler.js +164 -0
- package/vendor/symbiote-node/engine/packs/ai/replicate-lipsync.handler.js +341 -0
- package/vendor/symbiote-node/engine/packs/ai/tts.handler.js +241 -0
- package/vendor/symbiote-node/engine/packs/ai/whisper.handler.js +191 -0
- package/vendor/symbiote-node/engine/packs/data/db-query.handler.js +67 -0
- package/vendor/symbiote-node/engine/packs/data/news-accumulate.handler.js +281 -0
- package/vendor/symbiote-node/engine/packs/data/personas.handler.js +160 -0
- package/vendor/symbiote-node/engine/packs/data/prompt-loader.handler.js +193 -0
- package/vendor/symbiote-node/engine/packs/data/roles.handler.js +216 -0
- package/vendor/symbiote-node/engine/packs/data/rss-feed.handler.js +244 -0
- package/vendor/symbiote-node/engine/packs/debug/inject.handler.js +52 -0
- package/vendor/symbiote-node/engine/packs/flow/agent.handler.js +73 -0
- package/vendor/symbiote-node/engine/packs/flow/if.handler.js +107 -0
- package/vendor/symbiote-node/engine/packs/flow/loop.handler.js +58 -0
- package/vendor/symbiote-node/engine/packs/flow/merge.handler.js +60 -0
- package/vendor/symbiote-node/engine/packs/flow/retry.handler.js +65 -0
- package/vendor/symbiote-node/engine/packs/flow/switch.handler.js +64 -0
- package/vendor/symbiote-node/engine/packs/flow/wait-all.handler.js +39 -0
- package/vendor/symbiote-node/engine/packs/io/http-request.handler.js +82 -0
- package/vendor/symbiote-node/engine/packs/io/read-file.handler.js +60 -0
- package/vendor/symbiote-node/engine/packs/io/write-file.handler.js +63 -0
- package/vendor/symbiote-node/engine/packs/transform/anchor-match.handler.js +494 -0
- package/vendor/symbiote-node/engine/packs/transform/effects-skeleton.handler.js +417 -0
- package/vendor/symbiote-node/engine/packs/transform/json-parse.handler.js +43 -0
- package/vendor/symbiote-node/engine/packs/transform/lipsync-select.handler.js +339 -0
- package/vendor/symbiote-node/engine/packs/transform/riopla-adapt.handler.js +432 -0
- package/vendor/symbiote-node/engine/packs/transform/set.handler.js +57 -0
- package/vendor/symbiote-node/engine/packs/transform/template-builder.handler.js +134 -0
- package/vendor/symbiote-node/engine/packs/transform/template.handler.js +79 -0
- package/vendor/symbiote-node/engine/packs/transform/timeline-build.handler.js +399 -0
- package/vendor/symbiote-node/engine/packs/util/delay.handler.js +39 -0
- package/vendor/symbiote-node/engine/packs/util/log.handler.js +44 -0
- package/vendor/symbiote-node/engine/packs/video-pack.js +323 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Graph.js - Universal graph data model
|
|
3
|
+
*
|
|
4
|
+
* Stores nodes and connections. Provides CRUD operations
|
|
5
|
+
* for AI agents and programmatic graph construction.
|
|
6
|
+
*
|
|
7
|
+
* @module symbiote-node/Graph */
|
|
8
|
+
|
|
9
|
+
import { nanoid } from './nanoid.js';
|
|
10
|
+
import { getNodeType, registerCustomDrivers } from './Registry.js';
|
|
11
|
+
import { areSocketsCompatible } from './SocketTypes.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @typedef {object} GraphNode
|
|
15
|
+
* @property {string} id - Unique node ID (nd_ prefix)
|
|
16
|
+
* @property {string} type - Node type identifier
|
|
17
|
+
* @property {string} [name] - Human-readable label
|
|
18
|
+
* @property {object} params - Node parameters
|
|
19
|
+
* @property {'auto'|'freeze'|'force'} [cacheMode='auto'] - Cache behavior mode
|
|
20
|
+
* @property {object} [_output] - Cached execution output
|
|
21
|
+
* @property {object} [_meta] - Metadata (variant flags, etc.)
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @typedef {object} Connection
|
|
26
|
+
* @property {string} from - Source node ID
|
|
27
|
+
* @property {string} out - Source output socket name
|
|
28
|
+
* @property {string} to - Target node ID
|
|
29
|
+
* @property {string} in - Target input socket name
|
|
30
|
+
* @property {string} [type] - Semantic connection type (for knowledge graphs)
|
|
31
|
+
* @property {string} [label] - Human-readable connection label
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export class Graph {
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* @param {object} [data] - Optional workflow JSON to load
|
|
38
|
+
*/
|
|
39
|
+
constructor(data) {
|
|
40
|
+
/** @type {string} */
|
|
41
|
+
this.id = `wf_${nanoid()}`;
|
|
42
|
+
|
|
43
|
+
/** @type {string} */
|
|
44
|
+
this.name = 'Untitled';
|
|
45
|
+
|
|
46
|
+
/** @type {number} */
|
|
47
|
+
this.version = 1;
|
|
48
|
+
|
|
49
|
+
/** @type {object} */
|
|
50
|
+
this.execution = { mode: 'sync', cache: true };
|
|
51
|
+
|
|
52
|
+
/** @type {Map<string, GraphNode>} */
|
|
53
|
+
this.nodes = new Map();
|
|
54
|
+
|
|
55
|
+
/** @type {Connection[]} */
|
|
56
|
+
this.connections = [];
|
|
57
|
+
|
|
58
|
+
/** @type {object} */
|
|
59
|
+
this.ui = { positions: {}, zoom: 1.0, pan: [0, 0] };
|
|
60
|
+
|
|
61
|
+
if (data) {
|
|
62
|
+
this.fromJSON(data);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Add a node to the graph
|
|
68
|
+
* @param {string} type - Node type (e.g., 'ai/llm')
|
|
69
|
+
* @param {object} [params={}] - Node parameters
|
|
70
|
+
* @param {object} [options={}] - Additional options
|
|
71
|
+
* @param {string} [options.id] - Custom node ID
|
|
72
|
+
* @param {string} [options.name] - Human-readable name
|
|
73
|
+
* @param {number[]} [options.position] - [x, y] position for UI
|
|
74
|
+
* @param {'auto'|'freeze'|'force'} [options.cacheMode='auto'] - Cache mode
|
|
75
|
+
* @returns {string} Node ID
|
|
76
|
+
*/
|
|
77
|
+
addNode(type, params = {}, options = {}) {
|
|
78
|
+
const id = options.id || `nd_${nanoid()}`;
|
|
79
|
+
|
|
80
|
+
const typeDef = getNodeType(type);
|
|
81
|
+
|
|
82
|
+
// Merge defaults from driver
|
|
83
|
+
let mergedParams = { ...params };
|
|
84
|
+
if (typeDef?.driver?.params) {
|
|
85
|
+
for (const [key, paramDef] of Object.entries(typeDef.driver.params)) {
|
|
86
|
+
if (mergedParams[key] === undefined && paramDef.default !== undefined) {
|
|
87
|
+
mergedParams[key] = paramDef.default;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** @type {GraphNode} */
|
|
93
|
+
const node = {
|
|
94
|
+
id,
|
|
95
|
+
type,
|
|
96
|
+
name: options.name || typeDef?.driver?.description?.slice(0, 30) || type,
|
|
97
|
+
params: mergedParams,
|
|
98
|
+
cacheMode: options.cacheMode || 'auto',
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
this.nodes.set(id, node);
|
|
102
|
+
|
|
103
|
+
if (options.position) {
|
|
104
|
+
this.ui.positions[id] = options.position;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return id;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Remove a node and all its connections
|
|
112
|
+
* @param {string} id - Node ID
|
|
113
|
+
* @returns {boolean}
|
|
114
|
+
*/
|
|
115
|
+
removeNode(id) {
|
|
116
|
+
if (!this.nodes.has(id)) return false;
|
|
117
|
+
this.nodes.delete(id);
|
|
118
|
+
this.connections = this.connections.filter(c => c.from !== id && c.to !== id);
|
|
119
|
+
delete this.ui.positions[id];
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Connect two nodes
|
|
125
|
+
* @param {string} fromNode - Source node ID
|
|
126
|
+
* @param {string} fromSocket - Source output socket name
|
|
127
|
+
* @param {string} toNode - Target node ID
|
|
128
|
+
* @param {string} toSocket - Target input socket name
|
|
129
|
+
* @param {object} [options={}]
|
|
130
|
+
* @param {string} [options.type] - Semantic connection type
|
|
131
|
+
* @param {string} [options.label] - Human-readable label
|
|
132
|
+
* @returns {Connection}
|
|
133
|
+
*/
|
|
134
|
+
connect(fromNode, fromSocket, toNode, toSocket, options = {}) {
|
|
135
|
+
if (!this.nodes.has(fromNode)) throw new Error(`Source node "${fromNode}" not found`);
|
|
136
|
+
if (!this.nodes.has(toNode)) throw new Error(`Target node "${toNode}" not found`);
|
|
137
|
+
|
|
138
|
+
// Validate socket compatibility if drivers available
|
|
139
|
+
const fromType = getNodeType(this.nodes.get(fromNode).type);
|
|
140
|
+
const toType = getNodeType(this.nodes.get(toNode).type);
|
|
141
|
+
|
|
142
|
+
if (fromType?.driver?.outputs && toType?.driver?.inputs) {
|
|
143
|
+
const outDef = fromType.driver.outputs.find(o => o.name === fromSocket);
|
|
144
|
+
const inDef = toType.driver.inputs.find(i => i.name === toSocket);
|
|
145
|
+
|
|
146
|
+
if (outDef && inDef && !areSocketsCompatible(outDef.type, inDef.type)) {
|
|
147
|
+
throw new Error(`Socket type mismatch: ${outDef.type} → ${inDef.type} (${fromNode}.${fromSocket} → ${toNode}.${toSocket})`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @type {Connection} */
|
|
152
|
+
const conn = {
|
|
153
|
+
from: fromNode,
|
|
154
|
+
out: fromSocket,
|
|
155
|
+
to: toNode,
|
|
156
|
+
in: toSocket,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
if (options.type) conn.type = options.type;
|
|
160
|
+
if (options.label) conn.label = options.label;
|
|
161
|
+
|
|
162
|
+
this.connections.push(conn);
|
|
163
|
+
return conn;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Disconnect two nodes
|
|
168
|
+
* @param {string} fromNode
|
|
169
|
+
* @param {string} fromSocket
|
|
170
|
+
* @param {string} toNode
|
|
171
|
+
* @param {string} toSocket
|
|
172
|
+
* @returns {boolean}
|
|
173
|
+
*/
|
|
174
|
+
disconnect(fromNode, fromSocket, toNode, toSocket) {
|
|
175
|
+
const idx = this.connections.findIndex(c =>
|
|
176
|
+
c.from === fromNode && c.out === fromSocket &&
|
|
177
|
+
c.to === toNode && c.in === toSocket
|
|
178
|
+
);
|
|
179
|
+
if (idx === -1) return false;
|
|
180
|
+
this.connections.splice(idx, 1);
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get a node by ID
|
|
186
|
+
* @param {string} id
|
|
187
|
+
* @returns {GraphNode|undefined}
|
|
188
|
+
*/
|
|
189
|
+
getNode(id) {
|
|
190
|
+
return this.nodes.get(id);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Update node parameters
|
|
195
|
+
* @param {string} id
|
|
196
|
+
* @param {object} params - Params to merge
|
|
197
|
+
* @returns {GraphNode}
|
|
198
|
+
*/
|
|
199
|
+
updateParams(id, params) {
|
|
200
|
+
const node = this.nodes.get(id);
|
|
201
|
+
if (!node) throw new Error(`Node "${id}" not found`);
|
|
202
|
+
node.params = { ...node.params, ...params };
|
|
203
|
+
return node;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Set cache mode for a node
|
|
208
|
+
* @param {string} id - Node ID
|
|
209
|
+
* @param {'auto'|'freeze'|'force'} mode
|
|
210
|
+
*/
|
|
211
|
+
setCacheMode(id, mode) {
|
|
212
|
+
const node = this.nodes.get(id);
|
|
213
|
+
if (!node) throw new Error(`Node "${id}" not found`);
|
|
214
|
+
if (!['auto', 'freeze', 'force'].includes(mode)) {
|
|
215
|
+
throw new Error(`Invalid cache mode: ${mode}. Must be auto, freeze, or force`);
|
|
216
|
+
}
|
|
217
|
+
node.cacheMode = mode;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Get orphan nodes (not connected to anything)
|
|
222
|
+
* @returns {GraphNode[]}
|
|
223
|
+
*/
|
|
224
|
+
getOrphans() {
|
|
225
|
+
const connected = new Set();
|
|
226
|
+
for (const c of this.connections) {
|
|
227
|
+
connected.add(c.from);
|
|
228
|
+
connected.add(c.to);
|
|
229
|
+
}
|
|
230
|
+
return [...this.nodes.values()].filter(n => !connected.has(n.id));
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Serialize graph to JSON
|
|
235
|
+
* @returns {object}
|
|
236
|
+
*/
|
|
237
|
+
toJSON() {
|
|
238
|
+
return {
|
|
239
|
+
version: this.version,
|
|
240
|
+
id: this.id,
|
|
241
|
+
name: this.name,
|
|
242
|
+
execution: this.execution,
|
|
243
|
+
nodes: [...this.nodes.values()].map(n => {
|
|
244
|
+
const obj = { id: n.id, type: n.type, name: n.name, params: n.params };
|
|
245
|
+
if (n.cacheMode && n.cacheMode !== 'auto') obj.cacheMode = n.cacheMode;
|
|
246
|
+
if (n._output) obj._output = n._output;
|
|
247
|
+
if (n._meta) obj._meta = n._meta;
|
|
248
|
+
if (n.driver) obj.driver = n.driver;
|
|
249
|
+
if (n.subgraph) obj.subgraph = n.subgraph;
|
|
250
|
+
return obj;
|
|
251
|
+
}),
|
|
252
|
+
connections: this.connections,
|
|
253
|
+
ui: this.ui,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Load graph from JSON
|
|
259
|
+
* @param {object} data - Workflow JSON
|
|
260
|
+
* @returns {Graph} this
|
|
261
|
+
*/
|
|
262
|
+
fromJSON(data) {
|
|
263
|
+
this.version = data.version || 1;
|
|
264
|
+
this.id = data.id || this.id;
|
|
265
|
+
this.name = data.name || 'Untitled';
|
|
266
|
+
this.execution = data.execution || { mode: 'sync', cache: true };
|
|
267
|
+
this.ui = data.ui || { positions: {}, zoom: 1.0, pan: [0, 0] };
|
|
268
|
+
|
|
269
|
+
// Register custom drivers if present
|
|
270
|
+
if (data.customDrivers) {
|
|
271
|
+
registerCustomDrivers(data.customDrivers);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Load nodes
|
|
275
|
+
this.nodes.clear();
|
|
276
|
+
for (const n of (data.nodes || [])) {
|
|
277
|
+
const node = { id: n.id, type: n.type, name: n.name, params: n.params || {}, cacheMode: n.cacheMode || 'auto' };
|
|
278
|
+
if (n._output) node._output = n._output;
|
|
279
|
+
if (n._meta) node._meta = n._meta;
|
|
280
|
+
if (n.driver) node.driver = n.driver;
|
|
281
|
+
if (n.subgraph) node.subgraph = n.subgraph;
|
|
282
|
+
this.nodes.set(n.id, node);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Load connections (handle both {out, in} and {fromSocket, toSocket} DB formats)
|
|
286
|
+
this.connections = (data.connections || []).map(c => {
|
|
287
|
+
const conn = {
|
|
288
|
+
from: c.from,
|
|
289
|
+
out: c.out || c.fromSocket,
|
|
290
|
+
to: c.to,
|
|
291
|
+
in: c.in || c.toSocket,
|
|
292
|
+
}; if (c.type) conn.type = c.type;
|
|
293
|
+
if (c.label) conn.label = c.label;
|
|
294
|
+
return conn;
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
return this;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Get execution statistics
|
|
302
|
+
* @returns {object}
|
|
303
|
+
*/
|
|
304
|
+
stats() {
|
|
305
|
+
const types = new Set();
|
|
306
|
+
for (const n of this.nodes.values()) types.add(n.type);
|
|
307
|
+
return {
|
|
308
|
+
totalNodes: this.nodes.size,
|
|
309
|
+
totalConnections: this.connections.length,
|
|
310
|
+
orphans: this.getOrphans().length,
|
|
311
|
+
uniqueTypes: types.size,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
}
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphServer.js - WebSocket + HTTP server for symbiote-node *
|
|
3
|
+
* Provides real-time graph synchronization between server and UI clients.
|
|
4
|
+
* Supports file-based workflow watching, handler hot-reload, and server-side execution.
|
|
5
|
+
*
|
|
6
|
+
* Protocol messages follow SPEC.md P23 Agent Bridge specification.
|
|
7
|
+
*
|
|
8
|
+
* @module symbiote-node/GraphServer */
|
|
9
|
+
|
|
10
|
+
import { createServer as createHttpServer } from 'node:http';
|
|
11
|
+
import { readFile, writeFile, watch as fsWatch } from 'node:fs/promises';
|
|
12
|
+
import { resolve, extname } from 'node:path';
|
|
13
|
+
import { WebSocketServer } from 'ws';
|
|
14
|
+
|
|
15
|
+
import { Graph } from './Graph.js';
|
|
16
|
+
import { Executor } from './Executor.js';
|
|
17
|
+
import { getNodeType, listDrivers } from './Registry.js';
|
|
18
|
+
import { loadHandlers, watchHandlers } from './HandlerLoader.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @typedef {object} ServerOptions
|
|
22
|
+
* @property {number} [port=3100] - HTTP/WebSocket port
|
|
23
|
+
* @property {string} [handlersDir] - Directory for .handler.js files
|
|
24
|
+
* @property {string} [workflowFile] - Path to .workflow.json
|
|
25
|
+
* @property {boolean} [watchFiles=true] - Enable file watching
|
|
26
|
+
* @property {boolean} [verbose=false] - Verbose logging
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Create an symbiote-node server instance * @param {ServerOptions} options
|
|
31
|
+
* @returns {Promise<{server: import('http').Server, wss: WebSocketServer, graph: Graph, close: () => Promise<void>}>}
|
|
32
|
+
*/
|
|
33
|
+
export async function createServer(options = {}) {
|
|
34
|
+
const {
|
|
35
|
+
port = 3100,
|
|
36
|
+
handlersDir,
|
|
37
|
+
workflowFile,
|
|
38
|
+
watchFiles = true,
|
|
39
|
+
verbose = false,
|
|
40
|
+
} = options;
|
|
41
|
+
|
|
42
|
+
let graph = new Graph();
|
|
43
|
+
const executor = new Executor();
|
|
44
|
+
const watchers = [];
|
|
45
|
+
const log = verbose ? console.log.bind(console) : () => { };
|
|
46
|
+
|
|
47
|
+
// Load initial workflow
|
|
48
|
+
if (workflowFile) {
|
|
49
|
+
try {
|
|
50
|
+
const json = await readFile(resolve(workflowFile), 'utf-8');
|
|
51
|
+
const data = JSON.parse(json);
|
|
52
|
+
graph = deserialize(data);
|
|
53
|
+
log(`📄 Loaded workflow: ${workflowFile} (${graph.nodes.size} nodes)`);
|
|
54
|
+
} catch (err) {
|
|
55
|
+
log(`⚠️ Could not load workflow: ${err.message}`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Load handler files
|
|
60
|
+
if (handlersDir) {
|
|
61
|
+
const dir = resolve(handlersDir);
|
|
62
|
+
const registered = await loadHandlers(dir);
|
|
63
|
+
log(`🔧 Loaded ${registered.length} handler(s) from ${handlersDir}`);
|
|
64
|
+
|
|
65
|
+
if (watchFiles) {
|
|
66
|
+
const stopWatch = watchHandlers(dir, (type) => {
|
|
67
|
+
log(`♻️ Handler reloaded: ${type}`);
|
|
68
|
+
broadcast({ type: 'registry:add', payload: { type, category: type.split('/')[0] } });
|
|
69
|
+
});
|
|
70
|
+
watchers.push(stopWatch);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── HTTP Server ────────────────────────────────────
|
|
75
|
+
|
|
76
|
+
const httpServer = createHttpServer(async (req, res) => {
|
|
77
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
78
|
+
|
|
79
|
+
// CORS headers
|
|
80
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
81
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
82
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
83
|
+
|
|
84
|
+
if (req.method === 'OPTIONS') {
|
|
85
|
+
res.writeHead(200);
|
|
86
|
+
res.end();
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
if (url.pathname === '/api/graph' && req.method === 'GET') {
|
|
92
|
+
const data = graph.toJSON();
|
|
93
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
94
|
+
res.end(JSON.stringify(data));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (url.pathname === '/api/graph' && req.method === 'POST') {
|
|
99
|
+
const body = await readBody(req);
|
|
100
|
+
const data = JSON.parse(body);
|
|
101
|
+
graph = new Graph();
|
|
102
|
+
graph.fromJSON(data);
|
|
103
|
+
broadcast({ type: 'graph:update', payload: graph.toJSON() });
|
|
104
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
105
|
+
res.end(JSON.stringify({ ok: true }));
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (url.pathname === '/api/graph/execute' && req.method === 'POST') {
|
|
110
|
+
await executeGraph(res);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (url.pathname === '/api/registry' && req.method === 'GET') {
|
|
115
|
+
const drivers = listDrivers();
|
|
116
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
117
|
+
res.end(JSON.stringify(drivers));
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Health check
|
|
122
|
+
if (url.pathname === '/api/health') {
|
|
123
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
124
|
+
res.end(JSON.stringify({ status: 'ok', nodes: graph.nodes.size }));
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
res.writeHead(404);
|
|
129
|
+
res.end('Not Found');
|
|
130
|
+
} catch (err) {
|
|
131
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
132
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ─── WebSocket Server ────────────────────────────────
|
|
137
|
+
|
|
138
|
+
const wss = new WebSocketServer({ server: httpServer });
|
|
139
|
+
/** @type {Set<import('ws').WebSocket>} */
|
|
140
|
+
const clients = new Set();
|
|
141
|
+
|
|
142
|
+
wss.on('connection', (ws) => {
|
|
143
|
+
clients.add(ws);
|
|
144
|
+
log(`🔌 Client connected (${clients.size} total)`);
|
|
145
|
+
|
|
146
|
+
// Send current state on connect
|
|
147
|
+
ws.send(JSON.stringify({ type: 'graph:update', payload: graph.toJSON() }));
|
|
148
|
+
|
|
149
|
+
ws.on('message', async (data) => {
|
|
150
|
+
try {
|
|
151
|
+
const msg = JSON.parse(data.toString());
|
|
152
|
+
await handleWsMessage(msg, ws);
|
|
153
|
+
} catch (err) {
|
|
154
|
+
ws.send(JSON.stringify({ type: 'error', payload: { message: err.message } }));
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
ws.on('close', () => {
|
|
159
|
+
clients.delete(ws);
|
|
160
|
+
log(`🔌 Client disconnected (${clients.size} total)`);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Broadcast message to all connected clients
|
|
166
|
+
* @param {object} msg
|
|
167
|
+
* @param {import('ws').WebSocket} [exclude] - Client to exclude
|
|
168
|
+
*/
|
|
169
|
+
function broadcast(msg, exclude) {
|
|
170
|
+
const json = JSON.stringify(msg);
|
|
171
|
+
for (const client of clients) {
|
|
172
|
+
if (client !== exclude && client.readyState === 1) {
|
|
173
|
+
client.send(json);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Handle incoming WebSocket message
|
|
180
|
+
* @param {{type: string, payload: object}} msg
|
|
181
|
+
* @param {import('ws').WebSocket} ws
|
|
182
|
+
*/
|
|
183
|
+
async function handleWsMessage(msg, ws) {
|
|
184
|
+
const { type, payload } = msg;
|
|
185
|
+
|
|
186
|
+
switch (type) {
|
|
187
|
+
case 'graph:action': {
|
|
188
|
+
const { action, nodeId, data } = payload;
|
|
189
|
+
|
|
190
|
+
switch (action) {
|
|
191
|
+
case 'addNode': {
|
|
192
|
+
const id = graph.addNode(data.type, data.params, data.options);
|
|
193
|
+
broadcast({ type: 'graph:update', payload: graph.toJSON() }, ws);
|
|
194
|
+
ws.send(JSON.stringify({ type: 'graph:actionResult', payload: { action, nodeId: id } }));
|
|
195
|
+
break;
|
|
196
|
+
}
|
|
197
|
+
case 'removeNode': {
|
|
198
|
+
graph.removeNode(nodeId);
|
|
199
|
+
broadcast({ type: 'graph:update', payload: graph.toJSON() }, ws);
|
|
200
|
+
break;
|
|
201
|
+
}
|
|
202
|
+
case 'connect': {
|
|
203
|
+
const { from, out, to, in: inp } = data;
|
|
204
|
+
graph.connect(from, out, to, inp);
|
|
205
|
+
broadcast({ type: 'graph:update', payload: graph.toJSON() }, ws);
|
|
206
|
+
break;
|
|
207
|
+
}
|
|
208
|
+
case 'updateParams': {
|
|
209
|
+
graph.updateParams(nodeId, data.params);
|
|
210
|
+
broadcast({ type: 'graph:update', payload: graph.toJSON() }, ws);
|
|
211
|
+
break;
|
|
212
|
+
}
|
|
213
|
+
case 'execute': {
|
|
214
|
+
await executeAndStream();
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
default:
|
|
218
|
+
ws.send(JSON.stringify({ type: 'error', payload: { message: `Unknown action: ${action}` } }));
|
|
219
|
+
}
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Agent UI commands — forward to all other clients
|
|
224
|
+
case 'ui:layout':
|
|
225
|
+
case 'ui:focus':
|
|
226
|
+
case 'ui:select':
|
|
227
|
+
case 'ui:navigate':
|
|
228
|
+
case 'ui:playback':
|
|
229
|
+
case 'ui:notify':
|
|
230
|
+
case 'ui:cursor':
|
|
231
|
+
broadcast(msg, ws);
|
|
232
|
+
break;
|
|
233
|
+
|
|
234
|
+
default:
|
|
235
|
+
ws.send(JSON.stringify({ type: 'error', payload: { message: `Unknown message type: ${type}` } }));
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Execute graph and stream progress via WebSocket
|
|
241
|
+
*/
|
|
242
|
+
async function executeAndStream() {
|
|
243
|
+
const result = await executor.run(graph, {
|
|
244
|
+
onNodeStart: (nodeId) => {
|
|
245
|
+
broadcast({ type: 'node:progress', payload: { nodeId, progress: 0, phase: 'start' } });
|
|
246
|
+
},
|
|
247
|
+
onNodeComplete: (nodeId, output, timeMs) => {
|
|
248
|
+
const cached = !!(output && output._fromCache);
|
|
249
|
+
broadcast({ type: 'node:result', payload: { nodeId, status: 'done', cached, timeMs } });
|
|
250
|
+
},
|
|
251
|
+
onNodeSkipped: (nodeId) => {
|
|
252
|
+
broadcast({ type: 'node:result', payload: { nodeId, status: 'skipped' } });
|
|
253
|
+
},
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
broadcast({ type: 'graph:executed', payload: { totalTime: result.totalTime, log: result.log } });
|
|
257
|
+
|
|
258
|
+
// Save to workflow file if configured
|
|
259
|
+
if (workflowFile) {
|
|
260
|
+
try {
|
|
261
|
+
await writeFile(resolve(workflowFile), JSON.stringify(graph.toJSON(), null, 2));
|
|
262
|
+
} catch (err) {
|
|
263
|
+
log(`⚠️ Could not save workflow: ${err.message}`);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return result;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Execute graph via HTTP and return result
|
|
272
|
+
* @param {import('http').ServerResponse} res
|
|
273
|
+
*/
|
|
274
|
+
async function executeGraph(res) {
|
|
275
|
+
try {
|
|
276
|
+
const result = await executeAndStream();
|
|
277
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
278
|
+
res.end(JSON.stringify({
|
|
279
|
+
ok: true,
|
|
280
|
+
totalTime: result.totalTime,
|
|
281
|
+
outputs: result.outputs,
|
|
282
|
+
log: result.log,
|
|
283
|
+
}));
|
|
284
|
+
} catch (err) {
|
|
285
|
+
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
286
|
+
res.end(JSON.stringify({ error: err.message }));
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// ─── File Watching ────────────────────────────────
|
|
291
|
+
|
|
292
|
+
if (watchFiles && workflowFile) {
|
|
293
|
+
const wfPath = resolve(workflowFile);
|
|
294
|
+
let debounce = null;
|
|
295
|
+
const ac = new AbortController();
|
|
296
|
+
|
|
297
|
+
(async () => {
|
|
298
|
+
try {
|
|
299
|
+
const watcher = fsWatch(wfPath, { signal: ac.signal });
|
|
300
|
+
for await (const event of watcher) {
|
|
301
|
+
if (debounce) clearTimeout(debounce);
|
|
302
|
+
debounce = setTimeout(async () => {
|
|
303
|
+
try {
|
|
304
|
+
const json = await readFile(wfPath, 'utf-8');
|
|
305
|
+
const data = JSON.parse(json);
|
|
306
|
+
graph = deserialize(data);
|
|
307
|
+
broadcast({ type: 'graph:update', payload: data });
|
|
308
|
+
log(`📄 Workflow reloaded: ${workflowFile}`);
|
|
309
|
+
} catch (err) {
|
|
310
|
+
log(`⚠️ Workflow reload error: ${err.message}`);
|
|
311
|
+
}
|
|
312
|
+
}, 200);
|
|
313
|
+
}
|
|
314
|
+
} catch (err) {
|
|
315
|
+
if (err.name !== 'AbortError') log(`⚠️ Workflow watch error: ${err.message}`);
|
|
316
|
+
}
|
|
317
|
+
})();
|
|
318
|
+
|
|
319
|
+
watchers.push(() => ac.abort());
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ─── Start & Close ────────────────────────────────
|
|
323
|
+
|
|
324
|
+
await new Promise((resolve) => httpServer.listen(port, resolve));
|
|
325
|
+
log(`🚀 symbiote-node server on http://localhost:${port}`);
|
|
326
|
+
async function close() {
|
|
327
|
+
for (const stop of watchers) {
|
|
328
|
+
if (typeof stop === 'function') stop();
|
|
329
|
+
}
|
|
330
|
+
for (const client of clients) {
|
|
331
|
+
client.close();
|
|
332
|
+
}
|
|
333
|
+
wss.close();
|
|
334
|
+
await new Promise((resolve) => httpServer.close(resolve));
|
|
335
|
+
log('🛑 Server stopped');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return { server: httpServer, wss, graph, executor, broadcast, close };
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Read HTTP request body
|
|
343
|
+
* @param {import('http').IncomingMessage} req
|
|
344
|
+
* @returns {Promise<string>}
|
|
345
|
+
*/
|
|
346
|
+
function readBody(req) {
|
|
347
|
+
return new Promise((resolve, reject) => {
|
|
348
|
+
let body = '';
|
|
349
|
+
req.on('data', (chunk) => { body += chunk; });
|
|
350
|
+
req.on('end', () => resolve(body));
|
|
351
|
+
req.on('error', reject);
|
|
352
|
+
});
|
|
353
|
+
}
|