nebula-notebook 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.
Files changed (97) hide show
  1. package/README.md +222 -0
  2. package/bin/nebula-notebook.js +33 -0
  3. package/dist/assets/index-C1h_sArD.css +32 -0
  4. package/dist/assets/index-CDSTBon8.js +658 -0
  5. package/dist/favicon.svg +11 -0
  6. package/dist/index.html +73 -0
  7. package/node-server/dist/app.d.ts +5 -0
  8. package/node-server/dist/app.js +38 -0
  9. package/node-server/dist/auth/auth-middleware.d.ts +24 -0
  10. package/node-server/dist/auth/auth-middleware.js +276 -0
  11. package/node-server/dist/auth/auth-service.d.ts +84 -0
  12. package/node-server/dist/auth/auth-service.js +265 -0
  13. package/node-server/dist/auth/index.d.ts +3 -0
  14. package/node-server/dist/auth/index.js +8 -0
  15. package/node-server/dist/cluster/client-registration.d.ts +43 -0
  16. package/node-server/dist/cluster/client-registration.js +217 -0
  17. package/node-server/dist/cluster/cluster-secret.d.ts +11 -0
  18. package/node-server/dist/cluster/cluster-secret.js +90 -0
  19. package/node-server/dist/cluster/kernel-proxy.d.ts +100 -0
  20. package/node-server/dist/cluster/kernel-proxy.js +361 -0
  21. package/node-server/dist/cluster/server-registry.d.ts +109 -0
  22. package/node-server/dist/cluster/server-registry.js +217 -0
  23. package/node-server/dist/config/output-limits.d.ts +7 -0
  24. package/node-server/dist/config/output-limits.js +10 -0
  25. package/node-server/dist/discovery/discovery-service.d.ts +198 -0
  26. package/node-server/dist/discovery/discovery-service.js +811 -0
  27. package/node-server/dist/discovery/index.d.ts +5 -0
  28. package/node-server/dist/discovery/index.js +21 -0
  29. package/node-server/dist/discovery/types.d.ts +48 -0
  30. package/node-server/dist/discovery/types.js +24 -0
  31. package/node-server/dist/fs/fs-service.d.ts +218 -0
  32. package/node-server/dist/fs/fs-service.js +1422 -0
  33. package/node-server/dist/fs/index.d.ts +5 -0
  34. package/node-server/dist/fs/index.js +21 -0
  35. package/node-server/dist/fs/types.d.ts +132 -0
  36. package/node-server/dist/fs/types.js +5 -0
  37. package/node-server/dist/index.d.ts +13 -0
  38. package/node-server/dist/index.js +556 -0
  39. package/node-server/dist/kernel/default-kernel.d.ts +5 -0
  40. package/node-server/dist/kernel/default-kernel.js +138 -0
  41. package/node-server/dist/kernel/index.d.ts +7 -0
  42. package/node-server/dist/kernel/index.js +23 -0
  43. package/node-server/dist/kernel/kernel-service.d.ts +290 -0
  44. package/node-server/dist/kernel/kernel-service.js +1714 -0
  45. package/node-server/dist/kernel/kernelspec.d.ts +29 -0
  46. package/node-server/dist/kernel/kernelspec.js +210 -0
  47. package/node-server/dist/kernel/session-store.d.ts +87 -0
  48. package/node-server/dist/kernel/session-store.js +303 -0
  49. package/node-server/dist/kernel/types.d.ts +143 -0
  50. package/node-server/dist/kernel/types.js +17 -0
  51. package/node-server/dist/llm/index.d.ts +5 -0
  52. package/node-server/dist/llm/index.js +21 -0
  53. package/node-server/dist/llm/llm-service.d.ts +77 -0
  54. package/node-server/dist/llm/llm-service.js +454 -0
  55. package/node-server/dist/llm/types.d.ts +40 -0
  56. package/node-server/dist/llm/types.js +15 -0
  57. package/node-server/dist/notebook/cell-metadata.d.ts +27 -0
  58. package/node-server/dist/notebook/cell-metadata.js +76 -0
  59. package/node-server/dist/notebook/headless-handler.d.ts +127 -0
  60. package/node-server/dist/notebook/headless-handler.js +1530 -0
  61. package/node-server/dist/notebook/notebook-websocket.d.ts +12 -0
  62. package/node-server/dist/notebook/notebook-websocket.js +103 -0
  63. package/node-server/dist/notebook/operation-router.d.ts +115 -0
  64. package/node-server/dist/notebook/operation-router.js +641 -0
  65. package/node-server/dist/notebook/undoRedoManager.d.ts +194 -0
  66. package/node-server/dist/notebook/undoRedoManager.js +558 -0
  67. package/node-server/dist/output/display-data.d.ts +14 -0
  68. package/node-server/dist/output/display-data.js +134 -0
  69. package/node-server/dist/resources/resource-service.d.ts +69 -0
  70. package/node-server/dist/resources/resource-service.js +363 -0
  71. package/node-server/dist/routes/auth.d.ts +5 -0
  72. package/node-server/dist/routes/auth.js +61 -0
  73. package/node-server/dist/routes/cluster.d.ts +7 -0
  74. package/node-server/dist/routes/cluster.js +94 -0
  75. package/node-server/dist/routes/fs.d.ts +7 -0
  76. package/node-server/dist/routes/fs.js +392 -0
  77. package/node-server/dist/routes/kernel.d.ts +13 -0
  78. package/node-server/dist/routes/kernel.js +637 -0
  79. package/node-server/dist/routes/llm.d.ts +8 -0
  80. package/node-server/dist/routes/llm.js +105 -0
  81. package/node-server/dist/routes/notebook.d.ts +10 -0
  82. package/node-server/dist/routes/notebook.js +335 -0
  83. package/node-server/dist/routes/python.d.ts +8 -0
  84. package/node-server/dist/routes/python.js +187 -0
  85. package/node-server/dist/routes/resources.d.ts +7 -0
  86. package/node-server/dist/routes/resources.js +77 -0
  87. package/node-server/dist/scripts/show-auth-qr.d.ts +1 -0
  88. package/node-server/dist/scripts/show-auth-qr.js +81 -0
  89. package/node-server/dist/terminal/pty-manager.d.ts +100 -0
  90. package/node-server/dist/terminal/pty-manager.js +246 -0
  91. package/node-server/dist/terminal/server.d.ts +19 -0
  92. package/node-server/dist/terminal/server.js +254 -0
  93. package/node-server/dist/terminal/types.d.ts +50 -0
  94. package/node-server/dist/terminal/types.js +9 -0
  95. package/node-server/package.json +45 -0
  96. package/package.json +99 -0
  97. package/scripts/postinstall.cjs +26 -0
