nebula-notebook 0.1.1 → 0.2.1
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 +110 -16
- package/dist/assets/errorwidget-Wy5ajPiM.js +5 -0
- package/dist/assets/fa-brands-400-CEJbCg16.woff +0 -0
- package/dist/assets/fa-brands-400-CSYNqBb_.ttf +0 -0
- package/dist/assets/fa-brands-400-DnkPfk3o.eot +0 -0
- package/dist/assets/fa-brands-400-UxlILjvJ.woff2 +0 -0
- package/dist/assets/fa-brands-400-cH1MgKbP.svg +3717 -0
- package/dist/assets/fa-regular-400-BhTwtT8w.eot +0 -0
- package/dist/assets/fa-regular-400-D1vz6WBx.ttf +0 -0
- package/dist/assets/fa-regular-400-DFnMcJPd.woff +0 -0
- package/dist/assets/fa-regular-400-DGzu1beS.woff2 +0 -0
- package/dist/assets/fa-regular-400-gwj8Pxq-.svg +801 -0
- package/dist/assets/fa-solid-900-B4ZZ7kfP.svg +5034 -0
- package/dist/assets/fa-solid-900-B6Axprfb.eot +0 -0
- package/dist/assets/fa-solid-900-BUswJgRo.woff2 +0 -0
- package/dist/assets/fa-solid-900-DOXgCApm.woff +0 -0
- package/dist/assets/fa-solid-900-mxuxnBEa.ttf +0 -0
- package/dist/assets/index-B_Bs5koo.js +1 -0
- package/dist/assets/index-BtWv4MIT.css +7 -0
- package/dist/assets/index-DK4nS74r.js +829 -0
- package/dist/assets/index-DeQN03nW.js +81 -0
- package/dist/assets/index-DzjpmUak.css +32 -0
- package/dist/assets/index-g6MjT-VG.js +1 -0
- package/dist/assets/services-shim-BirHVsih.js +33 -0
- package/dist/assets/viewlist-uomDf7I7.js +1 -0
- package/dist/assets/widgets-X7J3NxEn.css +1 -0
- package/dist/index.html +2 -2
- package/node-server/dist/auth/auth-middleware.js +22 -4
- package/node-server/dist/auth/auth-service.js +37 -7
- package/node-server/dist/cluster/client-registration.d.ts +12 -1
- package/node-server/dist/cluster/client-registration.js +38 -2
- package/node-server/dist/cluster/kernel-proxy.js +43 -12
- package/node-server/dist/cluster/server-registry.d.ts +17 -0
- package/node-server/dist/cluster/server-registry.js +57 -7
- package/node-server/dist/discovery/conda-locations.d.ts +73 -0
- package/node-server/dist/discovery/conda-locations.js +427 -0
- package/node-server/dist/discovery/discovery-service.d.ts +73 -4
- package/node-server/dist/discovery/discovery-service.js +211 -127
- package/node-server/dist/discovery/types.d.ts +0 -1
- package/node-server/dist/discovery/types.js +1 -2
- package/node-server/dist/environment.d.ts +12 -0
- package/node-server/dist/environment.js +104 -0
- package/node-server/dist/fs/fs-service.d.ts +86 -8
- package/node-server/dist/fs/fs-service.js +640 -99
- package/node-server/dist/fs/notebook-formats/percent.d.ts +25 -0
- package/node-server/dist/fs/notebook-formats/percent.js +286 -0
- package/node-server/dist/fs/notebook-formats/qmd.d.ts +29 -0
- package/node-server/dist/fs/notebook-formats/qmd.js +307 -0
- package/node-server/dist/fs/notebook-formats/registry.d.ts +12 -0
- package/node-server/dist/fs/notebook-formats/registry.js +77 -0
- package/node-server/dist/fs/notebook-formats/types.d.ts +37 -0
- package/node-server/dist/fs/notebook-formats/types.js +13 -0
- package/node-server/dist/fs/types.d.ts +9 -0
- package/node-server/dist/fs/types.js +8 -0
- package/node-server/dist/idle-exit.d.ts +52 -0
- package/node-server/dist/idle-exit.js +83 -0
- package/node-server/dist/index.js +198 -8
- package/node-server/dist/kernel/kernel-service.d.ts +130 -2
- package/node-server/dist/kernel/kernel-service.js +834 -64
- package/node-server/dist/kernel/kernelspec.d.ts +27 -0
- package/node-server/dist/kernel/kernelspec.js +75 -4
- package/node-server/dist/notebook/headless-handler.d.ts +12 -0
- package/node-server/dist/notebook/headless-handler.js +125 -29
- package/node-server/dist/notebook/operation-router.js +7 -2
- package/node-server/dist/notebook/undoRedoManager.d.ts +12 -1
- package/node-server/dist/notebook/undoRedoManager.js +49 -18
- package/node-server/dist/output/display-data.js +2 -0
- package/node-server/dist/private-tmp.d.ts +14 -0
- package/node-server/dist/private-tmp.js +70 -0
- package/node-server/dist/routes/autocomplete.d.ts +19 -0
- package/node-server/dist/routes/autocomplete.js +294 -0
- package/node-server/dist/routes/cluster.js +2 -2
- package/node-server/dist/routes/compute.d.ts +8 -0
- package/node-server/dist/routes/compute.js +136 -0
- package/node-server/dist/routes/fs.js +23 -6
- package/node-server/dist/routes/kernel.js +156 -3
- package/node-server/dist/routes/notebook.js +37 -3
- package/node-server/dist/routes/python.d.ts +1 -2
- package/node-server/dist/routes/python.js +139 -3
- package/node-server/dist/scheduler/allocation-service.d.ts +63 -0
- package/node-server/dist/scheduler/allocation-service.js +285 -0
- package/node-server/dist/scheduler/job-template.d.ts +30 -0
- package/node-server/dist/scheduler/job-template.js +85 -0
- package/node-server/dist/scheduler/mock-scheduler.d.ts +30 -0
- package/node-server/dist/scheduler/mock-scheduler.js +125 -0
- package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
- package/node-server/dist/scheduler/slurm-scheduler.js +406 -0
- package/node-server/dist/scheduler/types.d.ts +118 -0
- package/node-server/dist/scheduler/types.js +8 -0
- package/node-server/dist/scheduler/util.d.ts +7 -0
- package/node-server/dist/scheduler/util.js +20 -0
- package/node-server/dist/terminal/agent-registry.d.ts +87 -0
- package/node-server/dist/terminal/agent-registry.js +246 -0
- package/node-server/dist/terminal/binding-store.d.ts +57 -0
- package/node-server/dist/terminal/binding-store.js +0 -0
- package/node-server/dist/terminal/pty-manager.d.ts +23 -6
- package/node-server/dist/terminal/pty-manager.js +138 -19
- package/node-server/dist/terminal/server.js +155 -23
- package/node-server/dist/terminal/types.d.ts +2 -0
- package/node-server/dist/update-check.d.ts +20 -0
- package/node-server/dist/update-check.js +114 -0
- package/node-server/package.json +2 -0
- package/node_modules/nebula-autocomplete/README.md +145 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
- package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
- package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
- package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/index.js +5 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
- package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
- package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
- package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
- package/node_modules/nebula-autocomplete/dist/types.js +1 -0
- package/node_modules/nebula-autocomplete/package.json +58 -0
- package/node_modules/nebula-autocomplete/server.d.ts +3 -0
- package/node_modules/nebula-autocomplete/server.js +3 -0
- package/package.json +18 -5
- package/scripts/postinstall.cjs +25 -5
- package/dist/assets/index-BvrHu37J.js +0 -648
- package/dist/assets/index-Dfj_xsKU.css +0 -32
- package/node-server/dist/llm/index.d.ts +0 -5
- package/node-server/dist/llm/index.js +0 -21
- package/node-server/dist/llm/llm-service.d.ts +0 -77
- package/node-server/dist/llm/llm-service.js +0 -454
- package/node-server/dist/llm/types.d.ts +0 -40
- package/node-server/dist/llm/types.js +0 -15
- package/node-server/dist/routes/llm.d.ts +0 -8
- package/node-server/dist/routes/llm.js +0 -105
|
@@ -45,10 +45,13 @@ const path = __importStar(require("path"));
|
|
|
45
45
|
const os = __importStar(require("os"));
|
|
46
46
|
const kernel_service_1 = require("../kernel/kernel-service");
|
|
47
47
|
const kernelspec_1 = require("../kernel/kernelspec");
|
|
48
|
+
const discovery_service_1 = require("../discovery/discovery-service");
|
|
49
|
+
const types_1 = require("../discovery/types");
|
|
48
50
|
const fs_service_1 = require("../fs/fs-service");
|
|
49
51
|
const operation_router_1 = require("../notebook/operation-router");
|
|
50
52
|
const kernel_proxy_1 = require("../cluster/kernel-proxy");
|
|
51
53
|
const server_registry_1 = require("../cluster/server-registry");
|
|
54
|
+
const allocation_service_1 = require("../scheduler/allocation-service");
|
|
52
55
|
// Track all WebSocket connections per kernel session for broadcasting
|
|
53
56
|
const sessionWebSockets = new Map();
|
|
54
57
|
// Only send streaming outputs to sockets after they've performed an initial output sync.
|
|
@@ -58,12 +61,61 @@ const outputSubscribedSockets = new WeakSet();
|
|
|
58
61
|
// Shared kernel service instance - exported for use by headless handler
|
|
59
62
|
const kernelService = new kernel_service_1.KernelService();
|
|
60
63
|
exports.kernelService = kernelService;
|
|
64
|
+
// When a kernel dies (crash, OOM, reattached PID gone), push the status to
|
|
65
|
+
// every client immediately — otherwise the UI shows 'busy' until the next
|
|
66
|
+
// poll or failed execute.
|
|
67
|
+
kernelService.onSessionDead((sessionId) => {
|
|
68
|
+
const sockets = sessionWebSockets.get(sessionId);
|
|
69
|
+
if (!sockets || sockets.size === 0)
|
|
70
|
+
return;
|
|
71
|
+
const msg = JSON.stringify({ type: 'status', status: 'dead' });
|
|
72
|
+
for (const socket of sockets) {
|
|
73
|
+
try {
|
|
74
|
+
if (socket.readyState === ws_1.WebSocket.OPEN)
|
|
75
|
+
socket.send(msg);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
// Socket already closing — the reconnect loop will handle it
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
// Broadcast kernel-originated comm messages (ipywidgets state sync, etc.) to
|
|
83
|
+
// every client attached to the session. This includes idle-time traffic from
|
|
84
|
+
// widget observers — comm messages are demuxed regardless of parent request.
|
|
85
|
+
kernelService.onComm((sessionId, comm) => {
|
|
86
|
+
const sockets = sessionWebSockets.get(sessionId);
|
|
87
|
+
if (!sockets || sockets.size === 0)
|
|
88
|
+
return;
|
|
89
|
+
const msg = JSON.stringify({
|
|
90
|
+
type: 'comm',
|
|
91
|
+
comm: {
|
|
92
|
+
msg_type: comm.msgType,
|
|
93
|
+
comm_id: comm.commId,
|
|
94
|
+
...(comm.targetName !== undefined && { target_name: comm.targetName }),
|
|
95
|
+
data: comm.data,
|
|
96
|
+
...(comm.buffers && { buffers: comm.buffers }),
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
for (const socket of sockets) {
|
|
100
|
+
try {
|
|
101
|
+
if (socket.readyState === ws_1.WebSocket.OPEN)
|
|
102
|
+
socket.send(msg);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Socket already closing — the reconnect loop will handle it
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
61
109
|
// Initialize kernel service on module load
|
|
62
110
|
kernelService.initialize().catch(err => {
|
|
63
111
|
console.error('Failed to initialize kernel service:', err);
|
|
64
112
|
});
|
|
65
113
|
function buildKernelMetadata(kernelName) {
|
|
66
|
-
|
|
114
|
+
// env: kernels have no on-disk spec — synthesize one, preferring the
|
|
115
|
+
// discovery cache's label ("Python 3.10 (conda: hypir)") over the raw path.
|
|
116
|
+
const envPython = (0, kernelspec_1.envKernelPythonPath)(kernelName);
|
|
117
|
+
const cachedLabel = envPython ? discovery_service_1.pythonDiscovery.getFromCache()?.[envPython]?.displayName : undefined;
|
|
118
|
+
const spec = (0, kernelspec_1.resolveKernelSpec)(kernelName, cachedLabel);
|
|
67
119
|
return {
|
|
68
120
|
kernelspec: {
|
|
69
121
|
name: kernelName,
|
|
@@ -72,6 +124,20 @@ function buildKernelMetadata(kernelName) {
|
|
|
72
124
|
},
|
|
73
125
|
};
|
|
74
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Map provisioning failures to structured responses (matching routes/python.ts)
|
|
129
|
+
* so the UI can branch on `code` — e.g. open the ipykernel install prompt —
|
|
130
|
+
* instead of parsing error prose. Returns true if the error was handled.
|
|
131
|
+
*/
|
|
132
|
+
function sendProvisionError(reply, err) {
|
|
133
|
+
if (!(err instanceof types_1.KernelProvisionError))
|
|
134
|
+
return false;
|
|
135
|
+
const status = err.code === 'python_not_found' ? 404
|
|
136
|
+
: err.code === 'externally_managed' || err.code === 'needs_ipykernel' ? 422
|
|
137
|
+
: 500;
|
|
138
|
+
reply.code(status).send({ detail: err.message, code: err.code, install_hint: err.installHint });
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
75
141
|
async function persistNotebookKernelMetadata(filePath, kernelName) {
|
|
76
142
|
// Best-effort: persisting the kernel choice into the notebook's metadata is a
|
|
77
143
|
// secondary side-effect. If it fails (e.g. the notebook file doesn't exist yet,
|
|
@@ -190,6 +256,41 @@ function setupKernelWebSocket(wss) {
|
|
|
190
256
|
const result = await kernelService.complete(sessionId, code, cursorPos);
|
|
191
257
|
ws.send(JSON.stringify({ type: 'complete_reply', result }));
|
|
192
258
|
}
|
|
259
|
+
else if (message.type === 'comm') {
|
|
260
|
+
// Client -> kernel comm message (ipywidgets interaction):
|
|
261
|
+
// { type: 'comm', comm: { msg_type, comm_id, target_name?, data, buffers? } }
|
|
262
|
+
const comm = (message.comm && typeof message.comm === 'object') ? message.comm : {};
|
|
263
|
+
const msgType = comm.msg_type;
|
|
264
|
+
if (msgType !== 'comm_open' && msgType !== 'comm_msg' && msgType !== 'comm_close') {
|
|
265
|
+
ws.send(JSON.stringify({ type: 'error', error: `Invalid comm msg_type: ${String(msgType)}` }));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (typeof comm.comm_id !== 'string' || comm.comm_id.length === 0) {
|
|
269
|
+
ws.send(JSON.stringify({ type: 'error', error: 'comm.comm_id is required' }));
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
const content = {
|
|
273
|
+
comm_id: comm.comm_id,
|
|
274
|
+
data: (comm.data && typeof comm.data === 'object') ? comm.data : {},
|
|
275
|
+
};
|
|
276
|
+
if (msgType === 'comm_open') {
|
|
277
|
+
content.target_name = typeof comm.target_name === 'string' ? comm.target_name : '';
|
|
278
|
+
}
|
|
279
|
+
const buffers = Array.isArray(comm.buffers)
|
|
280
|
+
? comm.buffers.filter((b) => typeof b === 'string')
|
|
281
|
+
: undefined;
|
|
282
|
+
await kernelService.sendCommMessage(sessionId, msgType, content, buffers);
|
|
283
|
+
}
|
|
284
|
+
else if (message.type === 'comm_info') {
|
|
285
|
+
// Late-joiner support: list open comms known for this session.
|
|
286
|
+
// Reply only to the requesting socket.
|
|
287
|
+
const openComms = kernelService.getOpenComms(sessionId);
|
|
288
|
+
const comms = {};
|
|
289
|
+
for (const [commId, info] of Object.entries(openComms)) {
|
|
290
|
+
comms[commId] = { target_name: info.targetName };
|
|
291
|
+
}
|
|
292
|
+
ws.send(JSON.stringify({ type: 'comm_info_reply', comms }));
|
|
293
|
+
}
|
|
193
294
|
}
|
|
194
295
|
catch (err) {
|
|
195
296
|
const errMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
@@ -340,6 +441,10 @@ async function kernelRoutes(fastify) {
|
|
|
340
441
|
return reply.code(500).send({ detail: message });
|
|
341
442
|
}
|
|
342
443
|
}
|
|
444
|
+
// Confirmed-dead rows (process gone) are auto-removed without user
|
|
445
|
+
// confirmation — only ambiguous leftovers (PID still alive) are reported
|
|
446
|
+
// for the explicit Clean Up flow.
|
|
447
|
+
await kernelService.autoCleanupDeadSessions().catch(() => { });
|
|
343
448
|
const deadSessions = kernelService.getDeadSessions();
|
|
344
449
|
const sessions = deadSessions.map(s => ({
|
|
345
450
|
session_id: s.sessionId,
|
|
@@ -413,6 +518,8 @@ async function kernelRoutes(fastify) {
|
|
|
413
518
|
return reply.send({ session_id: sessionId, kernel_name, server_id: localServerId, mtime: notebookMtime });
|
|
414
519
|
}
|
|
415
520
|
catch (err) {
|
|
521
|
+
if (sendProvisionError(reply, err))
|
|
522
|
+
return reply;
|
|
416
523
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
417
524
|
return reply.code(500).send({ detail: message });
|
|
418
525
|
}
|
|
@@ -441,7 +548,7 @@ async function kernelRoutes(fastify) {
|
|
|
441
548
|
*/
|
|
442
549
|
fastify.post('/kernels/for-file', async (request, reply) => {
|
|
443
550
|
try {
|
|
444
|
-
const { file_path, kernel_name
|
|
551
|
+
const { file_path, kernel_name, server_id, client_origin } = request.body;
|
|
445
552
|
if (!file_path) {
|
|
446
553
|
return reply.code(400).send({ detail: 'file_path is required' });
|
|
447
554
|
}
|
|
@@ -463,6 +570,25 @@ async function kernelRoutes(fastify) {
|
|
|
463
570
|
}
|
|
464
571
|
}
|
|
465
572
|
}
|
|
573
|
+
// No explicit kernel and no saved preference: resolve from the
|
|
574
|
+
// notebook's own kernelspec metadata (headless callers — CLI/MCP —
|
|
575
|
+
// don't resolve it client-side the way the UI does). Only then fall
|
|
576
|
+
// back to python3.
|
|
577
|
+
if (!effectiveKernelName) {
|
|
578
|
+
try {
|
|
579
|
+
const metadata = await fs_service_1.fsService.getNotebookMetadataAsync(normalizedFilePath);
|
|
580
|
+
const specName = metadata.kernelspec?.name;
|
|
581
|
+
if (specName) {
|
|
582
|
+
effectiveKernelName = specName;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
// fall through to the default
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (!effectiveKernelName) {
|
|
590
|
+
effectiveKernelName = 'python3';
|
|
591
|
+
}
|
|
466
592
|
// Check if we should start on a remote server
|
|
467
593
|
if (effectiveServerId && effectiveServerId !== localServerId && effectiveServerId !== 'local') {
|
|
468
594
|
// Start on remote server
|
|
@@ -509,6 +635,8 @@ async function kernelRoutes(fastify) {
|
|
|
509
635
|
});
|
|
510
636
|
}
|
|
511
637
|
catch (err) {
|
|
638
|
+
if (sendProvisionError(reply, err))
|
|
639
|
+
return reply;
|
|
512
640
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
513
641
|
return reply.code(500).send({ detail: message });
|
|
514
642
|
}
|
|
@@ -612,10 +740,35 @@ async function kernelRoutes(fastify) {
|
|
|
612
740
|
if ((0, kernel_proxy_1.isProxiedSession)(sessionId)) {
|
|
613
741
|
try {
|
|
614
742
|
const remoteStatus = await (0, kernel_proxy_1.getRemoteKernelStatus)(sessionId);
|
|
615
|
-
|
|
743
|
+
// Return the COMPOSITE session id (serverId::remoteId) — the remote
|
|
744
|
+
// server reports its own local id, and leaking that to clients makes
|
|
745
|
+
// them reference a session the main server doesn't recognize.
|
|
746
|
+
return reply.send({ ...remoteStatus, id: sessionId });
|
|
616
747
|
}
|
|
617
748
|
catch (err) {
|
|
618
749
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
750
|
+
// "Server is offline / not found" from the registry is a HEARTBEAT
|
|
751
|
+
// signal, not ground truth: a compute node saturated by a heavy
|
|
752
|
+
// execution can miss heartbeats for minutes while its SLURM job (and
|
|
753
|
+
// kernel) are perfectly alive — then recover. Treating that as
|
|
754
|
+
// terminal permanently killed live kernels (red kernel, reconnect
|
|
755
|
+
// loop stopped, only a page refresh recovered).
|
|
756
|
+
//
|
|
757
|
+
// The scheduler is the authority: return 410 Gone (terminal) only
|
|
758
|
+
// when the session's allocation is confirmed ended/failed/cancelled
|
|
759
|
+
// by squeue polling. Everything else is 503 (transient) so the
|
|
760
|
+
// client's reconnect loop keeps trying and revives the kernel when
|
|
761
|
+
// the node recovers.
|
|
762
|
+
if (/server (is offline|not found)/i.test(message)) {
|
|
763
|
+
const { serverId } = (0, kernel_proxy_1.parseSessionId)(sessionId);
|
|
764
|
+
const allocation = serverId
|
|
765
|
+
? allocation_service_1.allocationService.list().find(a => a.serverId === serverId)
|
|
766
|
+
: undefined;
|
|
767
|
+
if (allocation && ['ended', 'failed', 'cancelled'].includes(allocation.state)) {
|
|
768
|
+
return reply.code(410).send({ detail: `Allocation ${allocation.state} — kernel stopped`, reason: 'server_gone' });
|
|
769
|
+
}
|
|
770
|
+
return reply.code(503).send({ detail: `${message} (may be transient — compute node busy or re-registering)`, reason: 'server_unreachable' });
|
|
771
|
+
}
|
|
619
772
|
return reply.code(500).send({ detail: message });
|
|
620
773
|
}
|
|
621
774
|
}
|
|
@@ -87,6 +87,12 @@ async function notebookRoutes(fastify) {
|
|
|
87
87
|
return reply.code(400).send({ detail: 'cells is required' });
|
|
88
88
|
}
|
|
89
89
|
const result = await fs_service_1.fsService.saveNotebookBundle(filePath, cells, kernel_name, history);
|
|
90
|
+
// Outputs-unchanged sentinel couldn't be resolved against the file on
|
|
91
|
+
// disk (first save, changed ids, external rewrite…) — tell the client
|
|
92
|
+
// to resend the full payload.
|
|
93
|
+
if (!result.success && result.needsFull) {
|
|
94
|
+
return reply.code(409).send({ detail: 'unchanged-outputs sentinel unresolved; resend full payload', code: 'needs_full' });
|
|
95
|
+
}
|
|
90
96
|
// The UI just wrote newer content than whatever the headless handler may
|
|
91
97
|
// have cached. Drop the cache so headless ops (an agent working after the
|
|
92
98
|
// user closes the tab) reload from disk instead of serving — or worse,
|
|
@@ -109,7 +115,7 @@ async function notebookRoutes(fastify) {
|
|
|
109
115
|
if (!notebookPath) {
|
|
110
116
|
return reply.code(400).send({ detail: 'notebook_path query parameter is required' });
|
|
111
117
|
}
|
|
112
|
-
const history = fs_service_1.fsService.loadHistory(notebookPath);
|
|
118
|
+
const history = await fs_service_1.fsService.loadHistory(notebookPath);
|
|
113
119
|
return reply.send({ notebook_path: notebookPath, history });
|
|
114
120
|
}
|
|
115
121
|
catch (err) {
|
|
@@ -184,7 +190,8 @@ async function notebookRoutes(fastify) {
|
|
|
184
190
|
return reply.send({
|
|
185
191
|
status: 'ok',
|
|
186
192
|
notebook_path,
|
|
187
|
-
|
|
193
|
+
mtime: result.mtime,
|
|
194
|
+
...(result.status || await fs_service_1.fsService.getAgentPermissionStatusAsync(notebook_path)),
|
|
188
195
|
});
|
|
189
196
|
}
|
|
190
197
|
catch (err) {
|
|
@@ -201,7 +208,7 @@ async function notebookRoutes(fastify) {
|
|
|
201
208
|
if (!filePath) {
|
|
202
209
|
return reply.code(400).send({ detail: 'path query parameter is required' });
|
|
203
210
|
}
|
|
204
|
-
const status = fs_service_1.fsService.
|
|
211
|
+
const status = await fs_service_1.fsService.getAgentPermissionStatusAsync(filePath);
|
|
205
212
|
return reply.send({
|
|
206
213
|
notebook_path: filePath,
|
|
207
214
|
...status,
|
|
@@ -338,4 +345,31 @@ async function notebookRoutes(fastify) {
|
|
|
338
345
|
return reply.code(500).send({ detail: message });
|
|
339
346
|
}
|
|
340
347
|
});
|
|
348
|
+
/**
|
|
349
|
+
* Persist live ipywidgets state into notebook metadata.widgets
|
|
350
|
+
* (application/vnd.jupyter.widget-state+json), making saved notebooks
|
|
351
|
+
* render static widgets in Jupyter/nbviewer.
|
|
352
|
+
*/
|
|
353
|
+
fastify.post('/notebook/widget-state', async (request, reply) => {
|
|
354
|
+
try {
|
|
355
|
+
const { path: filePath, widget_state } = request.body;
|
|
356
|
+
if (!filePath) {
|
|
357
|
+
return reply.code(400).send({ detail: 'path is required' });
|
|
358
|
+
}
|
|
359
|
+
if (!widget_state || typeof widget_state !== 'object') {
|
|
360
|
+
return reply.code(400).send({ detail: 'widget_state must be an object' });
|
|
361
|
+
}
|
|
362
|
+
const updateResult = await fs_service_1.fsService.updateNotebookMetadata(filePath, {
|
|
363
|
+
widgets: { 'application/vnd.jupyter.widget-state+json': widget_state },
|
|
364
|
+
});
|
|
365
|
+
if (!updateResult.success) {
|
|
366
|
+
return reply.code(500).send({ detail: updateResult.error || 'Failed to update notebook metadata' });
|
|
367
|
+
}
|
|
368
|
+
return reply.send({ status: 'ok', notebook_path: filePath, mtime: updateResult.mtime });
|
|
369
|
+
}
|
|
370
|
+
catch (err) {
|
|
371
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
372
|
+
return reply.code(500).send({ detail: message });
|
|
373
|
+
}
|
|
374
|
+
});
|
|
341
375
|
}
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* Python Discovery API Routes
|
|
3
3
|
*/
|
|
4
4
|
import { FastifyInstance } from 'fastify';
|
|
5
|
-
|
|
6
|
-
declare const discoveryService: PythonDiscoveryService;
|
|
5
|
+
declare const discoveryService: import("../discovery/discovery-service").PythonDiscoveryService;
|
|
7
6
|
export default function pythonRoutes(fastify: FastifyInstance): Promise<void>;
|
|
8
7
|
export { discoveryService };
|
|
@@ -11,7 +11,10 @@ const kernelspec_1 = require("../kernel/kernelspec");
|
|
|
11
11
|
const default_kernel_1 = require("../kernel/default-kernel");
|
|
12
12
|
const server_registry_1 = require("../cluster/server-registry");
|
|
13
13
|
const kernel_1 = require("./kernel");
|
|
14
|
-
|
|
14
|
+
// The shared singleton — kernel-service preflight/labels and per-env
|
|
15
|
+
// kernelspec scanning read its in-memory cache, so routes must write through
|
|
16
|
+
// the SAME instance (a second instance would leave the singleton stale).
|
|
17
|
+
const discoveryService = discovery_service_1.pythonDiscovery;
|
|
15
18
|
exports.discoveryService = discoveryService;
|
|
16
19
|
async function pythonRoutes(fastify) {
|
|
17
20
|
/**
|
|
@@ -41,6 +44,9 @@ async function pythonRoutes(fastify) {
|
|
|
41
44
|
const data = await response.json();
|
|
42
45
|
return reply.send(data);
|
|
43
46
|
}
|
|
47
|
+
// Discover Python environments FIRST: kernelspec discovery also scans
|
|
48
|
+
// each known env's share/jupyter/kernels, so it must see the fresh list.
|
|
49
|
+
const environments = await discoveryService.discover({ forceRefresh: refresh });
|
|
44
50
|
// Get Jupyter kernelspecs and transform to snake_case
|
|
45
51
|
const kernelspecs = (0, kernelspec_1.discoverKernelSpecs)(refresh).map(k => ({
|
|
46
52
|
name: k.name,
|
|
@@ -50,8 +56,6 @@ async function pythonRoutes(fastify) {
|
|
|
50
56
|
python_path: k.argv?.[0] || null, // First element of argv is typically the Python executable
|
|
51
57
|
}));
|
|
52
58
|
const kernelspecNames = new Set(kernelspecs.map(k => k.name));
|
|
53
|
-
// Get discovered Python environments
|
|
54
|
-
const environments = await discoveryService.discover({ forceRefresh: refresh });
|
|
55
59
|
// Convert to snake_case and match with kernelspecs
|
|
56
60
|
const envObjects = environments.map(env => ({
|
|
57
61
|
path: env.path,
|
|
@@ -75,6 +79,138 @@ async function pythonRoutes(fastify) {
|
|
|
75
79
|
return reply.code(500).send({ detail: message });
|
|
76
80
|
}
|
|
77
81
|
});
|
|
82
|
+
/**
|
|
83
|
+
* Install ipykernel into an environment (no kernelspec registration — env
|
|
84
|
+
* kernels raw-launch). One installer chosen up front (conda → uv → pip).
|
|
85
|
+
*
|
|
86
|
+
* Responds with an NDJSON stream so the UI can show installer output LIVE:
|
|
87
|
+
* {"type":"output","data":"…"} — stdout/stderr chunks as they arrive
|
|
88
|
+
* {"type":"done","installer":…} — success terminator
|
|
89
|
+
* {"type":"error","detail":…,"code":…,"install_hint":…} — failure terminator
|
|
90
|
+
* Pre-stream validation failures are plain JSON error responses.
|
|
91
|
+
*/
|
|
92
|
+
fastify.post('/python/install-ipykernel', async (request, reply) => {
|
|
93
|
+
const { python_path, server_id } = request.body;
|
|
94
|
+
if (!python_path) {
|
|
95
|
+
return reply.code(400).send({ detail: 'python_path is required' });
|
|
96
|
+
}
|
|
97
|
+
const localServerId = server_registry_1.serverRegistry.getLocalServerId();
|
|
98
|
+
if (server_id && server_id !== localServerId && server_id !== 'local') {
|
|
99
|
+
const server = server_registry_1.serverRegistry.getServer(server_id);
|
|
100
|
+
if (!server) {
|
|
101
|
+
return reply.code(404).send({ detail: `Server not found: ${server_id}` });
|
|
102
|
+
}
|
|
103
|
+
if (server.status !== 'online') {
|
|
104
|
+
return reply.code(503).send({ detail: `Server is offline: ${server_id}` });
|
|
105
|
+
}
|
|
106
|
+
const response = await fetch(`${server.url}/api/python/install-ipykernel`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
...(process.env.NEBULA_CLUSTER_SECRET ? { 'X-Nebula-Cluster-Secret': process.env.NEBULA_CLUSTER_SECRET } : {}),
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify({ python_path }),
|
|
113
|
+
});
|
|
114
|
+
if (!response.ok || !response.body) {
|
|
115
|
+
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
|
116
|
+
return reply.code(response.status).send({
|
|
117
|
+
detail: error.detail || 'Failed to install ipykernel',
|
|
118
|
+
code: error.code,
|
|
119
|
+
install_hint: error.install_hint,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// Pipe the remote NDJSON stream through untouched.
|
|
123
|
+
reply.hijack();
|
|
124
|
+
reply.raw.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' });
|
|
125
|
+
try {
|
|
126
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
127
|
+
for await (const chunk of response.body) {
|
|
128
|
+
reply.raw.write(chunk);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch { /* upstream died — terminate what we have */ }
|
|
132
|
+
reply.raw.end();
|
|
133
|
+
return reply;
|
|
134
|
+
}
|
|
135
|
+
// Local install: stream output as it happens, then a terminator event.
|
|
136
|
+
reply.hijack();
|
|
137
|
+
reply.raw.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' });
|
|
138
|
+
const send = (obj) => reply.raw.write(`${JSON.stringify(obj)}\n`);
|
|
139
|
+
try {
|
|
140
|
+
const result = await discoveryService.installIpykernel(python_path, (chunk) => send({ type: 'output', data: chunk }));
|
|
141
|
+
send({ type: 'done', installer: result.installer, message: result.message, python_path });
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
if (err instanceof types_1.KernelProvisionError) {
|
|
145
|
+
send({ type: 'error', detail: err.message, code: err.code, install_hint: err.installHint });
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
send({ type: 'error', detail: err instanceof Error ? err.message : 'Unknown error' });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
reply.raw.end();
|
|
152
|
+
return reply;
|
|
153
|
+
});
|
|
154
|
+
/**
|
|
155
|
+
* Probe a manually-entered interpreter path ("Enter interpreter path…"):
|
|
156
|
+
* validate it runs, classify + enrich it, persist it into the discovery
|
|
157
|
+
* cache, and return it in the same snake_case shape as /python/environments.
|
|
158
|
+
*/
|
|
159
|
+
fastify.post('/python/probe', async (request, reply) => {
|
|
160
|
+
try {
|
|
161
|
+
const { python_path, server_id } = request.body;
|
|
162
|
+
if (!python_path) {
|
|
163
|
+
return reply.code(400).send({ detail: 'python_path is required' });
|
|
164
|
+
}
|
|
165
|
+
const localServerId = server_registry_1.serverRegistry.getLocalServerId();
|
|
166
|
+
if (server_id && server_id !== localServerId && server_id !== 'local') {
|
|
167
|
+
const server = server_registry_1.serverRegistry.getServer(server_id);
|
|
168
|
+
if (!server) {
|
|
169
|
+
return reply.code(404).send({ detail: `Server not found: ${server_id}` });
|
|
170
|
+
}
|
|
171
|
+
if (server.status !== 'online') {
|
|
172
|
+
return reply.code(503).send({ detail: `Server is offline: ${server_id}` });
|
|
173
|
+
}
|
|
174
|
+
const response = await fetch(`${server.url}/api/python/probe`, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: {
|
|
177
|
+
'Content-Type': 'application/json',
|
|
178
|
+
...(process.env.NEBULA_CLUSTER_SECRET ? { 'X-Nebula-Cluster-Secret': process.env.NEBULA_CLUSTER_SECRET } : {}),
|
|
179
|
+
},
|
|
180
|
+
body: JSON.stringify({ python_path }),
|
|
181
|
+
});
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
|
184
|
+
return reply.code(response.status).send({
|
|
185
|
+
detail: error.detail || 'Failed to probe interpreter',
|
|
186
|
+
code: error.code,
|
|
187
|
+
install_hint: error.install_hint,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return reply.send(await response.json());
|
|
191
|
+
}
|
|
192
|
+
const env = await discoveryService.probeAndRemember(python_path);
|
|
193
|
+
return reply.send({
|
|
194
|
+
path: env.path,
|
|
195
|
+
version: env.version,
|
|
196
|
+
display_name: env.displayName,
|
|
197
|
+
env_type: env.envType,
|
|
198
|
+
env_name: env.envName,
|
|
199
|
+
has_ipykernel: env.hasIpykernel,
|
|
200
|
+
externally_managed: env.externallyManaged,
|
|
201
|
+
install_hint: env.installHint,
|
|
202
|
+
kernel_name: env.kernelName,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
if (err instanceof types_1.KernelProvisionError) {
|
|
207
|
+
const status = err.code === 'python_not_found' ? 404 : 500;
|
|
208
|
+
return reply.code(status).send({ detail: err.message, code: err.code, install_hint: err.installHint });
|
|
209
|
+
}
|
|
210
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
211
|
+
return reply.code(500).send({ detail: message });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
78
214
|
/**
|
|
79
215
|
* Install ipykernel and register a Python environment as a Jupyter kernel
|
|
80
216
|
*/
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Allocation service — owns the lifecycle a compute allocation has *before* and
|
|
3
|
+
* *around* the cluster registry: submit a job, follow it through the queue, and
|
|
4
|
+
* correlate the client-server's registration (by one-time token) back to the
|
|
5
|
+
* allocation. Once correlated, the allocation is a normal registered server and
|
|
6
|
+
* kernels run on it through the existing cluster path.
|
|
7
|
+
*
|
|
8
|
+
* Phase-1 MVP: in-memory allocations, direct transport (no SSH tunnel — not
|
|
9
|
+
* needed where compute↔login is directly reachable).
|
|
10
|
+
*/
|
|
11
|
+
import type { Scheduler, JobSpec } from './types';
|
|
12
|
+
import { type LaunchContext } from './job-template';
|
|
13
|
+
export type AllocationState = 'pending' | 'running' | 'active' | 'ended' | 'failed' | 'cancelled';
|
|
14
|
+
export interface Allocation {
|
|
15
|
+
id: string;
|
|
16
|
+
jobId?: string;
|
|
17
|
+
token: string;
|
|
18
|
+
spec: JobSpec;
|
|
19
|
+
state: AllocationState;
|
|
20
|
+
serverId?: string;
|
|
21
|
+
nodes?: string[];
|
|
22
|
+
reason?: string;
|
|
23
|
+
createdAt: number;
|
|
24
|
+
walltimeEndsAt?: number;
|
|
25
|
+
}
|
|
26
|
+
declare class AllocationService {
|
|
27
|
+
private scheduler;
|
|
28
|
+
private ctx;
|
|
29
|
+
private allocations;
|
|
30
|
+
private pollTimer;
|
|
31
|
+
private enabled;
|
|
32
|
+
private lastPollAt;
|
|
33
|
+
private lostListenerRegistered;
|
|
34
|
+
init(scheduler: Scheduler, ctx: LaunchContext): void;
|
|
35
|
+
/** Poll immediately (debounced to 2s so error bursts don't hammer squeue). */
|
|
36
|
+
pollNow(): void;
|
|
37
|
+
private stateFile;
|
|
38
|
+
/**
|
|
39
|
+
* Allocations survive head-server restarts: persisted on every change,
|
|
40
|
+
* reloaded on init. A reloaded 'active' allocation is demoted to 'running'
|
|
41
|
+
* with its serverId cleared — the registry is empty after a restart, and
|
|
42
|
+
* the compute node's client-server re-registers itself (heartbeat -> 404
|
|
43
|
+
* -> re-register with its allocation token) within ~30s, at which point
|
|
44
|
+
* poll() re-correlates and promotes it back to 'active'. The SLURM job
|
|
45
|
+
* itself is re-checked by jobId on the next poll, so jobs that died while
|
|
46
|
+
* we were down are marked ended/failed instead of lingering.
|
|
47
|
+
*/
|
|
48
|
+
private persist;
|
|
49
|
+
private loadPersisted;
|
|
50
|
+
/** Pick the poll cadence from what we're actually waiting for. */
|
|
51
|
+
private nextPollDelay;
|
|
52
|
+
private scheduleNextPoll;
|
|
53
|
+
isEnabled(): boolean;
|
|
54
|
+
getScheduler(): Scheduler | null;
|
|
55
|
+
list(): Allocation[];
|
|
56
|
+
get(id: string): Allocation | undefined;
|
|
57
|
+
create(spec: JobSpec): Promise<Allocation>;
|
|
58
|
+
cancel(id: string): Promise<boolean>;
|
|
59
|
+
private poll;
|
|
60
|
+
shutdown(): void;
|
|
61
|
+
}
|
|
62
|
+
export declare const allocationService: AllocationService;
|
|
63
|
+
export {};
|