nebula-notebook 0.2.0 → 0.2.2
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 +35 -20
- package/dist/assets/{errorwidget-C4r2j2DQ.js → errorwidget-Wy5ajPiM.js} +1 -1
- package/dist/assets/{index-Day3QcNs.js → index-B_Bs5koo.js} +1 -1
- package/dist/assets/index-DK4nS74r.js +829 -0
- package/dist/assets/{index-D5w21_Z8.js → index-DeQN03nW.js} +1 -1
- package/dist/assets/index-DzjpmUak.css +32 -0
- package/dist/assets/{index-CsHoPQy-.js → index-g6MjT-VG.js} +1 -1
- package/dist/assets/{services-shim-D6p_A67v.js → services-shim-BirHVsih.js} +1 -1
- 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 +35 -2
- package/node-server/dist/cluster/kernel-proxy.js +19 -3
- package/node-server/dist/cluster/server-registry.d.ts +9 -0
- package/node-server/dist/cluster/server-registry.js +26 -0
- 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 +31 -1
- package/node-server/dist/fs/fs-service.js +160 -28
- package/node-server/dist/fs/types.d.ts +9 -0
- package/node-server/dist/fs/types.js +8 -0
- package/node-server/dist/index.js +71 -4
- package/node-server/dist/kernel/kernel-service.d.ts +17 -0
- package/node-server/dist/kernel/kernel-service.js +72 -4
- 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 +10 -0
- package/node-server/dist/notebook/headless-handler.js +55 -5
- package/node-server/dist/notebook/undoRedoManager.d.ts +8 -0
- package/node-server/dist/notebook/undoRedoManager.js +39 -16
- 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/fs.js +21 -4
- package/node-server/dist/routes/kernel.js +45 -2
- package/node-server/dist/routes/notebook.js +8 -2
- 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 +20 -0
- package/node-server/dist/scheduler/allocation-service.js +124 -8
- package/node-server/dist/scheduler/mock-scheduler.js +7 -3
- package/node-server/dist/scheduler/slurm-scheduler.js +21 -8
- package/node-server/dist/scheduler/types.d.ts +3 -2
- 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 +130 -19
- package/node-server/dist/terminal/server.js +112 -21
- package/node-server/dist/terminal/types.d.ts +2 -0
- package/node-server/package.json +1 -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 +17 -5
- package/scripts/postinstall.cjs +25 -5
- package/dist/assets/index-7-YBurka.js +0 -716
- package/dist/assets/index-CFBUnxSZ.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
|
@@ -44,6 +44,13 @@ export declare class FilesystemService {
|
|
|
44
44
|
/**
|
|
45
45
|
* Async variant of atomicWriteFileSync (write temp, fsync, rename, fsync dir).
|
|
46
46
|
* Used on hot notebook-save paths so large writes never block the event loop.
|
|
47
|
+
*
|
|
48
|
+
* `durable: false` skips both fsyncs while keeping the tmp+rename atomicity.
|
|
49
|
+
* Use it for auxiliary files (history/session/journal/lastsave): on network
|
|
50
|
+
* filesystems (GPFS) each fsync can take seconds, and a save doing ~10 of
|
|
51
|
+
* them was observed at 20-27s. A crash can lose the last few seconds of an
|
|
52
|
+
* auxiliary file, but never corrupt it — the rename is still atomic. The
|
|
53
|
+
* notebook file itself stays fully durable.
|
|
47
54
|
*/
|
|
48
55
|
private atomicWriteFile;
|
|
49
56
|
private writeJsonAtomic;
|
|
@@ -59,6 +66,9 @@ export declare class FilesystemService {
|
|
|
59
66
|
* Get file info for a path
|
|
60
67
|
*/
|
|
61
68
|
private getFileInfo;
|
|
69
|
+
/** Async, non-blocking variant of getFileInfo (one `fsp.stat`). Used by
|
|
70
|
+
* listDirectory so a folder's entries stat in parallel off the event loop. */
|
|
71
|
+
private getFileInfoAsync;
|
|
62
72
|
/**
|
|
63
73
|
* Convert FileInfo to FileInfoResponse for API
|
|
64
74
|
*/
|
|
@@ -74,7 +84,7 @@ export declare class FilesystemService {
|
|
|
74
84
|
/**
|
|
75
85
|
* List contents of a directory
|
|
76
86
|
*/
|
|
77
|
-
listDirectory(dirPath: string): DirectoryListing
|
|
87
|
+
listDirectory(dirPath: string): Promise<DirectoryListing>;
|
|
78
88
|
/**
|
|
79
89
|
* Read a file's contents
|
|
80
90
|
*/
|
|
@@ -198,6 +208,12 @@ export declare class FilesystemService {
|
|
|
198
208
|
* Get notebook-level metadata
|
|
199
209
|
*/
|
|
200
210
|
getNotebookMetadata(notebookPath: string): Record<string, unknown>;
|
|
211
|
+
/**
|
|
212
|
+
* Async variant of getNotebookMetadata. Nebula-written notebooks put
|
|
213
|
+
* `metadata` first, so the head-bytes scan avoids reading and parsing the
|
|
214
|
+
* whole file; externally written notebooks fall back to a full async read.
|
|
215
|
+
*/
|
|
216
|
+
getNotebookMetadataAsync(notebookPath: string): Promise<Record<string, unknown>>;
|
|
201
217
|
/**
|
|
202
218
|
* Update notebook-level metadata without modifying cells
|
|
203
219
|
*/
|
|
@@ -222,6 +238,14 @@ export declare class FilesystemService {
|
|
|
222
238
|
* Derive agent permission status from persisted notebook metadata.
|
|
223
239
|
*/
|
|
224
240
|
getAgentPermissionStatus(notebookPath: string): AgentPermissionSnapshot;
|
|
241
|
+
/**
|
|
242
|
+
* Async variant of getAgentPermissionStatus for hot request paths (it runs
|
|
243
|
+
* on every agent write operation). Reads metadata via the head-bytes fast
|
|
244
|
+
* path and checks history without parsing it, so the per-operation gate
|
|
245
|
+
* never blocks the event loop on a slow network filesystem.
|
|
246
|
+
*/
|
|
247
|
+
getAgentPermissionStatusAsync(notebookPath: string): Promise<AgentPermissionSnapshot>;
|
|
248
|
+
private deriveAgentPermission;
|
|
225
249
|
/**
|
|
226
250
|
* Check if a notebook is permitted for agent modifications
|
|
227
251
|
*/
|
|
@@ -230,6 +254,12 @@ export declare class FilesystemService {
|
|
|
230
254
|
* Check if a notebook has history tracking enabled
|
|
231
255
|
*/
|
|
232
256
|
hasHistory(notebookPath: string): boolean;
|
|
257
|
+
/**
|
|
258
|
+
* Async variant of hasHistory that only parses small files: history journals
|
|
259
|
+
* grow to hundreds of KB, and this check runs on every agent operation — a
|
|
260
|
+
* file that large necessarily has entries, so its size alone answers.
|
|
261
|
+
*/
|
|
262
|
+
hasHistoryAsync(notebookPath: string): Promise<boolean>;
|
|
233
263
|
/**
|
|
234
264
|
* Save operation history for a notebook
|
|
235
265
|
*/
|
|
@@ -45,6 +45,7 @@ const path = __importStar(require("path"));
|
|
|
45
45
|
const os = __importStar(require("os"));
|
|
46
46
|
const crypto = __importStar(require("crypto"));
|
|
47
47
|
const util_1 = require("util");
|
|
48
|
+
const types_1 = require("./types");
|
|
48
49
|
const default_kernel_1 = require("../kernel/default-kernel");
|
|
49
50
|
const registry_1 = require("./notebook-formats/registry");
|
|
50
51
|
const percent_1 = require("./notebook-formats/percent");
|
|
@@ -351,8 +352,16 @@ class FilesystemService {
|
|
|
351
352
|
/**
|
|
352
353
|
* Async variant of atomicWriteFileSync (write temp, fsync, rename, fsync dir).
|
|
353
354
|
* Used on hot notebook-save paths so large writes never block the event loop.
|
|
355
|
+
*
|
|
356
|
+
* `durable: false` skips both fsyncs while keeping the tmp+rename atomicity.
|
|
357
|
+
* Use it for auxiliary files (history/session/journal/lastsave): on network
|
|
358
|
+
* filesystems (GPFS) each fsync can take seconds, and a save doing ~10 of
|
|
359
|
+
* them was observed at 20-27s. A crash can lose the last few seconds of an
|
|
360
|
+
* auxiliary file, but never corrupt it — the rename is still atomic. The
|
|
361
|
+
* notebook file itself stays fully durable.
|
|
354
362
|
*/
|
|
355
|
-
async atomicWriteFile(targetPath, data) {
|
|
363
|
+
async atomicWriteFile(targetPath, data, opts = {}) {
|
|
364
|
+
const durable = opts.durable !== false;
|
|
356
365
|
const dir = path.dirname(targetPath);
|
|
357
366
|
await fs_1.promises.mkdir(dir, { recursive: true });
|
|
358
367
|
const tmpName = `.${path.basename(targetPath)}.${process.pid}.${Date.now()}.${crypto.randomUUID()}.tmp`;
|
|
@@ -360,12 +369,15 @@ class FilesystemService {
|
|
|
360
369
|
const handle = await fs_1.promises.open(tmpPath, 'w', 0o600);
|
|
361
370
|
try {
|
|
362
371
|
await handle.writeFile(data, 'utf-8');
|
|
363
|
-
|
|
372
|
+
if (durable)
|
|
373
|
+
await handle.sync();
|
|
364
374
|
}
|
|
365
375
|
finally {
|
|
366
376
|
await handle.close();
|
|
367
377
|
}
|
|
368
378
|
await fs_1.promises.rename(tmpPath, targetPath);
|
|
379
|
+
if (!durable)
|
|
380
|
+
return;
|
|
369
381
|
// Best-effort directory fsync
|
|
370
382
|
try {
|
|
371
383
|
const dirHandle = await fs_1.promises.open(dir, 'r');
|
|
@@ -380,8 +392,8 @@ class FilesystemService {
|
|
|
380
392
|
// Ignore fsync errors on directory handles
|
|
381
393
|
}
|
|
382
394
|
}
|
|
383
|
-
async writeJsonAtomic(targetPath, payload) {
|
|
384
|
-
await this.atomicWriteFile(targetPath, JSON.stringify(payload, null, 2));
|
|
395
|
+
async writeJsonAtomic(targetPath, payload, opts = {}) {
|
|
396
|
+
await this.atomicWriteFile(targetPath, JSON.stringify(payload, null, 2), opts);
|
|
385
397
|
}
|
|
386
398
|
/**
|
|
387
399
|
* Format file size for display
|
|
@@ -438,6 +450,21 @@ class FilesystemService {
|
|
|
438
450
|
extension: isDir ? '' : path.extname(name),
|
|
439
451
|
};
|
|
440
452
|
}
|
|
453
|
+
/** Async, non-blocking variant of getFileInfo (one `fsp.stat`). Used by
|
|
454
|
+
* listDirectory so a folder's entries stat in parallel off the event loop. */
|
|
455
|
+
async getFileInfoAsync(filePath) {
|
|
456
|
+
const stat = await fs_1.promises.stat(filePath);
|
|
457
|
+
const name = path.basename(filePath);
|
|
458
|
+
const isDir = stat.isDirectory();
|
|
459
|
+
return {
|
|
460
|
+
name,
|
|
461
|
+
path: filePath,
|
|
462
|
+
isDirectory: isDir,
|
|
463
|
+
size: isDir ? 0 : stat.size,
|
|
464
|
+
modified: stat.mtimeMs / 1000,
|
|
465
|
+
extension: isDir ? '' : path.extname(name),
|
|
466
|
+
};
|
|
467
|
+
}
|
|
441
468
|
/**
|
|
442
469
|
* Convert FileInfo to FileInfoResponse for API
|
|
443
470
|
*/
|
|
@@ -492,32 +519,35 @@ class FilesystemService {
|
|
|
492
519
|
/**
|
|
493
520
|
* List contents of a directory
|
|
494
521
|
*/
|
|
495
|
-
listDirectory(dirPath) {
|
|
522
|
+
async listDirectory(dirPath) {
|
|
496
523
|
const normalizedPath = this.normalizePath(dirPath);
|
|
497
|
-
|
|
524
|
+
let stat;
|
|
525
|
+
try {
|
|
526
|
+
stat = await fs_1.promises.stat(normalizedPath);
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
498
529
|
throw new Error(`Path not found: ${normalizedPath}`);
|
|
499
530
|
}
|
|
500
|
-
const stat = fs.statSync(normalizedPath);
|
|
501
531
|
if (!stat.isDirectory()) {
|
|
502
532
|
throw new Error(`Not a directory: ${normalizedPath}`);
|
|
503
533
|
}
|
|
504
|
-
const
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
534
|
+
const entries = await fs_1.promises.readdir(normalizedPath);
|
|
535
|
+
// Stat every visible entry in PARALLEL rather than one blocking statSync at a
|
|
536
|
+
// time. On a network filesystem a serial loop stalls the single event
|
|
537
|
+
// loop for N × stat-latency, delaying every other in-flight request (kernel
|
|
538
|
+
// starts, terminal creation, saves) — the source of the "everything hangs
|
|
539
|
+
// while a folder lists" latency.
|
|
540
|
+
const settled = await Promise.all(entries
|
|
541
|
+
.filter((name) => !name.startsWith('.')) // skip hidden files
|
|
542
|
+
.map(async (name) => {
|
|
512
543
|
try {
|
|
513
|
-
|
|
514
|
-
items.push(this.toFileInfoResponse(info));
|
|
544
|
+
return this.toFileInfoResponse(await this.getFileInfoAsync(path.join(normalizedPath, name)));
|
|
515
545
|
}
|
|
516
546
|
catch {
|
|
517
|
-
//
|
|
518
|
-
continue;
|
|
547
|
+
return null; // skip entries we can't access
|
|
519
548
|
}
|
|
520
|
-
}
|
|
549
|
+
}));
|
|
550
|
+
const items = settled.filter((x) => x !== null);
|
|
521
551
|
// Sort: directories first, then by name
|
|
522
552
|
items.sort((a, b) => {
|
|
523
553
|
if (a.isDirectory !== b.isDirectory) {
|
|
@@ -672,7 +702,7 @@ class FilesystemService {
|
|
|
672
702
|
const canonical = adapter.parse(serializedText).cells;
|
|
673
703
|
await this.writeJsonAtomic(this.getLastSavePath(notebookPath), {
|
|
674
704
|
cells: canonical.map((c) => ({ id: c.id, type: c.type, content: c.content })),
|
|
675
|
-
});
|
|
705
|
+
}, { durable: false });
|
|
676
706
|
}
|
|
677
707
|
catch (e) {
|
|
678
708
|
console.warn('[FilesystemService] Failed to write lastsave record:', e);
|
|
@@ -1217,6 +1247,29 @@ class FilesystemService {
|
|
|
1217
1247
|
}
|
|
1218
1248
|
kernelName = kernelName || 'python3';
|
|
1219
1249
|
const displayName = kernelName === 'python3' ? 'Python 3' : kernelName.replace(/[-_]/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
|
1250
|
+
// Resolve outputs-unchanged sentinels from the file on disk: the client
|
|
1251
|
+
// elides unchanged outputs (often MBs of base64 images) from autosaves so
|
|
1252
|
+
// slow uplinks aren't saturated. If anything can't be resolved, ask the
|
|
1253
|
+
// client to retry with a full payload rather than guessing.
|
|
1254
|
+
const sentinelCells = cells.filter((c) => c.outputs === types_1.OUTPUTS_UNCHANGED_SENTINEL);
|
|
1255
|
+
const priorOutputs = new Map();
|
|
1256
|
+
if (sentinelCells.length > 0) {
|
|
1257
|
+
try {
|
|
1258
|
+
const existing = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
|
|
1259
|
+
for (const jc of existing.cells || []) {
|
|
1260
|
+
const nid = jc.metadata?.nebula_id;
|
|
1261
|
+
if (nid)
|
|
1262
|
+
priorOutputs.set(String(nid), { outputs: jc.outputs || [], execution_count: jc.execution_count ?? null });
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
catch {
|
|
1266
|
+
return { success: false, mtime: 0, needsFull: true };
|
|
1267
|
+
}
|
|
1268
|
+
for (const c of sentinelCells) {
|
|
1269
|
+
if (!priorOutputs.has(c.id))
|
|
1270
|
+
return { success: false, mtime: 0, needsFull: true };
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1220
1273
|
const nbCells = cells.map((cell) => {
|
|
1221
1274
|
const preservedMetadata = cell._metadata || {};
|
|
1222
1275
|
const cellMetadata = {
|
|
@@ -1235,8 +1288,15 @@ class FilesystemService {
|
|
|
1235
1288
|
metadata: cellMetadata,
|
|
1236
1289
|
};
|
|
1237
1290
|
if (cell.type === 'code') {
|
|
1238
|
-
|
|
1239
|
-
|
|
1291
|
+
if (cell.outputs === types_1.OUTPUTS_UNCHANGED_SENTINEL) {
|
|
1292
|
+
const prior = priorOutputs.get(cell.id);
|
|
1293
|
+
nbCell.outputs = prior.outputs;
|
|
1294
|
+
nbCell.execution_count = cell.executionCount ?? prior.execution_count;
|
|
1295
|
+
}
|
|
1296
|
+
else {
|
|
1297
|
+
nbCell.outputs = this.convertOutputsToJupyter(cell.outputs);
|
|
1298
|
+
nbCell.execution_count = cell.executionCount;
|
|
1299
|
+
}
|
|
1240
1300
|
}
|
|
1241
1301
|
return nbCell;
|
|
1242
1302
|
});
|
|
@@ -1307,12 +1367,14 @@ class FilesystemService {
|
|
|
1307
1367
|
// Ensure .nebula directory exists for journal/history/session
|
|
1308
1368
|
const { nebulaDir } = this.getNebulaPaths(notebookPath);
|
|
1309
1369
|
await fs_1.promises.mkdir(nebulaDir, { recursive: true });
|
|
1310
|
-
|
|
1370
|
+
// Auxiliary files: atomic but non-durable (no fsync) — see atomicWriteFile.
|
|
1371
|
+
// Only the notebook itself (inside saveNotebookCells) pays for fsync.
|
|
1372
|
+
await this.writeJsonAtomic(journalPath, journal, { durable: false });
|
|
1311
1373
|
if (historyPath) {
|
|
1312
|
-
await this.writeJsonAtomic(historyPath, history || []);
|
|
1374
|
+
await this.writeJsonAtomic(historyPath, history || [], { durable: false });
|
|
1313
1375
|
}
|
|
1314
1376
|
if (sessionPath) {
|
|
1315
|
-
await this.writeJsonAtomic(sessionPath, session || {});
|
|
1377
|
+
await this.writeJsonAtomic(sessionPath, session || {}, { durable: false });
|
|
1316
1378
|
}
|
|
1317
1379
|
const result = await this.saveNotebookCells(notebookPath, cells, kernelName, notebookMetadata);
|
|
1318
1380
|
const committedJournal = {
|
|
@@ -1320,7 +1382,7 @@ class FilesystemService {
|
|
|
1320
1382
|
status: 'commit',
|
|
1321
1383
|
committedAt: Date.now(),
|
|
1322
1384
|
};
|
|
1323
|
-
await this.writeJsonAtomic(journalPath, committedJournal);
|
|
1385
|
+
await this.writeJsonAtomic(journalPath, committedJournal, { durable: false });
|
|
1324
1386
|
try {
|
|
1325
1387
|
await fs_1.promises.unlink(journalPath);
|
|
1326
1388
|
}
|
|
@@ -1335,6 +1397,9 @@ class FilesystemService {
|
|
|
1335
1397
|
* Outputs and execution counts are never serialized — by format design.
|
|
1336
1398
|
*/
|
|
1337
1399
|
async saveTextNotebookCells(adapter, normalizedPath, cells, kernelName, notebookMetadata) {
|
|
1400
|
+
// Text formats never serialize outputs; neutralize any outputs-unchanged
|
|
1401
|
+
// sentinels so nothing downstream sees the marker string.
|
|
1402
|
+
cells = cells.map((c) => c.outputs === types_1.OUTPUTS_UNCHANGED_SENTINEL ? { ...c, outputs: [] } : c);
|
|
1338
1403
|
let existingMetadata = {};
|
|
1339
1404
|
try {
|
|
1340
1405
|
existingMetadata = adapter.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8')).metadata;
|
|
@@ -1384,6 +1449,39 @@ class FilesystemService {
|
|
|
1384
1449
|
return {};
|
|
1385
1450
|
}
|
|
1386
1451
|
}
|
|
1452
|
+
/**
|
|
1453
|
+
* Async variant of getNotebookMetadata. Nebula-written notebooks put
|
|
1454
|
+
* `metadata` first, so the head-bytes scan avoids reading and parsing the
|
|
1455
|
+
* whole file; externally written notebooks fall back to a full async read.
|
|
1456
|
+
*/
|
|
1457
|
+
async getNotebookMetadataAsync(notebookPath) {
|
|
1458
|
+
const normalizedPath = this.normalizePath(notebookPath);
|
|
1459
|
+
const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
|
|
1460
|
+
try {
|
|
1461
|
+
if (formatAdapter) {
|
|
1462
|
+
return formatAdapter.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8')).metadata;
|
|
1463
|
+
}
|
|
1464
|
+
const handle = await fs_1.promises.open(normalizedPath, 'r');
|
|
1465
|
+
let head;
|
|
1466
|
+
try {
|
|
1467
|
+
const buf = Buffer.alloc(NOTEBOOK_METADATA_FAST_PATH_BYTES);
|
|
1468
|
+
const { bytesRead } = await handle.read(buf, 0, NOTEBOOK_METADATA_FAST_PATH_BYTES, 0);
|
|
1469
|
+
head = buf.toString('utf-8', 0, bytesRead);
|
|
1470
|
+
}
|
|
1471
|
+
finally {
|
|
1472
|
+
await handle.close();
|
|
1473
|
+
}
|
|
1474
|
+
const extracted = extractTopLevelObjectField(head, 'metadata');
|
|
1475
|
+
if (extracted) {
|
|
1476
|
+
return extracted;
|
|
1477
|
+
}
|
|
1478
|
+
const notebook = JSON.parse(await fs_1.promises.readFile(normalizedPath, 'utf-8'));
|
|
1479
|
+
return notebook.metadata || {};
|
|
1480
|
+
}
|
|
1481
|
+
catch {
|
|
1482
|
+
return {};
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1387
1485
|
/**
|
|
1388
1486
|
* Update notebook-level metadata without modifying cells
|
|
1389
1487
|
*/
|
|
@@ -1564,8 +1662,24 @@ class FilesystemService {
|
|
|
1564
1662
|
*/
|
|
1565
1663
|
getAgentPermissionStatus(notebookPath) {
|
|
1566
1664
|
const metadata = this.getNotebookMetadata(notebookPath);
|
|
1567
|
-
const nebula = (metadata.nebula || {});
|
|
1568
1665
|
const hasHistory = this.hasHistory(notebookPath);
|
|
1666
|
+
return this.deriveAgentPermission(metadata, hasHistory);
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Async variant of getAgentPermissionStatus for hot request paths (it runs
|
|
1670
|
+
* on every agent write operation). Reads metadata via the head-bytes fast
|
|
1671
|
+
* path and checks history without parsing it, so the per-operation gate
|
|
1672
|
+
* never blocks the event loop on a slow network filesystem.
|
|
1673
|
+
*/
|
|
1674
|
+
async getAgentPermissionStatusAsync(notebookPath) {
|
|
1675
|
+
const [metadata, hasHistory] = await Promise.all([
|
|
1676
|
+
this.getNotebookMetadataAsync(notebookPath),
|
|
1677
|
+
this.hasHistoryAsync(notebookPath),
|
|
1678
|
+
]);
|
|
1679
|
+
return this.deriveAgentPermission(metadata, hasHistory);
|
|
1680
|
+
}
|
|
1681
|
+
deriveAgentPermission(metadata, hasHistory) {
|
|
1682
|
+
const nebula = (metadata.nebula || {});
|
|
1569
1683
|
const agentCreated = Boolean(nebula.agent_created);
|
|
1570
1684
|
const agentPermitted = Boolean(nebula.agent_permitted);
|
|
1571
1685
|
// Tri-state: an explicit user revoke (agent_permitted === false) always wins,
|
|
@@ -1616,6 +1730,24 @@ class FilesystemService {
|
|
|
1616
1730
|
return false;
|
|
1617
1731
|
}
|
|
1618
1732
|
}
|
|
1733
|
+
/**
|
|
1734
|
+
* Async variant of hasHistory that only parses small files: history journals
|
|
1735
|
+
* grow to hundreds of KB, and this check runs on every agent operation — a
|
|
1736
|
+
* file that large necessarily has entries, so its size alone answers.
|
|
1737
|
+
*/
|
|
1738
|
+
async hasHistoryAsync(notebookPath) {
|
|
1739
|
+
const historyPath = this.getHistoryPath(notebookPath);
|
|
1740
|
+
try {
|
|
1741
|
+
const stat = await fs_1.promises.stat(historyPath);
|
|
1742
|
+
if (stat.size > 4096)
|
|
1743
|
+
return true;
|
|
1744
|
+
const history = JSON.parse(await fs_1.promises.readFile(historyPath, 'utf-8'));
|
|
1745
|
+
return Array.isArray(history) && history.length > 0;
|
|
1746
|
+
}
|
|
1747
|
+
catch {
|
|
1748
|
+
return false;
|
|
1749
|
+
}
|
|
1750
|
+
}
|
|
1619
1751
|
/**
|
|
1620
1752
|
* Save operation history for a notebook
|
|
1621
1753
|
*/
|
|
@@ -85,7 +85,16 @@ export interface NotebookCellsResponse {
|
|
|
85
85
|
export interface SaveNotebookResult {
|
|
86
86
|
success: boolean;
|
|
87
87
|
mtime: number;
|
|
88
|
+
/** Client sent outputs-unchanged sentinels the server couldn't resolve — it must retry with a full payload. */
|
|
89
|
+
needsFull?: boolean;
|
|
88
90
|
}
|
|
91
|
+
/**
|
|
92
|
+
* Sentinel the client puts in place of a code cell's outputs when they are
|
|
93
|
+
* unchanged since its last successful save. The server re-uses the outputs
|
|
94
|
+
* already in the file (matched by nebula_id), so autosaves don't re-upload
|
|
95
|
+
* megabytes of unchanged base64 images over slow uplinks.
|
|
96
|
+
*/
|
|
97
|
+
export declare const OUTPUTS_UNCHANGED_SENTINEL = "__nebula-outputs-unchanged-v1__";
|
|
89
98
|
export interface JupyterCellMetadata {
|
|
90
99
|
nebula_id?: string;
|
|
91
100
|
scrolled?: boolean;
|
|
@@ -3,3 +3,11 @@
|
|
|
3
3
|
* Filesystem Service Types
|
|
4
4
|
*/
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.OUTPUTS_UNCHANGED_SENTINEL = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* Sentinel the client puts in place of a code cell's outputs when they are
|
|
9
|
+
* unchanged since its last successful save. The server re-uses the outputs
|
|
10
|
+
* already in the file (matched by nebula_id), so autosaves don't re-upload
|
|
11
|
+
* megabytes of unchanged base64 images over slow uplinks.
|
|
12
|
+
*/
|
|
13
|
+
exports.OUTPUTS_UNCHANGED_SENTINEL = '__nebula-outputs-unchanged-v1__';
|
|
@@ -59,6 +59,7 @@ const fs = __importStar(require("fs"));
|
|
|
59
59
|
const os = __importStar(require("os"));
|
|
60
60
|
const crypto = __importStar(require("crypto"));
|
|
61
61
|
const crypto_1 = require("crypto");
|
|
62
|
+
const zlib = __importStar(require("zlib"));
|
|
62
63
|
// Import routes
|
|
63
64
|
const kernel_1 = __importStar(require("./routes/kernel"));
|
|
64
65
|
const fs_1 = __importDefault(require("./routes/fs"));
|
|
@@ -68,6 +69,7 @@ const auth_1 = __importDefault(require("./routes/auth"));
|
|
|
68
69
|
const cluster_1 = __importDefault(require("./routes/cluster"));
|
|
69
70
|
const resources_1 = __importDefault(require("./routes/resources"));
|
|
70
71
|
const compute_1 = __importDefault(require("./routes/compute"));
|
|
72
|
+
const autocomplete_1 = __importDefault(require("./routes/autocomplete"));
|
|
71
73
|
// Import cluster
|
|
72
74
|
const server_registry_1 = require("./cluster/server-registry");
|
|
73
75
|
const cluster_secret_1 = require("./cluster/cluster-secret");
|
|
@@ -80,6 +82,7 @@ const mock_scheduler_1 = require("./scheduler/mock-scheduler");
|
|
|
80
82
|
const auth_2 = require("./auth");
|
|
81
83
|
const fs_service_1 = require("./fs/fs-service");
|
|
82
84
|
const update_check_1 = require("./update-check");
|
|
85
|
+
const environment_1 = require("./environment");
|
|
83
86
|
// Import terminal routes (existing)
|
|
84
87
|
const server_1 = require("./terminal/server");
|
|
85
88
|
// Import notebook WebSocket
|
|
@@ -240,6 +243,22 @@ async function createApp() {
|
|
|
240
243
|
});
|
|
241
244
|
console.log('[Server] HTTP/1.1 mode (no TLS)');
|
|
242
245
|
}
|
|
246
|
+
// Gzipped JSON uploads (Content-Type: application/gzip). The client
|
|
247
|
+
// compresses large bodies (notebook autosaves) so slow-upload tunnels
|
|
248
|
+
// aren't saturated for seconds per save; bodyLimit applies to the
|
|
249
|
+
// COMPRESSED size, which is the wire cost we care about.
|
|
250
|
+
fastify.addContentTypeParser('application/gzip', { parseAs: 'buffer' }, (_req, body, done) => {
|
|
251
|
+
zlib.gunzip(body, (err, buf) => {
|
|
252
|
+
if (err)
|
|
253
|
+
return done(err);
|
|
254
|
+
try {
|
|
255
|
+
done(null, JSON.parse(buf.toString('utf-8')));
|
|
256
|
+
}
|
|
257
|
+
catch (e) {
|
|
258
|
+
done(e);
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
});
|
|
243
262
|
// Request timing hook
|
|
244
263
|
fastify.addHook('onResponse', (request, reply, done) => {
|
|
245
264
|
const duration = reply.elapsedTime;
|
|
@@ -248,9 +267,46 @@ async function createApp() {
|
|
|
248
267
|
}
|
|
249
268
|
done();
|
|
250
269
|
});
|
|
251
|
-
//
|
|
270
|
+
// Event-loop stall detector: a 100ms heartbeat that logs when it fires late.
|
|
271
|
+
// Any [perf] line here means the WHOLE server (terminal WS forwarding
|
|
272
|
+
// included) was frozen that long by synchronous work — the prime suspect
|
|
273
|
+
// whenever "everything hangs for a moment". Correlate timestamps with
|
|
274
|
+
// [Slow Request] lines to find the culprit route.
|
|
275
|
+
{
|
|
276
|
+
let last = process.hrtime.bigint();
|
|
277
|
+
const timer = setInterval(() => {
|
|
278
|
+
const now = process.hrtime.bigint();
|
|
279
|
+
const stallMs = Number(now - last) / 1e6 - 100;
|
|
280
|
+
last = now;
|
|
281
|
+
if (stallMs > 150) {
|
|
282
|
+
console.warn(`[perf] event-loop stall ~${Math.round(stallMs)}ms at ${new Date().toISOString()}`);
|
|
283
|
+
}
|
|
284
|
+
}, 100);
|
|
285
|
+
timer.unref(); // never keep the process alive for this
|
|
286
|
+
}
|
|
287
|
+
// Register CORS. Allowlist instead of '*': cross-origin BROWSER pages lose
|
|
288
|
+
// API access (drive-by protection), while nothing we ship is affected —
|
|
289
|
+
// the terminal/agent/MCP are non-browser clients (no Origin header, CORS
|
|
290
|
+
// doesn't apply), the served UI is same-origin, ssh port-forwards land on
|
|
291
|
+
// localhost, and vite dev is a localhost origin. Extra origins for exotic
|
|
292
|
+
// serving setups: NEBULA_ALLOWED_ORIGINS=https://a.com,https://b.com
|
|
293
|
+
const extraOrigins = (process.env.NEBULA_ALLOWED_ORIGINS || '')
|
|
294
|
+
.split(',').map((s) => s.trim()).filter(Boolean);
|
|
252
295
|
await fastify.register(cors_1.default, {
|
|
253
|
-
origin:
|
|
296
|
+
origin: (origin, cb) => {
|
|
297
|
+
if (!origin)
|
|
298
|
+
return cb(null, true); // non-browser client or same-origin
|
|
299
|
+
if (extraOrigins.includes(origin))
|
|
300
|
+
return cb(null, true);
|
|
301
|
+
try {
|
|
302
|
+
const { hostname } = new URL(origin);
|
|
303
|
+
if (hostname === 'localhost' || hostname === '::1' || hostname === '127.0.0.1' || hostname.startsWith('127.')) {
|
|
304
|
+
return cb(null, true);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch { /* malformed Origin — treat as disallowed */ }
|
|
308
|
+
cb(null, false); // no CORS headers -> browser blocks the cross-origin page
|
|
309
|
+
},
|
|
254
310
|
credentials: true,
|
|
255
311
|
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
|
|
256
312
|
allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key', 'X-API-Provider'],
|
|
@@ -287,6 +343,7 @@ async function createApp() {
|
|
|
287
343
|
ready: kernel_1.kernelService.isReady,
|
|
288
344
|
cwd: rootDir,
|
|
289
345
|
update,
|
|
346
|
+
environment: (0, environment_1.getEnvironment)(),
|
|
290
347
|
});
|
|
291
348
|
});
|
|
292
349
|
fastify.get('/api/ready', async (_request, reply) => {
|
|
@@ -318,6 +375,7 @@ async function createApp() {
|
|
|
318
375
|
});
|
|
319
376
|
// API routes (protected)
|
|
320
377
|
await fastify.register(kernel_1.default, { prefix: '/api' });
|
|
378
|
+
await fastify.register(autocomplete_1.default, { prefix: '/api' });
|
|
321
379
|
await fastify.register(fs_1.default, { prefix: '/api' });
|
|
322
380
|
await fastify.register(notebook_1.default, { prefix: '/api' });
|
|
323
381
|
await fastify.register(python_1.default, { prefix: '/api' });
|
|
@@ -548,7 +606,7 @@ async function main() {
|
|
|
548
606
|
'',
|
|
549
607
|
' On a remote machine? From your laptop:',
|
|
550
608
|
` ssh -L ${PORT}:localhost:${PORT} ${host} then open http://localhost:${PORT}`,
|
|
551
|
-
' Cluster guide: https://github.com/
|
|
609
|
+
' Cluster guide: https://github.com/jzhoulab/nebula-notebook/blob/main/docs/CLUSTER_SETUP.md',
|
|
552
610
|
' ──────────────────────────────────────────────────────',
|
|
553
611
|
'',
|
|
554
612
|
];
|
|
@@ -628,7 +686,7 @@ async function main() {
|
|
|
628
686
|
const clientServerId = `${os.hostname()}:${boundPort}`;
|
|
629
687
|
server_registry_1.serverRegistry.setLocalServerId(clientServerId);
|
|
630
688
|
kernel_1.kernelService.setServerIdentity(clientServerId, serverInstanceId);
|
|
631
|
-
client_registration_1.clientRegistration.initFromEnv(boundPort);
|
|
689
|
+
void client_registration_1.clientRegistration.initFromEnv(boundPort); // async: resolves the controller-routable IP first
|
|
632
690
|
// Opt-in idle auto-release: exit this process after N idle minutes so the
|
|
633
691
|
// batch job completes and the allocation ends naturally (no scancel needed).
|
|
634
692
|
const idleExitMinutes = Math.floor(Number(process.env.NEBULA_IDLE_EXIT_MINUTES) || 0);
|
|
@@ -670,6 +728,15 @@ async function main() {
|
|
|
670
728
|
}
|
|
671
729
|
}
|
|
672
730
|
// Run
|
|
731
|
+
// A single stray rejection must not take down every kernel, terminal, and
|
|
732
|
+
// SLURM allocation this process is holding. Log loudly and keep serving —
|
|
733
|
+
// for a state-holding server, dying is strictly worse than degrading.
|
|
734
|
+
process.on('unhandledRejection', (reason) => {
|
|
735
|
+
console.error('[fatal-ish] Unhandled promise rejection (continuing):', reason);
|
|
736
|
+
});
|
|
737
|
+
process.on('uncaughtException', (err) => {
|
|
738
|
+
console.error('[fatal-ish] Uncaught exception (continuing):', err);
|
|
739
|
+
});
|
|
673
740
|
main().catch((err) => {
|
|
674
741
|
console.error('[Server] Fatal error:', err);
|
|
675
742
|
process.exit(1);
|
|
@@ -20,6 +20,23 @@ export interface CommEvent {
|
|
|
20
20
|
export declare const IOPUB_QUEUE_CLOSED: unique symbol;
|
|
21
21
|
/** Resolved by BoundedAsyncQueue.next(timeoutMs) when the wait timed out. */
|
|
22
22
|
export declare const IOPUB_QUEUE_TIMEOUT: unique symbol;
|
|
23
|
+
/**
|
|
24
|
+
* Polyfill IPython-style `!command` lines for kernels whose language lacks
|
|
25
|
+
* them, by rewriting each such line into the language's own shell-call idiom
|
|
26
|
+
* before the code reaches the kernel. Because the rewritten code still runs
|
|
27
|
+
* in the kernel process, cwd, environment (conda env, loaded modules), and
|
|
28
|
+
* machine (allocation node) are all the kernel's — the same guarantees
|
|
29
|
+
* IPython's native `!` gives Python users.
|
|
30
|
+
*
|
|
31
|
+
* Python kernels are deliberately untouched: IPython's `!` is richer than a
|
|
32
|
+
* plain shell call (output capture `x = !ls`, `{var}` interpolation,
|
|
33
|
+
* streaming output) and a rewrite would break it.
|
|
34
|
+
*
|
|
35
|
+
* Only whole lines that start with `!` are rewritten. Known limits (v1):
|
|
36
|
+
* a line inside a multi-line string that happens to start with `!` would be
|
|
37
|
+
* rewritten too, and R's intern=TRUE buffers output until the command exits.
|
|
38
|
+
*/
|
|
39
|
+
export declare function rewriteShellLines(code: string, language: string | undefined): string;
|
|
23
40
|
export declare class KernelService {
|
|
24
41
|
private sessions;
|
|
25
42
|
private fileToSession;
|