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.
Files changed (100) hide show
  1. package/README.md +35 -20
  2. package/dist/assets/{errorwidget-C4r2j2DQ.js → errorwidget-Wy5ajPiM.js} +1 -1
  3. package/dist/assets/{index-Day3QcNs.js → index-B_Bs5koo.js} +1 -1
  4. package/dist/assets/index-DK4nS74r.js +829 -0
  5. package/dist/assets/{index-D5w21_Z8.js → index-DeQN03nW.js} +1 -1
  6. package/dist/assets/index-DzjpmUak.css +32 -0
  7. package/dist/assets/{index-CsHoPQy-.js → index-g6MjT-VG.js} +1 -1
  8. package/dist/assets/{services-shim-D6p_A67v.js → services-shim-BirHVsih.js} +1 -1
  9. package/dist/index.html +2 -2
  10. package/node-server/dist/auth/auth-middleware.js +22 -4
  11. package/node-server/dist/auth/auth-service.js +37 -7
  12. package/node-server/dist/cluster/client-registration.d.ts +12 -1
  13. package/node-server/dist/cluster/client-registration.js +35 -2
  14. package/node-server/dist/cluster/kernel-proxy.js +19 -3
  15. package/node-server/dist/cluster/server-registry.d.ts +9 -0
  16. package/node-server/dist/cluster/server-registry.js +26 -0
  17. package/node-server/dist/discovery/conda-locations.d.ts +73 -0
  18. package/node-server/dist/discovery/conda-locations.js +427 -0
  19. package/node-server/dist/discovery/discovery-service.d.ts +73 -4
  20. package/node-server/dist/discovery/discovery-service.js +211 -127
  21. package/node-server/dist/discovery/types.d.ts +0 -1
  22. package/node-server/dist/discovery/types.js +1 -2
  23. package/node-server/dist/environment.d.ts +12 -0
  24. package/node-server/dist/environment.js +104 -0
  25. package/node-server/dist/fs/fs-service.d.ts +31 -1
  26. package/node-server/dist/fs/fs-service.js +160 -28
  27. package/node-server/dist/fs/types.d.ts +9 -0
  28. package/node-server/dist/fs/types.js +8 -0
  29. package/node-server/dist/index.js +71 -4
  30. package/node-server/dist/kernel/kernel-service.d.ts +17 -0
  31. package/node-server/dist/kernel/kernel-service.js +72 -4
  32. package/node-server/dist/kernel/kernelspec.d.ts +27 -0
  33. package/node-server/dist/kernel/kernelspec.js +75 -4
  34. package/node-server/dist/notebook/headless-handler.d.ts +10 -0
  35. package/node-server/dist/notebook/headless-handler.js +55 -5
  36. package/node-server/dist/notebook/undoRedoManager.d.ts +8 -0
  37. package/node-server/dist/notebook/undoRedoManager.js +39 -16
  38. package/node-server/dist/private-tmp.d.ts +14 -0
  39. package/node-server/dist/private-tmp.js +70 -0
  40. package/node-server/dist/routes/autocomplete.d.ts +19 -0
  41. package/node-server/dist/routes/autocomplete.js +294 -0
  42. package/node-server/dist/routes/fs.js +21 -4
  43. package/node-server/dist/routes/kernel.js +45 -2
  44. package/node-server/dist/routes/notebook.js +8 -2
  45. package/node-server/dist/routes/python.d.ts +1 -2
  46. package/node-server/dist/routes/python.js +139 -3
  47. package/node-server/dist/scheduler/allocation-service.d.ts +20 -0
  48. package/node-server/dist/scheduler/allocation-service.js +124 -8
  49. package/node-server/dist/scheduler/mock-scheduler.js +7 -3
  50. package/node-server/dist/scheduler/slurm-scheduler.js +21 -8
  51. package/node-server/dist/scheduler/types.d.ts +3 -2
  52. package/node-server/dist/terminal/agent-registry.d.ts +87 -0
  53. package/node-server/dist/terminal/agent-registry.js +246 -0
  54. package/node-server/dist/terminal/binding-store.d.ts +57 -0
  55. package/node-server/dist/terminal/binding-store.js +0 -0
  56. package/node-server/dist/terminal/pty-manager.d.ts +23 -6
  57. package/node-server/dist/terminal/pty-manager.js +130 -19
  58. package/node-server/dist/terminal/server.js +112 -21
  59. package/node-server/dist/terminal/types.d.ts +2 -0
  60. package/node-server/package.json +1 -0
  61. package/node_modules/nebula-autocomplete/README.md +145 -0
  62. package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
  63. package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
  64. package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
  65. package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
  66. package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
  67. package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
  68. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
  69. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
  70. package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
  71. package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
  72. package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
  73. package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
  74. package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
  75. package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
  76. package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
  77. package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
  78. package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
  79. package/node_modules/nebula-autocomplete/dist/index.js +5 -0
  80. package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
  81. package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
  82. package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
  83. package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
  84. package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
  85. package/node_modules/nebula-autocomplete/dist/types.js +1 -0
  86. package/node_modules/nebula-autocomplete/package.json +58 -0
  87. package/node_modules/nebula-autocomplete/server.d.ts +3 -0
  88. package/node_modules/nebula-autocomplete/server.js +3 -0
  89. package/package.json +17 -5
  90. package/scripts/postinstall.cjs +25 -5
  91. package/dist/assets/index-7-YBurka.js +0 -716
  92. package/dist/assets/index-CFBUnxSZ.css +0 -32
  93. package/node-server/dist/llm/index.d.ts +0 -5
  94. package/node-server/dist/llm/index.js +0 -21
  95. package/node-server/dist/llm/llm-service.d.ts +0 -77
  96. package/node-server/dist/llm/llm-service.js +0 -454
  97. package/node-server/dist/llm/types.d.ts +0 -40
  98. package/node-server/dist/llm/types.js +0 -15
  99. package/node-server/dist/routes/llm.d.ts +0 -8
  100. 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
- await handle.sync();
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
- if (!fs.existsSync(normalizedPath)) {
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 items = [];
505
- const entries = fs.readdirSync(normalizedPath);
506
- for (const name of entries) {
507
- // Skip hidden files
508
- if (name.startsWith('.')) {
509
- continue;
510
- }
511
- const fullPath = path.join(normalizedPath, name);
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
- const info = this.getFileInfo(fullPath);
514
- items.push(this.toFileInfoResponse(info));
544
+ return this.toFileInfoResponse(await this.getFileInfoAsync(path.join(normalizedPath, name)));
515
545
  }
516
546
  catch {
517
- // Skip files we can't access
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
- nbCell.outputs = this.convertOutputsToJupyter(cell.outputs);
1239
- nbCell.execution_count = cell.executionCount;
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
- await this.writeJsonAtomic(journalPath, journal);
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
- // Register CORS
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/jzthree/nebula-notebook/blob/main/docs/CLUSTER_SETUP.md',
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;