nebula-notebook 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +222 -0
  2. package/bin/nebula-notebook.js +33 -0
  3. package/dist/assets/index-C1h_sArD.css +32 -0
  4. package/dist/assets/index-CDSTBon8.js +658 -0
  5. package/dist/favicon.svg +11 -0
  6. package/dist/index.html +73 -0
  7. package/node-server/dist/app.d.ts +5 -0
  8. package/node-server/dist/app.js +38 -0
  9. package/node-server/dist/auth/auth-middleware.d.ts +24 -0
  10. package/node-server/dist/auth/auth-middleware.js +276 -0
  11. package/node-server/dist/auth/auth-service.d.ts +84 -0
  12. package/node-server/dist/auth/auth-service.js +265 -0
  13. package/node-server/dist/auth/index.d.ts +3 -0
  14. package/node-server/dist/auth/index.js +8 -0
  15. package/node-server/dist/cluster/client-registration.d.ts +43 -0
  16. package/node-server/dist/cluster/client-registration.js +217 -0
  17. package/node-server/dist/cluster/cluster-secret.d.ts +11 -0
  18. package/node-server/dist/cluster/cluster-secret.js +90 -0
  19. package/node-server/dist/cluster/kernel-proxy.d.ts +100 -0
  20. package/node-server/dist/cluster/kernel-proxy.js +361 -0
  21. package/node-server/dist/cluster/server-registry.d.ts +109 -0
  22. package/node-server/dist/cluster/server-registry.js +217 -0
  23. package/node-server/dist/config/output-limits.d.ts +7 -0
  24. package/node-server/dist/config/output-limits.js +10 -0
  25. package/node-server/dist/discovery/discovery-service.d.ts +198 -0
  26. package/node-server/dist/discovery/discovery-service.js +811 -0
  27. package/node-server/dist/discovery/index.d.ts +5 -0
  28. package/node-server/dist/discovery/index.js +21 -0
  29. package/node-server/dist/discovery/types.d.ts +48 -0
  30. package/node-server/dist/discovery/types.js +24 -0
  31. package/node-server/dist/fs/fs-service.d.ts +218 -0
  32. package/node-server/dist/fs/fs-service.js +1422 -0
  33. package/node-server/dist/fs/index.d.ts +5 -0
  34. package/node-server/dist/fs/index.js +21 -0
  35. package/node-server/dist/fs/types.d.ts +132 -0
  36. package/node-server/dist/fs/types.js +5 -0
  37. package/node-server/dist/index.d.ts +13 -0
  38. package/node-server/dist/index.js +556 -0
  39. package/node-server/dist/kernel/default-kernel.d.ts +5 -0
  40. package/node-server/dist/kernel/default-kernel.js +138 -0
  41. package/node-server/dist/kernel/index.d.ts +7 -0
  42. package/node-server/dist/kernel/index.js +23 -0
  43. package/node-server/dist/kernel/kernel-service.d.ts +290 -0
  44. package/node-server/dist/kernel/kernel-service.js +1714 -0
  45. package/node-server/dist/kernel/kernelspec.d.ts +29 -0
  46. package/node-server/dist/kernel/kernelspec.js +210 -0
  47. package/node-server/dist/kernel/session-store.d.ts +87 -0
  48. package/node-server/dist/kernel/session-store.js +303 -0
  49. package/node-server/dist/kernel/types.d.ts +143 -0
  50. package/node-server/dist/kernel/types.js +17 -0
  51. package/node-server/dist/llm/index.d.ts +5 -0
  52. package/node-server/dist/llm/index.js +21 -0
  53. package/node-server/dist/llm/llm-service.d.ts +77 -0
  54. package/node-server/dist/llm/llm-service.js +454 -0
  55. package/node-server/dist/llm/types.d.ts +40 -0
  56. package/node-server/dist/llm/types.js +15 -0
  57. package/node-server/dist/notebook/cell-metadata.d.ts +27 -0
  58. package/node-server/dist/notebook/cell-metadata.js +76 -0
  59. package/node-server/dist/notebook/headless-handler.d.ts +127 -0
  60. package/node-server/dist/notebook/headless-handler.js +1530 -0
  61. package/node-server/dist/notebook/notebook-websocket.d.ts +12 -0
  62. package/node-server/dist/notebook/notebook-websocket.js +103 -0
  63. package/node-server/dist/notebook/operation-router.d.ts +115 -0
  64. package/node-server/dist/notebook/operation-router.js +641 -0
  65. package/node-server/dist/notebook/undoRedoManager.d.ts +194 -0
  66. package/node-server/dist/notebook/undoRedoManager.js +558 -0
  67. package/node-server/dist/output/display-data.d.ts +14 -0
  68. package/node-server/dist/output/display-data.js +134 -0
  69. package/node-server/dist/resources/resource-service.d.ts +69 -0
  70. package/node-server/dist/resources/resource-service.js +363 -0
  71. package/node-server/dist/routes/auth.d.ts +5 -0
  72. package/node-server/dist/routes/auth.js +61 -0
  73. package/node-server/dist/routes/cluster.d.ts +7 -0
  74. package/node-server/dist/routes/cluster.js +94 -0
  75. package/node-server/dist/routes/fs.d.ts +7 -0
  76. package/node-server/dist/routes/fs.js +392 -0
  77. package/node-server/dist/routes/kernel.d.ts +13 -0
  78. package/node-server/dist/routes/kernel.js +637 -0
  79. package/node-server/dist/routes/llm.d.ts +8 -0
  80. package/node-server/dist/routes/llm.js +105 -0
  81. package/node-server/dist/routes/notebook.d.ts +10 -0
  82. package/node-server/dist/routes/notebook.js +335 -0
  83. package/node-server/dist/routes/python.d.ts +8 -0
  84. package/node-server/dist/routes/python.js +187 -0
  85. package/node-server/dist/routes/resources.d.ts +7 -0
  86. package/node-server/dist/routes/resources.js +77 -0
  87. package/node-server/dist/scripts/show-auth-qr.d.ts +1 -0
  88. package/node-server/dist/scripts/show-auth-qr.js +81 -0
  89. package/node-server/dist/terminal/pty-manager.d.ts +100 -0
  90. package/node-server/dist/terminal/pty-manager.js +246 -0
  91. package/node-server/dist/terminal/server.d.ts +19 -0
  92. package/node-server/dist/terminal/server.js +254 -0
  93. package/node-server/dist/terminal/types.d.ts +50 -0
  94. package/node-server/dist/terminal/types.js +9 -0
  95. package/node-server/package.json +45 -0
  96. package/package.json +99 -0
  97. package/scripts/postinstall.cjs +26 -0
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ /**
3
+ * LLM API Routes
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.llmService = void 0;
7
+ exports.default = llmRoutes;
8
+ const llm_service_1 = require("../llm/llm-service");
9
+ const llmService = new llm_service_1.LLMService();
10
+ exports.llmService = llmService;
11
+ async function llmRoutes(fastify) {
12
+ /**
13
+ * List available LLM providers and models
14
+ */
15
+ fastify.get('/llm/providers', async (_request, reply) => {
16
+ const providers = llmService.getAvailableProviders();
17
+ return reply.send({ providers });
18
+ });
19
+ /**
20
+ * Generate text/code from LLM
21
+ */
22
+ fastify.post('/llm/generate', async (request, reply) => {
23
+ try {
24
+ const { prompt, system_prompt, provider = 'google', model = 'gemini-2.5-flash', temperature = 0.2, images, } = request.body;
25
+ // Check for API key from header
26
+ const apiKey = request.headers['x-api-key'];
27
+ const apiProvider = request.headers['x-api-provider'];
28
+ const response = await llmService.generate({
29
+ prompt,
30
+ systemPrompt: system_prompt,
31
+ config: {
32
+ provider: provider,
33
+ model,
34
+ temperature,
35
+ apiKey: apiProvider === provider ? apiKey : undefined,
36
+ },
37
+ images: images,
38
+ });
39
+ return reply.send({ response });
40
+ }
41
+ catch (err) {
42
+ const message = err instanceof Error ? err.message : 'Unknown error';
43
+ return reply.code(500).send({ detail: message });
44
+ }
45
+ });
46
+ /**
47
+ * Generate structured JSON response from LLM
48
+ */
49
+ fastify.post('/llm/generate-structured', async (request, reply) => {
50
+ try {
51
+ const { prompt, system_prompt, provider = 'google', model = 'gemini-2.5-flash', temperature = 0.2, } = request.body;
52
+ // Check for API key from header
53
+ const apiKey = request.headers['x-api-key'];
54
+ const apiProvider = request.headers['x-api-provider'];
55
+ const response = await llmService.generateStructured({
56
+ prompt,
57
+ systemPrompt: system_prompt,
58
+ config: {
59
+ provider: provider,
60
+ model,
61
+ temperature,
62
+ apiKey: apiProvider === provider ? apiKey : undefined,
63
+ },
64
+ });
65
+ return reply.send({ response });
66
+ }
67
+ catch (err) {
68
+ const message = err instanceof Error ? err.message : 'Unknown error';
69
+ return reply.code(500).send({ detail: message });
70
+ }
71
+ });
72
+ /**
73
+ * Chat with LLM including history
74
+ */
75
+ fastify.post('/llm/chat', async (request, reply) => {
76
+ try {
77
+ const { message, history = [], system_prompt, provider = 'google', model = 'gemini-2.5-flash', temperature = 0.2, images, } = request.body;
78
+ // Check for API key from header
79
+ const apiKey = request.headers['x-api-key'];
80
+ const apiProvider = request.headers['x-api-provider'];
81
+ // Convert history format
82
+ const chatHistory = history.map((h) => ({
83
+ role: h.role,
84
+ content: h.content,
85
+ }));
86
+ const response = await llmService.chat({
87
+ message,
88
+ history: chatHistory,
89
+ systemPrompt: system_prompt,
90
+ config: {
91
+ provider: provider,
92
+ model,
93
+ temperature,
94
+ apiKey: apiProvider === provider ? apiKey : undefined,
95
+ },
96
+ images: images,
97
+ });
98
+ return reply.send({ response });
99
+ }
100
+ catch (err) {
101
+ const message = err instanceof Error ? err.message : 'Unknown error';
102
+ return reply.code(500).send({ detail: message });
103
+ }
104
+ });
105
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Notebook API Routes
3
+ */
4
+ import { FastifyInstance } from 'fastify';
5
+ import { fsService } from '../fs/fs-service';
6
+ import { operationRouter } from '../notebook/operation-router';
7
+ import { HeadlessOperationHandler } from '../notebook/headless-handler';
8
+ declare const headlessHandler: HeadlessOperationHandler;
9
+ export default function notebookRoutes(fastify: FastifyInstance): Promise<void>;
10
+ export { fsService, operationRouter, headlessHandler };
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+ /**
3
+ * Notebook API Routes
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.headlessHandler = exports.operationRouter = exports.fsService = void 0;
7
+ exports.default = notebookRoutes;
8
+ const fs_service_1 = require("../fs/fs-service");
9
+ Object.defineProperty(exports, "fsService", { enumerable: true, get: function () { return fs_service_1.fsService; } });
10
+ const operation_router_1 = require("../notebook/operation-router");
11
+ Object.defineProperty(exports, "operationRouter", { enumerable: true, get: function () { return operation_router_1.operationRouter; } });
12
+ const headless_handler_1 = require("../notebook/headless-handler");
13
+ const kernel_1 = require("./kernel");
14
+ // Initialize headless handler with kernel service for cell execution
15
+ const headlessHandler = new headless_handler_1.HeadlessOperationHandler(fs_service_1.fsService, operation_router_1.operationRouter, kernel_1.kernelService);
16
+ exports.headlessHandler = headlessHandler;
17
+ operation_router_1.operationRouter.setHeadlessHandler(headlessHandler);
18
+ async function notebookRoutes(fastify) {
19
+ /**
20
+ * Get cell metadata schema
21
+ */
22
+ fastify.get('/cell/metadata-schema', async (_request, reply) => {
23
+ return reply.send({
24
+ id: {
25
+ type: 'string',
26
+ description: 'Unique cell identifier. Agent-created cells should use human-readable IDs.',
27
+ agentMutable: true,
28
+ },
29
+ type: {
30
+ type: 'enum',
31
+ values: ['code', 'markdown'],
32
+ description: 'Cell type: code for executable cells, markdown for documentation.',
33
+ agentMutable: true,
34
+ },
35
+ scrolled: {
36
+ type: 'boolean',
37
+ description: 'Whether cell output is collapsed (Jupyter standard).',
38
+ agentMutable: true,
39
+ default: false,
40
+ },
41
+ scrolledHeight: {
42
+ type: 'number',
43
+ description: 'Height in pixels when output is collapsed.',
44
+ agentMutable: true,
45
+ },
46
+ });
47
+ });
48
+ /**
49
+ * Get notebook cells in internal format
50
+ */
51
+ fastify.get('/notebook/cells', async (request, reply) => {
52
+ try {
53
+ const filePath = request.query.path;
54
+ if (!filePath) {
55
+ return reply.code(400).send({ detail: 'path query parameter is required' });
56
+ }
57
+ const normalizedPath = fs_service_1.fsService.normalizePath(filePath);
58
+ const result = await fs_service_1.fsService.getNotebookCellsWithKernel(filePath);
59
+ return reply.send({
60
+ path: normalizedPath,
61
+ cells: result.cells,
62
+ metadata: result.metadata,
63
+ kernelspec: result.kernelspec,
64
+ mtime: result.mtime,
65
+ });
66
+ }
67
+ catch (err) {
68
+ if (err instanceof Error && err.message.includes('not found')) {
69
+ return reply.code(404).send({ detail: err.message });
70
+ }
71
+ else {
72
+ const message = err instanceof Error ? err.message : 'Unknown error';
73
+ return reply.code(500).send({ detail: message });
74
+ }
75
+ }
76
+ });
77
+ /**
78
+ * Save notebook cells
79
+ */
80
+ fastify.post('/notebook/save', async (request, reply) => {
81
+ try {
82
+ const { path: filePath, cells, kernel_name, history } = request.body;
83
+ if (!filePath) {
84
+ return reply.code(400).send({ detail: 'path is required' });
85
+ }
86
+ if (!cells) {
87
+ return reply.code(400).send({ detail: 'cells is required' });
88
+ }
89
+ const result = await fs_service_1.fsService.saveNotebookBundle(filePath, cells, kernel_name, history);
90
+ return reply.send({ status: 'ok', path: filePath, mtime: result.mtime });
91
+ }
92
+ catch (err) {
93
+ const message = err instanceof Error ? err.message : 'Unknown error';
94
+ return reply.code(500).send({ detail: message });
95
+ }
96
+ });
97
+ /**
98
+ * Load operation history for a notebook
99
+ */
100
+ fastify.get('/notebook/history', async (request, reply) => {
101
+ try {
102
+ const notebookPath = request.query.notebook_path;
103
+ if (!notebookPath) {
104
+ return reply.code(400).send({ detail: 'notebook_path query parameter is required' });
105
+ }
106
+ const history = fs_service_1.fsService.loadHistory(notebookPath);
107
+ return reply.send({ notebook_path: notebookPath, history });
108
+ }
109
+ catch (err) {
110
+ const message = err instanceof Error ? err.message : 'Unknown error';
111
+ return reply.code(500).send({ detail: message });
112
+ }
113
+ });
114
+ /**
115
+ * Save operation history for a notebook
116
+ */
117
+ fastify.post('/notebook/history', async (request, reply) => {
118
+ try {
119
+ const { notebook_path, history } = request.body;
120
+ if (!notebook_path) {
121
+ return reply.code(400).send({ detail: 'notebook_path is required' });
122
+ }
123
+ await fs_service_1.fsService.saveHistory(notebook_path, history || []);
124
+ return reply.send({ status: 'ok', notebook_path });
125
+ }
126
+ catch (err) {
127
+ const message = err instanceof Error ? err.message : 'Unknown error';
128
+ return reply.code(500).send({ detail: message });
129
+ }
130
+ });
131
+ /**
132
+ * Load session state for a notebook
133
+ */
134
+ fastify.get('/notebook/session', async (request, reply) => {
135
+ try {
136
+ const notebookPath = request.query.notebook_path;
137
+ if (!notebookPath) {
138
+ return reply.code(400).send({ detail: 'notebook_path query parameter is required' });
139
+ }
140
+ const session = fs_service_1.fsService.loadSession(notebookPath);
141
+ return reply.send({ notebook_path: notebookPath, session });
142
+ }
143
+ catch (err) {
144
+ const message = err instanceof Error ? err.message : 'Unknown error';
145
+ return reply.code(500).send({ detail: message });
146
+ }
147
+ });
148
+ /**
149
+ * Save session state for a notebook
150
+ */
151
+ fastify.post('/notebook/session', async (request, reply) => {
152
+ try {
153
+ const { notebook_path, session } = request.body;
154
+ if (!notebook_path) {
155
+ return reply.code(400).send({ detail: 'notebook_path is required' });
156
+ }
157
+ await fs_service_1.fsService.saveSession(notebook_path, session || {});
158
+ return reply.send({ status: 'ok', notebook_path });
159
+ }
160
+ catch (err) {
161
+ const message = err instanceof Error ? err.message : 'Unknown error';
162
+ return reply.code(500).send({ detail: message });
163
+ }
164
+ });
165
+ /**
166
+ * Grant or revoke agent permission to modify a notebook
167
+ */
168
+ fastify.post('/notebook/permit-agent', async (request, reply) => {
169
+ try {
170
+ const { notebook_path, permitted = true } = request.body;
171
+ if (!notebook_path) {
172
+ return reply.code(400).send({ detail: 'notebook_path is required' });
173
+ }
174
+ const result = await fs_service_1.fsService.setAgentPermission(notebook_path, permitted);
175
+ if (!result.success) {
176
+ return reply.code(400).send({ detail: result.error || 'Failed to update notebook' });
177
+ }
178
+ return reply.send({
179
+ status: 'ok',
180
+ notebook_path,
181
+ ...(result.status || fs_service_1.fsService.getAgentPermissionStatus(notebook_path)),
182
+ });
183
+ }
184
+ catch (err) {
185
+ const message = err instanceof Error ? err.message : 'Unknown error';
186
+ return reply.code(500).send({ detail: message });
187
+ }
188
+ });
189
+ /**
190
+ * Get agent permission status for a notebook
191
+ */
192
+ fastify.get('/notebook/agent-status', async (request, reply) => {
193
+ try {
194
+ const filePath = request.query.path;
195
+ if (!filePath) {
196
+ return reply.code(400).send({ detail: 'path query parameter is required' });
197
+ }
198
+ const status = fs_service_1.fsService.getAgentPermissionStatus(filePath);
199
+ return reply.send({
200
+ notebook_path: filePath,
201
+ ...status,
202
+ });
203
+ }
204
+ catch (err) {
205
+ const message = err instanceof Error ? err.message : 'Unknown error';
206
+ return reply.code(500).send({ detail: message });
207
+ }
208
+ });
209
+ /**
210
+ * Read notebook via operation router
211
+ *
212
+ * Supports output truncation for large outputs.
213
+ * When include_outputs=true (default), outputs are truncated with defaults.
214
+ *
215
+ * Uses operation router to get live state from UI if connected,
216
+ * otherwise reads from file.
217
+ */
218
+ fastify.get('/notebook/read', async (request, reply) => {
219
+ try {
220
+ const filePath = request.query.path;
221
+ if (!filePath) {
222
+ return reply.code(400).send({ detail: 'path query parameter is required' });
223
+ }
224
+ const includeOutputs = request.query.include_outputs !== 'false';
225
+ const maxLines = request.query.max_lines ? parseInt(request.query.max_lines, 10) : undefined;
226
+ const maxChars = request.query.max_chars ? parseInt(request.query.max_chars, 10) : undefined;
227
+ const maxLinesError = request.query.max_lines_error ? parseInt(request.query.max_lines_error, 10) : undefined;
228
+ const maxCharsError = request.query.max_chars_error ? parseInt(request.query.max_chars_error, 10) : undefined;
229
+ // Use operation router to get state from UI if connected, otherwise from file
230
+ const result = await operation_router_1.operationRouter.readNotebook(filePath, includeOutputs, maxLines, maxChars, maxLinesError, maxCharsError);
231
+ return reply.send(result);
232
+ }
233
+ catch (err) {
234
+ const message = err instanceof Error ? err.message : 'Unknown error';
235
+ return reply.send({ success: false, error: message });
236
+ }
237
+ });
238
+ /**
239
+ * Check if UI is connected for a notebook
240
+ */
241
+ fastify.get('/notebook/has-ui', async (request, reply) => {
242
+ const filePath = request.query.path;
243
+ if (!filePath) {
244
+ return reply.code(400).send({ detail: 'path query parameter is required' });
245
+ }
246
+ const hasUI = operation_router_1.operationRouter.hasUI(filePath);
247
+ return reply.send({ hasUI, path: filePath });
248
+ });
249
+ /**
250
+ * Apply a notebook operation
251
+ *
252
+ * Routes to UI if connected, otherwise uses headless handler.
253
+ */
254
+ fastify.post('/notebook/operation', async (request, reply) => {
255
+ try {
256
+ // Python uses {operation: {...}} wrapper via NotebookOperationRequest model
257
+ const operation = request.body.operation || request.body;
258
+ if (!operation || !operation.type) {
259
+ return reply.code(400).send({ detail: 'Operation with type is required' });
260
+ }
261
+ const result = await operation_router_1.operationRouter.applyOperation(operation);
262
+ return reply.send(result);
263
+ }
264
+ catch (err) {
265
+ const message = err instanceof Error ? err.message : 'Unknown error';
266
+ return reply.send({ success: false, error: message });
267
+ }
268
+ });
269
+ /**
270
+ * Get notebook settings (nebula metadata)
271
+ */
272
+ fastify.get('/notebook/settings', async (request, reply) => {
273
+ try {
274
+ const filePath = request.query.path;
275
+ if (!filePath) {
276
+ return reply.code(400).send({ detail: 'path query parameter is required' });
277
+ }
278
+ const metadata = fs_service_1.fsService.getNotebookMetadata(filePath);
279
+ const nebula = (metadata.nebula || {});
280
+ return reply.send({
281
+ notebook_path: filePath,
282
+ output_logging: nebula.output_logging || 'minimal',
283
+ full_width: nebula.full_width === true,
284
+ });
285
+ }
286
+ catch (err) {
287
+ const message = err instanceof Error ? err.message : 'Unknown error';
288
+ return reply.code(500).send({ detail: message });
289
+ }
290
+ });
291
+ /**
292
+ * Update notebook settings (nebula metadata)
293
+ */
294
+ fastify.post('/notebook/settings', async (request, reply) => {
295
+ try {
296
+ const { path: filePath, output_logging, full_width } = request.body;
297
+ if (!filePath) {
298
+ return reply.code(400).send({ detail: 'path is required' });
299
+ }
300
+ // Validate output_logging value
301
+ if (output_logging !== undefined && output_logging !== 'minimal' && output_logging !== 'full') {
302
+ return reply.code(400).send({ detail: 'output_logging must be "minimal" or "full"' });
303
+ }
304
+ if (full_width !== undefined && typeof full_width !== 'boolean') {
305
+ return reply.code(400).send({ detail: 'full_width must be a boolean' });
306
+ }
307
+ // Get existing metadata
308
+ const metadata = fs_service_1.fsService.getNotebookMetadata(filePath);
309
+ const nebula = (metadata.nebula || {});
310
+ // Update settings
311
+ if (output_logging !== undefined) {
312
+ nebula.output_logging = output_logging;
313
+ }
314
+ if (full_width !== undefined) {
315
+ nebula.full_width = full_width;
316
+ }
317
+ // Save back
318
+ const updateResult = await fs_service_1.fsService.updateNotebookMetadata(filePath, { nebula });
319
+ if (!updateResult.success) {
320
+ return reply.code(500).send({ detail: updateResult.error || 'Failed to update notebook metadata' });
321
+ }
322
+ return reply.send({
323
+ status: 'ok',
324
+ notebook_path: filePath,
325
+ output_logging: nebula.output_logging || 'minimal',
326
+ full_width: nebula.full_width === true,
327
+ mtime: updateResult.mtime,
328
+ });
329
+ }
330
+ catch (err) {
331
+ const message = err instanceof Error ? err.message : 'Unknown error';
332
+ return reply.code(500).send({ detail: message });
333
+ }
334
+ });
335
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Python Discovery API Routes
3
+ */
4
+ import { FastifyInstance } from 'fastify';
5
+ import { PythonDiscoveryService } from '../discovery/discovery-service';
6
+ declare const discoveryService: PythonDiscoveryService;
7
+ export default function pythonRoutes(fastify: FastifyInstance): Promise<void>;
8
+ export { discoveryService };
@@ -0,0 +1,187 @@
1
+ "use strict";
2
+ /**
3
+ * Python Discovery API Routes
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.discoveryService = void 0;
7
+ exports.default = pythonRoutes;
8
+ const discovery_service_1 = require("../discovery/discovery-service");
9
+ const types_1 = require("../discovery/types");
10
+ const kernelspec_1 = require("../kernel/kernelspec");
11
+ const default_kernel_1 = require("../kernel/default-kernel");
12
+ const server_registry_1 = require("../cluster/server-registry");
13
+ const kernel_1 = require("./kernel");
14
+ const discoveryService = new discovery_service_1.PythonDiscoveryService();
15
+ exports.discoveryService = discoveryService;
16
+ async function pythonRoutes(fastify) {
17
+ /**
18
+ * List all discovered Python environments
19
+ * Transforms to snake_case to match Python API format expected by frontend
20
+ */
21
+ fastify.get('/python/environments', async (request, reply) => {
22
+ try {
23
+ const refresh = request.query.refresh === 'true';
24
+ const serverId = request.query.server_id;
25
+ const localServerId = server_registry_1.serverRegistry.getLocalServerId();
26
+ if (serverId && serverId !== localServerId && serverId !== 'local') {
27
+ const server = server_registry_1.serverRegistry.getServer(serverId);
28
+ if (!server) {
29
+ return reply.code(404).send({ detail: `Server not found: ${serverId}` });
30
+ }
31
+ if (server.status !== 'online') {
32
+ return reply.code(503).send({ detail: `Server is offline: ${serverId}` });
33
+ }
34
+ const response = await fetch(`${server.url}/api/python/environments?refresh=${refresh ? 'true' : 'false'}`, {
35
+ headers: process.env.NEBULA_CLUSTER_SECRET ? { 'X-Nebula-Cluster-Secret': process.env.NEBULA_CLUSTER_SECRET } : undefined,
36
+ });
37
+ if (!response.ok) {
38
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
39
+ return reply.code(response.status).send({ detail: error.detail || 'Failed to fetch Python environments' });
40
+ }
41
+ const data = await response.json();
42
+ return reply.send(data);
43
+ }
44
+ // Get Jupyter kernelspecs and transform to snake_case
45
+ const kernelspecs = (0, kernelspec_1.discoverKernelSpecs)(refresh).map(k => ({
46
+ name: k.name,
47
+ display_name: k.displayName,
48
+ language: k.language,
49
+ path: k.path,
50
+ python_path: k.argv?.[0] || null, // First element of argv is typically the Python executable
51
+ }));
52
+ const kernelspecNames = new Set(kernelspecs.map(k => k.name));
53
+ // Get discovered Python environments
54
+ const environments = await discoveryService.discover({ forceRefresh: refresh });
55
+ // Convert to snake_case and match with kernelspecs
56
+ const envObjects = environments.map(env => ({
57
+ path: env.path,
58
+ version: env.version,
59
+ display_name: env.displayName,
60
+ env_type: env.envType,
61
+ env_name: env.envName,
62
+ has_ipykernel: env.hasIpykernel,
63
+ externally_managed: env.externallyManaged,
64
+ install_hint: env.installHint,
65
+ kernel_name: env.kernelName && kernelspecNames.has(env.kernelName) ? env.kernelName : null,
66
+ }));
67
+ return reply.send({
68
+ kernelspecs,
69
+ environments: envObjects,
70
+ cache_info: discoveryService.getCacheInfo(),
71
+ });
72
+ }
73
+ catch (err) {
74
+ const message = err instanceof Error ? err.message : 'Unknown error';
75
+ return reply.code(500).send({ detail: message });
76
+ }
77
+ });
78
+ /**
79
+ * Install ipykernel and register a Python environment as a Jupyter kernel
80
+ */
81
+ fastify.post('/python/install-kernel', async (request, reply) => {
82
+ try {
83
+ const { python_path, kernel_name, server_id } = request.body;
84
+ if (!python_path) {
85
+ return reply.code(400).send({ detail: 'python_path is required' });
86
+ }
87
+ const localServerId = server_registry_1.serverRegistry.getLocalServerId();
88
+ if (server_id && server_id !== localServerId && server_id !== 'local') {
89
+ const server = server_registry_1.serverRegistry.getServer(server_id);
90
+ if (!server) {
91
+ return reply.code(404).send({ detail: `Server not found: ${server_id}` });
92
+ }
93
+ if (server.status !== 'online') {
94
+ return reply.code(503).send({ detail: `Server is offline: ${server_id}` });
95
+ }
96
+ const response = await fetch(`${server.url}/api/python/install-kernel`, {
97
+ method: 'POST',
98
+ headers: {
99
+ 'Content-Type': 'application/json',
100
+ ...(process.env.NEBULA_CLUSTER_SECRET ? { 'X-Nebula-Cluster-Secret': process.env.NEBULA_CLUSTER_SECRET } : {}),
101
+ },
102
+ body: JSON.stringify({ python_path, kernel_name }),
103
+ });
104
+ if (!response.ok) {
105
+ // Forward the structured error (code + install_hint) so cluster clients
106
+ // get the same guidance as a local externally-managed env.
107
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
108
+ return reply.code(response.status).send({
109
+ detail: error.detail || 'Failed to install kernel',
110
+ code: error.code,
111
+ install_hint: error.install_hint,
112
+ });
113
+ }
114
+ const data = await response.json();
115
+ return reply.send(data);
116
+ }
117
+ const result = await discoveryService.installKernel(python_path, kernel_name);
118
+ // Invalidate caches so the new kernel is discovered without a restart.
119
+ (0, kernelspec_1.invalidateKernelspecCache)();
120
+ (0, default_kernel_1.invalidateDefaultKernelName)();
121
+ kernel_1.kernelService.refreshKernelSpecs();
122
+ // snake_case to match the frontend KernelService client contract.
123
+ return reply.send({
124
+ kernel_name: result.kernelName,
125
+ python_path: result.pythonPath,
126
+ message: result.message,
127
+ });
128
+ }
129
+ catch (err) {
130
+ // Structured provisioning errors carry a stable code + guidance hint so the
131
+ // UI can show actionable help instead of a raw traceback.
132
+ if (err instanceof types_1.KernelProvisionError) {
133
+ const status = err.code === 'python_not_found' ? 404
134
+ : err.code === 'externally_managed' || err.code === 'needs_ipykernel' ? 422
135
+ : 500;
136
+ return reply.code(status).send({ detail: err.message, code: err.code, install_hint: err.installHint });
137
+ }
138
+ if (err instanceof Error) {
139
+ if (err.message.includes('not found') || err.message.includes('ENOENT')) {
140
+ return reply.code(404).send({ detail: err.message });
141
+ }
142
+ else {
143
+ return reply.code(500).send({ detail: err.message });
144
+ }
145
+ }
146
+ else {
147
+ return reply.code(500).send({ detail: 'Unknown error' });
148
+ }
149
+ }
150
+ });
151
+ /**
152
+ * Force refresh the Python environment cache
153
+ */
154
+ fastify.post('/python/refresh', async (request, reply) => {
155
+ try {
156
+ const serverId = request.query.server_id;
157
+ const localServerId = server_registry_1.serverRegistry.getLocalServerId();
158
+ if (serverId && serverId !== localServerId && serverId !== 'local') {
159
+ const server = server_registry_1.serverRegistry.getServer(serverId);
160
+ if (!server) {
161
+ return reply.code(404).send({ detail: `Server not found: ${serverId}` });
162
+ }
163
+ if (server.status !== 'online') {
164
+ return reply.code(503).send({ detail: `Server is offline: ${serverId}` });
165
+ }
166
+ const response = await fetch(`${server.url}/api/python/refresh`, {
167
+ headers: process.env.NEBULA_CLUSTER_SECRET ? { 'X-Nebula-Cluster-Secret': process.env.NEBULA_CLUSTER_SECRET } : undefined,
168
+ });
169
+ if (!response.ok) {
170
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
171
+ return reply.code(response.status).send({ detail: error.detail || 'Failed to refresh Python environments' });
172
+ }
173
+ const data = await response.json();
174
+ return reply.send(data);
175
+ }
176
+ const environments = await discoveryService.discover({ forceRefresh: true });
177
+ return reply.send({
178
+ count: environments.length,
179
+ cache_info: discoveryService.getCacheInfo(),
180
+ });
181
+ }
182
+ catch (err) {
183
+ const message = err instanceof Error ? err.message : 'Unknown error';
184
+ return reply.code(500).send({ detail: message });
185
+ }
186
+ });
187
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Resources API Routes
3
+ *
4
+ * Provides endpoints for system resource monitoring (RAM, GPU).
5
+ */
6
+ import { FastifyInstance } from 'fastify';
7
+ export default function resourceRoutes(fastify: FastifyInstance): Promise<void>;