nebula-notebook 0.1.1 → 0.2.1

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 (142) hide show
  1. package/README.md +110 -16
  2. package/dist/assets/errorwidget-Wy5ajPiM.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-B_Bs5koo.js +1 -0
  19. package/dist/assets/index-BtWv4MIT.css +7 -0
  20. package/dist/assets/index-DK4nS74r.js +829 -0
  21. package/dist/assets/index-DeQN03nW.js +81 -0
  22. package/dist/assets/index-DzjpmUak.css +32 -0
  23. package/dist/assets/index-g6MjT-VG.js +1 -0
  24. package/dist/assets/services-shim-BirHVsih.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/auth/auth-middleware.js +22 -4
  29. package/node-server/dist/auth/auth-service.js +37 -7
  30. package/node-server/dist/cluster/client-registration.d.ts +12 -1
  31. package/node-server/dist/cluster/client-registration.js +38 -2
  32. package/node-server/dist/cluster/kernel-proxy.js +43 -12
  33. package/node-server/dist/cluster/server-registry.d.ts +17 -0
  34. package/node-server/dist/cluster/server-registry.js +57 -7
  35. package/node-server/dist/discovery/conda-locations.d.ts +73 -0
  36. package/node-server/dist/discovery/conda-locations.js +427 -0
  37. package/node-server/dist/discovery/discovery-service.d.ts +73 -4
  38. package/node-server/dist/discovery/discovery-service.js +211 -127
  39. package/node-server/dist/discovery/types.d.ts +0 -1
  40. package/node-server/dist/discovery/types.js +1 -2
  41. package/node-server/dist/environment.d.ts +12 -0
  42. package/node-server/dist/environment.js +104 -0
  43. package/node-server/dist/fs/fs-service.d.ts +86 -8
  44. package/node-server/dist/fs/fs-service.js +640 -99
  45. package/node-server/dist/fs/notebook-formats/percent.d.ts +25 -0
  46. package/node-server/dist/fs/notebook-formats/percent.js +286 -0
  47. package/node-server/dist/fs/notebook-formats/qmd.d.ts +29 -0
  48. package/node-server/dist/fs/notebook-formats/qmd.js +307 -0
  49. package/node-server/dist/fs/notebook-formats/registry.d.ts +12 -0
  50. package/node-server/dist/fs/notebook-formats/registry.js +77 -0
  51. package/node-server/dist/fs/notebook-formats/types.d.ts +37 -0
  52. package/node-server/dist/fs/notebook-formats/types.js +13 -0
  53. package/node-server/dist/fs/types.d.ts +9 -0
  54. package/node-server/dist/fs/types.js +8 -0
  55. package/node-server/dist/idle-exit.d.ts +52 -0
  56. package/node-server/dist/idle-exit.js +83 -0
  57. package/node-server/dist/index.js +198 -8
  58. package/node-server/dist/kernel/kernel-service.d.ts +130 -2
  59. package/node-server/dist/kernel/kernel-service.js +834 -64
  60. package/node-server/dist/kernel/kernelspec.d.ts +27 -0
  61. package/node-server/dist/kernel/kernelspec.js +75 -4
  62. package/node-server/dist/notebook/headless-handler.d.ts +12 -0
  63. package/node-server/dist/notebook/headless-handler.js +125 -29
  64. package/node-server/dist/notebook/operation-router.js +7 -2
  65. package/node-server/dist/notebook/undoRedoManager.d.ts +12 -1
  66. package/node-server/dist/notebook/undoRedoManager.js +49 -18
  67. package/node-server/dist/output/display-data.js +2 -0
  68. package/node-server/dist/private-tmp.d.ts +14 -0
  69. package/node-server/dist/private-tmp.js +70 -0
  70. package/node-server/dist/routes/autocomplete.d.ts +19 -0
  71. package/node-server/dist/routes/autocomplete.js +294 -0
  72. package/node-server/dist/routes/cluster.js +2 -2
  73. package/node-server/dist/routes/compute.d.ts +8 -0
  74. package/node-server/dist/routes/compute.js +136 -0
  75. package/node-server/dist/routes/fs.js +23 -6
  76. package/node-server/dist/routes/kernel.js +156 -3
  77. package/node-server/dist/routes/notebook.js +37 -3
  78. package/node-server/dist/routes/python.d.ts +1 -2
  79. package/node-server/dist/routes/python.js +139 -3
  80. package/node-server/dist/scheduler/allocation-service.d.ts +63 -0
  81. package/node-server/dist/scheduler/allocation-service.js +285 -0
  82. package/node-server/dist/scheduler/job-template.d.ts +30 -0
  83. package/node-server/dist/scheduler/job-template.js +85 -0
  84. package/node-server/dist/scheduler/mock-scheduler.d.ts +30 -0
  85. package/node-server/dist/scheduler/mock-scheduler.js +125 -0
  86. package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
  87. package/node-server/dist/scheduler/slurm-scheduler.js +406 -0
  88. package/node-server/dist/scheduler/types.d.ts +118 -0
  89. package/node-server/dist/scheduler/types.js +8 -0
  90. package/node-server/dist/scheduler/util.d.ts +7 -0
  91. package/node-server/dist/scheduler/util.js +20 -0
  92. package/node-server/dist/terminal/agent-registry.d.ts +87 -0
  93. package/node-server/dist/terminal/agent-registry.js +246 -0
  94. package/node-server/dist/terminal/binding-store.d.ts +57 -0
  95. package/node-server/dist/terminal/binding-store.js +0 -0
  96. package/node-server/dist/terminal/pty-manager.d.ts +23 -6
  97. package/node-server/dist/terminal/pty-manager.js +138 -19
  98. package/node-server/dist/terminal/server.js +155 -23
  99. package/node-server/dist/terminal/types.d.ts +2 -0
  100. package/node-server/dist/update-check.d.ts +20 -0
  101. package/node-server/dist/update-check.js +114 -0
  102. package/node-server/package.json +2 -0
  103. package/node_modules/nebula-autocomplete/README.md +145 -0
  104. package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
  105. package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
  106. package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
  107. package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
  108. package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
  109. package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
  110. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
  111. package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
  112. package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
  113. package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
  114. package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
  115. package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
  116. package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
  117. package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
  118. package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
  119. package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
  120. package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
  121. package/node_modules/nebula-autocomplete/dist/index.js +5 -0
  122. package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
  123. package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
  124. package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
  125. package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
  126. package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
  127. package/node_modules/nebula-autocomplete/dist/types.js +1 -0
  128. package/node_modules/nebula-autocomplete/package.json +58 -0
  129. package/node_modules/nebula-autocomplete/server.d.ts +3 -0
  130. package/node_modules/nebula-autocomplete/server.js +3 -0
  131. package/package.json +18 -5
  132. package/scripts/postinstall.cjs +25 -5
  133. package/dist/assets/index-BvrHu37J.js +0 -648
  134. package/dist/assets/index-Dfj_xsKU.css +0 -32
  135. package/node-server/dist/llm/index.d.ts +0 -5
  136. package/node-server/dist/llm/index.js +0 -21
  137. package/node-server/dist/llm/llm-service.d.ts +0 -77
  138. package/node-server/dist/llm/llm-service.js +0 -454
  139. package/node-server/dist/llm/types.d.ts +0 -40
  140. package/node-server/dist/llm/types.js +0 -15
  141. package/node-server/dist/routes/llm.d.ts +0 -8
  142. package/node-server/dist/routes/llm.js +0 -105
