nebula-notebook-mcp 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 +314 -0
- package/bin/nebula-mcp.js +10 -0
- package/dist/circuit-breaker.d.ts +157 -0
- package/dist/circuit-breaker.d.ts.map +1 -0
- package/dist/circuit-breaker.js +237 -0
- package/dist/circuit-breaker.js.map +1 -0
- package/dist/errors.d.ts +72 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +314 -0
- package/dist/errors.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +41 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp/index.d.ts +8 -0
- package/dist/mcp/index.d.ts.map +1 -0
- package/dist/mcp/index.js +13 -0
- package/dist/mcp/index.js.map +1 -0
- package/dist/mcp/server.d.ts +31 -0
- package/dist/mcp/server.d.ts.map +1 -0
- package/dist/mcp/server.js +237 -0
- package/dist/mcp/server.js.map +1 -0
- package/dist/notebook/client.d.ts +643 -0
- package/dist/notebook/client.d.ts.map +1 -0
- package/dist/notebook/client.js +1720 -0
- package/dist/notebook/client.js.map +1 -0
- package/dist/notebook/index.d.ts +6 -0
- package/dist/notebook/index.d.ts.map +1 -0
- package/dist/notebook/index.js +6 -0
- package/dist/notebook/index.js.map +1 -0
- package/dist/notebook/tools.d.ts +244 -0
- package/dist/notebook/tools.d.ts.map +1 -0
- package/dist/notebook/tools.js +279 -0
- package/dist/notebook/tools.js.map +1 -0
- package/dist/tools/execution.d.ts +38 -0
- package/dist/tools/execution.d.ts.map +1 -0
- package/dist/tools/execution.js +116 -0
- package/dist/tools/execution.js.map +1 -0
- package/dist/tools/files.d.ts +70 -0
- package/dist/tools/files.d.ts.map +1 -0
- package/dist/tools/files.js +286 -0
- package/dist/tools/files.js.map +1 -0
- package/dist/tools/index.d.ts +74 -0
- package/dist/tools/index.d.ts.map +1 -0
- package/dist/tools/index.js +217 -0
- package/dist/tools/index.js.map +1 -0
- package/dist/tools/kernel.d.ts +36 -0
- package/dist/tools/kernel.d.ts.map +1 -0
- package/dist/tools/kernel.js +182 -0
- package/dist/tools/kernel.js.map +1 -0
- package/dist/tools/notebook.d.ts +252 -0
- package/dist/tools/notebook.d.ts.map +1 -0
- package/dist/tools/notebook.js +1089 -0
- package/dist/tools/notebook.js.map +1 -0
- package/dist/tools/types.d.ts +78 -0
- package/dist/tools/types.d.ts.map +1 -0
- package/dist/tools/types.js +8 -0
- package/dist/tools/types.js.map +1 -0
- package/dist/types.d.ts +473 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/utils/imageResize.d.ts +24 -0
- package/dist/utils/imageResize.d.ts.map +1 -0
- package/dist/utils/imageResize.js +67 -0
- package/dist/utils/imageResize.js.map +1 -0
- package/dist/utils/polling.d.ts +40 -0
- package/dist/utils/polling.d.ts.map +1 -0
- package/dist/utils/polling.js +49 -0
- package/dist/utils/polling.js.map +1 -0
- package/package.json +61 -0
- package/setup-mcp.js +468 -0
|
@@ -0,0 +1,1720 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Nebula Notebook Client - Unified API for Notebook Operations
|
|
3
|
+
*
|
|
4
|
+
* This client provides programmatic access to Nebula Notebook, enabling AI agents
|
|
5
|
+
* and automation tools to manipulate notebooks through a consistent interface.
|
|
6
|
+
*
|
|
7
|
+
* ## Architecture
|
|
8
|
+
*
|
|
9
|
+
* The client routes operations through Nebula's Operation Router, which transparently
|
|
10
|
+
* handles both UI-connected and headless modes:
|
|
11
|
+
*
|
|
12
|
+
* ```
|
|
13
|
+
* NebulaClient → HTTP/WS → Operation Router → UI (WebSocket) or Headless (File)
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* From the client's perspective, both modes are identical - the router handles
|
|
17
|
+
* the complexity of determining where to apply operations.
|
|
18
|
+
*
|
|
19
|
+
* ## Key Features
|
|
20
|
+
*
|
|
21
|
+
* - **Kernel Management**: Start, stop, restart, interrupt Jupyter kernels
|
|
22
|
+
* - **Cell Operations**: Insert, delete, update, move, duplicate cells
|
|
23
|
+
* - **Code Execution**: WebSocket streaming with real-time output capture
|
|
24
|
+
* - **Agent Sessions**: Lock/unlock notebooks to prevent concurrent access
|
|
25
|
+
* - **Dual Mode**: Works seamlessly with or without UI connected
|
|
26
|
+
*
|
|
27
|
+
* ## Usage
|
|
28
|
+
*
|
|
29
|
+
* ```typescript
|
|
30
|
+
* const client = new NebulaClient({ baseUrl: 'http://localhost:8000' });
|
|
31
|
+
*
|
|
32
|
+
* // Start agent session (shows indicator in UI)
|
|
33
|
+
* await client.startAgentSession(path, 'my-agent');
|
|
34
|
+
*
|
|
35
|
+
* // Insert a cell
|
|
36
|
+
* const result = await client.insertCellOp(path, 0, {
|
|
37
|
+
* id: 'imports',
|
|
38
|
+
* type: 'code',
|
|
39
|
+
* content: 'import numpy as np'
|
|
40
|
+
* });
|
|
41
|
+
*
|
|
42
|
+
* // Execute the cell
|
|
43
|
+
* const execResult = await client.executeCell(path, sessionId, { cellIndex: 0 });
|
|
44
|
+
*
|
|
45
|
+
* // End session
|
|
46
|
+
* await client.endAgentSession(path);
|
|
47
|
+
* ```
|
|
48
|
+
*
|
|
49
|
+
* ## Error Handling
|
|
50
|
+
*
|
|
51
|
+
* All methods return `ToolResult<T>` with `success` boolean and either `data` or `error`:
|
|
52
|
+
*
|
|
53
|
+
* ```typescript
|
|
54
|
+
* const result = await client.insertCellOp(path, 0, cell);
|
|
55
|
+
* if (!result.success) {
|
|
56
|
+
* console.error(result.error);
|
|
57
|
+
* return;
|
|
58
|
+
* }
|
|
59
|
+
* console.log('Inserted cell:', result.data.cellId);
|
|
60
|
+
* ```
|
|
61
|
+
*
|
|
62
|
+
* @see {@link https://github.com/jzthree/nebula-notebook/blob/main/docs/AGENTIC_ARCHITECTURE.md}
|
|
63
|
+
* @module NebulaClient
|
|
64
|
+
*/
|
|
65
|
+
/**
|
|
66
|
+
* Client for Nebula Notebook headless API
|
|
67
|
+
*/
|
|
68
|
+
export class NebulaClient {
|
|
69
|
+
baseUrl;
|
|
70
|
+
timeout;
|
|
71
|
+
retries;
|
|
72
|
+
agentId;
|
|
73
|
+
clientName;
|
|
74
|
+
clientVersion;
|
|
75
|
+
autoStartAgentSession;
|
|
76
|
+
activeAgentSessions = new Set();
|
|
77
|
+
agentSessionInFlight = new Map();
|
|
78
|
+
pinnedKernelSessions = new Map();
|
|
79
|
+
lastBackend;
|
|
80
|
+
lastAutoStartWarning;
|
|
81
|
+
autoStartWarnedPaths = new Set();
|
|
82
|
+
activeNotebookPath = null;
|
|
83
|
+
// Track last tool call timestamp per notebook path for user change detection
|
|
84
|
+
lastToolCallTimestamp = new Map();
|
|
85
|
+
constructor(config = {}) {
|
|
86
|
+
this.baseUrl = config.baseUrl || 'http://localhost:8000';
|
|
87
|
+
this.timeout = config.timeout || 30000;
|
|
88
|
+
this.retries = config.retries ?? 3;
|
|
89
|
+
this.agentId = config.agentId;
|
|
90
|
+
this.clientName = config.clientName;
|
|
91
|
+
this.clientVersion = config.clientVersion;
|
|
92
|
+
this.autoStartAgentSession = config.autoStartAgentSession ?? Boolean(config.agentId);
|
|
93
|
+
}
|
|
94
|
+
recordBackend(backend) {
|
|
95
|
+
if (backend) {
|
|
96
|
+
this.lastBackend = backend;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
consumeLastBackend() {
|
|
100
|
+
const backend = this.lastBackend;
|
|
101
|
+
this.lastBackend = undefined;
|
|
102
|
+
return backend;
|
|
103
|
+
}
|
|
104
|
+
consumeAutoStartWarning() {
|
|
105
|
+
const warning = this.lastAutoStartWarning;
|
|
106
|
+
this.lastAutoStartWarning = undefined;
|
|
107
|
+
return warning;
|
|
108
|
+
}
|
|
109
|
+
hasActiveAgentSession(path) {
|
|
110
|
+
return this.activeAgentSessions.has(path);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get the notebook path associated with the active agent session (if any).
|
|
114
|
+
*/
|
|
115
|
+
getActiveNotebookPath() {
|
|
116
|
+
return this.activeNotebookPath;
|
|
117
|
+
}
|
|
118
|
+
getPinnedKernelSessionId(path) {
|
|
119
|
+
return this.pinnedKernelSessions.get(path) ?? null;
|
|
120
|
+
}
|
|
121
|
+
setActiveNotebookPath(path) {
|
|
122
|
+
this.activeNotebookPath = path;
|
|
123
|
+
}
|
|
124
|
+
clearActiveNotebookPath(path) {
|
|
125
|
+
if (this.activeNotebookPath === path) {
|
|
126
|
+
const remaining = Array.from(this.activeAgentSessions);
|
|
127
|
+
this.activeNotebookPath = remaining.length > 0 ? remaining[remaining.length - 1] : null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
pinKernelSession(path, sessionId) {
|
|
131
|
+
this.pinnedKernelSessions.set(path, sessionId);
|
|
132
|
+
}
|
|
133
|
+
clearPinnedKernelSession(path) {
|
|
134
|
+
this.pinnedKernelSessions.delete(path);
|
|
135
|
+
}
|
|
136
|
+
clearPinnedKernelSessionById(sessionId) {
|
|
137
|
+
for (const [path, pinnedSessionId] of this.pinnedKernelSessions.entries()) {
|
|
138
|
+
if (pinnedSessionId === sessionId) {
|
|
139
|
+
this.pinnedKernelSessions.delete(path);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
selectBestSessionForFile(sessions, notebookPath) {
|
|
144
|
+
const matching = sessions.filter(s => s.file_path === notebookPath);
|
|
145
|
+
if (matching.length === 0) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
return matching.reduce((best, session) => {
|
|
149
|
+
const bestCreated = typeof best.created_at === 'number' ? best.created_at : -Infinity;
|
|
150
|
+
const sessionCreated = typeof session.created_at === 'number' ? session.created_at : -Infinity;
|
|
151
|
+
return sessionCreated > bestCreated ? session : best;
|
|
152
|
+
}, matching[0]);
|
|
153
|
+
}
|
|
154
|
+
async resolveKernelSessionIdForNotebook(path, options = {}) {
|
|
155
|
+
const pinnedSessionId = this.getPinnedKernelSessionId(path);
|
|
156
|
+
if (pinnedSessionId) {
|
|
157
|
+
return { success: true, data: { sessionId: pinnedSessionId } };
|
|
158
|
+
}
|
|
159
|
+
const sessions = await this.listSessions();
|
|
160
|
+
if (sessions.success) {
|
|
161
|
+
const existing = this.selectBestSessionForFile(sessions.data || [], path);
|
|
162
|
+
if (existing?.id) {
|
|
163
|
+
this.pinKernelSession(path, existing.id);
|
|
164
|
+
return { success: true, data: { sessionId: existing.id } };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
else if (!options.createIfMissing) {
|
|
168
|
+
return { success: false, error: sessions.error };
|
|
169
|
+
}
|
|
170
|
+
if (!options.createIfMissing) {
|
|
171
|
+
return { success: false, error: 'Kernel session not found for notebook' };
|
|
172
|
+
}
|
|
173
|
+
const created = await this.getOrCreateKernelForFile(path, options.kernelName);
|
|
174
|
+
if (!created.success) {
|
|
175
|
+
return { success: false, error: created.error };
|
|
176
|
+
}
|
|
177
|
+
return { success: true, data: { sessionId: created.data.sessionId } };
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Check if an error is retryable (network/connection errors)
|
|
181
|
+
*/
|
|
182
|
+
isRetryable(error) {
|
|
183
|
+
if (error instanceof Error) {
|
|
184
|
+
const msg = error.message.toLowerCase();
|
|
185
|
+
return (msg.includes('network') ||
|
|
186
|
+
msg.includes('connection') ||
|
|
187
|
+
msg.includes('econnrefused') ||
|
|
188
|
+
msg.includes('econnreset') ||
|
|
189
|
+
msg.includes('timeout'));
|
|
190
|
+
}
|
|
191
|
+
return false;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Check if an error is a connection failure (server not reachable)
|
|
195
|
+
*/
|
|
196
|
+
isConnectionError(error) {
|
|
197
|
+
if (error instanceof Error) {
|
|
198
|
+
const msg = error.message.toLowerCase();
|
|
199
|
+
return (msg.includes('econnrefused') ||
|
|
200
|
+
msg.includes('fetch failed') ||
|
|
201
|
+
msg.includes('network request failed') ||
|
|
202
|
+
msg.includes('failed to fetch') ||
|
|
203
|
+
msg.includes('unable to connect') ||
|
|
204
|
+
msg.includes('enotfound') ||
|
|
205
|
+
msg.includes('getaddrinfo'));
|
|
206
|
+
}
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Delay for retry backoff
|
|
211
|
+
*/
|
|
212
|
+
delay(ms) {
|
|
213
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* Fetch with automatic retry for transient errors
|
|
217
|
+
*/
|
|
218
|
+
async fetch(path, options = {}) {
|
|
219
|
+
let lastError = 'Unknown error';
|
|
220
|
+
for (let attempt = 0; attempt < this.retries; attempt++) {
|
|
221
|
+
const controller = new AbortController();
|
|
222
|
+
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
|
|
223
|
+
try {
|
|
224
|
+
const response = await fetch(`${this.baseUrl}${path}`, {
|
|
225
|
+
...options,
|
|
226
|
+
signal: controller.signal,
|
|
227
|
+
headers: {
|
|
228
|
+
'Content-Type': 'application/json',
|
|
229
|
+
...options.headers,
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
clearTimeout(timeoutId);
|
|
233
|
+
if (!response.ok) {
|
|
234
|
+
const error = await response.text();
|
|
235
|
+
// Don't retry 4xx errors (client errors)
|
|
236
|
+
if (response.status >= 400 && response.status < 500) {
|
|
237
|
+
return { success: false, error: `API error ${response.status}: ${error}` };
|
|
238
|
+
}
|
|
239
|
+
lastError = `API error ${response.status}: ${error}`;
|
|
240
|
+
// Retry 5xx errors
|
|
241
|
+
if (attempt < this.retries - 1) {
|
|
242
|
+
await this.delay(1000 * (attempt + 1));
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
return { success: false, error: lastError };
|
|
246
|
+
}
|
|
247
|
+
const data = await response.json();
|
|
248
|
+
return { success: true, data: data };
|
|
249
|
+
}
|
|
250
|
+
catch (e) {
|
|
251
|
+
clearTimeout(timeoutId);
|
|
252
|
+
if (e instanceof Error && e.name === 'AbortError') {
|
|
253
|
+
lastError = 'Request timeout';
|
|
254
|
+
}
|
|
255
|
+
else if (this.isConnectionError(e)) {
|
|
256
|
+
// Provide helpful error for connection failures
|
|
257
|
+
lastError = `Cannot connect to Nebula server at ${this.baseUrl}. ` +
|
|
258
|
+
`Ensure the server is running or use connect_server to configure the correct URL.`;
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
lastError = `Request failed: ${e instanceof Error ? e.message : String(e)}`;
|
|
262
|
+
}
|
|
263
|
+
// Retry if retryable and not last attempt
|
|
264
|
+
if (this.isRetryable(e) && attempt < this.retries - 1) {
|
|
265
|
+
await this.delay(1000 * (attempt + 1));
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
return { success: false, error: lastError };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
return { success: false, error: lastError };
|
|
272
|
+
}
|
|
273
|
+
// ===========================================================================
|
|
274
|
+
// Kernel Operations
|
|
275
|
+
// ===========================================================================
|
|
276
|
+
/**
|
|
277
|
+
* List available kernel specs with display names and versions
|
|
278
|
+
*/
|
|
279
|
+
async listKernels() {
|
|
280
|
+
const result = await this.fetch('/api/kernels');
|
|
281
|
+
if (!result.success)
|
|
282
|
+
return { success: false, error: result.error };
|
|
283
|
+
return {
|
|
284
|
+
success: true,
|
|
285
|
+
data: result.data?.kernels.map((k) => ({
|
|
286
|
+
name: k.name,
|
|
287
|
+
displayName: k.display_name,
|
|
288
|
+
language: k.language,
|
|
289
|
+
})) ?? [],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* List active kernel sessions
|
|
294
|
+
*/
|
|
295
|
+
async listSessions() {
|
|
296
|
+
const result = await this.fetch('/api/kernels/sessions');
|
|
297
|
+
if (!result.success)
|
|
298
|
+
return { success: false, error: result.error };
|
|
299
|
+
return { success: true, data: result.data?.sessions ?? [] };
|
|
300
|
+
}
|
|
301
|
+
/**
|
|
302
|
+
* Start a new kernel session
|
|
303
|
+
*/
|
|
304
|
+
async startKernel(kernelName = 'python3', filePath) {
|
|
305
|
+
const result = await this.fetch('/api/kernels/start', {
|
|
306
|
+
method: 'POST',
|
|
307
|
+
body: JSON.stringify({ kernel_name: kernelName, file_path: filePath }),
|
|
308
|
+
});
|
|
309
|
+
if (!result.success)
|
|
310
|
+
return { success: false, error: result.error };
|
|
311
|
+
if (filePath) {
|
|
312
|
+
this.pinKernelSession(filePath, result.data.session_id);
|
|
313
|
+
}
|
|
314
|
+
return {
|
|
315
|
+
success: true,
|
|
316
|
+
data: {
|
|
317
|
+
sessionId: result.data.session_id,
|
|
318
|
+
kernelName: result.data.kernel_name,
|
|
319
|
+
status: 'idle',
|
|
320
|
+
filePath,
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/**
|
|
325
|
+
* Get or create a kernel session for a notebook file
|
|
326
|
+
*/
|
|
327
|
+
async getOrCreateKernelForFile(filePath, kernelName) {
|
|
328
|
+
const result = await this.fetch('/api/kernels/for-file', {
|
|
329
|
+
method: 'POST',
|
|
330
|
+
body: JSON.stringify({ file_path: filePath, kernel_name: kernelName }),
|
|
331
|
+
});
|
|
332
|
+
if (!result.success)
|
|
333
|
+
return { success: false, error: result.error };
|
|
334
|
+
this.pinKernelSession(filePath, result.data.session_id);
|
|
335
|
+
return {
|
|
336
|
+
success: true,
|
|
337
|
+
data: {
|
|
338
|
+
sessionId: result.data.session_id,
|
|
339
|
+
kernelName: result.data.kernel_name,
|
|
340
|
+
status: 'idle',
|
|
341
|
+
filePath: result.data.file_path,
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Execute code in a kernel session (simple REST version)
|
|
347
|
+
*/
|
|
348
|
+
async executeCode(sessionId, code) {
|
|
349
|
+
return this.fetch(`/api/kernels/${sessionId}/execute`, {
|
|
350
|
+
method: 'POST',
|
|
351
|
+
body: JSON.stringify({ code }),
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Execute code with WebSocket streaming for real-time output
|
|
356
|
+
*/
|
|
357
|
+
async executeCodeStreaming(sessionId, code, timeoutMs = 60000) {
|
|
358
|
+
return new Promise((resolve) => {
|
|
359
|
+
const wsUrl = this.baseUrl.replace(/^http/, 'ws') + `/api/kernels/${sessionId}/ws`;
|
|
360
|
+
const outputs = [];
|
|
361
|
+
let executionCount;
|
|
362
|
+
let hasError = false;
|
|
363
|
+
let errorMessage;
|
|
364
|
+
// Use dynamic import for ws in Node.js environment
|
|
365
|
+
const connectWs = async () => {
|
|
366
|
+
try {
|
|
367
|
+
// Try browser WebSocket first
|
|
368
|
+
const WebSocketImpl = typeof WebSocket !== 'undefined' ? WebSocket : (await import('ws')).default;
|
|
369
|
+
const ws = new WebSocketImpl(wsUrl);
|
|
370
|
+
const timeout = setTimeout(() => {
|
|
371
|
+
ws.close();
|
|
372
|
+
resolve({
|
|
373
|
+
success: false,
|
|
374
|
+
error: `Execution timeout after ${timeoutMs / 1000}s`,
|
|
375
|
+
data: { outputs, success: false, executionCount },
|
|
376
|
+
});
|
|
377
|
+
}, timeoutMs);
|
|
378
|
+
ws.onopen = () => {
|
|
379
|
+
ws.send(JSON.stringify({ type: 'execute', code }));
|
|
380
|
+
};
|
|
381
|
+
ws.onmessage = (event) => {
|
|
382
|
+
try {
|
|
383
|
+
const data = typeof event.data === 'string' ? event.data : event.data.toString();
|
|
384
|
+
const msg = JSON.parse(data);
|
|
385
|
+
if (msg.type === 'output') {
|
|
386
|
+
outputs.push(this.parseJupyterOutput(msg.output));
|
|
387
|
+
}
|
|
388
|
+
else if (msg.type === 'result') {
|
|
389
|
+
executionCount = msg.result?.execution_count;
|
|
390
|
+
if (msg.result?.outputs) {
|
|
391
|
+
outputs.push(...msg.result.outputs.map((o) => this.parseJupyterOutput(o)));
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else if (msg.type === 'error') {
|
|
395
|
+
hasError = true;
|
|
396
|
+
errorMessage = msg.error;
|
|
397
|
+
outputs.push({ type: 'error', content: msg.error || 'Unknown error' });
|
|
398
|
+
}
|
|
399
|
+
else if (msg.type === 'status' && msg.status === 'idle') {
|
|
400
|
+
clearTimeout(timeout);
|
|
401
|
+
ws.close();
|
|
402
|
+
resolve({
|
|
403
|
+
success: !hasError,
|
|
404
|
+
data: { outputs, success: !hasError, executionCount, error: errorMessage },
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
catch (e) {
|
|
409
|
+
// Ignore parse errors for non-JSON messages
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
ws.onerror = (error) => {
|
|
413
|
+
clearTimeout(timeout);
|
|
414
|
+
ws.close();
|
|
415
|
+
resolve({
|
|
416
|
+
success: false,
|
|
417
|
+
error: `WebSocket error: ${error.message || 'Unknown error'}`,
|
|
418
|
+
});
|
|
419
|
+
};
|
|
420
|
+
ws.onclose = () => {
|
|
421
|
+
clearTimeout(timeout);
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
catch (e) {
|
|
425
|
+
// Fall back to REST API if WebSocket fails
|
|
426
|
+
const result = await this.executeCode(sessionId, code);
|
|
427
|
+
resolve(result);
|
|
428
|
+
}
|
|
429
|
+
};
|
|
430
|
+
connectWs();
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
/**
|
|
434
|
+
* Interrupt a running kernel
|
|
435
|
+
*/
|
|
436
|
+
async interruptKernel(sessionId) {
|
|
437
|
+
return this.fetch(`/api/kernels/${sessionId}/interrupt`, {
|
|
438
|
+
method: 'POST',
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Restart a kernel session
|
|
443
|
+
*/
|
|
444
|
+
async restartKernel(sessionId) {
|
|
445
|
+
return this.fetch(`/api/kernels/${sessionId}/restart`, {
|
|
446
|
+
method: 'POST',
|
|
447
|
+
});
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Shutdown a kernel session
|
|
451
|
+
*/
|
|
452
|
+
async shutdownKernel(sessionId) {
|
|
453
|
+
const result = await this.fetch(`/api/kernels/${sessionId}`, {
|
|
454
|
+
method: 'DELETE',
|
|
455
|
+
});
|
|
456
|
+
if (result.success) {
|
|
457
|
+
this.clearPinnedKernelSessionById(sessionId);
|
|
458
|
+
}
|
|
459
|
+
return result;
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Stop a kernel session (alias for shutdownKernel)
|
|
463
|
+
*/
|
|
464
|
+
async stopKernel(sessionId) {
|
|
465
|
+
return this.shutdownKernel(sessionId);
|
|
466
|
+
}
|
|
467
|
+
// ===========================================================================
|
|
468
|
+
// File Operations (non-notebook specific)
|
|
469
|
+
// ===========================================================================
|
|
470
|
+
/**
|
|
471
|
+
* List files in a directory
|
|
472
|
+
*/
|
|
473
|
+
async listFiles(path = '.') {
|
|
474
|
+
const result = await this.fetch(`/api/fs/list?path=${encodeURIComponent(path)}`);
|
|
475
|
+
if (!result.success)
|
|
476
|
+
return { success: false, error: result.error };
|
|
477
|
+
// Transform the response to match expected format
|
|
478
|
+
const items = result.data.items.map((item) => ({
|
|
479
|
+
name: item.name,
|
|
480
|
+
type: item.isDirectory ? 'directory' : item.fileType || 'file',
|
|
481
|
+
}));
|
|
482
|
+
return { success: true, data: items };
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Read a file's content
|
|
486
|
+
*/
|
|
487
|
+
async readFile(path) {
|
|
488
|
+
const result = await this.fetch(`/api/fs/read?path=${encodeURIComponent(path)}`);
|
|
489
|
+
if (!result.success)
|
|
490
|
+
return { success: false, error: result.error };
|
|
491
|
+
// Handle binary files
|
|
492
|
+
if (result.data.type === 'binary') {
|
|
493
|
+
return { success: false, error: result.data.message || 'Binary file cannot be read as text' };
|
|
494
|
+
}
|
|
495
|
+
// Handle notebook files (return as JSON string)
|
|
496
|
+
if (result.data.type === 'notebook') {
|
|
497
|
+
return { success: true, data: { content: JSON.stringify(result.data.content, null, 2) } };
|
|
498
|
+
}
|
|
499
|
+
// Handle text files
|
|
500
|
+
return { success: true, data: { content: result.data.content } };
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Write content to a file
|
|
504
|
+
*/
|
|
505
|
+
async writeFile(path, content) {
|
|
506
|
+
return this.fetch('/api/fs/write', {
|
|
507
|
+
method: 'POST',
|
|
508
|
+
body: JSON.stringify({ path, content, file_type: 'text' }),
|
|
509
|
+
});
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* Delete a file or directory
|
|
513
|
+
*/
|
|
514
|
+
async deleteFile(path) {
|
|
515
|
+
return this.fetch(`/api/fs/delete?path=${encodeURIComponent(path)}`, {
|
|
516
|
+
method: 'DELETE',
|
|
517
|
+
});
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Rename/move a file or directory
|
|
521
|
+
*/
|
|
522
|
+
async renameFile(oldPath, newPath) {
|
|
523
|
+
return this.fetch('/api/fs/rename', {
|
|
524
|
+
method: 'POST',
|
|
525
|
+
body: JSON.stringify({ old_path: oldPath, new_path: newPath }),
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Download a file as Buffer (supports both text and binary)
|
|
530
|
+
* Uses the /fs/download endpoint that streams raw file content
|
|
531
|
+
*/
|
|
532
|
+
async downloadFile(serverPath) {
|
|
533
|
+
try {
|
|
534
|
+
const url = `${this.baseUrl}/api/fs/download?path=${encodeURIComponent(serverPath)}`;
|
|
535
|
+
const response = await fetch(url);
|
|
536
|
+
if (!response.ok) {
|
|
537
|
+
const errorText = await response.text();
|
|
538
|
+
let errorDetail = 'Download failed';
|
|
539
|
+
try {
|
|
540
|
+
const errorJson = JSON.parse(errorText);
|
|
541
|
+
errorDetail = errorJson.detail || errorDetail;
|
|
542
|
+
}
|
|
543
|
+
catch {
|
|
544
|
+
errorDetail = errorText || errorDetail;
|
|
545
|
+
}
|
|
546
|
+
return { success: false, error: errorDetail };
|
|
547
|
+
}
|
|
548
|
+
const arrayBuffer = await response.arrayBuffer();
|
|
549
|
+
const content = Buffer.from(arrayBuffer);
|
|
550
|
+
return { success: true, data: { content } };
|
|
551
|
+
}
|
|
552
|
+
catch (e) {
|
|
553
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
554
|
+
return { success: false, error: `Download failed: ${error}` };
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Upload a file using multipart form data (supports binary)
|
|
559
|
+
*/
|
|
560
|
+
async uploadFile(destDir, content, filename) {
|
|
561
|
+
try {
|
|
562
|
+
const blob = new Blob([content]);
|
|
563
|
+
const formData = new FormData();
|
|
564
|
+
// Path must precede the file part. The Nebula backend uses
|
|
565
|
+
// @fastify/multipart request.file(), which may not expose fields that
|
|
566
|
+
// arrive after the file stream in the multipart payload.
|
|
567
|
+
formData.append('path', destDir);
|
|
568
|
+
formData.append('file', blob, filename);
|
|
569
|
+
const url = `${this.baseUrl}/api/fs/upload`;
|
|
570
|
+
const response = await fetch(url, {
|
|
571
|
+
method: 'POST',
|
|
572
|
+
body: formData,
|
|
573
|
+
});
|
|
574
|
+
if (!response.ok) {
|
|
575
|
+
const errorText = await response.text();
|
|
576
|
+
let errorDetail = 'Upload failed';
|
|
577
|
+
try {
|
|
578
|
+
const errorJson = JSON.parse(errorText);
|
|
579
|
+
errorDetail = errorJson.detail || errorDetail;
|
|
580
|
+
}
|
|
581
|
+
catch {
|
|
582
|
+
errorDetail = errorText || errorDetail;
|
|
583
|
+
}
|
|
584
|
+
return { success: false, error: errorDetail };
|
|
585
|
+
}
|
|
586
|
+
return { success: true };
|
|
587
|
+
}
|
|
588
|
+
catch (e) {
|
|
589
|
+
const error = e instanceof Error ? e.message : String(e);
|
|
590
|
+
return { success: false, error: `Upload failed: ${error}` };
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* Get the metadata schema from Nebula API
|
|
595
|
+
*/
|
|
596
|
+
async getMetadataSchema() {
|
|
597
|
+
return this.fetch('/api/cell/metadata-schema');
|
|
598
|
+
}
|
|
599
|
+
// ===========================================================================
|
|
600
|
+
// Execution
|
|
601
|
+
// ===========================================================================
|
|
602
|
+
/**
|
|
603
|
+
* Execute a cell in a notebook and save the outputs back
|
|
604
|
+
*
|
|
605
|
+
* This combines read -> execute -> save into a single operation,
|
|
606
|
+
* eliminating round-trips for the common iterative workflow.
|
|
607
|
+
*
|
|
608
|
+
* Supports both cellIndex (positional) and cellId (stable).
|
|
609
|
+
* When save is true, outputs are saved incrementally during execution
|
|
610
|
+
* so the notebook file reflects progress in real-time.
|
|
611
|
+
*/
|
|
612
|
+
async executeCell(path, sessionId, options = {}) {
|
|
613
|
+
const { cellIndex, cellId, timeout = 60000, save = true, saveIntervalMs = 2000 } = options;
|
|
614
|
+
if (cellIndex === undefined && cellId === undefined) {
|
|
615
|
+
return { success: false, error: 'Must provide either cellIndex or cellId' };
|
|
616
|
+
}
|
|
617
|
+
// Read the notebook via router (gets UI state if connected, else file)
|
|
618
|
+
const notebookResult = await this.readNotebookViaRouter(path);
|
|
619
|
+
if (!notebookResult.success) {
|
|
620
|
+
return { success: false, error: notebookResult.error };
|
|
621
|
+
}
|
|
622
|
+
const notebook = notebookResult.data;
|
|
623
|
+
let foundIndex = -1;
|
|
624
|
+
let cell;
|
|
625
|
+
if (cellId !== undefined) {
|
|
626
|
+
foundIndex = notebook.cells.findIndex(c => c.id === cellId);
|
|
627
|
+
if (foundIndex === -1) {
|
|
628
|
+
return { success: false, error: `Cell with id '${cellId}' not found` };
|
|
629
|
+
}
|
|
630
|
+
cell = notebook.cells[foundIndex];
|
|
631
|
+
}
|
|
632
|
+
else {
|
|
633
|
+
if (cellIndex < 0 || cellIndex >= notebook.cells.length) {
|
|
634
|
+
return { success: false, error: `Cell index ${cellIndex} out of range (0-${notebook.cells.length - 1})` };
|
|
635
|
+
}
|
|
636
|
+
foundIndex = cellIndex;
|
|
637
|
+
cell = notebook.cells[foundIndex];
|
|
638
|
+
}
|
|
639
|
+
if (cell.type !== 'code') {
|
|
640
|
+
return { success: false, error: `Cell ${foundIndex} is not a code cell (type: ${cell.type})` };
|
|
641
|
+
}
|
|
642
|
+
// Execute with incremental saves via operation router
|
|
643
|
+
const result = await this.executeWithIncrementalSave(sessionId, cell.content, path, cell.id, { timeout, save, saveIntervalMs });
|
|
644
|
+
return {
|
|
645
|
+
success: result.success,
|
|
646
|
+
data: result.data ? {
|
|
647
|
+
...result.data,
|
|
648
|
+
cellIndex: foundIndex,
|
|
649
|
+
cellId: cell.id,
|
|
650
|
+
} : undefined,
|
|
651
|
+
error: result.error,
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
/**
|
|
655
|
+
* Execute code with incremental saves via operation router
|
|
656
|
+
* Outputs are sent to UI (if connected) or saved to file via updateOutputs operation
|
|
657
|
+
*/
|
|
658
|
+
async executeWithIncrementalSave(sessionId, code, path, cellId, options) {
|
|
659
|
+
const { timeout, save, saveIntervalMs } = options;
|
|
660
|
+
return new Promise((resolve) => {
|
|
661
|
+
const wsUrl = this.baseUrl.replace(/^http/, 'ws') + `/api/kernels/${sessionId}/ws`;
|
|
662
|
+
const outputs = [];
|
|
663
|
+
let executionCount;
|
|
664
|
+
let hasError = false;
|
|
665
|
+
let errorMessage;
|
|
666
|
+
let lastSaveTime = Date.now();
|
|
667
|
+
let saveTimer;
|
|
668
|
+
const saveOutputs = async () => {
|
|
669
|
+
if (!save)
|
|
670
|
+
return;
|
|
671
|
+
// Use operation router to update outputs (goes to UI if connected)
|
|
672
|
+
await this.updateOutputsOp(path, cellId, [...outputs], executionCount);
|
|
673
|
+
lastSaveTime = Date.now();
|
|
674
|
+
};
|
|
675
|
+
const connectWs = async () => {
|
|
676
|
+
try {
|
|
677
|
+
const WebSocketImpl = typeof WebSocket !== 'undefined' ? WebSocket : (await import('ws')).default;
|
|
678
|
+
const ws = new WebSocketImpl(wsUrl);
|
|
679
|
+
const timeoutTimer = setTimeout(async () => {
|
|
680
|
+
if (saveTimer)
|
|
681
|
+
clearInterval(saveTimer);
|
|
682
|
+
await saveOutputs(); // Final save before closing
|
|
683
|
+
ws.close();
|
|
684
|
+
resolve({
|
|
685
|
+
success: true, // Timeout is not an error, just incomplete
|
|
686
|
+
data: { outputs, success: true, executionCount },
|
|
687
|
+
});
|
|
688
|
+
}, timeout);
|
|
689
|
+
// Set up periodic save interval
|
|
690
|
+
if (save) {
|
|
691
|
+
saveTimer = setInterval(async () => {
|
|
692
|
+
if (outputs.length > 0 && Date.now() - lastSaveTime >= saveIntervalMs) {
|
|
693
|
+
await saveOutputs();
|
|
694
|
+
}
|
|
695
|
+
}, saveIntervalMs);
|
|
696
|
+
}
|
|
697
|
+
ws.onopen = () => {
|
|
698
|
+
ws.send(JSON.stringify({ type: 'execute', code }));
|
|
699
|
+
};
|
|
700
|
+
ws.onmessage = (event) => {
|
|
701
|
+
try {
|
|
702
|
+
const data = typeof event.data === 'string' ? event.data : event.data.toString();
|
|
703
|
+
const msg = JSON.parse(data);
|
|
704
|
+
if (msg.type === 'output') {
|
|
705
|
+
outputs.push(this.parseJupyterOutput(msg.output));
|
|
706
|
+
}
|
|
707
|
+
else if (msg.type === 'result') {
|
|
708
|
+
executionCount = msg.result?.execution_count;
|
|
709
|
+
if (msg.result?.outputs) {
|
|
710
|
+
outputs.push(...msg.result.outputs.map((o) => this.parseJupyterOutput(o)));
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
else if (msg.type === 'error') {
|
|
714
|
+
hasError = true;
|
|
715
|
+
errorMessage = msg.error;
|
|
716
|
+
outputs.push({ type: 'error', content: msg.error || 'Unknown error' });
|
|
717
|
+
}
|
|
718
|
+
else if (msg.type === 'status' && msg.status === 'idle') {
|
|
719
|
+
clearTimeout(timeoutTimer);
|
|
720
|
+
if (saveTimer)
|
|
721
|
+
clearInterval(saveTimer);
|
|
722
|
+
// Final save
|
|
723
|
+
(async () => {
|
|
724
|
+
await saveOutputs();
|
|
725
|
+
ws.close();
|
|
726
|
+
resolve({
|
|
727
|
+
success: !hasError,
|
|
728
|
+
data: { outputs, success: !hasError, executionCount, error: errorMessage },
|
|
729
|
+
});
|
|
730
|
+
})();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
catch (e) {
|
|
734
|
+
// Ignore parse errors for non-JSON messages
|
|
735
|
+
}
|
|
736
|
+
};
|
|
737
|
+
ws.onerror = async (error) => {
|
|
738
|
+
clearTimeout(timeoutTimer);
|
|
739
|
+
if (saveTimer)
|
|
740
|
+
clearInterval(saveTimer);
|
|
741
|
+
await saveOutputs(); // Save what we have
|
|
742
|
+
ws.close();
|
|
743
|
+
resolve({
|
|
744
|
+
success: false,
|
|
745
|
+
error: `WebSocket error: ${error.message || 'Unknown error'}`,
|
|
746
|
+
});
|
|
747
|
+
};
|
|
748
|
+
ws.onclose = () => {
|
|
749
|
+
clearTimeout(timeoutTimer);
|
|
750
|
+
if (saveTimer)
|
|
751
|
+
clearInterval(saveTimer);
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
catch (e) {
|
|
755
|
+
// Fall back to non-incremental execution
|
|
756
|
+
const result = await this.executeCodeStreaming(sessionId, code, timeout);
|
|
757
|
+
if (result.success && save) {
|
|
758
|
+
await this.updateOutputsOp(path, cellId, result.data.outputs, result.data.executionCount);
|
|
759
|
+
}
|
|
760
|
+
resolve(result);
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
connectWs();
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Search cells in a notebook by keyword
|
|
768
|
+
*/
|
|
769
|
+
async searchCells(path, query, limit = 5) {
|
|
770
|
+
const notebookResult = await this.readNotebookViaRouter(path);
|
|
771
|
+
if (!notebookResult.success) {
|
|
772
|
+
return { success: false, error: notebookResult.error };
|
|
773
|
+
}
|
|
774
|
+
const notebook = notebookResult.data;
|
|
775
|
+
const terms = query.toLowerCase().split(/\s+/).filter((t) => t.length >= 2);
|
|
776
|
+
if (terms.length === 0) {
|
|
777
|
+
return { success: true, data: { cells: [], totalCells: notebook.cells.length } };
|
|
778
|
+
}
|
|
779
|
+
// Score each cell
|
|
780
|
+
const scored = notebook.cells.map((cell, index) => {
|
|
781
|
+
const content = cell.content.toLowerCase();
|
|
782
|
+
let score = 0;
|
|
783
|
+
for (const term of terms) {
|
|
784
|
+
const matches = (content.match(new RegExp(term, 'g')) || []).length;
|
|
785
|
+
score += matches;
|
|
786
|
+
}
|
|
787
|
+
// Boost exact phrase matches
|
|
788
|
+
if (content.includes(query.toLowerCase())) {
|
|
789
|
+
score += 10;
|
|
790
|
+
}
|
|
791
|
+
return { ...cell, index, score };
|
|
792
|
+
});
|
|
793
|
+
// Filter and sort by score
|
|
794
|
+
const matches = scored
|
|
795
|
+
.filter((c) => c.score > 0)
|
|
796
|
+
.sort((a, b) => b.score - a.score)
|
|
797
|
+
.slice(0, limit);
|
|
798
|
+
return {
|
|
799
|
+
success: true,
|
|
800
|
+
data: {
|
|
801
|
+
cells: matches.map((m) => ({
|
|
802
|
+
index: m.index,
|
|
803
|
+
score: m.score,
|
|
804
|
+
type: m.type,
|
|
805
|
+
content: m.content,
|
|
806
|
+
id: m.id,
|
|
807
|
+
})),
|
|
808
|
+
totalCells: notebook.cells.length,
|
|
809
|
+
},
|
|
810
|
+
};
|
|
811
|
+
}
|
|
812
|
+
// ===========================================================================
|
|
813
|
+
// LLM Operations
|
|
814
|
+
// ===========================================================================
|
|
815
|
+
/**
|
|
816
|
+
* Generate code using LLM
|
|
817
|
+
*/
|
|
818
|
+
async generateCode(prompt, context, provider, model) {
|
|
819
|
+
return this.fetch('/api/llm/generate', {
|
|
820
|
+
method: 'POST',
|
|
821
|
+
body: JSON.stringify({
|
|
822
|
+
prompt,
|
|
823
|
+
context,
|
|
824
|
+
provider,
|
|
825
|
+
model,
|
|
826
|
+
}),
|
|
827
|
+
});
|
|
828
|
+
}
|
|
829
|
+
/**
|
|
830
|
+
* Chat with notebook context
|
|
831
|
+
*/
|
|
832
|
+
async chat(message, notebookContext, history, provider, model) {
|
|
833
|
+
return this.fetch('/api/llm/chat', {
|
|
834
|
+
method: 'POST',
|
|
835
|
+
body: JSON.stringify({
|
|
836
|
+
message,
|
|
837
|
+
context: notebookContext,
|
|
838
|
+
history,
|
|
839
|
+
provider,
|
|
840
|
+
model,
|
|
841
|
+
}),
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
// ===========================================================================
|
|
845
|
+
// Helpers
|
|
846
|
+
// ===========================================================================
|
|
847
|
+
parseJupyterOutput(output) {
|
|
848
|
+
// Handle outputs already in internal format (from WebSocket)
|
|
849
|
+
if (output.type && output.content !== undefined) {
|
|
850
|
+
return {
|
|
851
|
+
type: output.type,
|
|
852
|
+
content: output.content,
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
// Handle Jupyter notebook format
|
|
856
|
+
if (output.output_type === 'stream') {
|
|
857
|
+
return {
|
|
858
|
+
type: output.name === 'stderr' ? 'stderr' : 'stdout',
|
|
859
|
+
content: Array.isArray(output.text) ? output.text.join('') : output.text,
|
|
860
|
+
};
|
|
861
|
+
}
|
|
862
|
+
if (output.output_type === 'error') {
|
|
863
|
+
return {
|
|
864
|
+
type: 'error',
|
|
865
|
+
content: (output.traceback || []).join('\n'),
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
if (output.output_type === 'execute_result' || output.output_type === 'display_data') {
|
|
869
|
+
if (output.data?.['image/png']) {
|
|
870
|
+
return { type: 'image', content: output.data['image/png'] };
|
|
871
|
+
}
|
|
872
|
+
if (output.data?.['text/html']) {
|
|
873
|
+
return { type: 'html', content: output.data['text/html'] };
|
|
874
|
+
}
|
|
875
|
+
if (output.data?.['text/plain']) {
|
|
876
|
+
return { type: 'stdout', content: output.data['text/plain'] };
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
return { type: 'stdout', content: JSON.stringify(output) };
|
|
880
|
+
}
|
|
881
|
+
toJupyterOutput(output) {
|
|
882
|
+
switch (output.type) {
|
|
883
|
+
case 'stdout':
|
|
884
|
+
return { output_type: 'stream', name: 'stdout', text: output.content };
|
|
885
|
+
case 'stderr':
|
|
886
|
+
return { output_type: 'stream', name: 'stderr', text: output.content };
|
|
887
|
+
case 'error':
|
|
888
|
+
return { output_type: 'error', traceback: output.content.split('\n') };
|
|
889
|
+
case 'image':
|
|
890
|
+
return { output_type: 'display_data', data: { 'image/png': output.content } };
|
|
891
|
+
case 'html':
|
|
892
|
+
return { output_type: 'display_data', data: { 'text/html': output.content } };
|
|
893
|
+
default:
|
|
894
|
+
return { output_type: 'stream', name: 'stdout', text: output.content };
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
// ===========================================================================
|
|
898
|
+
// Operation-Based API (routes through backend to UI or headless)
|
|
899
|
+
// ===========================================================================
|
|
900
|
+
//
|
|
901
|
+
// These methods use the Operation Router pattern:
|
|
902
|
+
// 1. Client sends operation to POST /api/notebook/operation
|
|
903
|
+
// 2. Backend checks if UI is connected via WebSocket
|
|
904
|
+
// 3. If UI connected: forwards to UI, UI applies and returns result
|
|
905
|
+
// 4. If no UI: applies via HeadlessOperationHandler (file-based)
|
|
906
|
+
//
|
|
907
|
+
// From the agent's perspective, both paths are identical.
|
|
908
|
+
// ===========================================================================
|
|
909
|
+
/**
|
|
910
|
+
* Apply a notebook operation through the operation router.
|
|
911
|
+
*
|
|
912
|
+
* Operations are routed to:
|
|
913
|
+
* - Connected UI via WebSocket (if available) - UI applies and saves
|
|
914
|
+
* - Headless manager (file-based) otherwise
|
|
915
|
+
*
|
|
916
|
+
* From the agent's perspective, both modes behave identically.
|
|
917
|
+
*/
|
|
918
|
+
async applyOperation(operation) {
|
|
919
|
+
if (this.agentId) {
|
|
920
|
+
operation.agentId = this.agentId;
|
|
921
|
+
if (this.clientName) {
|
|
922
|
+
operation.clientName = this.clientName;
|
|
923
|
+
}
|
|
924
|
+
if (this.clientVersion) {
|
|
925
|
+
operation.clientVersion = this.clientVersion;
|
|
926
|
+
}
|
|
927
|
+
if (this.autoStartAgentSession && this.isWriteOperation(operation.type)) {
|
|
928
|
+
const ensured = await this.ensureAgentSession(operation.notebookPath);
|
|
929
|
+
if (!ensured.success) {
|
|
930
|
+
return { success: false, error: ensured.error };
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
const result = await this.fetch('/api/notebook/operation', {
|
|
935
|
+
method: 'POST',
|
|
936
|
+
body: JSON.stringify({ operation }),
|
|
937
|
+
});
|
|
938
|
+
if (result.success) {
|
|
939
|
+
this.recordBackend(result.data?.backend);
|
|
940
|
+
}
|
|
941
|
+
return result;
|
|
942
|
+
}
|
|
943
|
+
isWriteOperation(opType) {
|
|
944
|
+
const readOnlyOps = new Set(['readCell', 'readCellOutput', 'searchCells', 'readNotebook']);
|
|
945
|
+
const sessionOps = new Set(['startAgentSession', 'endAgentSession']);
|
|
946
|
+
const creationOps = new Set(['createNotebook']); // File doesn't exist yet, no session needed
|
|
947
|
+
return !readOnlyOps.has(opType) && !sessionOps.has(opType) && !creationOps.has(opType);
|
|
948
|
+
}
|
|
949
|
+
async ensureAgentSession(path) {
|
|
950
|
+
if (!this.agentId) {
|
|
951
|
+
return { success: true, data: {} };
|
|
952
|
+
}
|
|
953
|
+
if (this.activeAgentSessions.has(path)) {
|
|
954
|
+
return { success: true, data: {} };
|
|
955
|
+
}
|
|
956
|
+
const inFlight = this.agentSessionInFlight.get(path);
|
|
957
|
+
if (inFlight) {
|
|
958
|
+
return inFlight;
|
|
959
|
+
}
|
|
960
|
+
const shouldWarn = !this.autoStartWarnedPaths.has(path);
|
|
961
|
+
const startPromise = this.startAgentSession(path, this.agentId);
|
|
962
|
+
this.agentSessionInFlight.set(path, startPromise);
|
|
963
|
+
try {
|
|
964
|
+
const result = await startPromise;
|
|
965
|
+
if (result.success && shouldWarn) {
|
|
966
|
+
this.lastAutoStartWarning = `Auto-started agent session for ${path}. Call start_agent_session/end_agent_session explicitly.`;
|
|
967
|
+
this.autoStartWarnedPaths.add(path);
|
|
968
|
+
}
|
|
969
|
+
return result;
|
|
970
|
+
}
|
|
971
|
+
finally {
|
|
972
|
+
this.agentSessionInFlight.delete(path);
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Read notebook through the operation router.
|
|
977
|
+
*
|
|
978
|
+
* If UI is connected, requests current state from UI.
|
|
979
|
+
* Otherwise reads from file.
|
|
980
|
+
*/
|
|
981
|
+
async readNotebookViaRouter(path, options = {}) {
|
|
982
|
+
// Build query string with options
|
|
983
|
+
const params = new URLSearchParams({ path });
|
|
984
|
+
if (options.includeOutputs !== undefined) {
|
|
985
|
+
params.set('include_outputs', String(options.includeOutputs));
|
|
986
|
+
}
|
|
987
|
+
if (options.maxLines !== undefined) {
|
|
988
|
+
params.set('max_lines', String(options.maxLines));
|
|
989
|
+
}
|
|
990
|
+
if (options.maxChars !== undefined) {
|
|
991
|
+
params.set('max_chars', String(options.maxChars));
|
|
992
|
+
}
|
|
993
|
+
if (options.maxLinesError !== undefined) {
|
|
994
|
+
params.set('max_lines_error', String(options.maxLinesError));
|
|
995
|
+
}
|
|
996
|
+
if (options.maxCharsError !== undefined) {
|
|
997
|
+
params.set('max_chars_error', String(options.maxCharsError));
|
|
998
|
+
}
|
|
999
|
+
const result = await this.fetch(`/api/notebook/read?${params.toString()}`);
|
|
1000
|
+
if (!result.success) {
|
|
1001
|
+
return { success: false, error: result.error };
|
|
1002
|
+
}
|
|
1003
|
+
// Router returns response with success/data/error structure
|
|
1004
|
+
const routerResult = result.data;
|
|
1005
|
+
this.recordBackend(routerResult.backend);
|
|
1006
|
+
if (!routerResult.success) {
|
|
1007
|
+
return { success: false, error: routerResult.error };
|
|
1008
|
+
}
|
|
1009
|
+
return {
|
|
1010
|
+
success: true,
|
|
1011
|
+
data: {
|
|
1012
|
+
path: routerResult.data.path,
|
|
1013
|
+
cells: routerResult.data.cells,
|
|
1014
|
+
metadata: routerResult.data.metadata,
|
|
1015
|
+
backend: routerResult.backend,
|
|
1016
|
+
},
|
|
1017
|
+
};
|
|
1018
|
+
}
|
|
1019
|
+
/**
|
|
1020
|
+
* Check if a UI is connected for a notebook path
|
|
1021
|
+
*/
|
|
1022
|
+
async hasUI(path) {
|
|
1023
|
+
const result = await this.fetch(`/api/notebook/has-ui?path=${encodeURIComponent(path)}`);
|
|
1024
|
+
return result.success && result.data?.hasUI === true;
|
|
1025
|
+
}
|
|
1026
|
+
// ===========================================================================
|
|
1027
|
+
// Operation-Based Notebook Methods
|
|
1028
|
+
// ===========================================================================
|
|
1029
|
+
/**
|
|
1030
|
+
* Create a new notebook using the operation router.
|
|
1031
|
+
*
|
|
1032
|
+
* Routes to UI if connected (UI creates file and tracks mtime),
|
|
1033
|
+
* otherwise creates via headless manager.
|
|
1034
|
+
*
|
|
1035
|
+
* @param path - Path to create the notebook
|
|
1036
|
+
* @param options - Optional settings
|
|
1037
|
+
* @param options.overwrite - Allow overwriting existing file (default: false)
|
|
1038
|
+
* @param options.kernelName - Kernel name (default: 'python3')
|
|
1039
|
+
* @param options.kernelDisplayName - Display name for kernel
|
|
1040
|
+
* @returns Result with path and mtime on success
|
|
1041
|
+
*/
|
|
1042
|
+
async createNotebookOp(path, options = {}) {
|
|
1043
|
+
const { overwrite = false, kernelName = 'python3', kernelDisplayName = 'Python 3' } = options;
|
|
1044
|
+
return this.applyOperation({
|
|
1045
|
+
type: 'createNotebook',
|
|
1046
|
+
notebookPath: path,
|
|
1047
|
+
overwrite,
|
|
1048
|
+
kernelName,
|
|
1049
|
+
kernelDisplayName,
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
// ===========================================================================
|
|
1053
|
+
// Operation-Based Cell Methods
|
|
1054
|
+
// ===========================================================================
|
|
1055
|
+
/**
|
|
1056
|
+
* Insert a cell using the operation router
|
|
1057
|
+
*/
|
|
1058
|
+
async insertCellOp(path, index, cell) {
|
|
1059
|
+
const operation = {
|
|
1060
|
+
type: 'insertCell',
|
|
1061
|
+
notebookPath: path,
|
|
1062
|
+
index,
|
|
1063
|
+
cell,
|
|
1064
|
+
};
|
|
1065
|
+
const result = await this.applyOperation(operation);
|
|
1066
|
+
if (!result.success) {
|
|
1067
|
+
return { success: false, error: result.error };
|
|
1068
|
+
}
|
|
1069
|
+
const opResult = result.data;
|
|
1070
|
+
if (!opResult.success) {
|
|
1071
|
+
return { success: false, error: opResult.error };
|
|
1072
|
+
}
|
|
1073
|
+
if (opResult.sessionId) {
|
|
1074
|
+
this.pinKernelSession(path, opResult.sessionId);
|
|
1075
|
+
}
|
|
1076
|
+
return {
|
|
1077
|
+
success: true,
|
|
1078
|
+
data: {
|
|
1079
|
+
cellIndex: opResult.cellIndex,
|
|
1080
|
+
cellId: opResult.cellId,
|
|
1081
|
+
totalCells: -1, // Not available from operation result
|
|
1082
|
+
idModified: opResult.idModified,
|
|
1083
|
+
requestedId: opResult.requestedId,
|
|
1084
|
+
},
|
|
1085
|
+
};
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Delete a cell using the operation router
|
|
1089
|
+
*/
|
|
1090
|
+
async deleteCellOp(path, options) {
|
|
1091
|
+
const operation = {
|
|
1092
|
+
type: 'deleteCell',
|
|
1093
|
+
notebookPath: path,
|
|
1094
|
+
cellId: options.cellId,
|
|
1095
|
+
cellIndex: options.cellIndex,
|
|
1096
|
+
};
|
|
1097
|
+
const result = await this.applyOperation(operation);
|
|
1098
|
+
if (!result.success) {
|
|
1099
|
+
return { success: false, error: result.error };
|
|
1100
|
+
}
|
|
1101
|
+
const opResult = result.data;
|
|
1102
|
+
if (!opResult.success) {
|
|
1103
|
+
return { success: false, error: opResult.error };
|
|
1104
|
+
}
|
|
1105
|
+
return { success: true };
|
|
1106
|
+
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Update cell content using the operation router
|
|
1109
|
+
*/
|
|
1110
|
+
async updateContentOp(path, cellId, content) {
|
|
1111
|
+
const operation = {
|
|
1112
|
+
type: 'updateContent',
|
|
1113
|
+
notebookPath: path,
|
|
1114
|
+
cellId,
|
|
1115
|
+
content,
|
|
1116
|
+
};
|
|
1117
|
+
const result = await this.applyOperation(operation);
|
|
1118
|
+
if (!result.success) {
|
|
1119
|
+
return { success: false, error: result.error };
|
|
1120
|
+
}
|
|
1121
|
+
const opResult = result.data;
|
|
1122
|
+
if (!opResult.success) {
|
|
1123
|
+
return { success: false, error: opResult.error };
|
|
1124
|
+
}
|
|
1125
|
+
return { success: true };
|
|
1126
|
+
}
|
|
1127
|
+
/**
|
|
1128
|
+
* Update cell metadata using the operation router
|
|
1129
|
+
*/
|
|
1130
|
+
async updateMetadataOp(path, cellId, changes) {
|
|
1131
|
+
const operation = {
|
|
1132
|
+
type: 'updateMetadata',
|
|
1133
|
+
notebookPath: path,
|
|
1134
|
+
cellId,
|
|
1135
|
+
changes,
|
|
1136
|
+
};
|
|
1137
|
+
const result = await this.applyOperation(operation);
|
|
1138
|
+
if (!result.success) {
|
|
1139
|
+
return { success: false, error: result.error };
|
|
1140
|
+
}
|
|
1141
|
+
const opResult = result.data;
|
|
1142
|
+
if (!opResult.success) {
|
|
1143
|
+
return { success: false, error: opResult.error };
|
|
1144
|
+
}
|
|
1145
|
+
return { success: true };
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Move a cell using the operation router
|
|
1149
|
+
*
|
|
1150
|
+
* Supports two modes:
|
|
1151
|
+
* 1. By index: moveCellOp(path, 0, 5) - move from index 0 to 5
|
|
1152
|
+
* 2. By ID: moveCellOp(path, 0, 0, { cellId: 'cell-1', afterCellId: 'cell-2' })
|
|
1153
|
+
*/
|
|
1154
|
+
async moveCellOp(path, fromIndex, toIndex, options) {
|
|
1155
|
+
const operation = {
|
|
1156
|
+
type: 'moveCell',
|
|
1157
|
+
notebookPath: path,
|
|
1158
|
+
fromIndex,
|
|
1159
|
+
toIndex,
|
|
1160
|
+
...(options?.cellId && { cellId: options.cellId }),
|
|
1161
|
+
...(options?.afterCellId && { afterCellId: options.afterCellId }),
|
|
1162
|
+
};
|
|
1163
|
+
const result = await this.applyOperation(operation);
|
|
1164
|
+
if (!result.success) {
|
|
1165
|
+
return { success: false, error: result.error };
|
|
1166
|
+
}
|
|
1167
|
+
const opResult = result.data;
|
|
1168
|
+
if (!opResult.success) {
|
|
1169
|
+
return { success: false, error: opResult.error };
|
|
1170
|
+
}
|
|
1171
|
+
return {
|
|
1172
|
+
success: true,
|
|
1173
|
+
data: {
|
|
1174
|
+
cellId: opResult.cellId,
|
|
1175
|
+
fromIndex: opResult.fromIndex ?? fromIndex,
|
|
1176
|
+
toIndex: opResult.toIndex ?? toIndex,
|
|
1177
|
+
},
|
|
1178
|
+
};
|
|
1179
|
+
}
|
|
1180
|
+
/**
|
|
1181
|
+
* Duplicate a cell using the operation router
|
|
1182
|
+
*/
|
|
1183
|
+
async duplicateCellOp(path, cellIndex, newCellId) {
|
|
1184
|
+
const operation = {
|
|
1185
|
+
type: 'duplicateCell',
|
|
1186
|
+
notebookPath: path,
|
|
1187
|
+
cellIndex,
|
|
1188
|
+
newCellId,
|
|
1189
|
+
};
|
|
1190
|
+
const result = await this.applyOperation(operation);
|
|
1191
|
+
if (!result.success) {
|
|
1192
|
+
return { success: false, error: result.error };
|
|
1193
|
+
}
|
|
1194
|
+
const opResult = result.data;
|
|
1195
|
+
if (!opResult.success) {
|
|
1196
|
+
return { success: false, error: opResult.error };
|
|
1197
|
+
}
|
|
1198
|
+
return {
|
|
1199
|
+
success: true,
|
|
1200
|
+
data: {
|
|
1201
|
+
cellIndex: opResult.cellIndex,
|
|
1202
|
+
cellId: opResult.cellId,
|
|
1203
|
+
metadata: opResult.metadata // Phase 2 ready: include metadata from backend
|
|
1204
|
+
},
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Update cell outputs using the operation router
|
|
1209
|
+
*/
|
|
1210
|
+
async updateOutputsOp(path, cellId, outputs, executionCount) {
|
|
1211
|
+
const operation = {
|
|
1212
|
+
type: 'updateOutputs',
|
|
1213
|
+
notebookPath: path,
|
|
1214
|
+
cellId,
|
|
1215
|
+
outputs,
|
|
1216
|
+
executionCount,
|
|
1217
|
+
};
|
|
1218
|
+
const result = await this.applyOperation(operation);
|
|
1219
|
+
if (!result.success) {
|
|
1220
|
+
return { success: false, error: result.error };
|
|
1221
|
+
}
|
|
1222
|
+
const opResult = result.data;
|
|
1223
|
+
if (!opResult.success) {
|
|
1224
|
+
return { success: false, error: opResult.error };
|
|
1225
|
+
}
|
|
1226
|
+
return { success: true };
|
|
1227
|
+
}
|
|
1228
|
+
// ===========================================================================
|
|
1229
|
+
// Read Operations (single cell, no full notebook read needed)
|
|
1230
|
+
// ===========================================================================
|
|
1231
|
+
/**
|
|
1232
|
+
* Read a single cell using the operation router.
|
|
1233
|
+
*
|
|
1234
|
+
* More efficient than readNotebook when you only need one cell.
|
|
1235
|
+
* Routes to UI if connected, otherwise reads from file.
|
|
1236
|
+
*/
|
|
1237
|
+
async readCellOp(path, options) {
|
|
1238
|
+
const operation = {
|
|
1239
|
+
type: 'readCell',
|
|
1240
|
+
notebookPath: path,
|
|
1241
|
+
cellId: options.cellId,
|
|
1242
|
+
cellIndex: options.cellIndex,
|
|
1243
|
+
};
|
|
1244
|
+
const result = await this.applyOperation(operation);
|
|
1245
|
+
if (!result.success) {
|
|
1246
|
+
return { success: false, error: result.error };
|
|
1247
|
+
}
|
|
1248
|
+
const opResult = result.data;
|
|
1249
|
+
if (!opResult.success) {
|
|
1250
|
+
return { success: false, error: opResult.error };
|
|
1251
|
+
}
|
|
1252
|
+
return {
|
|
1253
|
+
success: true,
|
|
1254
|
+
data: {
|
|
1255
|
+
cell: opResult.cell,
|
|
1256
|
+
cellIndex: opResult.cellIndex,
|
|
1257
|
+
},
|
|
1258
|
+
};
|
|
1259
|
+
}
|
|
1260
|
+
/**
|
|
1261
|
+
* Read cell outputs using the operation router.
|
|
1262
|
+
*
|
|
1263
|
+
* More efficient than readNotebook when you only need outputs.
|
|
1264
|
+
* Routes to UI if connected, otherwise reads from file.
|
|
1265
|
+
*
|
|
1266
|
+
* Supports truncation options for large outputs:
|
|
1267
|
+
* - maxLines: Max lines for regular output (default: 100)
|
|
1268
|
+
* - maxChars: Max characters for regular output (default: 10000)
|
|
1269
|
+
* - maxLinesError: Max lines for error output (default: 200)
|
|
1270
|
+
* - maxCharsError: Max characters for error output (default: 20000)
|
|
1271
|
+
* - lineOffset: Skip first N lines for pagination
|
|
1272
|
+
* - saveToFile: Force save full output to temp file
|
|
1273
|
+
*/
|
|
1274
|
+
async readCellOutputOp(path, options) {
|
|
1275
|
+
const operation = {
|
|
1276
|
+
type: 'readCellOutput',
|
|
1277
|
+
notebookPath: path,
|
|
1278
|
+
cellId: options.cellId,
|
|
1279
|
+
cellIndex: options.cellIndex,
|
|
1280
|
+
// Pass truncation options using snake_case for backend
|
|
1281
|
+
max_lines: options.maxLines,
|
|
1282
|
+
max_chars: options.maxChars,
|
|
1283
|
+
max_lines_error: options.maxLinesError,
|
|
1284
|
+
max_chars_error: options.maxCharsError,
|
|
1285
|
+
line_offset: options.lineOffset,
|
|
1286
|
+
save_to_file: options.saveToFile,
|
|
1287
|
+
};
|
|
1288
|
+
const result = await this.applyOperation(operation);
|
|
1289
|
+
if (!result.success) {
|
|
1290
|
+
return { success: false, error: result.error };
|
|
1291
|
+
}
|
|
1292
|
+
const opResult = result.data;
|
|
1293
|
+
if (!opResult.success) {
|
|
1294
|
+
return { success: false, error: opResult.error };
|
|
1295
|
+
}
|
|
1296
|
+
return {
|
|
1297
|
+
success: true,
|
|
1298
|
+
data: {
|
|
1299
|
+
outputs: opResult.outputs || [],
|
|
1300
|
+
executionCount: opResult.executionCount,
|
|
1301
|
+
executionStatus: opResult.executionStatus,
|
|
1302
|
+
cellId: opResult.cellId,
|
|
1303
|
+
cellIndex: opResult.cellIndex,
|
|
1304
|
+
temp_files: opResult.temp_files,
|
|
1305
|
+
},
|
|
1306
|
+
};
|
|
1307
|
+
}
|
|
1308
|
+
/**
|
|
1309
|
+
* Clear all cells from a notebook using the operation router
|
|
1310
|
+
*
|
|
1311
|
+
* This is the proper way to clear a notebook - routes to UI if connected
|
|
1312
|
+
* or falls back to headless mode. Returns the number of cells deleted.
|
|
1313
|
+
*/
|
|
1314
|
+
async clearNotebookOp(path) {
|
|
1315
|
+
const operation = {
|
|
1316
|
+
type: 'clearNotebook',
|
|
1317
|
+
notebookPath: path,
|
|
1318
|
+
};
|
|
1319
|
+
const result = await this.applyOperation(operation);
|
|
1320
|
+
if (!result.success) {
|
|
1321
|
+
return { success: false, error: result.error };
|
|
1322
|
+
}
|
|
1323
|
+
const opResult = result.data;
|
|
1324
|
+
if (!opResult.success) {
|
|
1325
|
+
return { success: false, error: opResult.error };
|
|
1326
|
+
}
|
|
1327
|
+
return {
|
|
1328
|
+
success: true,
|
|
1329
|
+
data: {
|
|
1330
|
+
deletedCount: opResult.deletedCount ?? 0,
|
|
1331
|
+
metadata: opResult.metadata // Phase 2 ready: include metadata from backend
|
|
1332
|
+
},
|
|
1333
|
+
};
|
|
1334
|
+
}
|
|
1335
|
+
// ===========================================================================
|
|
1336
|
+
// Batch Operations (Phase 1 enhancements)
|
|
1337
|
+
// ===========================================================================
|
|
1338
|
+
/**
|
|
1339
|
+
* Delete multiple cells by ID in a single operation
|
|
1340
|
+
*
|
|
1341
|
+
* More efficient than multiple single deletes - handles index shifting automatically.
|
|
1342
|
+
*/
|
|
1343
|
+
async deleteCellsOp(path, cellIds) {
|
|
1344
|
+
const operation = {
|
|
1345
|
+
type: 'deleteCells',
|
|
1346
|
+
notebookPath: path,
|
|
1347
|
+
cellIds,
|
|
1348
|
+
};
|
|
1349
|
+
const result = await this.applyOperation(operation);
|
|
1350
|
+
if (!result.success) {
|
|
1351
|
+
return { success: false, error: result.error };
|
|
1352
|
+
}
|
|
1353
|
+
const opResult = result.data;
|
|
1354
|
+
if (!opResult.success) {
|
|
1355
|
+
return { success: false, error: opResult.error };
|
|
1356
|
+
}
|
|
1357
|
+
return {
|
|
1358
|
+
success: true,
|
|
1359
|
+
data: {
|
|
1360
|
+
deletedCount: opResult.deletedCount ?? 0,
|
|
1361
|
+
deletedIds: opResult.deletedIds ?? [],
|
|
1362
|
+
notFound: opResult.notFound,
|
|
1363
|
+
totalCells: opResult.totalCells ?? 0,
|
|
1364
|
+
},
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
/**
|
|
1368
|
+
* Insert multiple cells at a position in a single operation
|
|
1369
|
+
*
|
|
1370
|
+
* More efficient than multiple single inserts.
|
|
1371
|
+
*/
|
|
1372
|
+
async insertCellsOp(path, cells, position = -1) {
|
|
1373
|
+
const operation = {
|
|
1374
|
+
type: 'insertCells',
|
|
1375
|
+
notebookPath: path,
|
|
1376
|
+
cells,
|
|
1377
|
+
position,
|
|
1378
|
+
};
|
|
1379
|
+
const result = await this.applyOperation(operation);
|
|
1380
|
+
if (!result.success) {
|
|
1381
|
+
return { success: false, error: result.error };
|
|
1382
|
+
}
|
|
1383
|
+
const opResult = result.data;
|
|
1384
|
+
if (!opResult.success) {
|
|
1385
|
+
return { success: false, error: opResult.error };
|
|
1386
|
+
}
|
|
1387
|
+
return {
|
|
1388
|
+
success: true,
|
|
1389
|
+
data: {
|
|
1390
|
+
insertedCount: opResult.insertedCount ?? 0,
|
|
1391
|
+
insertedIds: opResult.insertedIds ?? [],
|
|
1392
|
+
startIndex: opResult.startIndex ?? 0,
|
|
1393
|
+
totalCells: opResult.totalCells ?? 0,
|
|
1394
|
+
},
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* Search cells with optional output search
|
|
1399
|
+
*
|
|
1400
|
+
* Enhanced version that can search in cell outputs as well as source code.
|
|
1401
|
+
*/
|
|
1402
|
+
async searchCellsOp(path, query, options = {}) {
|
|
1403
|
+
const operation = {
|
|
1404
|
+
type: 'searchCells',
|
|
1405
|
+
notebookPath: path,
|
|
1406
|
+
query,
|
|
1407
|
+
includeOutputs: options.includeOutputs ?? false,
|
|
1408
|
+
limit: options.limit ?? 10,
|
|
1409
|
+
};
|
|
1410
|
+
const result = await this.applyOperation(operation);
|
|
1411
|
+
if (!result.success) {
|
|
1412
|
+
return { success: false, error: result.error };
|
|
1413
|
+
}
|
|
1414
|
+
const opResult = result.data;
|
|
1415
|
+
if (!opResult.success) {
|
|
1416
|
+
return { success: false, error: opResult.error };
|
|
1417
|
+
}
|
|
1418
|
+
return {
|
|
1419
|
+
success: true,
|
|
1420
|
+
data: {
|
|
1421
|
+
query: opResult.query ?? query,
|
|
1422
|
+
matchCount: opResult.matchCount ?? 0,
|
|
1423
|
+
matches: opResult.matches ?? [],
|
|
1424
|
+
hasMore: opResult.hasMore ?? false,
|
|
1425
|
+
},
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
/**
|
|
1429
|
+
* Clear outputs from one or more cells
|
|
1430
|
+
*
|
|
1431
|
+
* Useful for cleanup before sharing notebooks.
|
|
1432
|
+
*/
|
|
1433
|
+
async clearOutputsOp(path, cellIds) {
|
|
1434
|
+
const operation = {
|
|
1435
|
+
type: 'clearOutputs',
|
|
1436
|
+
notebookPath: path,
|
|
1437
|
+
...(cellIds && cellIds.length > 0 ? { cellIds } : {}),
|
|
1438
|
+
};
|
|
1439
|
+
const result = await this.applyOperation(operation);
|
|
1440
|
+
if (!result.success) {
|
|
1441
|
+
return { success: false, error: result.error };
|
|
1442
|
+
}
|
|
1443
|
+
const opResult = result.data;
|
|
1444
|
+
if (!opResult.success) {
|
|
1445
|
+
return { success: false, error: opResult.error };
|
|
1446
|
+
}
|
|
1447
|
+
return {
|
|
1448
|
+
success: true,
|
|
1449
|
+
data: {
|
|
1450
|
+
clearedCount: opResult.clearedCount ?? 0,
|
|
1451
|
+
clearedIds: opResult.clearedIds ?? [],
|
|
1452
|
+
notFound: opResult.notFound,
|
|
1453
|
+
},
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
// ===========================================================================
|
|
1457
|
+
// Execution Operations
|
|
1458
|
+
// ===========================================================================
|
|
1459
|
+
/**
|
|
1460
|
+
* Execute a cell through the operation router
|
|
1461
|
+
*
|
|
1462
|
+
* Routes to UI if connected (shows running indicator) or executes in headless mode.
|
|
1463
|
+
* For long-running cells, returns with status="busy" after maxWait seconds.
|
|
1464
|
+
* Use readCellOutputOp with max_wait to poll for more output.
|
|
1465
|
+
*/
|
|
1466
|
+
async executeCellOp(path, options) {
|
|
1467
|
+
const resolvedSession = options.sessionId
|
|
1468
|
+
? { success: true, data: { sessionId: options.sessionId } }
|
|
1469
|
+
: await this.resolveKernelSessionIdForNotebook(path, {
|
|
1470
|
+
createIfMissing: true,
|
|
1471
|
+
});
|
|
1472
|
+
if (!resolvedSession.success) {
|
|
1473
|
+
return { success: false, error: resolvedSession.error };
|
|
1474
|
+
}
|
|
1475
|
+
const effectiveSessionId = resolvedSession.data.sessionId;
|
|
1476
|
+
this.pinKernelSession(path, effectiveSessionId);
|
|
1477
|
+
const operation = {
|
|
1478
|
+
type: 'executeCell',
|
|
1479
|
+
notebookPath: path,
|
|
1480
|
+
cellId: options.cellId,
|
|
1481
|
+
cellIndex: options.cellIndex,
|
|
1482
|
+
sessionId: effectiveSessionId,
|
|
1483
|
+
maxWait: options.maxWait,
|
|
1484
|
+
saveOutputs: options.saveOutputs,
|
|
1485
|
+
};
|
|
1486
|
+
const result = await this.applyOperation(operation);
|
|
1487
|
+
if (!result.success) {
|
|
1488
|
+
return { success: false, error: result.error };
|
|
1489
|
+
}
|
|
1490
|
+
const opResult = result.data;
|
|
1491
|
+
if (!opResult.success) {
|
|
1492
|
+
return { success: false, error: opResult.error };
|
|
1493
|
+
}
|
|
1494
|
+
return {
|
|
1495
|
+
success: true,
|
|
1496
|
+
data: {
|
|
1497
|
+
cellId: opResult.cellId,
|
|
1498
|
+
cellIndex: opResult.cellIndex,
|
|
1499
|
+
executionStatus: opResult.executionStatus ?? 'idle',
|
|
1500
|
+
executionCount: opResult.executionCount,
|
|
1501
|
+
outputs: opResult.outputs ?? [],
|
|
1502
|
+
executionTime: opResult.executionTime,
|
|
1503
|
+
sessionId: opResult.sessionId,
|
|
1504
|
+
error: opResult.error,
|
|
1505
|
+
},
|
|
1506
|
+
};
|
|
1507
|
+
}
|
|
1508
|
+
// ===========================================================================
|
|
1509
|
+
// Agent Session Management
|
|
1510
|
+
// ===========================================================================
|
|
1511
|
+
//
|
|
1512
|
+
// Agent sessions provide:
|
|
1513
|
+
// 1. UI feedback: Shows "Agent" badge with purple styling when session active
|
|
1514
|
+
// 2. Conflict prevention: Warns if multiple agents try to access same notebook
|
|
1515
|
+
// 3. Session tracking: Records start time and agent ID for diagnostics
|
|
1516
|
+
//
|
|
1517
|
+
// Best practice: Always wrap agent operations in try/finally:
|
|
1518
|
+
//
|
|
1519
|
+
// await client.startAgentSession(path, 'my-agent');
|
|
1520
|
+
// try {
|
|
1521
|
+
// // ... operations ...
|
|
1522
|
+
// } finally {
|
|
1523
|
+
// await client.endAgentSession(path);
|
|
1524
|
+
// }
|
|
1525
|
+
//
|
|
1526
|
+
// In headless mode (no UI), session operations are no-ops that always succeed.
|
|
1527
|
+
// ===========================================================================
|
|
1528
|
+
/**
|
|
1529
|
+
* Start an agent session (locks notebook for agent use).
|
|
1530
|
+
* If a session is already active, returns a warning but still starts a new session.
|
|
1531
|
+
*
|
|
1532
|
+
* @param path - Path to the notebook file
|
|
1533
|
+
* @param agentId - Optional identifier for this agent session
|
|
1534
|
+
* @param force - If true, forcibly end any existing session and steal the lock.
|
|
1535
|
+
* Use only with explicit user permission.
|
|
1536
|
+
* @param lastSessionTimestamp - Optional timestamp (ms) to fetch updates since last session.
|
|
1537
|
+
*/
|
|
1538
|
+
async startAgentSession(path, agentId, force, lastSessionTimestamp) {
|
|
1539
|
+
const operation = {
|
|
1540
|
+
type: 'startAgentSession',
|
|
1541
|
+
notebookPath: path,
|
|
1542
|
+
agentId: agentId || this.agentId,
|
|
1543
|
+
clientName: this.clientName,
|
|
1544
|
+
clientVersion: this.clientVersion,
|
|
1545
|
+
force,
|
|
1546
|
+
lastSessionTimestamp,
|
|
1547
|
+
};
|
|
1548
|
+
const result = await this.applyOperation(operation);
|
|
1549
|
+
if (!result.success) {
|
|
1550
|
+
return { success: false, error: result.error };
|
|
1551
|
+
}
|
|
1552
|
+
const opResult = result.data;
|
|
1553
|
+
if (!opResult.success) {
|
|
1554
|
+
return { success: false, error: opResult.error };
|
|
1555
|
+
}
|
|
1556
|
+
this.activeAgentSessions.add(path);
|
|
1557
|
+
this.setActiveNotebookPath(path);
|
|
1558
|
+
await this.resolveKernelSessionIdForNotebook(path, { createIfMissing: false }).catch(() => undefined);
|
|
1559
|
+
return {
|
|
1560
|
+
success: true,
|
|
1561
|
+
data: {
|
|
1562
|
+
warning: opResult.warning,
|
|
1563
|
+
updatesSince: opResult.updatesSince,
|
|
1564
|
+
},
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
/**
|
|
1568
|
+
* End an agent session (unlocks notebook).
|
|
1569
|
+
* Should be called when agent work is complete.
|
|
1570
|
+
*/
|
|
1571
|
+
async endAgentSession(path) {
|
|
1572
|
+
const operation = {
|
|
1573
|
+
type: 'endAgentSession',
|
|
1574
|
+
notebookPath: path,
|
|
1575
|
+
};
|
|
1576
|
+
const result = await this.applyOperation(operation);
|
|
1577
|
+
if (!result.success) {
|
|
1578
|
+
return { success: false, error: result.error };
|
|
1579
|
+
}
|
|
1580
|
+
const opResult = result.data;
|
|
1581
|
+
if (!opResult.success) {
|
|
1582
|
+
return { success: false, error: opResult.error };
|
|
1583
|
+
}
|
|
1584
|
+
this.activeAgentSessions.delete(path);
|
|
1585
|
+
this.clearActiveNotebookPath(path);
|
|
1586
|
+
this.clearPinnedKernelSession(path);
|
|
1587
|
+
return {
|
|
1588
|
+
success: true,
|
|
1589
|
+
data: {
|
|
1590
|
+
sessionDuration: opResult.sessionDuration,
|
|
1591
|
+
warning: opResult.warning,
|
|
1592
|
+
},
|
|
1593
|
+
};
|
|
1594
|
+
}
|
|
1595
|
+
// ===========================================================================
|
|
1596
|
+
// Python Environment Discovery
|
|
1597
|
+
// ===========================================================================
|
|
1598
|
+
/**
|
|
1599
|
+
* List available Python environments on the system.
|
|
1600
|
+
*
|
|
1601
|
+
* Discovers Python installations from:
|
|
1602
|
+
* - System Python
|
|
1603
|
+
* - Conda environments
|
|
1604
|
+
* - pyenv versions
|
|
1605
|
+
* - Virtual environments
|
|
1606
|
+
* - Homebrew Python
|
|
1607
|
+
*/
|
|
1608
|
+
async listPythonEnvironments(refresh = false) {
|
|
1609
|
+
const result = await this.fetch(`/api/python/environments?refresh=${refresh}`);
|
|
1610
|
+
if (!result.success) {
|
|
1611
|
+
return { success: false, error: result.error };
|
|
1612
|
+
}
|
|
1613
|
+
return {
|
|
1614
|
+
success: true,
|
|
1615
|
+
data: result.data.environments.map(env => ({
|
|
1616
|
+
name: env.name,
|
|
1617
|
+
path: env.path,
|
|
1618
|
+
version: env.version,
|
|
1619
|
+
source: env.source,
|
|
1620
|
+
isActive: env.is_active,
|
|
1621
|
+
})),
|
|
1622
|
+
};
|
|
1623
|
+
}
|
|
1624
|
+
/**
|
|
1625
|
+
* Get detailed information about a Python environment.
|
|
1626
|
+
*
|
|
1627
|
+
* @param pythonPath - Path to the Python executable (optional, uses system Python if not provided)
|
|
1628
|
+
*/
|
|
1629
|
+
async getPythonInfo(pythonPath) {
|
|
1630
|
+
try {
|
|
1631
|
+
// If no path provided, get info about the first available Python
|
|
1632
|
+
if (!pythonPath) {
|
|
1633
|
+
const envResult = await this.listPythonEnvironments();
|
|
1634
|
+
if (!envResult.success || !envResult.data?.length) {
|
|
1635
|
+
return { success: false, error: 'No Python environments found' };
|
|
1636
|
+
}
|
|
1637
|
+
pythonPath = envResult.data[0].path;
|
|
1638
|
+
}
|
|
1639
|
+
// For now, return basic info from the environment list
|
|
1640
|
+
// A more detailed endpoint could be added to the backend
|
|
1641
|
+
const envResult = await this.listPythonEnvironments();
|
|
1642
|
+
if (!envResult.success) {
|
|
1643
|
+
return { success: false, error: envResult.error };
|
|
1644
|
+
}
|
|
1645
|
+
const env = envResult.data?.find(e => e.path === pythonPath);
|
|
1646
|
+
if (!env) {
|
|
1647
|
+
return { success: false, error: `Python not found: ${pythonPath}` };
|
|
1648
|
+
}
|
|
1649
|
+
return {
|
|
1650
|
+
success: true,
|
|
1651
|
+
data: {
|
|
1652
|
+
path: env.path,
|
|
1653
|
+
version: env.version || 'unknown',
|
|
1654
|
+
},
|
|
1655
|
+
};
|
|
1656
|
+
}
|
|
1657
|
+
catch (error) {
|
|
1658
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1659
|
+
return { success: false, error: message };
|
|
1660
|
+
}
|
|
1661
|
+
}
|
|
1662
|
+
// ===========================================================================
|
|
1663
|
+
// User Change Tracking
|
|
1664
|
+
// ===========================================================================
|
|
1665
|
+
/**
|
|
1666
|
+
* Get the last tool call timestamp for a notebook path.
|
|
1667
|
+
* Used internally to track user changes between tool calls.
|
|
1668
|
+
*/
|
|
1669
|
+
getLastToolCallTimestamp(path) {
|
|
1670
|
+
return this.lastToolCallTimestamp.get(path) ?? 0;
|
|
1671
|
+
}
|
|
1672
|
+
/**
|
|
1673
|
+
* Record the current time as the last tool call for a notebook path.
|
|
1674
|
+
* Called automatically after each operation.
|
|
1675
|
+
*/
|
|
1676
|
+
recordToolCallTimestamp(path, timestamp) {
|
|
1677
|
+
this.lastToolCallTimestamp.set(path, timestamp ?? Date.now());
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Get updates since the last tool call for a notebook.
|
|
1681
|
+
*
|
|
1682
|
+
* Returns summaries of edits and events since the last recorded
|
|
1683
|
+
* tool call timestamp (no source filtering).
|
|
1684
|
+
*/
|
|
1685
|
+
async getUpdatesSinceOp(path) {
|
|
1686
|
+
const sinceTimestamp = this.getLastToolCallTimestamp(path);
|
|
1687
|
+
const operation = {
|
|
1688
|
+
type: 'getUpdatesSince',
|
|
1689
|
+
notebookPath: path,
|
|
1690
|
+
sinceTimestamp,
|
|
1691
|
+
};
|
|
1692
|
+
const result = await this.applyOperation(operation);
|
|
1693
|
+
if (!result.success) {
|
|
1694
|
+
return { success: false, error: result.error };
|
|
1695
|
+
}
|
|
1696
|
+
const opResult = result.data;
|
|
1697
|
+
if (!opResult.success) {
|
|
1698
|
+
return { success: false, error: opResult.error };
|
|
1699
|
+
}
|
|
1700
|
+
// Update the timestamp with server's timestamp if provided
|
|
1701
|
+
if (opResult.serverTimestamp) {
|
|
1702
|
+
this.recordToolCallTimestamp(path, opResult.serverTimestamp);
|
|
1703
|
+
}
|
|
1704
|
+
return {
|
|
1705
|
+
success: true,
|
|
1706
|
+
data: {
|
|
1707
|
+
updates: opResult.updatesSince ?? [],
|
|
1708
|
+
sinceTimestamp,
|
|
1709
|
+
serverTimestamp: opResult.serverTimestamp ?? Date.now(),
|
|
1710
|
+
},
|
|
1711
|
+
};
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
/**
|
|
1715
|
+
* Create a new Nebula client
|
|
1716
|
+
*/
|
|
1717
|
+
export function createNebulaClient(config) {
|
|
1718
|
+
return new NebulaClient(config);
|
|
1719
|
+
}
|
|
1720
|
+
//# sourceMappingURL=client.js.map
|