@@ -0,0 +1,641 @@
1
+ "use strict";
2
+ /**
3
+ * Notebook Operation Router
4
+ *
5
+ * Routes notebook operations to either:
6
+ * 1. Connected UI via WebSocket (real-time collaboration)
7
+ * 2. Headless notebook manager (file-based when no UI)
8
+ *
9
+ * From the agent's (MCP) perspective, operations look identical regardless of mode.
10
+ */
11
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
12
+ if (k2 === undefined) k2 = k;
13
+ var desc = Object.getOwnPropertyDescriptor(m, k);
14
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
15
+ desc = { enumerable: true, get: function() { return m[k]; } };
16
+ }
17
+ Object.defineProperty(o, k2, desc);
18
+ }) : (function(o, m, k, k2) {
19
+ if (k2 === undefined) k2 = k;
20
+ o[k2] = m[k];
21
+ }));
22
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
23
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
24
+ }) : function(o, v) {
25
+ o["default"] = v;
26
+ });
27
+ var __importStar = (this && this.__importStar) || (function () {
28
+ var ownKeys = function(o) {
29
+ ownKeys = Object.getOwnPropertyNames || function (o) {
30
+ var ar = [];
31
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
32
+ return ar;
33
+ };
34
+ return ownKeys(o);
35
+ };
36
+ return function (mod) {
37
+ if (mod && mod.__esModule) return mod;
38
+ var result = {};
39
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
40
+ __setModuleDefault(result, mod);
41
+ return result;
42
+ };
43
+ })();
44
+ Object.defineProperty(exports, "__esModule", { value: true });
45
+ exports.operationRouter = exports.OperationRouter = void 0;
46
+ const path = __importStar(require("path"));
47
+ const fs = __importStar(require("fs"));
48
+ const os = __importStar(require("os"));
49
+ const ws_1 = require("ws");
50
+ const AGENT_LOCK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
51
+ const UI_STALE_TIMEOUT_MS = 45 * 1000; // 45 seconds
52
+ function normalizeNotebookPath(notebookPath) {
53
+ // IMPORTANT: This must match how the kernel service normalizes file paths.
54
+ // If the UI registers `~/foo.ipynb` but the kernel associates the session
55
+ // with `/home/user/foo.ipynb`, `hasUI()` would return false and background
56
+ // tasks (like output persistence) can incorrectly write the file and trigger
57
+ // spurious conflict dialogs.
58
+ return path.resolve(notebookPath.replace(/^~/, os.homedir()));
59
+ }
60
+ class OperationRouter {
61
+ uiConnections = new Map();
62
+ headlessHandler = null;
63
+ operationTimeout = 30000; // 30 seconds
64
+ agentLocks = new Map(); // path -> lock
65
+ setHeadlessHandler(handler) {
66
+ this.headlessHandler = handler;
67
+ }
68
+ /**
69
+ * Register a UI connection for a notebook path.
70
+ */
71
+ async registerUI(websocket, notebookPath) {
72
+ const normalizedPath = normalizeNotebookPath(notebookPath);
73
+ // Close existing connection if any
74
+ if (this.uiConnections.has(normalizedPath)) {
75
+ const oldConn = this.uiConnections.get(normalizedPath);
76
+ if (oldConn.websocket !== websocket) {
77
+ for (const [, request] of oldConn.pendingRequests) {
78
+ clearTimeout(request.timeoutId);
79
+ request.reject(new Error('Connection replaced'));
80
+ }
81
+ // Ensure the old connection doesn't later unregister the new one.
82
+ try {
83
+ oldConn.websocket.close(1000, 'Connection replaced');
84
+ }
85
+ catch {
86
+ // Ignore close errors from already-closed sockets.
87
+ }
88
+ }
89
+ }
90
+ this.uiConnections.set(normalizedPath, {
91
+ websocket,
92
+ notebookPath: normalizedPath,
93
+ pendingRequests: new Map(),
94
+ lastActivityAt: Date.now(),
95
+ });
96
+ console.log(`[OperationRouter] UI registered for: ${normalizedPath}`);
97
+ }
98
+ /**
99
+ * Unregister a UI connection.
100
+ */
101
+ unregisterUI(websocket, notebookPath) {
102
+ const normalizedPath = normalizeNotebookPath(notebookPath);
103
+ if (this.uiConnections.has(normalizedPath)) {
104
+ const conn = this.uiConnections.get(normalizedPath);
105
+ // If we replaced the UI connection and the old one closes later, ignore it.
106
+ if (conn.websocket !== websocket) {
107
+ return;
108
+ }
109
+ for (const [, request] of conn.pendingRequests) {
110
+ clearTimeout(request.timeoutId);
111
+ request.reject(new Error('UI disconnected'));
112
+ }
113
+ this.uiConnections.delete(normalizedPath);
114
+ console.log(`[OperationRouter] UI unregistered for: ${normalizedPath}`);
115
+ }
116
+ }
117
+ notifyKernelChanged(notebookPath, payload) {
118
+ const normalizedPath = normalizeNotebookPath(notebookPath);
119
+ const conn = this.getResponsiveUIConnection(normalizedPath);
120
+ if (!conn) {
121
+ return;
122
+ }
123
+ try {
124
+ conn.websocket.send(JSON.stringify({
125
+ type: 'kernelChanged',
126
+ kernelName: payload.kernelName,
127
+ serverId: payload.serverId ?? null,
128
+ }));
129
+ }
130
+ catch (err) {
131
+ console.warn(`[OperationRouter] Failed to notify UI about kernel change for ${normalizedPath}:`, err);
132
+ }
133
+ }
134
+ /**
135
+ * Check if a UI is connected for the notebook.
136
+ */
137
+ hasUI(notebookPath) {
138
+ const normalizedPath = normalizeNotebookPath(notebookPath);
139
+ return this.getResponsiveUIConnection(normalizedPath) !== null;
140
+ }
141
+ /**
142
+ * Record UI WebSocket activity so stale connections stop intercepting operations.
143
+ */
144
+ markUIActivity(websocket, notebookPath) {
145
+ const normalizedPath = normalizeNotebookPath(notebookPath);
146
+ const conn = this.uiConnections.get(normalizedPath);
147
+ if (!conn || conn.websocket !== websocket) {
148
+ return;
149
+ }
150
+ conn.lastActivityAt = Date.now();
151
+ }
152
+ /**
153
+ * Start an agent session with locking.
154
+ * Returns success if lock acquired, error if already locked by another agent.
155
+ */
156
+ startAgentSession(notebookPath, agentId, metadata) {
157
+ const normalizedPath = normalizeNotebookPath(notebookPath);
158
+ try {
159
+ const stat = fs.statSync(normalizedPath);
160
+ if (!stat.isFile()) {
161
+ return { success: false, error: `Notebook not found: ${normalizedPath}` };
162
+ }
163
+ }
164
+ catch {
165
+ return { success: false, error: `Notebook not found: ${normalizedPath}` };
166
+ }
167
+ // Clean up expired locks first
168
+ this.cleanupExpiredLocks();
169
+ const existingLock = this.agentLocks.get(normalizedPath);
170
+ if (existingLock) {
171
+ if (existingLock.agentId === agentId) {
172
+ // Same agent re-acquiring lock - refresh timeout
173
+ existingLock.expiresAt = Date.now() + AGENT_LOCK_TIMEOUT_MS;
174
+ console.log(`[OperationRouter] Agent session refreshed for: ${normalizedPath} (agent: ${agentId})`);
175
+ return { success: true, lock: existingLock };
176
+ }
177
+ else {
178
+ // Different agent has the lock
179
+ const lockedBy = existingLock.clientName || existingLock.agentId;
180
+ console.log(`[OperationRouter] Agent session BLOCKED for: ${normalizedPath} (requested by: ${agentId}, held by: ${lockedBy})`);
181
+ return {
182
+ success: false,
183
+ error: `Notebook is locked by ${lockedBy}. Lock expires in ${Math.ceil((existingLock.expiresAt - Date.now()) / 1000)}s.`,
184
+ lock: existingLock,
185
+ };
186
+ }
187
+ }
188
+ // Acquire new lock
189
+ const now = Date.now();
190
+ const newLock = {
191
+ agentId,
192
+ clientName: metadata?.clientName,
193
+ clientVersion: metadata?.clientVersion,
194
+ expiresAt: now + AGENT_LOCK_TIMEOUT_MS,
195
+ notebookPath: normalizedPath,
196
+ lockedAt: now,
197
+ };
198
+ this.agentLocks.set(normalizedPath, newLock);
199
+ console.log(`[OperationRouter] Agent session started for: ${normalizedPath} (agent: ${agentId}, client: ${metadata?.clientName || 'unknown'})`);
200
+ return { success: true, lock: newLock };
201
+ }
202
+ /**
203
+ * End an agent session and release the lock.
204
+ * Only the lock holder can release the lock.
205
+ */
206
+ endAgentSession(notebookPath, agentId) {
207
+ const normalizedPath = normalizeNotebookPath(notebookPath);
208
+ const existingLock = this.agentLocks.get(normalizedPath);
209
+ if (!existingLock) {
210
+ // No lock exists - that's fine
211
+ return { success: true };
212
+ }
213
+ if (existingLock.agentId !== agentId) {
214
+ console.log(`[OperationRouter] Agent session end REJECTED for: ${normalizedPath} (requested by: ${agentId}, held by: ${existingLock.agentId})`);
215
+ return {
216
+ success: false,
217
+ error: 'Cannot release lock held by another agent'
218
+ };
219
+ }
220
+ this.agentLocks.delete(normalizedPath);
221
+ console.log(`[OperationRouter] Agent session ended for: ${normalizedPath} (agent: ${agentId})`);
222
+ return { success: true };
223
+ }
224
+ /**
225
+ * Check if a notebook is in an agent session.
226
+ */
227
+ isAgentSession(notebookPath) {
228
+ const normalizedPath = normalizeNotebookPath(notebookPath);
229
+ const lock = this.agentLocks.get(normalizedPath);
230
+ return lock !== undefined && lock.expiresAt > Date.now();
231
+ }
232
+ /**
233
+ * Get the agent ID holding the lock, if any.
234
+ */
235
+ getAgentLock(notebookPath) {
236
+ const normalizedPath = normalizeNotebookPath(notebookPath);
237
+ const lock = this.agentLocks.get(normalizedPath);
238
+ if (lock && lock.expiresAt > Date.now()) {
239
+ return lock;
240
+ }
241
+ return null;
242
+ }
243
+ /**
244
+ * Refresh lock timeout for an agent operation.
245
+ */
246
+ refreshAgentLock(notebookPath, agentId) {
247
+ const normalizedPath = normalizeNotebookPath(notebookPath);
248
+ const lock = this.agentLocks.get(normalizedPath);
249
+ if (lock && lock.agentId === agentId) {
250
+ lock.expiresAt = Date.now() + AGENT_LOCK_TIMEOUT_MS;
251
+ return true;
252
+ }
253
+ return false;
254
+ }
255
+ /**
256
+ * Clean up expired locks.
257
+ */
258
+ cleanupExpiredLocks() {
259
+ const now = Date.now();
260
+ for (const [path, lock] of this.agentLocks) {
261
+ if (lock.expiresAt <= now) {
262
+ console.log(`[OperationRouter] Agent lock expired for: ${path} (agent: ${lock.agentId})`);
263
+ this.agentLocks.delete(path);
264
+ }
265
+ }
266
+ }
267
+ /**
268
+ * Get the first available UI connection (for operations that can use any UI).
269
+ */
270
+ getAnyUIConnection() {
271
+ for (const [uiPath, conn] of this.uiConnections) {
272
+ if (this.isUIConnectionResponsive(conn)) {
273
+ return { path: uiPath, connection: conn };
274
+ }
275
+ }
276
+ return null;
277
+ }
278
+ isUIConnectionResponsive(conn) {
279
+ return conn.websocket.readyState === ws_1.WebSocket.OPEN && (Date.now() - conn.lastActivityAt) <= UI_STALE_TIMEOUT_MS;
280
+ }
281
+ getResponsiveUIConnection(notebookPath) {
282
+ const conn = this.uiConnections.get(notebookPath);
283
+ if (!conn) {
284
+ return null;
285
+ }
286
+ if (!this.isUIConnectionResponsive(conn)) {
287
+ return null;
288
+ }
289
+ return conn;
290
+ }
291
+ /**
292
+ * Apply a notebook operation.
293
+ */
294
+ async applyOperation(operation) {
295
+ const notebookPath = operation.notebookPath || '';
296
+ const normalizedPath = normalizeNotebookPath(notebookPath);
297
+ const opType = operation.type;
298
+ const agentId = operation.agentId;
299
+ // For createNotebook, route to ANY connected UI (not path-specific)
300
+ // This allows the UI to open the new notebook in a new tab
301
+ const isCreateNotebook = opType === 'createNotebook';
302
+ const anyUI = isCreateNotebook ? this.getAnyUIConnection() : null;
303
+ const hasUI = isCreateNotebook ? (anyUI !== null) : (this.getResponsiveUIConnection(normalizedPath) !== null);
304
+ const backend = hasUI ? 'ui' : 'headless';
305
+ // Clean up expired locks
306
+ this.cleanupExpiredLocks();
307
+ const lock = this.agentLocks.get(normalizedPath);
308
+ const isLocked = lock !== undefined && lock.expiresAt > Date.now();
309
+ console.log(`[OperationRouter] applyOperation:`);
310
+ console.log(` op_type: ${opType}`);
311
+ console.log(` notebook_path: ${notebookPath}`);
312
+ console.log(` normalized_path: ${normalizedPath}`);
313
+ console.log(` registered_uis: ${Array.from(this.uiConnections.keys())}`);
314
+ console.log(` has_ui: ${hasUI}`);
315
+ console.log(` is_locked: ${isLocked}${lock ? ` (by: ${lock.agentId})` : ''}`);
316
+ console.log(` request_agent_id: ${agentId || '(none)'}`);
317
+ // Handle startAgentSession/endAgentSession at router level (before forwarding to UI)
318
+ // This ensures server-side lock is always managed regardless of UI connection
319
+ if (opType === 'startAgentSession') {
320
+ const reqAgentId = operation.agentId || 'unknown';
321
+ const clientName = operation.clientName;
322
+ const clientVersion = operation.clientVersion;
323
+ const lastSessionTimestamp = operation.lastSessionTimestamp;
324
+ const result = this.startAgentSession(notebookPath, reqAgentId, { clientName, clientVersion });
325
+ // Also forward to UI for UI state update (badge display)
326
+ if (result.success && hasUI) {
327
+ console.log(` -> Lock acquired, forwarding to UI for badge update`);
328
+ await this.forwardToUI(normalizedPath, operation);
329
+ }
330
+ // If lastSessionTimestamp provided and we have headless handler, return changes since then
331
+ let updatesSince;
332
+ if (result.success && lastSessionTimestamp !== undefined && this.headlessHandler) {
333
+ updatesSince = this.headlessHandler.getUpdatesSince(normalizedPath, lastSessionTimestamp);
334
+ console.log(` -> Returning ${updatesSince.length} updates since ${new Date(lastSessionTimestamp).toISOString()}`);
335
+ }
336
+ return { ...result, backend, updatesSince };
337
+ }
338
+ if (opType === 'endAgentSession') {
339
+ const reqAgentId = operation.agentId || 'unknown';
340
+ const result = this.endAgentSession(notebookPath, reqAgentId);
341
+ // Also forward to UI for UI state update (remove badge)
342
+ if (result.success && hasUI) {
343
+ console.log(` -> Lock released, forwarding to UI for badge update`);
344
+ await this.forwardToUI(normalizedPath, operation);
345
+ }
346
+ return { ...result, backend };
347
+ }
348
+ // Enforce agent session for write operations
349
+ const readOnlyOps = new Set(['readCell', 'readCellOutput', 'searchCells', 'readNotebook', 'getUpdatesSince']);
350
+ const sessionOps = new Set(['startAgentSession', 'endAgentSession']);
351
+ const creationOps = new Set(['createNotebook']); // Operations that create files (don't require session)
352
+ const isWrite = !readOnlyOps.has(opType) && !sessionOps.has(opType) && !creationOps.has(opType);
353
+ if (isWrite) {
354
+ if (!isLocked) {
355
+ console.log(` -> BLOCKED: Write requires active agent session`);
356
+ return {
357
+ success: false,
358
+ error: 'Agent session required for write operations. Call startAgentSession first.',
359
+ backend,
360
+ };
361
+ }
362
+ if (!agentId) {
363
+ console.log(` -> BLOCKED: Missing agentId for write operation`);
364
+ return {
365
+ success: false,
366
+ error: 'Agent session required for write operations (missing agentId).',
367
+ backend,
368
+ };
369
+ }
370
+ if (lock && lock.agentId !== agentId) {
371
+ console.log(` -> BLOCKED: Operation blocked by agent lock`);
372
+ return {
373
+ success: false,
374
+ error: `Notebook is locked by another agent (${lock.agentId}). Lock expires in ${Math.ceil((lock.expiresAt - Date.now()) / 1000)}s.`,
375
+ backend,
376
+ };
377
+ }
378
+ }
379
+ // Refresh lock timeout if this agent holds it
380
+ if (lock && agentId && lock.agentId === agentId) {
381
+ lock.expiresAt = Date.now() + AGENT_LOCK_TIMEOUT_MS;
382
+ }
383
+ if (hasUI) {
384
+ // For createNotebook, use any available UI connection
385
+ const uiPath = isCreateNotebook && anyUI ? anyUI.path : normalizedPath;
386
+ console.log(` -> Routing to UI (via ${uiPath})`);
387
+ const result = await this.forwardToUI(uiPath, operation);
388
+ return { ...result, backend: 'ui' };
389
+ }
390
+ else {
391
+ console.log(` -> Routing to HEADLESS`);
392
+ const result = await this.applyHeadless(operation);
393
+ return { ...result, backend: 'headless' };
394
+ }
395
+ }
396
+ async forwardToUI(notebookPath, operation) {
397
+ const conn = this.getResponsiveUIConnection(notebookPath);
398
+ if (!conn) {
399
+ return {
400
+ success: false,
401
+ error: 'UI connection is no longer responsive',
402
+ };
403
+ }
404
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
405
+ // Calculate timeout based on operation type
406
+ const opType = operation.type;
407
+ let timeout = this.operationTimeout;
408
+ if (opType === 'executeCell') {
409
+ const maxWait = (operation.maxWait ?? operation.max_wait ?? 10);
410
+ timeout = (maxWait + 5) * 1000;
411
+ }
412
+ else if (opType === 'readCellOutput') {
413
+ const maxWait = (operation.maxWait ?? operation.max_wait ?? 0);
414
+ timeout = Math.max(this.operationTimeout, (maxWait + 5) * 1000);
415
+ }
416
+ return new Promise((resolve, reject) => {
417
+ const timeoutId = setTimeout(() => {
418
+ conn.pendingRequests.delete(requestId);
419
+ // For executeCell, timeout means the cell is still running - return busy status, not error
420
+ if (opType === 'executeCell') {
421
+ resolve({
422
+ success: true,
423
+ executionStatus: 'busy',
424
+ // Pass through the original cellId/cellIndex from the operation for polling
425
+ cellId: operation.cellId,
426
+ cellIndex: operation.cellIndex,
427
+ outputs: [],
428
+ message: `Cell still executing after ${timeout / 1000}s. Use read_output with max_wait to poll for results.`,
429
+ });
430
+ }
431
+ else {
432
+ resolve({
433
+ success: false,
434
+ error: `Operation timed out after ${timeout / 1000}s`,
435
+ });
436
+ }
437
+ }, timeout);
438
+ conn.pendingRequests.set(requestId, { resolve, reject, timeoutId });
439
+ // Send operation to UI
440
+ const message = {
441
+ type: 'operation',
442
+ operation,
443
+ requestId,
444
+ };
445
+ try {
446
+ conn.websocket.send(JSON.stringify(message));
447
+ }
448
+ catch (err) {
449
+ clearTimeout(timeoutId);
450
+ conn.pendingRequests.delete(requestId);
451
+ resolve({
452
+ success: false,
453
+ error: `Failed to forward operation to UI: ${err}`,
454
+ });
455
+ }
456
+ });
457
+ }
458
+ /**
459
+ * Handle operation response from UI.
460
+ */
461
+ handleUIResponse(notebookPath, response) {
462
+ const normalizedPath = normalizeNotebookPath(notebookPath);
463
+ if (!this.getResponsiveUIConnection(normalizedPath)) {
464
+ console.log(`[OperationRouter] Received response for unknown notebook: ${notebookPath}`);
465
+ return;
466
+ }
467
+ const conn = this.getResponsiveUIConnection(normalizedPath);
468
+ conn.lastActivityAt = Date.now();
469
+ const requestId = response.requestId;
470
+ if (requestId && conn.pendingRequests.has(requestId)) {
471
+ const request = conn.pendingRequests.get(requestId);
472
+ clearTimeout(request.timeoutId);
473
+ conn.pendingRequests.delete(requestId);
474
+ const result = response.result || { success: false, error: 'No result in response' };
475
+ request.resolve(result);
476
+ }
477
+ }
478
+ async applyHeadless(operation) {
479
+ if (!this.headlessHandler) {
480
+ return {
481
+ success: false,
482
+ error: 'Headless manager not configured',
483
+ };
484
+ }
485
+ return await this.headlessHandler.applyOperation(operation);
486
+ }
487
+ /**
488
+ * Read notebook state.
489
+ */
490
+ async readNotebook(notebookPath, includeOutputs = true, maxLines, maxChars, maxLinesError, maxCharsError) {
491
+ const normalizedPath = normalizeNotebookPath(notebookPath);
492
+ if (this.getResponsiveUIConnection(normalizedPath)) {
493
+ const result = await this.readFromUI(normalizedPath);
494
+ if (result.success) {
495
+ const truncated = this.applyOutputTruncation(result, includeOutputs, maxLines, maxChars, maxLinesError, maxCharsError);
496
+ if (!truncated.backend) {
497
+ truncated.backend = 'ui';
498
+ }
499
+ return truncated;
500
+ }
501
+ if (!result.backend) {
502
+ result.backend = 'ui';
503
+ }
504
+ return result;
505
+ }
506
+ else {
507
+ const result = await this.readFromFile(notebookPath, includeOutputs, maxLines, maxChars, maxLinesError, maxCharsError);
508
+ if (!result.backend) {
509
+ result.backend = 'headless';
510
+ }
511
+ return result;
512
+ }
513
+ }
514
+ async readFromUI(notebookPath) {
515
+ const conn = this.getResponsiveUIConnection(notebookPath);
516
+ if (!conn) {
517
+ return this.readFromFile(notebookPath);
518
+ }
519
+ const requestId = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
520
+ return new Promise((resolve) => {
521
+ const timeoutId = setTimeout(() => {
522
+ conn.pendingRequests.delete(requestId);
523
+ console.log(`[OperationRouter] UI read timeout, falling back to file`);
524
+ this.readFromFile(notebookPath).then(resolve);
525
+ }, this.operationTimeout);
526
+ conn.pendingRequests.set(requestId, {
527
+ resolve,
528
+ reject: (err) => resolve({ success: false, error: String(err) }),
529
+ timeoutId,
530
+ });
531
+ const message = {
532
+ type: 'readNotebook',
533
+ requestId,
534
+ };
535
+ try {
536
+ conn.websocket.send(JSON.stringify(message));
537
+ }
538
+ catch (err) {
539
+ clearTimeout(timeoutId);
540
+ conn.pendingRequests.delete(requestId);
541
+ resolve({
542
+ success: false,
543
+ error: `Failed to read from UI: ${err}`,
544
+ backend: 'ui',
545
+ });
546
+ }
547
+ });
548
+ }
549
+ async readFromFile(notebookPath, includeOutputs = true, maxLines, maxChars, maxLinesError, maxCharsError) {
550
+ if (!this.headlessHandler) {
551
+ return {
552
+ success: false,
553
+ error: 'Headless manager not configured',
554
+ backend: 'headless',
555
+ };
556
+ }
557
+ const result = await this.headlessHandler.readNotebook(notebookPath, includeOutputs, maxLines, maxChars, maxLinesError, maxCharsError);
558
+ if (!result.backend) {
559
+ result.backend = 'headless';
560
+ }
561
+ return result;
562
+ }
563
+ applyOutputTruncation(result, includeOutputs, maxLines, maxChars, maxLinesError, maxCharsError) {
564
+ if (!result.success || !result.data) {
565
+ return result;
566
+ }
567
+ const data = result.data;
568
+ const cells = data.cells || [];
569
+ if (!includeOutputs) {
570
+ data.cells = cells.map((cell) => ({ ...cell, outputs: [] }));
571
+ return result;
572
+ }
573
+ // Apply truncation defaults
574
+ const effectiveMaxLines = maxLines ?? 100;
575
+ const effectiveMaxChars = maxChars ?? 10000;
576
+ const effectiveMaxLinesError = maxLinesError ?? 200;
577
+ const effectiveMaxCharsError = maxCharsError ?? 20000;
578
+ data.cells = cells.map((cell) => {
579
+ const c = cell;
580
+ const outputs = c.outputs || [];
581
+ return {
582
+ ...c,
583
+ outputs: outputs.map((output) => {
584
+ const o = output;
585
+ const outputType = o.type || 'stdout';
586
+ const content = o.content || '';
587
+ if (outputType === 'image' || outputType === 'html' || outputType === 'display_data') {
588
+ return { ...o, is_binary: outputType === 'image' };
589
+ }
590
+ const linesLimit = outputType === 'error' ? effectiveMaxLinesError : effectiveMaxLines;
591
+ const charsLimit = outputType === 'error' ? effectiveMaxCharsError : effectiveMaxChars;
592
+ const lines = content.split('\n');
593
+ const totalLines = lines.length;
594
+ const totalChars = content.length;
595
+ // Match Python's truncation logic exactly
596
+ let truncated = false;
597
+ let truncationReason = null;
598
+ const resultLines = [];
599
+ let charCount = 0;
600
+ const startLine = 0;
601
+ let endLine = 0;
602
+ for (let i = 0; i < lines.length; i++) {
603
+ const line = lines[i];
604
+ const newCharCount = charCount + line.length + (i > 0 ? 1 : 0); // +1 for newline
605
+ if (i >= linesLimit) {
606
+ truncated = true;
607
+ truncationReason = 'lines';
608
+ break;
609
+ }
610
+ if (newCharCount > charsLimit && i > 0) { // Always include at least 1 line
611
+ truncated = true;
612
+ truncationReason = 'chars';
613
+ break;
614
+ }
615
+ resultLines.push(line);
616
+ charCount = newCharCount;
617
+ endLine = startLine + i + 1;
618
+ }
619
+ const truncatedContent = resultLines.join('\n');
620
+ return {
621
+ type: outputType,
622
+ content: truncatedContent,
623
+ truncated,
624
+ truncation_reason: truncationReason,
625
+ total_lines: totalLines,
626
+ total_chars: totalChars,
627
+ returned_range: {
628
+ start_line: startLine,
629
+ end_line: endLine,
630
+ char_count: truncatedContent.length,
631
+ },
632
+ };
633
+ }),
634
+ };
635
+ });
636
+ return result;
637
+ }
638
+ }
639
+ exports.OperationRouter = OperationRouter;
640
+ // Global router instance
641
+ exports.operationRouter = new OperationRouter();