@@ -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 });
@@ -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__';
@@ -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
+ }
@@ -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"));
@@ -67,17 +68,28 @@ const python_1 = __importDefault(require("./routes/python"));
67
68
  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"));
71
+ const compute_1 = __importDefault(require("./routes/compute"));
72
+ const autocomplete_1 = __importDefault(require("./routes/autocomplete"));
70
73
  // Import cluster
71
74
  const server_registry_1 = require("./cluster/server-registry");
72
75
  const cluster_secret_1 = require("./cluster/cluster-secret");
73
76
  const client_registration_1 = require("./cluster/client-registration");
77
+ // Import scheduler (SLURM-backed compute allocations)
78
+ const allocation_service_1 = require("./scheduler/allocation-service");
79
+ const slurm_scheduler_1 = require("./scheduler/slurm-scheduler");
80
+ const mock_scheduler_1 = require("./scheduler/mock-scheduler");
74
81
  // Import auth
75
82
  const auth_2 = require("./auth");
76
83
  const fs_service_1 = require("./fs/fs-service");
84
+ const update_check_1 = require("./update-check");
85
+ const environment_1 = require("./environment");
77
86
  // Import terminal routes (existing)
78
87
  const server_1 = require("./terminal/server");
79
88
  // Import notebook WebSocket
