project-graph-mcp 2.3.0 → 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.
Files changed (66) hide show
  1. package/package.json +1 -3
  2. package/project-graph-mcp-2.3.0.tgz +0 -0
  3. package/src/network/web-server.js +1 -1
  4. package/vendor/symbiote-node/engine/AgentUICommands.js +100 -0
  5. package/vendor/symbiote-node/engine/Executor.js +371 -0
  6. package/vendor/symbiote-node/engine/Graph.js +314 -0
  7. package/vendor/symbiote-node/engine/GraphServer.js +353 -0
  8. package/vendor/symbiote-node/engine/HandlerLoader.js +145 -0
  9. package/vendor/symbiote-node/engine/History.js +83 -0
  10. package/vendor/symbiote-node/engine/Lifecycle.js +118 -0
  11. package/vendor/symbiote-node/engine/Persistence.js +84 -0
  12. package/vendor/symbiote-node/engine/Registry.js +264 -0
  13. package/vendor/symbiote-node/engine/SocketTypes.js +79 -0
  14. package/vendor/symbiote-node/engine/cli.js +404 -0
  15. package/vendor/symbiote-node/engine/index.js +56 -0
  16. package/vendor/symbiote-node/engine/nanoid.js +28 -0
  17. package/vendor/symbiote-node/engine/package.json +26 -0
  18. package/vendor/symbiote-node/engine/packs/ai/beat-detect.handler.js +215 -0
  19. package/vendor/symbiote-node/engine/packs/ai/content-adapt.handler.js +238 -0
  20. package/vendor/symbiote-node/engine/packs/ai/face-detect.handler.js +287 -0
  21. package/vendor/symbiote-node/engine/packs/ai/grok-generate.handler.js +565 -0
  22. package/vendor/symbiote-node/engine/packs/ai/kling-lipsync.handler.js +414 -0
  23. package/vendor/symbiote-node/engine/packs/ai/lesson-generate.handler.js +343 -0
  24. package/vendor/symbiote-node/engine/packs/ai/opencode.handler.js +164 -0
  25. package/vendor/symbiote-node/engine/packs/ai/replicate-lipsync.handler.js +341 -0
  26. package/vendor/symbiote-node/engine/packs/ai/tts.handler.js +241 -0
  27. package/vendor/symbiote-node/engine/packs/ai/whisper.handler.js +191 -0
  28. package/vendor/symbiote-node/engine/packs/data/db-query.handler.js +67 -0
  29. package/vendor/symbiote-node/engine/packs/data/news-accumulate.handler.js +281 -0
  30. package/vendor/symbiote-node/engine/packs/data/personas.handler.js +160 -0
  31. package/vendor/symbiote-node/engine/packs/data/prompt-loader.handler.js +193 -0
  32. package/vendor/symbiote-node/engine/packs/data/roles.handler.js +216 -0
  33. package/vendor/symbiote-node/engine/packs/data/rss-feed.handler.js +244 -0
  34. package/vendor/symbiote-node/engine/packs/debug/inject.handler.js +52 -0
  35. package/vendor/symbiote-node/engine/packs/flow/agent.handler.js +73 -0
  36. package/vendor/symbiote-node/engine/packs/flow/if.handler.js +107 -0
  37. package/vendor/symbiote-node/engine/packs/flow/loop.handler.js +58 -0
  38. package/vendor/symbiote-node/engine/packs/flow/merge.handler.js +60 -0
  39. package/vendor/symbiote-node/engine/packs/flow/retry.handler.js +65 -0
  40. package/vendor/symbiote-node/engine/packs/flow/switch.handler.js +64 -0
  41. package/vendor/symbiote-node/engine/packs/flow/wait-all.handler.js +39 -0
  42. package/vendor/symbiote-node/engine/packs/io/http-request.handler.js +82 -0
  43. package/vendor/symbiote-node/engine/packs/io/read-file.handler.js +60 -0
  44. package/vendor/symbiote-node/engine/packs/io/write-file.handler.js +63 -0
  45. package/vendor/symbiote-node/engine/packs/transform/anchor-match.handler.js +494 -0
  46. package/vendor/symbiote-node/engine/packs/transform/effects-skeleton.handler.js +417 -0
  47. package/vendor/symbiote-node/engine/packs/transform/json-parse.handler.js +43 -0
  48. package/vendor/symbiote-node/engine/packs/transform/lipsync-select.handler.js +339 -0
  49. package/vendor/symbiote-node/engine/packs/transform/riopla-adapt.handler.js +432 -0
  50. package/vendor/symbiote-node/engine/packs/transform/set.handler.js +57 -0
  51. package/vendor/symbiote-node/engine/packs/transform/template-builder.handler.js +134 -0
  52. package/vendor/symbiote-node/engine/packs/transform/template.handler.js +79 -0
  53. package/vendor/symbiote-node/engine/packs/transform/timeline-build.handler.js +399 -0
  54. package/vendor/symbiote-node/engine/packs/util/delay.handler.js +39 -0
  55. package/vendor/symbiote-node/engine/packs/util/log.handler.js +44 -0
  56. package/vendor/symbiote-node/engine/packs/video-pack.js +323 -0
  57. package/vendor/symbiote-node/package.json +2 -2
  58. package/web/app.js +6 -3
  59. package/web/components/canvas-graph.js +50 -11
  60. package/web/components/code-block.js +1 -1
  61. package/web/components/event-feed/MiniGraphWidget.js +105 -15
  62. package/web/components/follow-ribbon.js +134 -0
  63. package/web/follow-controller.js +241 -0
  64. package/web/panels/code-viewer.js +1 -1
  65. package/web/panels/dep-graph.js +21 -42
  66. package/web/style.css +6 -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
+ }