nebula-notebook 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +124 -24
  2. package/dist/assets/errorwidget-C4r2j2DQ.js +5 -0
  3. package/dist/assets/fa-brands-400-CEJbCg16.woff +0 -0
  4. package/dist/assets/fa-brands-400-CSYNqBb_.ttf +0 -0
  5. package/dist/assets/fa-brands-400-DnkPfk3o.eot +0 -0
  6. package/dist/assets/fa-brands-400-UxlILjvJ.woff2 +0 -0
  7. package/dist/assets/fa-brands-400-cH1MgKbP.svg +3717 -0
  8. package/dist/assets/fa-regular-400-BhTwtT8w.eot +0 -0
  9. package/dist/assets/fa-regular-400-D1vz6WBx.ttf +0 -0
  10. package/dist/assets/fa-regular-400-DFnMcJPd.woff +0 -0
  11. package/dist/assets/fa-regular-400-DGzu1beS.woff2 +0 -0
  12. package/dist/assets/fa-regular-400-gwj8Pxq-.svg +801 -0
  13. package/dist/assets/fa-solid-900-B4ZZ7kfP.svg +5034 -0
  14. package/dist/assets/fa-solid-900-B6Axprfb.eot +0 -0
  15. package/dist/assets/fa-solid-900-BUswJgRo.woff2 +0 -0
  16. package/dist/assets/fa-solid-900-DOXgCApm.woff +0 -0
  17. package/dist/assets/fa-solid-900-mxuxnBEa.ttf +0 -0
  18. package/dist/assets/index-7-YBurka.js +716 -0
  19. package/dist/assets/index-BtWv4MIT.css +7 -0
  20. package/dist/assets/index-CFBUnxSZ.css +32 -0
  21. package/dist/assets/index-CsHoPQy-.js +1 -0
  22. package/dist/assets/index-D5w21_Z8.js +81 -0
  23. package/dist/assets/index-Day3QcNs.js +1 -0
  24. package/dist/assets/services-shim-D6p_A67v.js +33 -0
  25. package/dist/assets/viewlist-uomDf7I7.js +1 -0
  26. package/dist/assets/widgets-X7J3NxEn.css +1 -0
  27. package/dist/index.html +2 -2
  28. package/node-server/dist/cluster/client-registration.js +3 -0
  29. package/node-server/dist/cluster/kernel-proxy.js +24 -9
  30. package/node-server/dist/cluster/server-registry.d.ts +8 -0
  31. package/node-server/dist/cluster/server-registry.js +31 -7
  32. package/node-server/dist/fs/fs-service.d.ts +55 -7
  33. package/node-server/dist/fs/fs-service.js +489 -80
  34. package/node-server/dist/fs/notebook-formats/percent.d.ts +25 -0
  35. package/node-server/dist/fs/notebook-formats/percent.js +286 -0
  36. package/node-server/dist/fs/notebook-formats/qmd.d.ts +29 -0
  37. package/node-server/dist/fs/notebook-formats/qmd.js +307 -0
  38. package/node-server/dist/fs/notebook-formats/registry.d.ts +12 -0
  39. package/node-server/dist/fs/notebook-formats/registry.js +77 -0
  40. package/node-server/dist/fs/notebook-formats/types.d.ts +37 -0
  41. package/node-server/dist/fs/notebook-formats/types.js +13 -0
  42. package/node-server/dist/idle-exit.d.ts +52 -0
  43. package/node-server/dist/idle-exit.js +83 -0
  44. package/node-server/dist/index.js +129 -9
  45. package/node-server/dist/kernel/kernel-service.d.ts +113 -2
  46. package/node-server/dist/kernel/kernel-service.js +762 -60
  47. package/node-server/dist/notebook/cell-hash.d.ts +13 -0
  48. package/node-server/dist/notebook/cell-hash.js +26 -0
  49. package/node-server/dist/notebook/headless-handler.d.ts +9 -0
  50. package/node-server/dist/notebook/headless-handler.js +124 -23
  51. package/node-server/dist/notebook/operation-router.d.ts +36 -0
  52. package/node-server/dist/notebook/operation-router.js +224 -9
  53. package/node-server/dist/notebook/undoRedoManager.d.ts +4 -1
  54. package/node-server/dist/notebook/undoRedoManager.js +10 -2
  55. package/node-server/dist/output/display-data.js +2 -0
  56. package/node-server/dist/routes/cluster.js +2 -2
  57. package/node-server/dist/routes/compute.d.ts +8 -0
  58. package/node-server/dist/routes/compute.js +136 -0
  59. package/node-server/dist/routes/fs.js +2 -2
  60. package/node-server/dist/routes/kernel.js +115 -3
  61. package/node-server/dist/routes/notebook.js +35 -1
  62. package/node-server/dist/scheduler/allocation-service.d.ts +43 -0
  63. package/node-server/dist/scheduler/allocation-service.js +169 -0
  64. package/node-server/dist/scheduler/job-template.d.ts +30 -0
  65. package/node-server/dist/scheduler/job-template.js +85 -0
  66. package/node-server/dist/scheduler/mock-scheduler.d.ts +30 -0
  67. package/node-server/dist/scheduler/mock-scheduler.js +121 -0
  68. package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
  69. package/node-server/dist/scheduler/slurm-scheduler.js +393 -0
  70. package/node-server/dist/scheduler/types.d.ts +117 -0
  71. package/node-server/dist/scheduler/types.js +8 -0
  72. package/node-server/dist/scheduler/util.d.ts +7 -0
  73. package/node-server/dist/scheduler/util.js +20 -0
  74. package/node-server/dist/terminal/pty-manager.js +8 -0
  75. package/node-server/dist/terminal/server.js +43 -2
  76. package/node-server/dist/update-check.d.ts +20 -0
  77. package/node-server/dist/update-check.js +114 -0
  78. package/node-server/package.json +1 -3
  79. package/package.json +3 -5
  80. package/dist/assets/index-C1h_sArD.css +0 -32
  81. package/dist/assets/index-CDSTBon8.js +0 -658
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ /**
3
+ * Registry for plain-text notebook format adapters.
4
+ *
5
+ * .ipynb is intentionally NOT registered here: dispatch sites check .ipynb
6
+ * first and fall through to the existing Jupyter JSON code path untouched.
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.getFormatAdapter = getFormatAdapter;
43
+ exports.defaultKernelForPath = defaultKernelForPath;
44
+ exports.isTextNotebookPath = isTextNotebookPath;
45
+ exports.isNotebookPath = isNotebookPath;
46
+ const path = __importStar(require("path"));
47
+ const percent_1 = require("./percent");
48
+ const qmd_1 = require("./qmd");
49
+ const ADAPTERS = [percent_1.percentAdapter, qmd_1.qmdAdapter];
50
+ const BY_EXTENSION = new Map();
51
+ for (const adapter of ADAPTERS) {
52
+ for (const ext of adapter.extensions)
53
+ BY_EXTENSION.set(ext, adapter);
54
+ }
55
+ function getFormatAdapter(filePath) {
56
+ return BY_EXTENSION.get(path.extname(filePath).toLowerCase()) ?? null;
57
+ }
58
+ /**
59
+ * Default kernel for a text-notebook file that does not declare one in its
60
+ * header, keyed by extension. Falls back to python3 for unknown extensions.
61
+ * (.qmd resolves its kernel from the first code fence's language instead.)
62
+ */
63
+ const DEFAULT_KERNEL_BY_EXTENSION = {
64
+ '.py': 'python3',
65
+ '.r': 'ir',
66
+ '.jl': 'julia',
67
+ };
68
+ function defaultKernelForPath(filePath) {
69
+ return DEFAULT_KERNEL_BY_EXTENSION[path.extname(filePath).toLowerCase()] ?? 'python3';
70
+ }
71
+ function isTextNotebookPath(filePath) {
72
+ return BY_EXTENSION.has(path.extname(filePath).toLowerCase());
73
+ }
74
+ /** True for any path Nebula can treat as a notebook (.ipynb or text format). */
75
+ function isNotebookPath(filePath) {
76
+ return path.extname(filePath).toLowerCase() === '.ipynb' || isTextNotebookPath(filePath);
77
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Plain-text notebook format adapters (jupytext percent .py, Quarto .qmd).
3
+ *
4
+ * These formats are "notebooks that don't serialize outputs": the in-memory
5
+ * model is the same NebulaCell[] the rest of the system uses; only the
6
+ * on-disk representation differs. Outputs, execution counts, and UI state
7
+ * are never written to disk for these formats — that is the point of them.
8
+ *
9
+ * .ipynb is deliberately NOT an adapter: every dispatch site checks .ipynb
10
+ * first and falls into the existing, unmodified Jupyter JSON code path.
11
+ */
12
+ import { NebulaCell } from '../types';
13
+ export interface ParsedTextNotebook {
14
+ /** Cells with outputs: [], executionCount: null. Ids come from in-file
15
+ * markers when present, else the positional `cell-${i}` fallback (the
16
+ * same degraded mode legacy .ipynb files without nebula_id use). */
17
+ cells: NebulaCell[];
18
+ /** Normalized notebook metadata: kernelspec?, nebula?, plus opaque
19
+ * format-specific keys the serializer round-trips. */
20
+ metadata: Record<string, unknown>;
21
+ /** Kernelspec name if the file declares one, else null (caller falls back
22
+ * to the existing env-default resolution). */
23
+ kernelspecName: string | null;
24
+ }
25
+ export interface NotebookFormatAdapter {
26
+ name: 'percent' | 'qmd';
27
+ /** Lowercased extensions including the dot, e.g. ['.py']. */
28
+ extensions: string[];
29
+ capabilities: {
30
+ storesOutputs: false;
31
+ /** Whether cell ids can persist in the file for ALL cell types.
32
+ * percent: true. qmd: code cells only (prose has no metadata slot). */
33
+ storesCellIds: boolean;
34
+ };
35
+ parse(text: string): ParsedTextNotebook;
36
+ serialize(cells: NebulaCell[], metadata: Record<string, unknown>, kernelName?: string): string;
37
+ }
@@ -0,0 +1,13 @@
1
+ "use strict";
2
+ /**
3
+ * Plain-text notebook format adapters (jupytext percent .py, Quarto .qmd).
4
+ *
5
+ * These formats are "notebooks that don't serialize outputs": the in-memory
6
+ * model is the same NebulaCell[] the rest of the system uses; only the
7
+ * on-disk representation differs. Outputs, execution counts, and UI state
8
+ * are never written to disk for these formats — that is the point of them.
9
+ *
10
+ * .ipynb is deliberately NOT an adapter: every dispatch site checks .ipynb
11
+ * first and falls into the existing, unmodified Jupyter JSON code path.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Idle auto-release for scheduler-launched client servers.
3
+ *
4
+ * Opt-in per allocation: when the compute-node client server is started with
5
+ * NEBULA_IDLE_EXIT_MINUTES=<n>, it watches its own kernel and terminal
6
+ * activity and exits its process after n idle minutes. The batch job then
7
+ * completes and the allocation ends naturally — no scancel plumbing, no
8
+ * scheduler-side state.
9
+ *
10
+ * "Idle" means all of:
11
+ * - no kernel session is busy (or still starting up), AND
12
+ * - the most recent kernel activity (execution start/finish, restart) is
13
+ * older than the timeout, AND
14
+ * - the most recent terminal (pty) activity is older than the timeout.
15
+ *
16
+ * The monitor's own start time is the initial activity baseline, so a fresh
17
+ * allocation gets the full timeout before anything has run on it.
18
+ */
19
+ /**
20
+ * Pure idleness decision — should the client server exit now?
21
+ *
22
+ * @param nowMs current time (ms since epoch)
23
+ * @param lastKernelActivityMs most recent kernel activity (ms), or null if no signal
24
+ * @param anyKernelBusy true when any kernel session is 'busy' or 'starting'
25
+ * @param lastPtyActivityMs most recent terminal activity (ms), or null if no signal
26
+ * @param timeoutMinutes idle timeout; <= 0 disables (never exit)
27
+ */
28
+ export declare function shouldIdleExit(nowMs: number, lastKernelActivityMs: number | null, anyKernelBusy: boolean, lastPtyActivityMs: number | null, timeoutMinutes: number): boolean;
29
+ export interface IdleExitMonitorOptions {
30
+ /** Idle minutes before self-exit (from NEBULA_IDLE_EXIT_MINUTES). */
31
+ timeoutMinutes: number;
32
+ /** Kernel-side activity snapshot (kernelService.getIdleSnapshot). */
33
+ getKernelSnapshot: () => {
34
+ anyBusy: boolean;
35
+ lastActivityMs: number | null;
36
+ };
37
+ /** Most recent terminal activity across pty sessions (null = none). */
38
+ getLastPtyActivityMs: () => number | null;
39
+ /** Called once when ~WARN_LEAD_MINUTES remain (re-armed if activity resumes). */
40
+ onWarn: (minutesLeft: number) => void;
41
+ /** Called once when the idle timeout is reached. */
42
+ onIdleExit: () => void | Promise<void>;
43
+ /** Check interval; default 60s. Exposed for tests. */
44
+ intervalMs?: number;
45
+ /** Activity baseline; default Date.now() (start of monitoring). */
46
+ startedAtMs?: number;
47
+ }
48
+ /**
49
+ * Start the periodic idleness check. Returns a stop function.
50
+ * The interval is unref()ed so it never keeps an exiting process alive.
51
+ */
52
+ export declare function startIdleExitMonitor(opts: IdleExitMonitorOptions): () => void;
@@ -0,0 +1,83 @@
1
+ "use strict";
2
+ /**
3
+ * Idle auto-release for scheduler-launched client servers.
4
+ *
5
+ * Opt-in per allocation: when the compute-node client server is started with
6
+ * NEBULA_IDLE_EXIT_MINUTES=<n>, it watches its own kernel and terminal
7
+ * activity and exits its process after n idle minutes. The batch job then
8
+ * completes and the allocation ends naturally — no scancel plumbing, no
9
+ * scheduler-side state.
10
+ *
11
+ * "Idle" means all of:
12
+ * - no kernel session is busy (or still starting up), AND
13
+ * - the most recent kernel activity (execution start/finish, restart) is
14
+ * older than the timeout, AND
15
+ * - the most recent terminal (pty) activity is older than the timeout.
16
+ *
17
+ * The monitor's own start time is the initial activity baseline, so a fresh
18
+ * allocation gets the full timeout before anything has run on it.
19
+ */
20
+ Object.defineProperty(exports, "__esModule", { value: true });
21
+ exports.shouldIdleExit = shouldIdleExit;
22
+ exports.startIdleExitMonitor = startIdleExitMonitor;
23
+ /** How far before the exit we log the "run anything to keep it" warning. */
24
+ const WARN_LEAD_MINUTES = 5;
25
+ /**
26
+ * Pure idleness decision — should the client server exit now?
27
+ *
28
+ * @param nowMs current time (ms since epoch)
29
+ * @param lastKernelActivityMs most recent kernel activity (ms), or null if no signal
30
+ * @param anyKernelBusy true when any kernel session is 'busy' or 'starting'
31
+ * @param lastPtyActivityMs most recent terminal activity (ms), or null if no signal
32
+ * @param timeoutMinutes idle timeout; <= 0 disables (never exit)
33
+ */
34
+ function shouldIdleExit(nowMs, lastKernelActivityMs, anyKernelBusy, lastPtyActivityMs, timeoutMinutes) {
35
+ if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0)
36
+ return false;
37
+ if (anyKernelBusy)
38
+ return false;
39
+ const last = Math.max(lastKernelActivityMs ?? 0, lastPtyActivityMs ?? 0);
40
+ if (last <= 0)
41
+ return false; // no baseline at all — never guess
42
+ return nowMs - last >= timeoutMinutes * 60_000;
43
+ }
44
+ /**
45
+ * Start the periodic idleness check. Returns a stop function.
46
+ * The interval is unref()ed so it never keeps an exiting process alive.
47
+ */
48
+ function startIdleExitMonitor(opts) {
49
+ const intervalMs = opts.intervalMs ?? 60_000;
50
+ const startedAt = opts.startedAtMs ?? Date.now();
51
+ let warned = false;
52
+ let exiting = false;
53
+ const tick = () => {
54
+ if (exiting)
55
+ return;
56
+ const now = Date.now();
57
+ const kernel = opts.getKernelSnapshot();
58
+ // The monitor start counts as activity so a fresh, never-used allocation
59
+ // still gets the full timeout before releasing itself.
60
+ const lastKernel = Math.max(kernel.lastActivityMs ?? 0, startedAt);
61
+ const lastPty = opts.getLastPtyActivityMs();
62
+ if (shouldIdleExit(now, lastKernel, kernel.anyBusy, lastPty, opts.timeoutMinutes)) {
63
+ exiting = true;
64
+ clearInterval(timer);
65
+ void opts.onIdleExit();
66
+ return;
67
+ }
68
+ // Warn once when within WARN_LEAD_MINUTES of the cutoff; re-arm on activity.
69
+ const last = Math.max(lastKernel, lastPty ?? 0);
70
+ const idleMinutes = (now - last) / 60_000;
71
+ const warnAt = opts.timeoutMinutes - WARN_LEAD_MINUTES;
72
+ if (kernel.anyBusy || idleMinutes < warnAt) {
73
+ warned = false;
74
+ }
75
+ else if (!warned && warnAt > 0) {
76
+ warned = true;
77
+ opts.onWarn(Math.max(1, Math.round(opts.timeoutMinutes - idleMinutes)));
78
+ }
79
+ };
80
+ const timer = setInterval(tick, intervalMs);
81
+ timer.unref?.();
82
+ return () => clearInterval(timer);
83
+ }
@@ -61,24 +61,32 @@ const crypto = __importStar(require("crypto"));
61
61
  const crypto_1 = require("crypto");