80
89
  const notebook_websocket_1 = require("./notebook/notebook-websocket");
90
+ // Idle auto-release (client mode, opt-in via NEBULA_IDLE_EXIT_MINUTES)
91
+ const idle_exit_1 = require("./idle-exit");
92
+ const pty_manager_1 = require("./terminal/pty-manager");
81
93
  const PORT = process.env.PORT || process.env.NODE_SERVER_PORT || 3000;
82
94
  const DEV_MODE = process.env.DEV_MODE === 'true' || process.argv.includes('--dev');
83
95
  const BODY_LIMIT = process.env.NEBULA_BODY_LIMIT ||
@@ -231,6 +243,22 @@ async function createApp() {
231
243
  });
232
244
  console.log('[Server] HTTP/1.1 mode (no TLS)');
233
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
+ });
234
262
  // Request timing hook
235
263
  fastify.addHook('onResponse', (request, reply, done) => {
236
264
  const duration = reply.elapsedTime;
@@ -239,9 +267,46 @@ async function createApp() {
239
267
  }
240
268
  done();
241
269
  });
242
- // 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);
243
295
  await fastify.register(cors_1.default, {
244
- 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
+ },
245
310
  credentials: true,
246
311
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
247
312
  allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key', 'X-API-Provider'],
@@ -271,11 +336,14 @@ async function createApp() {
271
336
  fastify.get('/api/health', async (_request, reply) => {
272
337
  // Use configured root directory (from .nebula-config.json or fallback to cwd)
273
338
  const rootDir = fs_service_1.fsService.normalizePath('~');
339
+ const update = (0, update_check_1.getUpdateInfo)();
274
340
  return reply.send({
275
341
  status: 'ok',
276
- version: '1.0.0',
342
+ version: update.current,
277
343
  ready: kernel_1.kernelService.isReady,
278
344
  cwd: rootDir,
345
+ update,
346
+ environment: (0, environment_1.getEnvironment)(),
279
347
  });
280
348
  });
281
349
  fastify.get('/api/ready', async (_request, reply) => {
@@ -307,10 +375,12 @@ async function createApp() {
307
375
  });
308
376
  // API routes (protected)
309
377
  await fastify.register(kernel_1.default, { prefix: '/api' });
378
+ await fastify.register(autocomplete_1.default, { prefix: '/api' });
310
379
  await fastify.register(fs_1.default, { prefix: '/api' });
311
380
  await fastify.register(notebook_1.default, { prefix: '/api' });
312
381
  await fastify.register(python_1.default, { prefix: '/api' });
313
382
  await fastify.register(cluster_1.default, { prefix: '/api' });
383
+ await fastify.register(compute_1.default, { prefix: '/api' });
314
384
  await fastify.register(resources_1.default, { prefix: '/api/resources' });
315
385
  // Terminal routes (registered directly on the app, not under /api prefix)
316
386
  await fastify.register(server_1.setupTerminalRoutes);
@@ -446,8 +516,9 @@ async function main() {
446
516
  else {
447
517
  console.log('[Auth] 2FA configured and ready');
448
518
  }
449
- // Set local server ID from hostname
450
- const localServerId = `${os.hostname()}:${PORT}`;
519
+ // Set local server ID from hostname (NEBULA_HOST overrides the advertised name
520
+ // when the OS hostname isn't the reachable/desired identity — e.g. demos).
521
+ const localServerId = `${process.env.NEBULA_HOST || os.hostname()}:${PORT}`;
451
522
  server_registry_1.serverRegistry.setLocalServerId(localServerId);
452
523
  console.log(`[Cluster] Local server ID: ${localServerId}`);
453
524
  const serverInstanceId = (0, crypto_1.randomUUID)();
@@ -468,6 +539,35 @@ async function main() {
468
539
  (0, server_1.setupTerminalWebSocket)(server);
469
540
  // Setup notebook operations WebSocket
470
541
  (0, notebook_websocket_1.setupNotebookWebSocket)(server);
542
+ // Scheduler-backed compute allocations (main server only; detection-gated)
543
+ let schedulerDetected = false;
544
+ if (!CLIENT_MODE) {
545
+ try {
546
+ // NEBULA_SCHEDULER=mock enables a fabricated-data scheduler (no real cluster)
547
+ // for demos/tests; otherwise use the real SLURM scheduler (detection-gated).
548
+ const scheduler = process.env.NEBULA_SCHEDULER === 'mock' ? new mock_scheduler_1.MockScheduler() : new slurm_scheduler_1.SlurmScheduler();
549
+ if (await scheduler.detect()) {
550
+ schedulerDetected = true;
551
+ const ctx = {
552
+ mainUrl: process.env.NEBULA_SCHEDULER_MAIN_URL || `http://${os.hostname()}:${PORT}`,
553
+ secret: process.env.NEBULA_CLUSTER_SECRET,
554
+ nodeBin: process.execPath,
555
+ execArgv: process.execArgv,
556
+ scriptPath: process.argv[1],
557
+ cwd: process.cwd(),
558
+ stateDir: process.env.NEBULA_SCHEDULER_STATE_DIR || path.join(os.homedir(), '.nebula', 'allocations'),
559
+ };
560
+ allocation_service_1.allocationService.init(scheduler, ctx);
561
+ console.log('[Scheduler] SLURM detected — compute allocations enabled');
562
+ }
563
+ else {
564
+ console.log('[Scheduler] No batch scheduler detected — compute allocations disabled');
565
+ }
566
+ }
567
+ catch (err) {
568
+ console.warn('[Scheduler] Init failed:', err instanceof Error ? err.message : String(err));
569
+ }
570
+ }
471
571
  if (REATTACH_KERNELS) {
472
572
  try {
473
573
  const result = await kernel_1.kernelService.reattachOrphanedSessions();
@@ -480,6 +580,41 @@ async function main() {
480
580
  console.warn(`[Kernel] Reattach failed: ${message}`);
481
581
  }
482
582
  }
583
+ // Final "you're up" banner — the one part of startup written for humans.
584
+ // Everything a first-time user needs: where to point the browser, where the
585
+ // files live, whether cluster compute is on, and how to reach a remote
586
+ // server from a laptop. Client-mode servers skip it (no user is watching).
587
+ if (!CLIENT_MODE) {
588
+ const host = os.hostname();
589
+ let rootDir = '~';
590
+ try {
591
+ rootDir = fs_service_1.fsService.getRootDirectory();
592
+ }
593
+ catch { /* keep default */ }
594
+ const lines = [
595
+ '',
596
+ ' ──────────────────────────────────────────────────────',
597
+ ' Nebula Notebook is running',
598
+ '',
599
+ ` Open: http://localhost:${PORT}`,
600
+ ` Network: http://${host}:${PORT}`,
601
+ ` Files: ${rootDir}`,
602
+ ` Compute: ${schedulerDetected
603
+ ? 'SLURM detected — allocate compute nodes from the kernel menu'
604
+ : 'no batch scheduler here — kernels run on this machine'}`,
605
+ ...(setupNeeded ? [' Sign-in: scan the QR code above with an authenticator app'] : []),
606
+ '',
607
+ ' On a remote machine? From your laptop:',
608
+ ` ssh -L ${PORT}:localhost:${PORT} ${host} then open http://localhost:${PORT}`,
609
+ ' Cluster guide: https://github.com/jzhoulab/nebula-notebook/blob/main/docs/CLUSTER_SETUP.md',
610
+ ' ──────────────────────────────────────────────────────',
611
+ '',
612
+ ];
613
+ console.log(lines.join('\n'));
614
+ }
615
+ // Daily notify-only update check (NEBULA_NO_UPDATE_CHECK=1 to disable)
616
+ if (!CLIENT_MODE)
617
+ (0, update_check_1.startUpdateChecker)();
483
618
  // Graceful shutdown
484
619
  let shuttingDown = false;
485
620
  const shutdown = async () => {
@@ -507,10 +642,11 @@ async function main() {
507
642
  catch (err) {
508
643
  console.error('[Server] Error during kernel cleanup:', err);
509
644
  }
510
- // Cleanup cluster registration
645
+ // Cleanup cluster registration + scheduler polling
511
646
  try {
512
647
  await client_registration_1.clientRegistration.shutdown();
513
648
  server_registry_1.serverRegistry.shutdown();
649
+ allocation_service_1.allocationService.shutdown();
514
650
  }
515
651
  catch (err) {
516
652
  console.error('[Server] Error during cluster cleanup:', err);
@@ -541,12 +677,66 @@ async function main() {
541
677
  console.log(`[Server] Notebook WebSocket: ${wsProtocol}://localhost:${PORT}/api/notebook/{path}/ws`);
542
678
  console.log(`[Server] Terminal WebSocket: ${wsProtocol}://localhost:${PORT}/ws?id={terminal_id}`);
543
679
  console.log(`[Server] Root directory: ${fs_service_1.fsService.getRootDirectory()} (change with --workdir)`);
544
- // Initialize client registration (explicit client mode only)
680
+ // Initialize client registration (explicit client mode only). With PORT=0
681
+ // (scheduler-launched jobs pick an ephemeral port), register the *actual* bound
682
+ // port so the main server proxies kernels to the right place.
545
683
  if (CLIENT_MODE) {
546
- client_registration_1.clientRegistration.initFromEnv(Number(PORT));
684
+ const addr = fastify.server.address();
685
+ const boundPort = addr && typeof addr === 'object' && addr ? addr.port : Number(PORT);
686
+ const clientServerId = `${os.hostname()}:${boundPort}`;
687
+ server_registry_1.serverRegistry.setLocalServerId(clientServerId);
688
+ kernel_1.kernelService.setServerIdentity(clientServerId, serverInstanceId);
689
+ void client_registration_1.clientRegistration.initFromEnv(boundPort); // async: resolves the controller-routable IP first
690
+ // Opt-in idle auto-release: exit this process after N idle minutes so the
691
+ // batch job completes and the allocation ends naturally (no scancel needed).
692
+ const idleExitMinutes = Math.floor(Number(process.env.NEBULA_IDLE_EXIT_MINUTES) || 0);
693
+ if (idleExitMinutes > 0) {
694
+ console.log(`[IdleExit] Enabled — allocation auto-ends after ${idleExitMinutes} idle minutes`);
695
+ (0, idle_exit_1.startIdleExitMonitor)({
696
+ timeoutMinutes: idleExitMinutes,
697
+ getKernelSnapshot: () => kernel_1.kernelService.getIdleSnapshot(),
698
+ getLastPtyActivityMs: () => {
699
+ const terminals = pty_manager_1.ptyManager.list();
700
+ if (terminals.length === 0)
701
+ return null;
702
+ return Math.max(...terminals.map((t) => t.lastActivity));
703
+ },
704
+ onWarn: (minutesLeft) => {
705
+ console.warn(`[IdleExit] Idle — will end allocation in ${minutesLeft}m; run anything to keep it`);
706
+ },
707
+ onIdleExit: async () => {
708
+ console.log(`[IdleExit] Idle for ${idleExitMinutes} minutes — releasing allocation (exiting)`);
709
+ // Shut kernels down cleanly WITHOUT preservation so nothing tries to
710
+ // resurrect them, then drop the cluster registration and exit 0 —
711
+ // the batch job completes and the allocation ends.
712
+ (0, server_1.cleanupTerminals)();
713
+ try {
714
+ await kernel_1.kernelService.shutdown({ preserveKernels: false });
715
+ }
716
+ catch (err) {
717
+ console.error('[IdleExit] Error during kernel cleanup:', err);
718
+ }
719
+ try {
720
+ await client_registration_1.clientRegistration.shutdown();
721
+ }
722
+ catch { /* best effort — we are exiting anyway */ }
723
+ console.log('[IdleExit] Allocation released — exiting');
724
+ process.exit(0);
725
+ },
726
+ });
727
+ }
547
728
  }
548
729
  }
549
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
+ });
550
740
  main().catch((err) => {
551
741
  console.error('[Server] Fatal error:', err);
552
742
  process.exit(1);