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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "project-graph-mcp",
3
- "version": "2.3.0",
3
+ "version": "2.3.2",
4
4
  "type": "module",
5
5
  "description": "MCP server for AI agents — project graph, code quality analysis, visual web explorer. JS, TS, Python, Go.",
6
6
  "main": "src/network/server.js",
@@ -10,7 +10,6 @@
10
10
  "publishConfig": {
11
11
  "access": "public"
12
12
  },
13
-
14
13
  "scripts": {
15
14
  "start": "node src/network/server.js",
16
15
  "test": "node --test tests/*.test.js"
@@ -40,7 +39,6 @@
40
39
  "license": "MIT",
41
40
  "dependencies": {
42
41
  "@symbiotejs/symbiote": "^3.2.1",
43
- "symbiote-node": "file:vendor/symbiote-node",
44
42
  "ws": "^8.20.0"
45
43
  },
46
44
  "engines": {
Binary file
@@ -1,6 +1,6 @@
1
1
  // @ctx .context/src/network/web-server.ctx
2
2
  import e from"node:http";import t from"node:fs";import o from"node:path";import n from"node:crypto";import{fileURLToPath as a}from"node:url";import{createRequire as _createRequire}from"node:module";import{WebSocketServer as s}from"ws";import{createServer as i}from"../mcp/mcp-server.js";import c from"../core/event-bus.js";import{registerService as r}from"./local-gateway.js";import{compressFile as _cf}from"../compact/compress.js";import{expandFile as _ef}from"../compact/expand.js";import{setRoots as _setRoots}from"../core/workspace.js";
3
- const d=o.dirname(a(import.meta.url)),p=o.join(d,"..","..");const _rq=_createRequire(import.meta.url);let _pkgVersion="0.0.0";try{_pkgVersion=JSON.parse(t.readFileSync(o.join(p,"package.json"),"utf8")).version}catch{}function _rv(k){try{const r=_rq.resolve(k);const marker=o.sep+"node_modules"+o.sep+k.replace(/\//g,o.sep);const idx=r.lastIndexOf(marker);if(idx>=0)return r.substring(0,idx+marker.length);return o.dirname(r)}catch{return o.join(p,"node_modules",...k.split("/"))}}const m=o.join(p,"web"),h={"symbiote-node":_rv("symbiote-node"),symbiote:_rv("@symbiotejs/symbiote")},f={".html":"text/html",".js":"text/javascript",".mjs":"text/javascript",".css":"text/css",".json":"application/json",".svg":"image/svg+xml",".png":"image/png",".ico":"image/x-icon",".woff2":"font/woff2"};
3
+ const d=o.dirname(a(import.meta.url)),p=o.join(d,"..","..");const _rq=_createRequire(import.meta.url);let _pkgVersion="0.0.0";try{_pkgVersion=JSON.parse(t.readFileSync(o.join(p,"package.json"),"utf8")).version}catch{}function _rv(k){try{const r=_rq.resolve(k);const marker=o.sep+"node_modules"+o.sep+k.replace(/\//g,o.sep);const idx=r.lastIndexOf(marker);if(idx>=0)return r.substring(0,idx+marker.length);return o.dirname(r)}catch{return o.join(p,"node_modules",...k.split("/"))}}const m=o.join(p,"web"),_symNodeVendor=o.join(p,"vendor","symbiote-node"),h={"symbiote-node":t.existsSync(_symNodeVendor)?_symNodeVendor:_rv("symbiote-node"),symbiote:_rv("@symbiotejs/symbiote")},f={".html":"text/html",".js":"text/javascript",".mjs":"text/javascript",".css":"text/css",".json":"application/json",".svg":"image/svg+xml",".png":"image/png",".ico":"image/x-icon",".woff2":"font/woff2"};
4
4
  function g(e,n){const a=o.normalize(e).replace(/^(\.\.[/\\])+/,""),s=a.match(/^[/\\]?vendor[/\\]([^/\\]+)[/\\]?(.*)/);let i,c;if(s&&h[s[1]]?(c=h[s[1]],i=o.join(c,s[2]||"index.js")):(c=m,i=o.join(m,"/"===a?"index.html":a)),!i.startsWith(c))return n.writeHead(403),void n.end("Forbidden");if(t.existsSync(i)&&t.statSync(i).isDirectory()&&(i=o.join(i,"index.html")),!t.existsSync(i))return n.writeHead(404),void n.end("Not Found");const r=o.extname(i),l=f[r]||"application/octet-stream",d=t.readFileSync(i);n.writeHead(200,{"Content-Type":l,"Cache-Control":"no-cache, no-store, must-revalidate"}),n.end(d)}
5
5
  function u(e){return n.createHash("sha1").update(e+"258EAFA5-E914-47DA-95CA-5AB5ADF35C70").digest("base64")}
6
6
  function y(e){const t=Buffer.from(e,"utf8"),o=t.length;let n;return o<126?(n=Buffer.alloc(2),n[0]=129,n[1]=o):o<65536?(n=Buffer.alloc(4),n[0]=129,n[1]=126,n.writeUInt16BE(o,2)):(n=Buffer.alloc(10),n[0]=129,n[1]=127,n.writeBigUInt64BE(BigInt(o),2)),Buffer.concat([n,t])}
@@ -0,0 +1,100 @@
1
+ /**
2
+ * AgentUICommands.js - Agent UI control command builders
3
+ *
4
+ * Pure data builders for WebSocket messages that control the UI.
5
+ * No WebSocket dependency — produces message objects for P23 bridge to send.
6
+ *
7
+ * @module agi-graph/AgentUICommands
8
+ */
9
+
10
+ /**
11
+ * @typedef {object} UICommand
12
+ * @property {string} type - Command type (ui:layout, ui:focus, etc.)
13
+ * @property {object} payload - Command payload
14
+ */
15
+
16
+ /**
17
+ * Switch UI layout — control which panels are visible
18
+ * @param {string[]} panels - Panel names to show
19
+ * @param {string} [split='horizontal'] - Split direction
20
+ * @returns {UICommand}
21
+ */
22
+ export function layout(panels, split = 'horizontal') {
23
+ return { type: 'ui:layout', payload: { panels, split } };
24
+ }
25
+
26
+ /**
27
+ * Focus on a specific panel or node
28
+ * @param {string} panel - Panel name to focus
29
+ * @param {string} [nodeId] - Optional node to zoom to
30
+ * @returns {UICommand}
31
+ */
32
+ export function focus(panel, nodeId) {
33
+ return { type: 'ui:focus', payload: { panel, nodeId } };
34
+ }
35
+
36
+ /**
37
+ * Select nodes on canvas
38
+ * @param {string[]} nodeIds - Node IDs to select
39
+ * @returns {UICommand}
40
+ */
41
+ export function select(nodeIds) {
42
+ return { type: 'ui:select', payload: { nodeIds } };
43
+ }
44
+
45
+ /**
46
+ * Navigate into a compound node
47
+ * @param {string} compoundId - Compound node ID to enter
48
+ * @returns {UICommand}
49
+ */
50
+ export function navigate(compoundId) {
51
+ return { type: 'ui:navigate', payload: { compoundId } };
52
+ }
53
+
54
+ /**
55
+ * Control timeline playback
56
+ * @param {'play'|'stop'|'seek'} action - Playback action
57
+ * @param {number} [frame] - Frame to seek to (for 'seek' action)
58
+ * @returns {UICommand}
59
+ */
60
+ export function playback(action, frame) {
61
+ const payload = { action };
62
+ if (frame !== undefined) payload.frame = frame;
63
+ return { type: 'ui:playback', payload };
64
+ }
65
+
66
+ /**
67
+ * Show notification to user
68
+ * @param {string} message - Notification text
69
+ * @param {'info'|'success'|'warning'|'error'} [type='info'] - Notification type
70
+ * @returns {UICommand}
71
+ */
72
+ export function notify(message, type = 'info') {
73
+ return { type: 'ui:notify', payload: { message, type } };
74
+ }
75
+
76
+ /**
77
+ * Move virtual agent cursor on canvas
78
+ * @param {number} x - X position
79
+ * @param {number} y - Y position
80
+ * @param {string} [label] - Cursor label text
81
+ * @returns {UICommand}
82
+ */
83
+ export function cursor(x, y, label) {
84
+ const payload = { x, y };
85
+ if (label) payload.label = label;
86
+ return { type: 'ui:cursor', payload };
87
+ }
88
+
89
+ /**
90
+ * All command types for reference
91
+ */
92
+ export const COMMAND_TYPES = [
93
+ 'ui:layout',
94
+ 'ui:focus',
95
+ 'ui:select',
96
+ 'ui:navigate',
97
+ 'ui:playback',
98
+ 'ui:notify',
99
+ 'ui:cursor',
100
+ ];
@@ -0,0 +1,371 @@
1
+ /**
2
+ * Executor.js - Topological sort execution engine
3
+ *
4
+ * Executes a directed acyclic graph (DAG) of nodes using
5
+ * Kahn's algorithm. Supports incremental execution,
6
+ * cache, async node processing, dynamic sockets,
7
+ * and compound node sub-graph execution.
8
+ *
9
+ * @module symbiote-node/Executor
10
+ */
11
+
12
+ import { getNodeType } from './Registry.js';
13
+ import { Graph } from './Graph.js';
14
+ import { runLifecycle } from './Lifecycle.js';
15
+
16
+ export class Executor {
17
+
18
+ constructor() {
19
+ /** @type {Map<string, any>} Cached outputs per node ID */
20
+ this._cache = new Map();
21
+
22
+ /** @type {Map<string, {key: string, outputs: object}>} Lifecycle cache store */
23
+ this._lifecycleCache = new Map();
24
+
25
+ /** @type {Set<string>} Nodes marked dirty (need re-execution) */
26
+ this._dirty = new Set();
27
+
28
+ /** @type {string|null} Currently executing node ID */
29
+ this.currentNode = null;
30
+
31
+ /** @type {Array<{nodeId: string, time: number, skipped: boolean, cached?: boolean, error?: string|null}>} */
32
+ this.executionLog = [];
33
+ }
34
+
35
+ /**
36
+ * Execute a graph
37
+ * @param {import('./Graph.js').Graph} graph
38
+ * @param {object} [options={}]
39
+ * @param {boolean} [options.cache=false] - Use incremental execution (skip unchanged)
40
+ * @param {function} [options.onNodeStart] - Callback(nodeId, node)
41
+ * @param {function} [options.onNodeComplete] - Callback(nodeId, output, timeMs)
42
+ * @param {function} [options.onNodeSkipped] - Callback(nodeId) for cached nodes
43
+ * @param {function} [options.onNodeCached] - Callback(nodeId, cacheHash) for lifecycle-cached
44
+ * @returns {Promise<{outputs: object, executionOrder: string[], log: Array, totalTime: number}>}
45
+ */
46
+ async run(graph, options = {}) {
47
+ const { cache = false, onNodeStart, onNodeComplete, onNodeSkipped, onNodeCached } = options;
48
+ const nodes = graph.nodes;
49
+ // Duck-typing: Editor has connections as Map, Graph has array
50
+ const connections = graph.connections instanceof Map
51
+ ? [...graph.connections.values()]
52
+ : graph.connections;
53
+
54
+ // Topological sort
55
+ const order = this._topologicalSort(nodes, connections);
56
+
57
+ // Execute in order
58
+ const results = new Map();
59
+ this.executionLog = [];
60
+
61
+ for (const nodeId of order) {
62
+ const node = nodes.get(nodeId);
63
+
64
+ // Skip cached clean nodes
65
+ if (cache && !this._dirty.has(nodeId) && this._cache.has(nodeId)) {
66
+ results.set(nodeId, this._cache.get(nodeId));
67
+ this.executionLog.push({ nodeId, time: 0, skipped: true });
68
+ if (onNodeSkipped) onNodeSkipped(nodeId);
69
+ continue;
70
+ }
71
+
72
+ if (onNodeStart) onNodeStart(nodeId, node);
73
+ this.currentNode = nodeId;
74
+ const startTime = performance.now();
75
+
76
+ // Resolve inputs from upstream connections
77
+ const inputs = this._resolveInputs(nodeId, connections, results);
78
+
79
+ // P22: Branch skipping — if node has incoming connections and
80
+ // all connected inputs are null, this node is on an inactive branch
81
+ const incomingConns = connections.filter(c => c.to === nodeId);
82
+ if (incomingConns.length > 0) {
83
+ const allNull = incomingConns.every(c => inputs[c.in] === null || inputs[c.in] === undefined);
84
+ // Skip merge nodes — they expect null from one branch
85
+ const isMergeType = node.type === 'flow/merge' || node.type === 'flow/wait-all';
86
+ if (allNull && !isMergeType) {
87
+ node._output = null;
88
+ results.set(nodeId, null);
89
+ const elapsed = performance.now() - startTime;
90
+ this.executionLog.push({ nodeId, time: elapsed, skipped: true, branchSkipped: true });
91
+ if (onNodeSkipped) onNodeSkipped(nodeId);
92
+ continue;
93
+ }
94
+ }
95
+
96
+ // Execute node processor
97
+ // Check for lifecycle hooks first, then fall back to process()
98
+ let output;
99
+ const typeDef = getNodeType(node.type);
100
+ const lifecycleHooks = typeDef?.lifecycle;
101
+
102
+ if (lifecycleHooks) {
103
+ // Lifecycle path: validate → cache → execute → postProcess
104
+ const cacheState = {
105
+ mode: node.cacheMode || 'auto',
106
+ store: this._lifecycleCache,
107
+ nodeId,
108
+ };
109
+
110
+ const lifecycleResult = await runLifecycle(lifecycleHooks, inputs, node.params, cacheState);
111
+
112
+ if (lifecycleResult.error) {
113
+ node._output = { _error: lifecycleResult.error };
114
+ node._cacheHash = lifecycleResult.cacheHash;
115
+ results.set(nodeId, node._output);
116
+ const elapsed = performance.now() - startTime;
117
+ this.executionLog.push({ nodeId, time: elapsed, skipped: false, cached: false, error: lifecycleResult.error });
118
+ if (onNodeComplete) onNodeComplete(nodeId, node._output, elapsed);
119
+ continue;
120
+ }
121
+
122
+ output = lifecycleResult.outputs;
123
+ node._cacheHash = lifecycleResult.cacheHash;
124
+
125
+ if (lifecycleResult.cached) {
126
+ if (onNodeCached) onNodeCached(nodeId, lifecycleResult.cacheHash);
127
+ }
128
+ } else {
129
+ // Legacy path: direct process() call
130
+ // Node-level process overrides type-level (for per-instance composition)
131
+ const processFn = node.process || typeDef?.process;
132
+
133
+ if (typeof processFn === 'function') {
134
+ output = await processFn(inputs, node.params);
135
+ } else {
136
+ // Passthrough: merge params with inputs
137
+ output = { ...node.params, ...inputs };
138
+ }
139
+ }
140
+
141
+ // Compound node: execute sub-graph if returned
142
+ if (output && output._subGraph) {
143
+ output = await this._executeSubGraph(output._subGraph, inputs, node.params);
144
+ }
145
+
146
+ // Dynamic sockets: process exposes runtime-generated outputs
147
+ if (output && output.dynamicOutputs && Array.isArray(output.dynamicOutputs)) {
148
+ node._dynamicSockets = output.dynamicOutputs;
149
+ }
150
+
151
+ // Store output in node
152
+ node._output = output;
153
+
154
+ results.set(nodeId, output);
155
+ this._cache.set(nodeId, output);
156
+ this._dirty.delete(nodeId);
157
+
158
+ const elapsed = performance.now() - startTime;
159
+ this.executionLog.push({ nodeId, time: elapsed, skipped: false });
160
+
161
+ if (onNodeComplete) onNodeComplete(nodeId, output, elapsed);
162
+ }
163
+
164
+ this.currentNode = null;
165
+
166
+ // Collect output nodes (no outgoing connections)
167
+ const outputNodeIds = this._findOutputNodes(nodes, connections);
168
+ const outputs = {};
169
+ for (const id of outputNodeIds) {
170
+ outputs[id] = results.get(id);
171
+ }
172
+
173
+ return {
174
+ outputs,
175
+ executionOrder: order,
176
+ log: this.executionLog,
177
+ totalTime: this.executionLog.reduce((sum, e) => sum + e.time, 0),
178
+ };
179
+ }
180
+
181
+ /**
182
+ * Mark a node as dirty (needs re-execution)
183
+ * Propagates downstream
184
+ * @param {string} nodeId
185
+ * @param {import('./Graph.js').Connection[]} connections
186
+ */
187
+ markDirty(nodeId, connections) {
188
+ if (this._dirty.has(nodeId)) return;
189
+ this._dirty.add(nodeId);
190
+ for (const conn of connections) {
191
+ if (conn.from === nodeId) {
192
+ this.markDirty(conn.to, connections);
193
+ }
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Clear all caches
199
+ */
200
+ clearCache() {
201
+ this._cache.clear();
202
+ this._lifecycleCache.clear();
203
+ this._dirty.clear();
204
+ }
205
+
206
+ /**
207
+ * Topological sort using Kahn's algorithm
208
+ * @param {Map<string, object>} nodes
209
+ * @param {Array<{from: string, to: string}>} connections
210
+ * @returns {string[]} Node IDs in execution order
211
+ * @private
212
+ */
213
+ _topologicalSort(nodes, connections) {
214
+ const inDegree = new Map();
215
+ const adjacency = new Map();
216
+
217
+ // Only include connected nodes (skip orphans)
218
+ const connectedIds = new Set();
219
+ for (const conn of connections) {
220
+ connectedIds.add(conn.from);
221
+ connectedIds.add(conn.to);
222
+ }
223
+
224
+ // Include source nodes (no incoming connections but exist in graph)
225
+ for (const id of nodes.keys()) {
226
+ if (connectedIds.has(id) || !connections.some(c => c.to === id || c.from === id)) {
227
+ // Include connected nodes; orphans are skipped
228
+ }
229
+ }
230
+
231
+ for (const id of connectedIds) {
232
+ if (!nodes.has(id)) continue;
233
+ inDegree.set(id, 0);
234
+ adjacency.set(id, []);
235
+ }
236
+
237
+ // Also include source nodes (nodes with outgoing but no incoming)
238
+ for (const id of nodes.keys()) {
239
+ if (!connectedIds.has(id)) continue;
240
+ if (!inDegree.has(id)) {
241
+ inDegree.set(id, 0);
242
+ adjacency.set(id, []);
243
+ }
244
+ }
245
+
246
+ for (const conn of connections) {
247
+ if (!adjacency.has(conn.from) || !inDegree.has(conn.to)) continue;
248
+ adjacency.get(conn.from).push(conn.to);
249
+ inDegree.set(conn.to, (inDegree.get(conn.to) || 0) + 1);
250
+ }
251
+
252
+ // Kahn's algorithm
253
+ const queue = [];
254
+ for (const [id, degree] of inDegree) {
255
+ if (degree === 0) queue.push(id);
256
+ }
257
+
258
+ const result = [];
259
+ while (queue.length > 0) {
260
+ const nodeId = queue.shift();
261
+ result.push(nodeId);
262
+ for (const neighbor of (adjacency.get(nodeId) || [])) {
263
+ const nd = inDegree.get(neighbor) - 1;
264
+ inDegree.set(neighbor, nd);
265
+ if (nd === 0) queue.push(neighbor);
266
+ }
267
+ }
268
+
269
+ // Cycle detection
270
+ const connectedCount = inDegree.size;
271
+ if (result.length < connectedCount) {
272
+ const remaining = [...inDegree.keys()].filter(id => !result.includes(id));
273
+ throw new Error(`Graph contains cycle(s). Nodes involved: ${remaining.join(', ')}`);
274
+ }
275
+
276
+ return result;
277
+ }
278
+
279
+ /**
280
+ * Resolve inputs for a node from upstream connections
281
+ * @param {string} nodeId
282
+ * @param {Array<{from: string, out: string, to: string, in: string}>} connections
283
+ * @param {Map<string, any>} results
284
+ * @returns {object}
285
+ * @private
286
+ */
287
+ _resolveInputs(nodeId, connections, results) {
288
+ const inputs = {};
289
+ for (const conn of connections) {
290
+ if (conn.to !== nodeId) continue;
291
+ const upstream = results.get(conn.from);
292
+ if (upstream === undefined) continue;
293
+
294
+ let value;
295
+ if (upstream && typeof upstream === 'object' && conn.out in upstream) {
296
+ value = upstream[conn.out];
297
+ } else if (upstream && typeof upstream === 'object' && upstream.dynamicOutputs) {
298
+ // Dynamic routing node (switch): missing key = inactive branch
299
+ value = null;
300
+ } else {
301
+ value = upstream;
302
+ }
303
+
304
+ // Multiple connections to same input: first non-null wins
305
+ if (inputs[conn.in] !== undefined && inputs[conn.in] !== null) continue;
306
+ inputs[conn.in] = value;
307
+ }
308
+ return inputs;
309
+ }
310
+
311
+ /**
312
+ * Find output nodes (no outgoing connections)
313
+ * @param {Map<string, object>} nodes
314
+ * @param {Array<{from: string}>} connections
315
+ * @returns {string[]}
316
+ * @private
317
+ */
318
+ _findOutputNodes(nodes, connections) {
319
+ const hasOutgoing = new Set();
320
+ for (const conn of connections) {
321
+ hasOutgoing.add(conn.from);
322
+ }
323
+ const connected = new Set();
324
+ for (const conn of connections) {
325
+ connected.add(conn.from);
326
+ connected.add(conn.to);
327
+ }
328
+ return [...connected].filter(id => !hasOutgoing.has(id) && nodes.has(id));
329
+ }
330
+
331
+ /**
332
+ * Execute a compound node's sub-graph
333
+ * @param {object} subGraphData - Sub-graph JSON definition
334
+ * @param {object} parentInputs - Inputs from parent graph
335
+ * @param {object} parentParams - Parent node params
336
+ * @returns {Promise<object>} Merged outputs from sub-graph output nodes
337
+ * @private
338
+ */
339
+ async _executeSubGraph(subGraphData, parentInputs, parentParams) {
340
+ const subGraph = new Graph(subGraphData);
341
+
342
+ // Inject parent inputs into sub-graph input nodes
343
+ for (const node of subGraph.nodes.values()) {
344
+ if (node.type === 'compound/input') {
345
+ const injectedOutput = { ...parentInputs, ...parentParams };
346
+ node._output = injectedOutput;
347
+ node.process = () => injectedOutput;
348
+ }
349
+ }
350
+
351
+ // Execute sub-graph with a fresh executor
352
+ const subExecutor = new Executor();
353
+ const result = await subExecutor.run(subGraph);
354
+
355
+ // Merge all output node results
356
+ const merged = {};
357
+ for (const [id, output] of Object.entries(result.outputs)) {
358
+ const node = subGraph.getNode(id);
359
+ if (node.type === 'compound/output' && output) {
360
+ Object.assign(merged, output);
361
+ }
362
+ }
363
+
364
+ // Include dynamic socket info if sub-graph produces segments
365
+ if (Object.keys(merged).length > 0) {
366
+ merged.dynamicOutputs = Object.keys(merged);
367
+ }
368
+
369
+ return merged;
370
+ }
371
+ }