62
62
  // Import routes
63
63
  const kernel_1 = __importStar(require("./routes/kernel"));
64
- const llm_1 = __importStar(require("./routes/llm"));
65
64
  const fs_1 = __importDefault(require("./routes/fs"));
66
65
  const notebook_1 = __importDefault(require("./routes/notebook"));
67
66
  const python_1 = __importDefault(require("./routes/python"));
68
67
  const auth_1 = __importDefault(require("./routes/auth"));
69
68
  const cluster_1 = __importDefault(require("./routes/cluster"));
70
69
  const resources_1 = __importDefault(require("./routes/resources"));
70
+ const compute_1 = __importDefault(require("./routes/compute"));
71
71
  // Import cluster
72
72
  const server_registry_1 = require("./cluster/server-registry");
73
73
  const cluster_secret_1 = require("./cluster/cluster-secret");
74
74
  const client_registration_1 = require("./cluster/client-registration");
75
+ // Import scheduler (SLURM-backed compute allocations)
76
+ const allocation_service_1 = require("./scheduler/allocation-service");
77
+ const slurm_scheduler_1 = require("./scheduler/slurm-scheduler");
78
+ const mock_scheduler_1 = require("./scheduler/mock-scheduler");
75
79
  // Import auth
76
80
  const auth_2 = require("./auth");
