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.
- package/README.md +222 -0
- package/bin/nebula-notebook.js +33 -0
- package/dist/assets/index-C1h_sArD.css +32 -0
- package/dist/assets/index-CDSTBon8.js +658 -0
- package/dist/favicon.svg +11 -0
- package/dist/index.html +73 -0
- package/node-server/dist/app.d.ts +5 -0
- package/node-server/dist/app.js +38 -0
- package/node-server/dist/auth/auth-middleware.d.ts +24 -0
- package/node-server/dist/auth/auth-middleware.js +276 -0
- package/node-server/dist/auth/auth-service.d.ts +84 -0
- package/node-server/dist/auth/auth-service.js +265 -0
- package/node-server/dist/auth/index.d.ts +3 -0
- package/node-server/dist/auth/index.js +8 -0
- package/node-server/dist/cluster/client-registration.d.ts +43 -0
- package/node-server/dist/cluster/client-registration.js +217 -0
- package/node-server/dist/cluster/cluster-secret.d.ts +11 -0
- package/node-server/dist/cluster/cluster-secret.js +90 -0
- package/node-server/dist/cluster/kernel-proxy.d.ts +100 -0
- package/node-server/dist/cluster/kernel-proxy.js +361 -0
- package/node-server/dist/cluster/server-registry.d.ts +109 -0
- package/node-server/dist/cluster/server-registry.js +217 -0
- package/node-server/dist/config/output-limits.d.ts +7 -0
- package/node-server/dist/config/output-limits.js +10 -0
- package/node-server/dist/discovery/discovery-service.d.ts +198 -0
- package/node-server/dist/discovery/discovery-service.js +811 -0
- package/node-server/dist/discovery/index.d.ts +5 -0
- package/node-server/dist/discovery/index.js +21 -0
- package/node-server/dist/discovery/types.d.ts +48 -0
- package/node-server/dist/discovery/types.js +24 -0
- package/node-server/dist/fs/fs-service.d.ts +218 -0
- package/node-server/dist/fs/fs-service.js +1422 -0
- package/node-server/dist/fs/index.d.ts +5 -0
- package/node-server/dist/fs/index.js +21 -0
- package/node-server/dist/fs/types.d.ts +132 -0
- package/node-server/dist/fs/types.js +5 -0
- package/node-server/dist/index.d.ts +13 -0
- package/node-server/dist/index.js +556 -0
- package/node-server/dist/kernel/default-kernel.d.ts +5 -0
- package/node-server/dist/kernel/default-kernel.js +138 -0
- package/node-server/dist/kernel/index.d.ts +7 -0
- package/node-server/dist/kernel/index.js +23 -0
- package/node-server/dist/kernel/kernel-service.d.ts +290 -0
- package/node-server/dist/kernel/kernel-service.js +1714 -0
- package/node-server/dist/kernel/kernelspec.d.ts +29 -0
- package/node-server/dist/kernel/kernelspec.js +210 -0
- package/node-server/dist/kernel/session-store.d.ts +87 -0
- package/node-server/dist/kernel/session-store.js +303 -0
- package/node-server/dist/kernel/types.d.ts +143 -0
- package/node-server/dist/kernel/types.js +17 -0
- package/node-server/dist/llm/index.d.ts +5 -0
- package/node-server/dist/llm/index.js +21 -0
- package/node-server/dist/llm/llm-service.d.ts +77 -0
- package/node-server/dist/llm/llm-service.js +454 -0
- package/node-server/dist/llm/types.d.ts +40 -0
- package/node-server/dist/llm/types.js +15 -0
- package/node-server/dist/notebook/cell-metadata.d.ts +27 -0
- package/node-server/dist/notebook/cell-metadata.js +76 -0
- package/node-server/dist/notebook/headless-handler.d.ts +127 -0
- package/node-server/dist/notebook/headless-handler.js +1530 -0
- package/node-server/dist/notebook/notebook-websocket.d.ts +12 -0
- package/node-server/dist/notebook/notebook-websocket.js +103 -0
- package/node-server/dist/notebook/operation-router.d.ts +115 -0
- package/node-server/dist/notebook/operation-router.js +641 -0
- package/node-server/dist/notebook/undoRedoManager.d.ts +194 -0
- package/node-server/dist/notebook/undoRedoManager.js +558 -0
- package/node-server/dist/output/display-data.d.ts +14 -0
- package/node-server/dist/output/display-data.js +134 -0
- package/node-server/dist/resources/resource-service.d.ts +69 -0
- package/node-server/dist/resources/resource-service.js +363 -0
- package/node-server/dist/routes/auth.d.ts +5 -0
- package/node-server/dist/routes/auth.js +61 -0
- package/node-server/dist/routes/cluster.d.ts +7 -0
- package/node-server/dist/routes/cluster.js +94 -0
- package/node-server/dist/routes/fs.d.ts +7 -0
- package/node-server/dist/routes/fs.js +392 -0
- package/node-server/dist/routes/kernel.d.ts +13 -0
- package/node-server/dist/routes/kernel.js +637 -0
- package/node-server/dist/routes/llm.d.ts +8 -0
- package/node-server/dist/routes/llm.js +105 -0
- package/node-server/dist/routes/notebook.d.ts +10 -0
- package/node-server/dist/routes/notebook.js +335 -0
- package/node-server/dist/routes/python.d.ts +8 -0
- package/node-server/dist/routes/python.js +187 -0
- package/node-server/dist/routes/resources.d.ts +7 -0
- package/node-server/dist/routes/resources.js +77 -0
- package/node-server/dist/scripts/show-auth-qr.d.ts +1 -0
- package/node-server/dist/scripts/show-auth-qr.js +81 -0
- package/node-server/dist/terminal/pty-manager.d.ts +100 -0
- package/node-server/dist/terminal/pty-manager.js +246 -0
- package/node-server/dist/terminal/server.d.ts +19 -0
- package/node-server/dist/terminal/server.js +254 -0
- package/node-server/dist/terminal/types.d.ts +50 -0
- package/node-server/dist/terminal/types.js +9 -0
- package/node-server/package.json +45 -0
- package/package.json +99 -0
- 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();
|