nebula-notebook 0.1.0 → 0.2.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 +124 -24
- package/dist/assets/errorwidget-C4r2j2DQ.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-7-YBurka.js +716 -0
- package/dist/assets/index-BtWv4MIT.css +7 -0
- package/dist/assets/index-CFBUnxSZ.css +32 -0
- package/dist/assets/index-CsHoPQy-.js +1 -0
- package/dist/assets/index-D5w21_Z8.js +81 -0
- package/dist/assets/index-Day3QcNs.js +1 -0
- package/dist/assets/services-shim-D6p_A67v.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/cluster/client-registration.js +3 -0
- package/node-server/dist/cluster/kernel-proxy.js +24 -9
- package/node-server/dist/cluster/server-registry.d.ts +8 -0
- package/node-server/dist/cluster/server-registry.js +31 -7
- package/node-server/dist/fs/fs-service.d.ts +55 -7
- package/node-server/dist/fs/fs-service.js +489 -80
- 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/idle-exit.d.ts +52 -0
- package/node-server/dist/idle-exit.js +83 -0
- package/node-server/dist/index.js +129 -9
- package/node-server/dist/kernel/kernel-service.d.ts +113 -2
- package/node-server/dist/kernel/kernel-service.js +762 -60
- package/node-server/dist/notebook/cell-hash.d.ts +13 -0
- package/node-server/dist/notebook/cell-hash.js +26 -0
- package/node-server/dist/notebook/headless-handler.d.ts +9 -0
- package/node-server/dist/notebook/headless-handler.js +124 -23
- package/node-server/dist/notebook/operation-router.d.ts +36 -0
- package/node-server/dist/notebook/operation-router.js +224 -9
- package/node-server/dist/notebook/undoRedoManager.d.ts +4 -1
- package/node-server/dist/notebook/undoRedoManager.js +10 -2
- package/node-server/dist/output/display-data.js +2 -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 +2 -2
- package/node-server/dist/routes/kernel.js +115 -3
- package/node-server/dist/routes/notebook.js +35 -1
- package/node-server/dist/scheduler/allocation-service.d.ts +43 -0
- package/node-server/dist/scheduler/allocation-service.js +169 -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 +121 -0
- package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
- package/node-server/dist/scheduler/slurm-scheduler.js +393 -0
- package/node-server/dist/scheduler/types.d.ts +117 -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/pty-manager.js +8 -0
- package/node-server/dist/terminal/server.js +43 -2
- package/node-server/dist/update-check.d.ts +20 -0
- package/node-server/dist/update-check.js +114 -0
- package/node-server/package.json +1 -3
- package/package.json +3 -5
- package/dist/assets/index-C1h_sArD.css +0 -32
- package/dist/assets/index-CDSTBon8.js +0 -658
|
@@ -182,7 +182,10 @@ export declare class HeadlessUndoRedoManager {
|
|
|
182
182
|
/**
|
|
183
183
|
* Clear state for a notebook (e.g., when closing).
|
|
184
184
|
*/
|
|
185
|
-
|
|
185
|
+
/** Drop in-memory state so the next access rebuilds from the persisted
|
|
186
|
+
* history file (the file/history may have changed underneath us: UI save,
|
|
187
|
+
* external-edit reconciliation). Omit the path to clear all. */
|
|
188
|
+
clearState(notebookPath?: string): void;
|
|
186
189
|
/**
|
|
187
190
|
* Get updates since a timestamp (for agent awareness).
|
|
188
191
|
* Returns human-readable summaries of operations made between agent sessions.
|
|
@@ -142,7 +142,7 @@ class HeadlessUndoRedoManager {
|
|
|
142
142
|
// Try to load existing history
|
|
143
143
|
let loadedHistory = [];
|
|
144
144
|
try {
|
|
145
|
-
loadedHistory = this.fsService.
|
|
145
|
+
loadedHistory = this.fsService.loadHistorySync(notebookPath);
|
|
146
146
|
}
|
|
147
147
|
catch (err) {
|
|
148
148
|
console.warn(`[UndoRedoManager] Failed to load history for ${notebookPath}:`, err);
|
|
@@ -357,8 +357,16 @@ class HeadlessUndoRedoManager {
|
|
|
357
357
|
/**
|
|
358
358
|
* Clear state for a notebook (e.g., when closing).
|
|
359
359
|
*/
|
|
360
|
+
/** Drop in-memory state so the next access rebuilds from the persisted
|
|
361
|
+
* history file (the file/history may have changed underneath us: UI save,
|
|
362
|
+
* external-edit reconciliation). Omit the path to clear all. */
|
|
360
363
|
clearState(notebookPath) {
|
|
361
|
-
|
|
364
|
+
if (notebookPath) {
|
|
365
|
+
this.states.delete(notebookPath);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
this.states.clear();
|
|
369
|
+
}
|
|
362
370
|
}
|
|
363
371
|
/**
|
|
364
372
|
* Get updates since a timestamp (for agent awareness).
|
|
@@ -8,6 +8,8 @@ exports.buildDisplayOutput = buildDisplayOutput;
|
|
|
8
8
|
exports.convertMimeBundleToJupyter = convertMimeBundleToJupyter;
|
|
9
9
|
const PREFERRED_MIME_TYPES = [
|
|
10
10
|
'application/vnd.nebula.web+json',
|
|
11
|
+
// Widget views must win over plotly/html when both are present
|
|
12
|
+
'application/vnd.jupyter.widget-view+json',
|
|
11
13
|
'application/vnd.plotly.v1+json',
|
|
12
14
|
'text/html',
|
|
13
15
|
'image/png',
|
|
@@ -27,11 +27,11 @@ async function clusterRoutes(fastify) {
|
|
|
27
27
|
* Body can include { resources } for initial resource info
|
|
28
28
|
*/
|
|
29
29
|
fastify.post('/servers/register', async (request, reply) => {
|
|
30
|
-
const { host, port, name, secret, resources } = request.body;
|
|
30
|
+
const { host, port, name, secret, resources, allocationToken } = request.body;
|
|
31
31
|
if (!host || !port) {
|
|
32
32
|
return reply.code(400).send({ error: 'host and port are required' });
|
|
33
33
|
}
|
|
34
|
-
const result = server_registry_1.serverRegistry.register({ host, port, name, secret, resources });
|
|
34
|
+
const result = server_registry_1.serverRegistry.register({ host, port, name, secret, resources, allocationToken });
|
|
35
35
|
if (!result.success) {
|
|
36
36
|
return reply.code(403).send({ error: result.error });
|
|
37
37
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compute allocation API — submit and manage scheduler-backed compute.
|
|
3
|
+
*
|
|
4
|
+
* All routes live under /api/compute and are auth-protected like the rest of the
|
|
5
|
+
* API. They are no-ops (return `enabled: false`) when no scheduler is detected.
|
|
6
|
+
*/
|
|
7
|
+
import { FastifyInstance } from 'fastify';
|
|
8
|
+
export default function computeRoutes(fastify: FastifyInstance): Promise<void>;
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Compute allocation API — submit and manage scheduler-backed compute.
|
|
4
|
+
*
|
|
5
|
+
* All routes live under /api/compute and are auth-protected like the rest of the
|
|
6
|
+
* API. They are no-ops (return `enabled: false`) when no scheduler is detected.
|
|
7
|
+
*/
|
|
8
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
9
|
+
if (k2 === undefined) k2 = k;
|
|
10
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
11
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
12
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
13
|
+
}
|
|
14
|
+
Object.defineProperty(o, k2, desc);
|
|
15
|
+
}) : (function(o, m, k, k2) {
|
|
16
|
+
if (k2 === undefined) k2 = k;
|
|
17
|
+
o[k2] = m[k];
|
|
18
|
+
}));
|
|
19
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
20
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
21
|
+
}) : function(o, v) {
|
|
22
|
+
o["default"] = v;
|
|
23
|
+
});
|
|
24
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
25
|
+
var ownKeys = function(o) {
|
|
26
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
27
|
+
var ar = [];
|
|
28
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
29
|
+
return ar;
|
|
30
|
+
};
|
|
31
|
+
return ownKeys(o);
|
|
32
|
+
};
|
|
33
|
+
return function (mod) {
|
|
34
|
+
if (mod && mod.__esModule) return mod;
|
|
35
|
+
var result = {};
|
|
36
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
37
|
+
__setModuleDefault(result, mod);
|
|
38
|
+
return result;
|
|
39
|
+
};
|
|
40
|
+
})();
|
|
41
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
42
|
+
exports.default = computeRoutes;
|
|
43
|
+
const os = __importStar(require("os"));
|
|
44
|
+
const allocation_service_1 = require("../scheduler/allocation-service");
|
|
45
|
+
function currentUser() {
|
|
46
|
+
return process.env.USER || process.env.LOGNAME || os.userInfo().username;
|
|
47
|
+
}
|
|
48
|
+
/** Build a JobSpec from request input, applying MVP defaults. */
|
|
49
|
+
function parseSpec(x) {
|
|
50
|
+
const partition = String(x?.partition || '').trim();
|
|
51
|
+
const qos = x?.qos ? String(x.qos).trim() : undefined;
|
|
52
|
+
const account = x?.account ? String(x.account).trim() : undefined;
|
|
53
|
+
const cpus = Math.max(1, Math.floor(Number(x?.cpus) || 1));
|
|
54
|
+
const memGb = Math.max(1, Math.floor(Number(x?.memGb) || 4));
|
|
55
|
+
const gpusRaw = Math.floor(Number(x?.gpus) || 0);
|
|
56
|
+
const gpus = gpusRaw > 0 ? gpusRaw : undefined;
|
|
57
|
+
const gpuType = gpus && x?.gpuType ? String(x.gpuType).trim() || undefined : undefined;
|
|
58
|
+
const walltimeMinutes = Math.max(1, Math.floor(Number(x?.walltimeMinutes) || 120));
|
|
59
|
+
// Opt-in idle auto-release (minutes). Absent/invalid/<=0 = disabled.
|
|
60
|
+
const idleRaw = Math.floor(Number(x?.idleTimeoutMinutes) || 0);
|
|
61
|
+
const idleTimeoutMinutes = idleRaw > 0 ? idleRaw : undefined;
|
|
62
|
+
const jobName = (x?.jobName ? String(x.jobName) : `nebula-${partition || 'compute'}`)
|
|
63
|
+
.replace(/[^A-Za-z0-9._-]/g, '-')
|
|
64
|
+
.slice(0, 60) || 'nebula';
|
|
65
|
+
return { partition, qos, account, cpus, memGb, gpus, gpuType, walltimeMinutes, jobName, idleTimeoutMinutes };
|
|
66
|
+
}
|
|
67
|
+
async function computeRoutes(fastify) {
|
|
68
|
+
/** Whether scheduler-backed compute is available on this server. */
|
|
69
|
+
fastify.get('/compute/status', async (_req, reply) => {
|
|
70
|
+
return reply.send({
|
|
71
|
+
enabled: allocation_service_1.allocationService.isEnabled(),
|
|
72
|
+
scheduler: allocation_service_1.allocationService.getScheduler()?.name ?? null,
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
/** Allowed partitions/QoS for the current user + a live load snapshot. */
|
|
76
|
+
fastify.get('/compute/partitions', async (_req, reply) => {
|
|
77
|
+
const sched = allocation_service_1.allocationService.getScheduler();
|
|
78
|
+
if (!sched)
|
|
79
|
+
return reply.send({ enabled: false, associations: null, load: null });
|
|
80
|
+
const [associations, load] = await Promise.all([
|
|
81
|
+
sched.associations(currentUser()).catch(() => null),
|
|
82
|
+
sched.load().catch(() => null),
|
|
83
|
+
]);
|
|
84
|
+
return reply.send({ enabled: true, associations, load });
|
|
85
|
+
});
|
|
86
|
+
/** QoS names a partition will accept (null = any). Drives the modal's QoS filter. */
|
|
87
|
+
fastify.get('/compute/partition-qos', async (req, reply) => {
|
|
88
|
+
const sched = allocation_service_1.allocationService.getScheduler();
|
|
89
|
+
if (!sched)
|
|
90
|
+
return reply.send({ allowed: null });
|
|
91
|
+
const partition = String(req.query?.partition || '').trim();
|
|
92
|
+
if (!partition)
|
|
93
|
+
return reply.send({ allowed: null });
|
|
94
|
+
const allowed = await sched.allowedQos(partition).catch(() => null);
|
|
95
|
+
return reply.send({ allowed });
|
|
96
|
+
});
|
|
97
|
+
/** Dry-run estimated start time for a prospective allocation. */
|
|
98
|
+
fastify.get('/compute/estimate', async (req, reply) => {
|
|
99
|
+
const sched = allocation_service_1.allocationService.getScheduler();
|
|
100
|
+
if (!sched)
|
|
101
|
+
return reply.code(400).send({ error: 'scheduler not available' });
|
|
102
|
+
const spec = parseSpec(req.query);
|
|
103
|
+
if (!spec.partition)
|
|
104
|
+
return reply.code(400).send({ error: 'partition is required' });
|
|
105
|
+
const estimate = await sched.estimateStart(spec);
|
|
106
|
+
return reply.send(estimate);
|
|
107
|
+
});
|
|
108
|
+
/** List current allocations (pending / running / active / ended). */
|
|
109
|
+
fastify.get('/compute/allocations', async (_req, reply) => {
|
|
110
|
+
return reply.send({ allocations: allocation_service_1.allocationService.list() });
|
|
111
|
+
});
|
|
112
|
+
/** Submit a new compute allocation. */
|
|
113
|
+
fastify.post('/compute/allocations', async (req, reply) => {
|
|
114
|
+
if (!allocation_service_1.allocationService.isEnabled()) {
|
|
115
|
+
return reply.code(400).send({ error: 'scheduler not available' });
|
|
116
|
+
}
|
|
117
|
+
const spec = parseSpec(req.body);
|
|
118
|
+
if (!spec.partition)
|
|
119
|
+
return reply.code(400).send({ error: 'partition is required' });
|
|
120
|
+
try {
|
|
121
|
+
const alloc = await allocation_service_1.allocationService.create(spec);
|
|
122
|
+
return reply.send(alloc);
|
|
123
|
+
}
|
|
124
|
+
catch (e) {
|
|
125
|
+
return reply.code(500).send({ error: e?.message || 'submit failed' });
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
/** Cancel an allocation (scancel + evict its server). */
|
|
129
|
+
fastify.delete('/compute/allocations/:id', async (req, reply) => {
|
|
130
|
+
const { id } = req.params;
|
|
131
|
+
const ok = await allocation_service_1.allocationService.cancel(id);
|
|
132
|
+
if (!ok)
|
|
133
|
+
return reply.code(404).send({ error: 'allocation not found' });
|
|
134
|
+
return reply.send({ cancelled: true });
|
|
135
|
+
});
|
|
136
|
+
}
|
|
@@ -121,7 +121,7 @@ async function fsRoutes(fastify) {
|
|
|
121
121
|
if (!filePath) {
|
|
122
122
|
return reply.code(400).send({ detail: 'path query parameter is required' });
|
|
123
123
|
}
|
|
124
|
-
const result = fs_service_1.fsService.getFileMtime(filePath);
|
|
124
|
+
const result = await fs_service_1.fsService.getFileMtime(filePath);
|
|
125
125
|
return reply.send(result);
|
|
126
126
|
}
|
|
127
127
|
catch (err) {
|
|
@@ -143,7 +143,7 @@ async function fsRoutes(fastify) {
|
|
|
143
143
|
if (!filePath) {
|
|
144
144
|
return reply.code(400).send({ detail: 'path query parameter is required' });
|
|
145
145
|
}
|
|
146
|
-
const result = fs_service_1.fsService.readFile(filePath);
|
|
146
|
+
const result = await fs_service_1.fsService.readFile(filePath);
|
|
147
147
|
return reply.send(result);
|
|
148
148
|
}
|
|
149
149
|
catch (err) {
|
|
@@ -49,6 +49,7 @@ const fs_service_1 = require("../fs/fs-service");
|
|
|
49
49
|
const operation_router_1 = require("../notebook/operation-router");
|
|
50
50
|
const kernel_proxy_1 = require("../cluster/kernel-proxy");
|
|
51
51
|
const server_registry_1 = require("../cluster/server-registry");
|
|
52
|
+
const allocation_service_1 = require("../scheduler/allocation-service");
|
|
52
53
|
// Track all WebSocket connections per kernel session for broadcasting
|
|
53
54
|
const sessionWebSockets = new Map();
|
|
54
55
|
// Only send streaming outputs to sockets after they've performed an initial output sync.
|
|
@@ -58,6 +59,51 @@ const outputSubscribedSockets = new WeakSet();
|
|
|
58
59
|
// Shared kernel service instance - exported for use by headless handler
|
|
59
60
|
const kernelService = new kernel_service_1.KernelService();
|
|
60
61
|
exports.kernelService = kernelService;
|
|
62
|
+
// When a kernel dies (crash, OOM, reattached PID gone), push the status to
|
|
63
|
+
// every client immediately — otherwise the UI shows 'busy' until the next
|
|
64
|
+
// poll or failed execute.
|
|
65
|
+
kernelService.onSessionDead((sessionId) => {
|
|
66
|
+
const sockets = sessionWebSockets.get(sessionId);
|
|
67
|
+
if (!sockets || sockets.size === 0)
|
|
68
|
+
return;
|
|
69
|
+
const msg = JSON.stringify({ type: 'status', status: 'dead' });
|
|
70
|
+
for (const socket of sockets) {
|
|
71
|
+
try {
|
|
72
|
+
if (socket.readyState === ws_1.WebSocket.OPEN)
|
|
73
|
+
socket.send(msg);
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
// Socket already closing — the reconnect loop will handle it
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
// Broadcast kernel-originated comm messages (ipywidgets state sync, etc.) to
|
|
81
|
+
// every client attached to the session. This includes idle-time traffic from
|
|
82
|
+
// widget observers — comm messages are demuxed regardless of parent request.
|
|
83
|
+
kernelService.onComm((sessionId, comm) => {
|
|
84
|
+
const sockets = sessionWebSockets.get(sessionId);
|
|
85
|
+
if (!sockets || sockets.size === 0)
|
|
86
|
+
return;
|
|
87
|
+
const msg = JSON.stringify({
|
|
88
|
+
type: 'comm',
|
|
89
|
+
comm: {
|
|
90
|
+
msg_type: comm.msgType,
|
|
91
|
+
comm_id: comm.commId,
|
|
92
|
+
...(comm.targetName !== undefined && { target_name: comm.targetName }),
|
|
93
|
+
data: comm.data,
|
|
94
|
+
...(comm.buffers && { buffers: comm.buffers }),
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
for (const socket of sockets) {
|
|
98
|
+
try {
|
|
99
|
+
if (socket.readyState === ws_1.WebSocket.OPEN)
|
|
100
|
+
socket.send(msg);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
// Socket already closing — the reconnect loop will handle it
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
});
|
|
61
107
|
// Initialize kernel service on module load
|
|
62
108
|
kernelService.initialize().catch(err => {
|
|
63
109
|
console.error('Failed to initialize kernel service:', err);
|
|
@@ -190,6 +236,41 @@ function setupKernelWebSocket(wss) {
|
|
|
190
236
|
const result = await kernelService.complete(sessionId, code, cursorPos);
|
|
191
237
|
ws.send(JSON.stringify({ type: 'complete_reply', result }));
|
|
192
238
|
}
|
|
239
|
+
else if (message.type === 'comm') {
|
|
240
|
+
// Client -> kernel comm message (ipywidgets interaction):
|
|
241
|
+
// { type: 'comm', comm: { msg_type, comm_id, target_name?, data, buffers? } }
|
|
242
|
+
const comm = (message.comm && typeof message.comm === 'object') ? message.comm : {};
|
|
243
|
+
const msgType = comm.msg_type;
|
|
244
|
+
if (msgType !== 'comm_open' && msgType !== 'comm_msg' && msgType !== 'comm_close') {
|
|
245
|
+
ws.send(JSON.stringify({ type: 'error', error: `Invalid comm msg_type: ${String(msgType)}` }));
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (typeof comm.comm_id !== 'string' || comm.comm_id.length === 0) {
|
|
249
|
+
ws.send(JSON.stringify({ type: 'error', error: 'comm.comm_id is required' }));
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const content = {
|
|
253
|
+
comm_id: comm.comm_id,
|
|
254
|
+
data: (comm.data && typeof comm.data === 'object') ? comm.data : {},
|
|
255
|
+
};
|
|
256
|
+
if (msgType === 'comm_open') {
|
|
257
|
+
content.target_name = typeof comm.target_name === 'string' ? comm.target_name : '';
|
|
258
|
+
}
|
|
259
|
+
const buffers = Array.isArray(comm.buffers)
|
|
260
|
+
? comm.buffers.filter((b) => typeof b === 'string')
|
|
261
|
+
: undefined;
|
|
262
|
+
await kernelService.sendCommMessage(sessionId, msgType, content, buffers);
|
|
263
|
+
}
|
|
264
|
+
else if (message.type === 'comm_info') {
|
|
265
|
+
// Late-joiner support: list open comms known for this session.
|
|
266
|
+
// Reply only to the requesting socket.
|
|
267
|
+
const openComms = kernelService.getOpenComms(sessionId);
|
|
268
|
+
const comms = {};
|
|
269
|
+
for (const [commId, info] of Object.entries(openComms)) {
|
|
270
|
+
comms[commId] = { target_name: info.targetName };
|
|
271
|
+
}
|
|
272
|
+
ws.send(JSON.stringify({ type: 'comm_info_reply', comms }));
|
|
273
|
+
}
|
|
193
274
|
}
|
|
194
275
|
catch (err) {
|
|
195
276
|
const errMessage = err instanceof Error ? err.message : 'Unknown error';
|
|
@@ -340,6 +421,10 @@ async function kernelRoutes(fastify) {
|
|
|
340
421
|
return reply.code(500).send({ detail: message });
|
|
341
422
|
}
|
|
342
423
|
}
|
|
424
|
+
// Confirmed-dead rows (process gone) are auto-removed without user
|
|
425
|
+
// confirmation — only ambiguous leftovers (PID still alive) are reported
|
|
426
|
+
// for the explicit Clean Up flow.
|
|
427
|
+
await kernelService.autoCleanupDeadSessions().catch(() => { });
|
|
343
428
|
const deadSessions = kernelService.getDeadSessions();
|
|
344
429
|
const sessions = deadSessions.map(s => ({
|
|
345
430
|
session_id: s.sessionId,
|
|
@@ -390,7 +475,7 @@ async function kernelRoutes(fastify) {
|
|
|
390
475
|
const metadataResult = await persistNotebookKernelMetadata(file_path, kernel_name);
|
|
391
476
|
notebookMtime = metadataResult.mtime;
|
|
392
477
|
if (client_origin !== 'ui') {
|
|
393
|
-
operation_router_1.operationRouter.notifyKernelChanged(file_path, { kernelName: kernel_name, serverId: server_id });
|
|
478
|
+
operation_router_1.operationRouter.notifyKernelChanged(file_path, { kernelName: kernel_name, serverId: server_id, mtime: metadataResult.mtime });
|
|
394
479
|
}
|
|
395
480
|
}
|
|
396
481
|
return reply.send({ session_id: result.sessionId, kernel_name, server_id, mtime: notebookMtime });
|
|
@@ -407,7 +492,7 @@ async function kernelRoutes(fastify) {
|
|
|
407
492
|
const metadataResult = await persistNotebookKernelMetadata(file_path, kernel_name);
|
|
408
493
|
notebookMtime = metadataResult.mtime;
|
|
409
494
|
if (client_origin !== 'ui') {
|
|
410
|
-
operation_router_1.operationRouter.notifyKernelChanged(file_path, { kernelName: kernel_name, serverId: localServerId });
|
|
495
|
+
operation_router_1.operationRouter.notifyKernelChanged(file_path, { kernelName: kernel_name, serverId: localServerId, mtime: metadataResult.mtime });
|
|
411
496
|
}
|
|
412
497
|
}
|
|
413
498
|
return reply.send({ session_id: sessionId, kernel_name, server_id: localServerId, mtime: notebookMtime });
|
|
@@ -473,6 +558,7 @@ async function kernelRoutes(fastify) {
|
|
|
473
558
|
operation_router_1.operationRouter.notifyKernelChanged(normalizedFilePath, {
|
|
474
559
|
kernelName: effectiveKernelName,
|
|
475
560
|
serverId: effectiveServerId,
|
|
561
|
+
mtime: metadataResult.mtime,
|
|
476
562
|
});
|
|
477
563
|
}
|
|
478
564
|
return reply.send({
|
|
@@ -494,6 +580,7 @@ async function kernelRoutes(fastify) {
|
|
|
494
580
|
operation_router_1.operationRouter.notifyKernelChanged(normalizedFilePath, {
|
|
495
581
|
kernelName: effectiveKernelName,
|
|
496
582
|
serverId: localServerId,
|
|
583
|
+
mtime: metadataResult.mtime,
|
|
497
584
|
});
|
|
498
585
|
}
|
|
499
586
|
return reply.send({
|
|
@@ -610,10 +697,35 @@ async function kernelRoutes(fastify) {
|
|
|
610
697
|
if ((0, kernel_proxy_1.isProxiedSession)(sessionId)) {
|
|
611
698
|
try {
|
|
612
699
|
const remoteStatus = await (0, kernel_proxy_1.getRemoteKernelStatus)(sessionId);
|
|
613
|
-
|
|
700
|
+
// Return the COMPOSITE session id (serverId::remoteId) — the remote
|
|
701
|
+
// server reports its own local id, and leaking that to clients makes
|
|
702
|
+
// them reference a session the main server doesn't recognize.
|
|
703
|
+
return reply.send({ ...remoteStatus, id: sessionId });
|
|
614
704
|
}
|
|
615
705
|
catch (err) {
|
|
616
706
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
707
|
+
// "Server is offline / not found" from the registry is a HEARTBEAT
|
|
708
|
+
// signal, not ground truth: a compute node saturated by a heavy
|
|
709
|
+
// execution can miss heartbeats for minutes while its SLURM job (and
|
|
710
|
+
// kernel) are perfectly alive — then recover. Treating that as
|
|
711
|
+
// terminal permanently killed live kernels (red kernel, reconnect
|
|
712
|
+
// loop stopped, only a page refresh recovered).
|
|
713
|
+
//
|
|
714
|
+
// The scheduler is the authority: return 410 Gone (terminal) only
|
|
715
|
+
// when the session's allocation is confirmed ended/failed/cancelled
|
|
716
|
+
// by squeue polling. Everything else is 503 (transient) so the
|
|
717
|
+
// client's reconnect loop keeps trying and revives the kernel when
|
|
718
|
+
// the node recovers.
|
|
719
|
+
if (/server (is offline|not found)/i.test(message)) {
|
|
720
|
+
const { serverId } = (0, kernel_proxy_1.parseSessionId)(sessionId);
|
|
721
|
+
const allocation = serverId
|
|
722
|
+
? allocation_service_1.allocationService.list().find(a => a.serverId === serverId)
|
|
723
|
+
: undefined;
|
|
724
|
+
if (allocation && ['ended', 'failed', 'cancelled'].includes(allocation.state)) {
|
|
725
|
+
return reply.code(410).send({ detail: `Allocation ${allocation.state} — kernel stopped`, reason: 'server_gone' });
|
|
726
|
+
}
|
|
727
|
+
return reply.code(503).send({ detail: `${message} (may be transient — compute node busy or re-registering)`, reason: 'server_unreachable' });
|
|
728
|
+
}
|
|
617
729
|
return reply.code(500).send({ detail: message });
|
|
618
730
|
}
|
|
619
731
|
}
|
|
@@ -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
|
+
// The UI just wrote newer content than whatever the headless handler may
|
|
91
|
+
// have cached. Drop the cache so headless ops (an agent working after the
|
|
92
|
+
// user closes the tab) reload from disk instead of serving — or worse,
|
|
93
|
+
// OCC-validating against — stale cells.
|
|
94
|
+
headlessHandler.invalidate(filePath);
|
|
95
|
+
headlessHandler.invalidate(fs_service_1.fsService.normalizePath(filePath));
|
|
90
96
|
return reply.send({ status: 'ok', path: filePath, mtime: result.mtime });
|
|
91
97
|
}
|
|
92
98
|
catch (err) {
|
|
@@ -103,7 +109,7 @@ async function notebookRoutes(fastify) {
|
|
|
103
109
|
if (!notebookPath) {
|
|
104
110
|
return reply.code(400).send({ detail: 'notebook_path query parameter is required' });
|
|
105
111
|
}
|
|
106
|
-
const history = fs_service_1.fsService.loadHistory(notebookPath);
|
|
112
|
+
const history = await fs_service_1.fsService.loadHistory(notebookPath);
|
|
107
113
|
return reply.send({ notebook_path: notebookPath, history });
|
|
108
114
|
}
|
|
109
115
|
catch (err) {
|
|
@@ -178,6 +184,7 @@ async function notebookRoutes(fastify) {
|
|
|
178
184
|
return reply.send({
|
|
179
185
|
status: 'ok',
|
|
180
186
|
notebook_path,
|
|
187
|
+
mtime: result.mtime,
|
|
181
188
|
...(result.status || fs_service_1.fsService.getAgentPermissionStatus(notebook_path)),
|
|
182
189
|
});
|
|
183
190
|
}
|
|
@@ -332,4 +339,31 @@ async function notebookRoutes(fastify) {
|
|
|
332
339
|
return reply.code(500).send({ detail: message });
|
|
333
340
|
}
|
|
334
341
|
});
|
|
342
|
+
/**
|
|
343
|
+
* Persist live ipywidgets state into notebook metadata.widgets
|
|
344
|
+
* (application/vnd.jupyter.widget-state+json), making saved notebooks
|
|
345
|
+
* render static widgets in Jupyter/nbviewer.
|
|
346
|
+
*/
|
|
347
|
+
fastify.post('/notebook/widget-state', async (request, reply) => {
|
|
348
|
+
try {
|
|
349
|
+
const { path: filePath, widget_state } = request.body;
|
|
350
|
+
if (!filePath) {
|
|
351
|
+
return reply.code(400).send({ detail: 'path is required' });
|
|
352
|
+
}
|
|
353
|
+
if (!widget_state || typeof widget_state !== 'object') {
|
|
354
|
+
return reply.code(400).send({ detail: 'widget_state must be an object' });
|
|
355
|
+
}
|
|
356
|
+
const updateResult = await fs_service_1.fsService.updateNotebookMetadata(filePath, {
|
|
357
|
+
widgets: { 'application/vnd.jupyter.widget-state+json': widget_state },
|
|
358
|
+
});
|
|
359
|
+
if (!updateResult.success) {
|
|
360
|
+
return reply.code(500).send({ detail: updateResult.error || 'Failed to update notebook metadata' });
|
|
361
|
+
}
|
|
362
|
+
return reply.send({ status: 'ok', notebook_path: filePath, mtime: updateResult.mtime });
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
366
|
+
return reply.code(500).send({ detail: message });
|
|
367
|
+
}
|
|
368
|
+
});
|
|
335
369
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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
|
+
init(scheduler: Scheduler, ctx: LaunchContext): void;
|
|
33
|
+
isEnabled(): boolean;
|
|
34
|
+
getScheduler(): Scheduler | null;
|
|
35
|
+
list(): Allocation[];
|
|
36
|
+
get(id: string): Allocation | undefined;
|
|
37
|
+
create(spec: JobSpec): Promise<Allocation>;
|
|
38
|
+
cancel(id: string): Promise<boolean>;
|
|
39
|
+
private poll;
|
|
40
|
+
shutdown(): void;
|
|
41
|
+
}
|
|
42
|
+
export declare const allocationService: AllocationService;
|
|
43
|
+
export {};
|