77
81
  const fs_service_1 = require("./fs/fs-service");
82
+ const update_check_1 = require("./update-check");
78
83
  // Import terminal routes (existing)
79
84
  const server_1 = require("./terminal/server");
80
85
  // Import notebook WebSocket
81
86
  const notebook_websocket_1 = require("./notebook/notebook-websocket");
87
+ // Idle auto-release (client mode, opt-in via NEBULA_IDLE_EXIT_MINUTES)
88
+ const idle_exit_1 = require("./idle-exit");
89
+ const pty_manager_1 = require("./terminal/pty-manager");
82
90
  const PORT = process.env.PORT || process.env.NODE_SERVER_PORT || 3000;
83
91
  const DEV_MODE = process.env.DEV_MODE === 'true' || process.argv.includes('--dev');
84
92
  const BODY_LIMIT = process.env.NEBULA_BODY_LIMIT ||
@@ -272,12 +280,13 @@ async function createApp() {
272
280
  fastify.get('/api/health', async (_request, reply) => {
273
281
  // Use configured root directory (from .nebula-config.json or fallback to cwd)
274
282
  const rootDir = fs_service_1.fsService.normalizePath('~');
283
+ const update = (0, update_check_1.getUpdateInfo)();
275
284
  return reply.send({
276
285
  status: 'ok',
277
- version: '1.0.0',
286
+ version: update.current,
278
287
  ready: kernel_1.kernelService.isReady,
279
- llm_providers: Object.keys(llm_1.llmService.getAvailableProviders()),
280
288
  cwd: rootDir,
289
+ update,
281
290
  });
282
291
  });
283
292
  fastify.get('/api/ready', async (_request, reply) => {
@@ -309,11 +318,11 @@ async function createApp() {
309
318
  });
310
319
  // API routes (protected)
311
320
  await fastify.register(kernel_1.default, { prefix: '/api' });
312
- await fastify.register(llm_1.default, { prefix: '/api' });
313
321
  await fastify.register(fs_1.default, { prefix: '/api' });
314
322
  await fastify.register(notebook_1.default, { prefix: '/api' });
315
323
  await fastify.register(python_1.default, { prefix: '/api' });
316
324
  await fastify.register(cluster_1.default, { prefix: '/api' });
325
+ await fastify.register(compute_1.default, { prefix: '/api' });
317
326
  await fastify.register(resources_1.default, { prefix: '/api/resources' });
318
327
  // Terminal routes (registered directly on the app, not under /api prefix)
319
328
  await fastify.register(server_1.setupTerminalRoutes);
@@ -449,8 +458,9 @@ async function main() {
449
458
  else {
450
459
  console.log('[Auth] 2FA configured and ready');
451
460
  }
452
- // Set local server ID from hostname
453
- const localServerId = `${os.hostname()}:${PORT}`;
461
+ // Set local server ID from hostname (NEBULA_HOST overrides the advertised name
462
+ // when the OS hostname isn't the reachable/desired identity — e.g. demos).
463
+ const localServerId = `${process.env.NEBULA_HOST || os.hostname()}:${PORT}`;
454
464
  server_registry_1.serverRegistry.setLocalServerId(localServerId);
455
465
  console.log(`[Cluster] Local server ID: ${localServerId}`);
456
466
  const serverInstanceId = (0, crypto_1.randomUUID)();
@@ -471,6 +481,35 @@ async function main() {
471
481
  (0, server_1.setupTerminalWebSocket)(server);
472
482
  // Setup notebook operations WebSocket
473
483
  (0, notebook_websocket_1.setupNotebookWebSocket)(server);
484
+ // Scheduler-backed compute allocations (main server only; detection-gated)
485
+ let schedulerDetected = false;
486
+ if (!CLIENT_MODE) {
487
+ try {
488
+ // NEBULA_SCHEDULER=mock enables a fabricated-data scheduler (no real cluster)
489
+ // for demos/tests; otherwise use the real SLURM scheduler (detection-gated).
490
+ const scheduler = process.env.NEBULA_SCHEDULER === 'mock' ? new mock_scheduler_1.MockScheduler() : new slurm_scheduler_1.SlurmScheduler();
491
+ if (await scheduler.detect()) {
492
+ schedulerDetected = true;
493
+ const ctx = {
494
+ mainUrl: process.env.NEBULA_SCHEDULER_MAIN_URL || `http://${os.hostname()}:${PORT}`,
495
+ secret: process.env.NEBULA_CLUSTER_SECRET,
496
+ nodeBin: process.execPath,
497
+ execArgv: process.execArgv,
498
+ scriptPath: process.argv[1],
499
+ cwd: process.cwd(),
500
+ stateDir: process.env.NEBULA_SCHEDULER_STATE_DIR || path.join(os.homedir(), '.nebula', 'allocations'),
501
+ };
502
+ allocation_service_1.allocationService.init(scheduler, ctx);
503
+ console.log('[Scheduler] SLURM detected — compute allocations enabled');
504
+ }
505
+ else {
506
+ console.log('[Scheduler] No batch scheduler detected — compute allocations disabled');
507
+ }
508
+ }
509
+ catch (err) {
510
+ console.warn('[Scheduler] Init failed:', err instanceof Error ? err.message : String(err));
511
+ }
512
+ }
474
513
  if (REATTACH_KERNELS) {
475
514
  try {
476
515
  const result = await kernel_1.kernelService.reattachOrphanedSessions();
@@ -483,6 +522,41 @@ async function main() {
483
522
  console.warn(`[Kernel] Reattach failed: ${message}`);
484
523
  }
485
524
  }
525
+ // Final "you're up" banner — the one part of startup written for humans.
526
+ // Everything a first-time user needs: where to point the browser, where the
527
+ // files live, whether cluster compute is on, and how to reach a remote
528
+ // server from a laptop. Client-mode servers skip it (no user is watching).
529
+ if (!CLIENT_MODE) {
530
+ const host = os.hostname();
531
+ let rootDir = '~';
532
+ try {
533
+ rootDir = fs_service_1.fsService.getRootDirectory();
534
+ }
535
+ catch { /* keep default */ }
536
+ const lines = [
537
+ '',
538
+ ' ──────────────────────────────────────────────────────',
539
+ ' Nebula Notebook is running',
540
+ '',
541
+ ` Open: http://localhost:${PORT}`,
542
+ ` Network: http://${host}:${PORT}`,
543
+ ` Files: ${rootDir}`,
544
+ ` Compute: ${schedulerDetected
545
+ ? 'SLURM detected — allocate compute nodes from the kernel menu'
546
+ : 'no batch scheduler here — kernels run on this machine'}`,
547
+ ...(setupNeeded ? [' Sign-in: scan the QR code above with an authenticator app'] : []),
548
+ '',
549
+ ' On a remote machine? From your laptop:',
550
+ ` 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',
552
+ ' ──────────────────────────────────────────────────────',
553
+ '',
554
+ ];
555
+ console.log(lines.join('\n'));
556
+ }
557
+ // Daily notify-only update check (NEBULA_NO_UPDATE_CHECK=1 to disable)
558
+ if (!CLIENT_MODE)
559
+ (0, update_check_1.startUpdateChecker)();
486
560
  // Graceful shutdown
487
561
  let shuttingDown = false;
488
562
  const shutdown = async () => {
@@ -510,10 +584,11 @@ async function main() {
510
584
  catch (err) {
511
585
  console.error('[Server] Error during kernel cleanup:', err);
512
586
  }
513
- // Cleanup cluster registration
587
+ // Cleanup cluster registration + scheduler polling
514
588
  try {
515
589
  await client_registration_1.clientRegistration.shutdown();
516
590
  server_registry_1.serverRegistry.shutdown();
591
+ allocation_service_1.allocationService.shutdown();
517
592
  }
518
593
  catch (err) {
519
594
  console.error('[Server] Error during cluster cleanup:', err);
@@ -544,9 +619,54 @@ async function main() {
544
619
  console.log(`[Server] Notebook WebSocket: ${wsProtocol}://localhost:${PORT}/api/notebook/{path}/ws`);
545
620
  console.log(`[Server] Terminal WebSocket: ${wsProtocol}://localhost:${PORT}/ws?id={terminal_id}`);
546
621
  console.log(`[Server] Root directory: ${fs_service_1.fsService.getRootDirectory()} (change with --workdir)`);
547
- // Initialize client registration (explicit client mode only)
622
+ // Initialize client registration (explicit client mode only). With PORT=0
623
+ // (scheduler-launched jobs pick an ephemeral port), register the *actual* bound
624
+ // port so the main server proxies kernels to the right place.
548
625
  if (CLIENT_MODE) {
549
- client_registration_1.clientRegistration.initFromEnv(Number(PORT));
626
+ const addr = fastify.server.address();
627
+ const boundPort = addr && typeof addr === 'object' && addr ? addr.port : Number(PORT);
628
+ const clientServerId = `${os.hostname()}:${boundPort}`;
629
+ server_registry_1.serverRegistry.setLocalServerId(clientServerId);
630
+ kernel_1.kernelService.setServerIdentity(clientServerId, serverInstanceId);
631
+ client_registration_1.clientRegistration.initFromEnv(boundPort);
632
+ // Opt-in idle auto-release: exit this process after N idle minutes so the
633
+ // batch job completes and the allocation ends naturally (no scancel needed).
634
+ const idleExitMinutes = Math.floor(Number(process.env.NEBULA_IDLE_EXIT_MINUTES) || 0);
635
+ if (idleExitMinutes > 0) {
636
+ console.log(`[IdleExit] Enabled — allocation auto-ends after ${idleExitMinutes} idle minutes`);
637
+ (0, idle_exit_1.startIdleExitMonitor)({
638
+ timeoutMinutes: idleExitMinutes,
639
+ getKernelSnapshot: () => kernel_1.kernelService.getIdleSnapshot(),
640
+ getLastPtyActivityMs: () => {
641
+ const terminals = pty_manager_1.ptyManager.list();
642
+ if (terminals.length === 0)
643
+ return null;
644
+ return Math.max(...terminals.map((t) => t.lastActivity));
645
+ },
646
+ onWarn: (minutesLeft) => {
647
+ console.warn(`[IdleExit] Idle — will end allocation in ${minutesLeft}m; run anything to keep it`);
648
+ },
649
+ onIdleExit: async () => {
650
+ console.log(`[IdleExit] Idle for ${idleExitMinutes} minutes — releasing allocation (exiting)`);
651
+ // Shut kernels down cleanly WITHOUT preservation so nothing tries to
652
+ // resurrect them, then drop the cluster registration and exit 0 —
653
+ // the batch job completes and the allocation ends.
654
+ (0, server_1.cleanupTerminals)();
655
+ try {
656
+ await kernel_1.kernelService.shutdown({ preserveKernels: false });
657
+ }
658
+ catch (err) {
659
+ console.error('[IdleExit] Error during kernel cleanup:', err);
660
+ }
661
+ try {
662
+ await client_registration_1.clientRegistration.shutdown();
663
+ }
664
+ catch { /* best effort — we are exiting anyway */ }
665
+ console.log('[IdleExit] Allocation released — exiting');
666
+ process.exit(0);
667
+ },
668
+ });
669
+ }
550
670
  }
551
671
  }
552
672
  // Run
@@ -7,6 +7,19 @@
7
7
  import { KernelOutput, ExecutionResult, ExecutionQueueInfo, StartKernelOptions, SessionInfo, KernelServiceConfig } from './types';
8
8
  import { SessionStore } from './session-store';
9
9
  import { KernelSpec } from './kernelspec';
10
+ /** Kernel-originated comm event surfaced to onComm listeners. */
11
+ export interface CommEvent {
12
+ msgType: 'comm_open' | 'comm_msg' | 'comm_close';
13
+ commId: string;
14
+ targetName?: string;
15
+ data: Record<string, unknown>;
16
+ /** Binary buffer frames, base64-encoded. Present only when non-empty. */
17
+ buffers?: string[];
18
+ }
19
+ /** Resolved by BoundedAsyncQueue.next() when the queue has been closed. */
20
+ export declare const IOPUB_QUEUE_CLOSED: unique symbol;
21
+ /** Resolved by BoundedAsyncQueue.next(timeoutMs) when the wait timed out. */
22
+ export declare const IOPUB_QUEUE_TIMEOUT: unique symbol;
10
23
  export declare class KernelService {
11
24
  private sessions;
12
25
  private fileToSession;
@@ -28,7 +41,23 @@ export declare class KernelService {
28
41
  private cellOutputBuffers;
29
42
  private cellOutputTracking;
30
43
  private executingCellIds;
44
+ private iopubReaders;
45
+ private iopubParentSubscribers;
46
+ private iopubCatchAllSubscribers;
47
+ private commStates;
48
+ private shellReaders;
49
+ private shellReplyWaiters;
31
50
  constructor(config?: KernelServiceConfig, sessionStore?: SessionStore);
51
+ private deadListeners;
52
+ onSessionDead(cb: (sessionId: string) => void): void;
53
+ private notifySessionDead;
54
+ private commListeners;
55
+ onComm(cb: (sessionId: string, comm: CommEvent) => void): void;
56
+ private notifyComm;
57
+ private livenessTimer;
58
+ private startLivenessSweep;
59
+ /** One liveness pass (exposed for tests; normally driven by the timer). */
60
+ sweepLiveness(): void;
32
61
  setServerIdentity(serverId: string, serverInstanceId?: string): void;
33
62
  /**
34
63
  * Check if a PID is still alive.
@@ -109,6 +138,57 @@ export declare class KernelService {
109
138
  * @param pid Optional kernel PID — used to distinguish "busy" from "dead" on timeout
110
139
  */
111
140
  private waitForReady;
141
+ private startIopubReader;
142
+ private stopIopubReader;
143
+ private closeIopubSubscribers;
144
+ private dispatchIopubMessage;
145
+ /**
146
+ * Subscribe to iopub messages parented by a specific msg_id. Must be called
147
+ * BEFORE sending the request so no reply can slip past the demux. Callers
148
+ * must unsubscribe in a finally block.
149
+ */
150
+ private subscribeIopubParent;
151
+ private unsubscribeIopubParent;
152
+ /** Subscribe to ALL iopub messages for a session (any parent). */
153
+ private subscribeIopubCatchAll;
154
+ private unsubscribeIopubCatchAll;
155
+ private startShellReader;
156
+ private stopShellReader;
157
+ private closeShellReplyWaiters;
158
+ /**
159
+ * Register a one-shot waiter for the shell reply parented by msgId. Must be
160
+ * called BEFORE sending the request so the reply cannot slip past the
161
+ * dispatch. Resolves 'closed' immediately if no reader loop is alive, or
162
+ * later when the reader stops (kernel stop/restart/cleanup).
163
+ */
164
+ private registerShellReplyWaiter;
165
+ private removeShellReplyWaiter;
166
+ /**
167
+ * Await a previously registered shell reply with a timeout. The waiter is
168
+ * always deregistered on the way out (timeout, reply, or error), so a late
169
+ * reply is dropped by the reader instead of leaking a waiter.
170
+ */
171
+ private waitForShellReply;
172
+ private handleKernelCommMessage;
173
+ private rememberOpenComm;
174
+ /**
175
+ * Open comms known for a session (for late-joining clients).
176
+ * Returns comm_id -> { targetName, openData } where openData is the last
177
+ * state-carrying comm_open payload observed for that comm.
178
+ */
179
+ getOpenComms(sessionId: string): Record<string, {
180
+ targetName: string;
181
+ openData: Record<string, unknown>;
182
+ }>;
183
+ /**
184
+ * Send a comm message (comm_open / comm_msg / comm_close) to the kernel on
185
+ * the shell channel. Comm messages produce NO shell reply, so the queued
186
+ * slot releases as soon as the send completes — we never block waiting for
187
+ * a reply that will not come.
188
+ *
189
+ * @param buffers Optional binary buffer frames, base64-encoded.
190
+ */
191
+ sendCommMessage(sessionId: string, msgType: 'comm_open' | 'comm_msg' | 'comm_close', content: Record<string, unknown>, buffers?: string[]): Promise<void>;
112
192
  /**
113
193
  * Monitor a busy reattached kernel on iopub. When its current execution
114
194
  * finishes (status: idle on iopub), verify shell connectivity and update
@@ -128,10 +208,16 @@ export declare class KernelService {
128
208
  * Get or create kernel for a file (one notebook = one kernel).
129
209
  * Returns whether a new session was created.
130
210
  */
211
+ /** In-flight create/attach per normalized file path (single-flight).
212
+ * Without this, two near-simultaneous requests (UI re-render + agent op,
213
+ * double-click) both miss the fileToSession check and spawn TWO kernel
214
+ * processes — the second overwrites the mapping and the first leaks. */
215
+ private inflightKernelCreates;
131
216
  getOrCreateKernel(filePath: string, kernelName?: string): Promise<{
132
217
  sessionId: string;
133
218
  created: boolean;
134
219
  }>;
220
+ private getOrCreateKernelInternal;
135
221
  /**
136
222
  * Get existing kernel session ID for a notebook file (if any).
137
223
  * Returns null if no live session is associated with the file.
@@ -156,7 +242,11 @@ export declare class KernelService {
156
242
  executeCode(sessionId: string, code: string, onOutput: (output: KernelOutput, cellId?: string | null) => Promise<void>, onQueueInfo?: (info: ExecutionQueueInfo) => void, cellId?: string | null): Promise<ExecutionResult>;
157
243
  private enqueueExecution;
158
244
  /**
159
- * Queue shell socket requests to prevent concurrent receive operations
245
+ * Serialize shell socket SENDS. Receives are owned by the unified shell
246
+ * reader, so tasks queued here must be send-only and release the slot as
247
+ * soon as the send completes (register any reply waiter BEFORE enqueueing,
248
+ * await the reply AFTER the slot is released). Holding the slot across a
249
+ * reply wait would let one slow request delay every later send.
160
250
  */
161
251
  private enqueueShellRequest;
162
252
  private reserveExecutionSlot;
@@ -174,7 +264,10 @@ export declare class KernelService {
174
264
  cursor_end: number;
175
265
  }>;
176
266
  /**
177
- * Internal completion implementation (runs within shell queue)
267
+ * Internal completion implementation. The send is serialized through the
268
+ * shell queue; the reply is awaited via the unified shell reader, so a
269
+ * timeout here simply drops the waiter and can never block or swallow a
270
+ * later request's reply.
178
271
  */
179
272
  private completeInternal;
180
273
  /**
@@ -247,6 +340,15 @@ export declare class KernelService {
247
340
  */
248
341
  getSessionStatusFast(sessionId: string): SessionInfo | null;
249
342
  getSessionStatus(sessionId: string): Promise<SessionInfo | null>;
343
+ /**
344
+ * Activity snapshot for the idle auto-release monitor (client mode):
345
+ * whether any kernel is busy/starting, and the most recent kernel activity
346
+ * across sessions in ms since epoch (sessions track it in seconds).
347
+ */
348
+ getIdleSnapshot(): {
349
+ anyBusy: boolean;
350
+ lastActivityMs: number | null;
351
+ };
250
352
  /**
251
353
  * Get all sessions
252
354
  */
@@ -261,6 +363,15 @@ export declare class KernelService {
261
363
  status: string;
262
364
  lastHeartbeat: number;
263
365
  }[];
366
+ /**
367
+ * Auto-delete dead session rows whose kernel process is CONFIRMED gone
368
+ * (no PID, PID not running, or PID reused by another process). These are
369
+ * pure bookkeeping — nothing to kill, nothing to lose — so they need no
370
+ * user confirmation. Rows whose PID is still alive are kept for the
371
+ * explicit "Clean Up" flow (killing a process should stay a user action,
372
+ * and legacy rows without a start-time fingerprint can't be verified).
373
+ */
374
+ autoCleanupDeadSessions(): Promise<number>;
264
375
  /**
265
376
  * Cleanup dead sessions by deleting them from the database
266
377
  */