nebula-notebook 0.1.1 → 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 +90 -11
- 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 -6
- 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/headless-handler.d.ts +2 -0
- package/node-server/dist/notebook/headless-handler.js +70 -24
- package/node-server/dist/notebook/operation-router.js +7 -2
- 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 +111 -1
- package/node-server/dist/routes/notebook.js +29 -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 -0
- package/package.json +2 -1
- package/dist/assets/index-BvrHu37J.js +0 -648
- package/dist/assets/index-Dfj_xsKU.css +0 -32
|
@@ -52,6 +52,8 @@ export declare class HeadlessOperationHandler {
|
|
|
52
52
|
* Persist dirty notebooks to disk.
|
|
53
53
|
*/
|
|
54
54
|
flush(notebookPath?: string): Promise<void>;
|
|
55
|
+
/** In-flight persist per notebook path (single-flight). */
|
|
56
|
+
private persisting;
|
|
55
57
|
private asyncPersist;
|
|
56
58
|
private schedulePersist;
|
|
57
59
|
/**
|
|
@@ -45,6 +45,7 @@ const cell_hash_1 = require("./cell-hash");
|
|
|
45
45
|
const path = __importStar(require("path"));
|
|
46
46
|
const os = __importStar(require("os"));
|
|
47
47
|
const uuid_1 = require("uuid");
|
|
48
|
+
const registry_1 = require("../fs/notebook-formats/registry");
|
|
48
49
|
const cell_metadata_1 = require("./cell-metadata");
|
|
49
50
|
const undoRedoManager_1 = require("./undoRedoManager");
|
|
50
51
|
const kernelspec_1 = require("../kernel/kernelspec");
|
|
@@ -80,7 +81,7 @@ class HeadlessOperationHandler {
|
|
|
80
81
|
*/
|
|
81
82
|
getCachedNotebook(notebookPath) {
|
|
82
83
|
if (!this.cache.has(notebookPath)) {
|
|
83
|
-
const result = this.fsService.
|
|
84
|
+
const result = this.fsService.getNotebookCellsSync(notebookPath);
|
|
84
85
|
this.cache.set(notebookPath, {
|
|
85
86
|
cells: result.cells || [],
|
|
86
87
|
metadata: result.metadata || {},
|
|
@@ -109,23 +110,41 @@ class HeadlessOperationHandler {
|
|
|
109
110
|
async flush(notebookPath) {
|
|
110
111
|
const paths = notebookPath ? [notebookPath] : Array.from(this.cache.keys());
|
|
111
112
|
for (const p of paths) {
|
|
112
|
-
|
|
113
|
-
|
|
113
|
+
// A scheduled persist may have already cleared the dirty flag but not
|
|
114
|
+
// finished writing — flush must wait for it, or callers read a stale
|
|
115
|
+
// file. The persist loop keeps writing while dirty, so awaiting the
|
|
116
|
+
// in-flight run covers later mutations too.
|
|
117
|
+
const inFlight = this.persisting.get(p);
|
|
118
|
+
if (inFlight) {
|
|
119
|
+
await inFlight;
|
|
120
|
+
}
|
|
121
|
+
if (this.cache.get(p)?.dirty) {
|
|
114
122
|
await this.asyncPersist(p);
|
|
115
123
|
}
|
|
116
124
|
}
|
|
117
125
|
}
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
notebook
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
126
|
+
/** In-flight persist per notebook path (single-flight). */
|
|
127
|
+
persisting = new Map();
|
|
128
|
+
asyncPersist(notebookPath) {
|
|
129
|
+
const existing = this.persisting.get(notebookPath);
|
|
130
|
+
if (existing)
|
|
131
|
+
return existing;
|
|
132
|
+
const run = (async () => {
|
|
133
|
+
const notebook = this.cache.get(notebookPath);
|
|
134
|
+
if (!notebook)
|
|
135
|
+
return;
|
|
136
|
+
// Keep writing while dirty
|
|
137
|
+
while (notebook.dirty) {
|
|
138
|
+
const cells = JSON.parse(JSON.stringify(notebook.cells));
|
|
139
|
+
notebook.dirty = false;
|
|
140
|
+
const history = this.undoRedoManager.getHistory(notebookPath, cells);
|
|
141
|
+
await this.fsService.saveNotebookBundle(notebookPath, cells, undefined, history);
|
|
142
|
+
}
|
|
143
|
+
})().finally(() => {
|
|
144
|
+
this.persisting.delete(notebookPath);
|
|
145
|
+
});
|
|
146
|
+
this.persisting.set(notebookPath, run);
|
|
147
|
+
return run;
|
|
129
148
|
}
|
|
130
149
|
schedulePersist(notebookPath) {
|
|
131
150
|
// Schedule async persist
|
|
@@ -143,6 +162,10 @@ class HeadlessOperationHandler {
|
|
|
143
162
|
else {
|
|
144
163
|
this.cache.clear();
|
|
145
164
|
}
|
|
165
|
+
// Undo/redo state is derived from the persisted history file; when the
|
|
166
|
+
// notebook may have changed underneath us (UI save, external edit
|
|
167
|
+
// reconciliation), rebuild it from disk on next access.
|
|
168
|
+
this.undoRedoManager.clearState(notebookPath);
|
|
146
169
|
}
|
|
147
170
|
/**
|
|
148
171
|
* Check if notebook has unsaved changes.
|
|
@@ -155,21 +178,22 @@ class HeadlessOperationHandler {
|
|
|
155
178
|
*/
|
|
156
179
|
checkAgentPermission(notebookPath, _operationType) {
|
|
157
180
|
const status = this.fsService.getAgentPermissionStatus(notebookPath);
|
|
158
|
-
|
|
181
|
+
// can_agent_modify already encodes the tri-state rule (agent-created notebooks
|
|
182
|
+
// are permitted unless explicitly revoked; user notebooks need permission +
|
|
183
|
+
// history), so an explicit user revoke wins even for agent-created notebooks.
|
|
184
|
+
if (status.can_agent_modify) {
|
|
159
185
|
return null;
|
|
160
186
|
}
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
}
|
|
168
|
-
return null;
|
|
187
|
+
// User-permitted but history not yet enabled (soft, fixable) vs. not permitted.
|
|
188
|
+
if (status.agent_permitted && !status.has_history && !status.agent_created) {
|
|
189
|
+
return {
|
|
190
|
+
success: false,
|
|
191
|
+
error: `Agent cannot modify "${notebookPath}": notebook is user-permitted but history is not enabled. Open the notebook in the UI first to enable history tracking, or the agent can create a new notebook.`,
|
|
192
|
+
};
|
|
169
193
|
}
|
|
170
194
|
return {
|
|
171
195
|
success: false,
|
|
172
|
-
error: `Agent cannot modify "${notebookPath}": notebook is not agent-permitted. Either open the notebook in Nebula UI and grant agent permission, or the agent can create a new notebook which will be automatically permitted.`,
|
|
196
|
+
error: `Agent cannot modify "${notebookPath}": notebook is not agent-permitted (agent access may have been revoked). Either open the notebook in Nebula UI and grant agent permission, or the agent can create a new notebook which will be automatically permitted.`,
|
|
173
197
|
};
|
|
174
198
|
}
|
|
175
199
|
/**
|
|
@@ -781,6 +805,28 @@ class HeadlessOperationHandler {
|
|
|
781
805
|
error: `Notebook already exists: ${notebookPath}. Use overwrite=true to replace.`,
|
|
782
806
|
};
|
|
783
807
|
}
|
|
808
|
+
// Text notebook formats (.py percent / .qmd): create through the format
|
|
809
|
+
// adapter — agent flags live in the file's own metadata like .ipynb.
|
|
810
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
811
|
+
if (formatAdapter) {
|
|
812
|
+
const dir = path.dirname(normalizedPath);
|
|
813
|
+
if (!fs.existsSync(dir)) {
|
|
814
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
815
|
+
}
|
|
816
|
+
const metadata = {
|
|
817
|
+
kernelspec: { name: kernelName, display_name: kernelDisplayName },
|
|
818
|
+
nebula: { agent_created: true, agent_permitted: true },
|
|
819
|
+
};
|
|
820
|
+
if (formatAdapter.name === 'qmd')
|
|
821
|
+
metadata.__qmd_language = 'python';
|
|
822
|
+
fs.writeFileSync(normalizedPath, formatAdapter.serialize([], metadata), 'utf-8');
|
|
823
|
+
this.invalidate(notebookPath);
|
|
824
|
+
return {
|
|
825
|
+
success: true,
|
|
826
|
+
path: notebookPath,
|
|
827
|
+
mtime: fs.statSync(normalizedPath).mtimeMs / 1000,
|
|
828
|
+
};
|
|
829
|
+
}
|
|
784
830
|
const notebook = {
|
|
785
831
|
nbformat: 4,
|
|
786
832
|
nbformat_minor: 5,
|
|
@@ -48,6 +48,7 @@ const fs = __importStar(require("fs"));
|
|
|
48
48
|
const os = __importStar(require("os"));
|
|
49
49
|
const ws_1 = require("ws");
|
|
50
50
|
const cell_hash_1 = require("./cell-hash");
|
|
51
|
+
const registry_1 = require("../fs/notebook-formats/registry");
|
|
51
52
|
const AGENT_LOCK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
52
53
|
const UI_STALE_TIMEOUT_MS = 45 * 1000; // 45 seconds
|
|
53
54
|
function normalizeNotebookPath(notebookPath) {
|
|
@@ -320,9 +321,13 @@ class OperationRouter {
|
|
|
320
321
|
const opType = operation.type;
|
|
321
322
|
const agentId = operation.agentId;
|
|
322
323
|
// For createNotebook, route to ANY connected UI (not path-specific)
|
|
323
|
-
// This allows the UI to open the new notebook in a new tab
|
|
324
|
+
// This allows the UI to open the new notebook in a new tab.
|
|
325
|
+
// EXCEPT text notebook formats (.py/.qmd): the UI's create handler only
|
|
326
|
+
// builds Jupyter JSON, so those are always created headless through the
|
|
327
|
+
// format adapters.
|
|
324
328
|
const isCreateNotebook = opType === 'createNotebook';
|
|
325
|
-
const
|
|
329
|
+
const isTextFormatCreate = isCreateNotebook && (0, registry_1.isTextNotebookPath)(operation.notebookPath || '');
|
|
330
|
+
const anyUI = isCreateNotebook && !isTextFormatCreate ? this.getAnyUIConnection() : null;
|
|
326
331
|
const hasUI = isCreateNotebook ? (anyUI !== null) : (this.getResponsiveUIConnection(normalizedPath) !== null);
|
|
327
332
|
const backend = hasUI ? 'ui' : 'headless';
|
|
328
333
|
// Clean up expired locks
|
|
@@ -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,
|
|
@@ -612,10 +697,35 @@ async function kernelRoutes(fastify) {
|
|
|
612
697
|
if ((0, kernel_proxy_1.isProxiedSession)(sessionId)) {
|
|
613
698
|
try {
|
|
614
699
|
const remoteStatus = await (0, kernel_proxy_1.getRemoteKernelStatus)(sessionId);
|
|
615
|
-
|
|
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 });
|
|
616
704
|
}
|
|
617
705
|
catch (err) {
|
|
618
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
|
+
}
|
|
619
729
|
return reply.code(500).send({ detail: message });
|
|
620
730
|
}
|
|
621
731
|
}
|
|
@@ -109,7 +109,7 @@ async function notebookRoutes(fastify) {
|
|
|
109
109
|
if (!notebookPath) {
|
|
110
110
|
return reply.code(400).send({ detail: 'notebook_path query parameter is required' });
|
|
111
111
|
}
|
|
112
|
-
const history = fs_service_1.fsService.loadHistory(notebookPath);
|
|
112
|
+
const history = await fs_service_1.fsService.loadHistory(notebookPath);
|
|
113
113
|
return reply.send({ notebook_path: notebookPath, history });
|
|
114
114
|
}
|
|
115
115
|
catch (err) {
|
|
@@ -184,6 +184,7 @@ async function notebookRoutes(fastify) {
|
|
|
184
184
|
return reply.send({
|
|
185
185
|
status: 'ok',
|
|
186
186
|
notebook_path,
|
|
187
|
+
mtime: result.mtime,
|
|
187
188
|
...(result.status || fs_service_1.fsService.getAgentPermissionStatus(notebook_path)),
|
|
188
189
|
});
|
|
189
190
|
}
|
|
@@ -338,4 +339,31 @@ async function notebookRoutes(fastify) {
|
|
|
338
339
|
return reply.code(500).send({ detail: message });
|
|
339
340
|
}
|
|
340
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
|
+
});
|
|
341
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 {};
|