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
@@ -45,10 +45,13 @@ const path = __importStar(require("path"));
45
45
  const os = __importStar(require("os"));
46
46
  const kernel_service_1 = require("../kernel/kernel-service");
47
47
  const kernelspec_1 = require("../kernel/kernelspec");
48
+ const discovery_service_1 = require("../discovery/discovery-service");
49
+ const types_1 = require("../discovery/types");
48
50
  const fs_service_1 = require("../fs/fs-service");
49
51
  const operation_router_1 = require("../notebook/operation-router");
50
52
  const kernel_proxy_1 = require("../cluster/kernel-proxy");
51
53
  const server_registry_1 = require("../cluster/server-registry");
54
+ const allocation_service_1 = require("../scheduler/allocation-service");
52
55
  // Track all WebSocket connections per kernel session for broadcasting
53
56
  const sessionWebSockets = new Map();
54
57
  // Only send streaming outputs to sockets after they've performed an initial output sync.
@@ -58,12 +61,61 @@ const outputSubscribedSockets = new WeakSet();
58
61
  // Shared kernel service instance - exported for use by headless handler
59
62
  const kernelService = new kernel_service_1.KernelService();
60
63
  exports.kernelService = kernelService;
64
+ // When a kernel dies (crash, OOM, reattached PID gone), push the status to
65
+ // every client immediately — otherwise the UI shows 'busy' until the next
66
+ // poll or failed execute.
67
+ kernelService.onSessionDead((sessionId) => {
68
+ const sockets = sessionWebSockets.get(sessionId);
69
+ if (!sockets || sockets.size === 0)
70
+ return;
71
+ const msg = JSON.stringify({ type: 'status', status: 'dead' });
72
+ for (const socket of sockets) {
73
+ try {
74
+ if (socket.readyState === ws_1.WebSocket.OPEN)
75
+ socket.send(msg);
76
+ }
77
+ catch {
78
+ // Socket already closing — the reconnect loop will handle it
79
+ }
80
+ }
81
+ });
82
+ // Broadcast kernel-originated comm messages (ipywidgets state sync, etc.) to
83
+ // every client attached to the session. This includes idle-time traffic from
84
+ // widget observers — comm messages are demuxed regardless of parent request.
85
+ kernelService.onComm((sessionId, comm) => {
86
+ const sockets = sessionWebSockets.get(sessionId);
87
+ if (!sockets || sockets.size === 0)
88
+ return;
89
+ const msg = JSON.stringify({
90
+ type: 'comm',
91
+ comm: {
92
+ msg_type: comm.msgType,
93
+ comm_id: comm.commId,
94
+ ...(comm.targetName !== undefined && { target_name: comm.targetName }),
95
+ data: comm.data,
96
+ ...(comm.buffers && { buffers: comm.buffers }),
97
+ },
98
+ });
99
+ for (const socket of sockets) {
100
+ try {
101
+ if (socket.readyState === ws_1.WebSocket.OPEN)
102
+ socket.send(msg);
103
+ }
104
+ catch {
105
+ // Socket already closing — the reconnect loop will handle it
106
+ }
107
+ }
108
+ });
61
109
  // Initialize kernel service on module load
62
110
  kernelService.initialize().catch(err => {
63
111
  console.error('Failed to initialize kernel service:', err);
64
112
  });
65
113
  function buildKernelMetadata(kernelName) {
66
- const spec = (0, kernelspec_1.getKernelSpec)(kernelName);
114
+ // env: kernels have no on-disk spec synthesize one, preferring the
115
+ // discovery cache's label ("Python 3.10 (conda: hypir)") over the raw path.
116
+ const envPython = (0, kernelspec_1.envKernelPythonPath)(kernelName);
117
+ const cachedLabel = envPython ? discovery_service_1.pythonDiscovery.getFromCache()?.[envPython]?.displayName : undefined;
118
+ const spec = (0, kernelspec_1.resolveKernelSpec)(kernelName, cachedLabel);
67
119
  return {
68
120
  kernelspec: {
69
121
  name: kernelName,
@@ -72,6 +124,20 @@ function buildKernelMetadata(kernelName) {
72
124
  },
73
125
  };
74
126
  }
127
+ /**
128
+ * Map provisioning failures to structured responses (matching routes/python.ts)
129
+ * so the UI can branch on `code` — e.g. open the ipykernel install prompt —
130
+ * instead of parsing error prose. Returns true if the error was handled.
131
+ */
132
+ function sendProvisionError(reply, err) {
133
+ if (!(err instanceof types_1.KernelProvisionError))
134
+ return false;
135
+ const status = err.code === 'python_not_found' ? 404
136
+ : err.code === 'externally_managed' || err.code === 'needs_ipykernel' ? 422
137
+ : 500;
138
+ reply.code(status).send({ detail: err.message, code: err.code, install_hint: err.installHint });
139
+ return true;
140
+ }
75
141
  async function persistNotebookKernelMetadata(filePath, kernelName) {
76
142
  // Best-effort: persisting the kernel choice into the notebook's metadata is a
77
143
  // secondary side-effect. If it fails (e.g. the notebook file doesn't exist yet,
@@ -190,6 +256,41 @@ function setupKernelWebSocket(wss) {
190
256
  const result = await kernelService.complete(sessionId, code, cursorPos);
191
257
  ws.send(JSON.stringify({ type: 'complete_reply', result }));
192
258
  }
259
+ else if (message.type === 'comm') {
260
+ // Client -> kernel comm message (ipywidgets interaction):
261
+ // { type: 'comm', comm: { msg_type, comm_id, target_name?, data, buffers? } }
262
+ const comm = (message.comm && typeof message.comm === 'object') ? message.comm : {};
263
+ const msgType = comm.msg_type;
264
+ if (msgType !== 'comm_open' && msgType !== 'comm_msg' && msgType !== 'comm_close') {
265
+ ws.send(JSON.stringify({ type: 'error', error: `Invalid comm msg_type: ${String(msgType)}` }));
266
+ return;
267
+ }
268
+ if (typeof comm.comm_id !== 'string' || comm.comm_id.length === 0) {
269
+ ws.send(JSON.stringify({ type: 'error', error: 'comm.comm_id is required' }));
270
+ return;
271
+ }
272
+ const content = {
273
+ comm_id: comm.comm_id,
274
+ data: (comm.data && typeof comm.data === 'object') ? comm.data : {},
275
+ };
276
+ if (msgType === 'comm_open') {
277
+ content.target_name = typeof comm.target_name === 'string' ? comm.target_name : '';
278
+ }
279
+ const buffers = Array.isArray(comm.buffers)
280
+ ? comm.buffers.filter((b) => typeof b === 'string')
281
+ : undefined;
282
+ await kernelService.sendCommMessage(sessionId, msgType, content, buffers);
283
+ }
284
+ else if (message.type === 'comm_info') {
285
+ // Late-joiner support: list open comms known for this session.
286
+ // Reply only to the requesting socket.
287
+ const openComms = kernelService.getOpenComms(sessionId);
288
+ const comms = {};
289
+ for (const [commId, info] of Object.entries(openComms)) {
290
+ comms[commId] = { target_name: info.targetName };
291
+ }
292
+ ws.send(JSON.stringify({ type: 'comm_info_reply', comms }));
293
+ }
193
294
  }
194
295
  catch (err) {
195
296
  const errMessage = err instanceof Error ? err.message : 'Unknown error';
@@ -340,6 +441,10 @@ async function kernelRoutes(fastify) {
340
441
  return reply.code(500).send({ detail: message });
341
442
  }
342
443
  }
444
+ // Confirmed-dead rows (process gone) are auto-removed without user
445
+ // confirmation — only ambiguous leftovers (PID still alive) are reported
446
+ // for the explicit Clean Up flow.
447
+ await kernelService.autoCleanupDeadSessions().catch(() => { });
343
448
  const deadSessions = kernelService.getDeadSessions();
344
449
  const sessions = deadSessions.map(s => ({
345
450
  session_id: s.sessionId,
@@ -413,6 +518,8 @@ async function kernelRoutes(fastify) {
413
518
  return reply.send({ session_id: sessionId, kernel_name, server_id: localServerId, mtime: notebookMtime });
414
519
  }
415
520
  catch (err) {
521
+ if (sendProvisionError(reply, err))
522
+ return reply;
416
523
  const message = err instanceof Error ? err.message : 'Unknown error';
417
524
  return reply.code(500).send({ detail: message });
418
525
  }
@@ -441,7 +548,7 @@ async function kernelRoutes(fastify) {
441
548
  */
442
549
  fastify.post('/kernels/for-file', async (request, reply) => {
443
550
  try {
444
- const { file_path, kernel_name = 'python3', server_id, client_origin } = request.body;
551
+ const { file_path, kernel_name, server_id, client_origin } = request.body;
445
552
  if (!file_path) {
446
553
  return reply.code(400).send({ detail: 'file_path is required' });
447
554
  }
@@ -463,6 +570,25 @@ async function kernelRoutes(fastify) {
463
570
  }
464
571
  }
465
572
  }
573
+ // No explicit kernel and no saved preference: resolve from the
574
+ // notebook's own kernelspec metadata (headless callers — CLI/MCP —
575
+ // don't resolve it client-side the way the UI does). Only then fall
576
+ // back to python3.
577
+ if (!effectiveKernelName) {
578
+ try {
579
+ const metadata = await fs_service_1.fsService.getNotebookMetadataAsync(normalizedFilePath);
580
+ const specName = metadata.kernelspec?.name;
581
+ if (specName) {
582
+ effectiveKernelName = specName;
583
+ }
584
+ }
585
+ catch {
586
+ // fall through to the default
587
+ }
588
+ }
589
+ if (!effectiveKernelName) {
590
+ effectiveKernelName = 'python3';
591
+ }
466
592
  // Check if we should start on a remote server
467
593
  if (effectiveServerId && effectiveServerId !== localServerId && effectiveServerId !== 'local') {
468
594
  // Start on remote server
@@ -509,6 +635,8 @@ async function kernelRoutes(fastify) {
509
635
  });
510
636
  }
511
637
  catch (err) {
638
+ if (sendProvisionError(reply, err))
639
+ return reply;
512
640
  const message = err instanceof Error ? err.message : 'Unknown error';
513
641
  return reply.code(500).send({ detail: message });
514
642
  }
@@ -612,10 +740,35 @@ async function kernelRoutes(fastify) {
612
740
  if ((0, kernel_proxy_1.isProxiedSession)(sessionId)) {
613
741
  try {
614
742
  const remoteStatus = await (0, kernel_proxy_1.getRemoteKernelStatus)(sessionId);
615
- return reply.send(remoteStatus);
743
+ // Return the COMPOSITE session id (serverId::remoteId) — the remote
744
+ // server reports its own local id, and leaking that to clients makes
745
+ // them reference a session the main server doesn't recognize.
746
+ return reply.send({ ...remoteStatus, id: sessionId });
616
747
  }
617
748
  catch (err) {
618
749
  const message = err instanceof Error ? err.message : 'Unknown error';
750
+ // "Server is offline / not found" from the registry is a HEARTBEAT
751
+ // signal, not ground truth: a compute node saturated by a heavy
752
+ // execution can miss heartbeats for minutes while its SLURM job (and
753
+ // kernel) are perfectly alive — then recover. Treating that as
754
+ // terminal permanently killed live kernels (red kernel, reconnect
755
+ // loop stopped, only a page refresh recovered).
756
+ //
757
+ // The scheduler is the authority: return 410 Gone (terminal) only
758
+ // when the session's allocation is confirmed ended/failed/cancelled
759
+ // by squeue polling. Everything else is 503 (transient) so the
760
+ // client's reconnect loop keeps trying and revives the kernel when
761
+ // the node recovers.
762
+ if (/server (is offline|not found)/i.test(message)) {
763
+ const { serverId } = (0, kernel_proxy_1.parseSessionId)(sessionId);
764
+ const allocation = serverId
765
+ ? allocation_service_1.allocationService.list().find(a => a.serverId === serverId)
766
+ : undefined;
767
+ if (allocation && ['ended', 'failed', 'cancelled'].includes(allocation.state)) {
768
+ return reply.code(410).send({ detail: `Allocation ${allocation.state} — kernel stopped`, reason: 'server_gone' });
769
+ }
770
+ return reply.code(503).send({ detail: `${message} (may be transient — compute node busy or re-registering)`, reason: 'server_unreachable' });
771
+ }
619
772
  return reply.code(500).send({ detail: message });
620
773
  }
621
774
  }
@@ -87,6 +87,12 @@ async function notebookRoutes(fastify) {
87
87
  return reply.code(400).send({ detail: 'cells is required' });
88
88
  }
89
89
  const result = await fs_service_1.fsService.saveNotebookBundle(filePath, cells, kernel_name, history);
90
+ // Outputs-unchanged sentinel couldn't be resolved against the file on
91
+ // disk (first save, changed ids, external rewrite…) — tell the client
92
+ // to resend the full payload.
93
+ if (!result.success && result.needsFull) {
94
+ return reply.code(409).send({ detail: 'unchanged-outputs sentinel unresolved; resend full payload', code: 'needs_full' });
95
+ }
90
96
  // The UI just wrote newer content than whatever the headless handler may
91
97
  // have cached. Drop the cache so headless ops (an agent working after the
92
98
  // user closes the tab) reload from disk instead of serving — or worse,
@@ -109,7 +115,7 @@ async function notebookRoutes(fastify) {
109
115
  if (!notebookPath) {
110
116
  return reply.code(400).send({ detail: 'notebook_path query parameter is required' });
111
117
  }
112
- const history = fs_service_1.fsService.loadHistory(notebookPath);
118
+ const history = await fs_service_1.fsService.loadHistory(notebookPath);
113
119
  return reply.send({ notebook_path: notebookPath, history });
114
120
  }
115
121
  catch (err) {
@@ -184,7 +190,8 @@ async function notebookRoutes(fastify) {
184
190
  return reply.send({
185
191
  status: 'ok',
186
192
  notebook_path,
187
- ...(result.status || fs_service_1.fsService.getAgentPermissionStatus(notebook_path)),
193
+ mtime: result.mtime,
194
+ ...(result.status || await fs_service_1.fsService.getAgentPermissionStatusAsync(notebook_path)),
188
195
  });
189
196
  }
190
197
  catch (err) {
@@ -201,7 +208,7 @@ async function notebookRoutes(fastify) {
201
208
  if (!filePath) {
202
209
  return reply.code(400).send({ detail: 'path query parameter is required' });
203
210
  }
204
- const status = fs_service_1.fsService.getAgentPermissionStatus(filePath);
211
+ const status = await fs_service_1.fsService.getAgentPermissionStatusAsync(filePath);
205
212
  return reply.send({
206
213
  notebook_path: filePath,
207
214
  ...status,
@@ -338,4 +345,31 @@ async function notebookRoutes(fastify) {
338
345
  return reply.code(500).send({ detail: message });
339
346
  }
340
347
  });
348
+ /**
349
+ * Persist live ipywidgets state into notebook metadata.widgets
350
+ * (application/vnd.jupyter.widget-state+json), making saved notebooks
351
+ * render static widgets in Jupyter/nbviewer.
352
+ */
353
+ fastify.post('/notebook/widget-state', async (request, reply) => {
354
+ try {
355
+ const { path: filePath, widget_state } = request.body;
356
+ if (!filePath) {
357
+ return reply.code(400).send({ detail: 'path is required' });
358
+ }
359
+ if (!widget_state || typeof widget_state !== 'object') {
360
+ return reply.code(400).send({ detail: 'widget_state must be an object' });
361
+ }
362
+ const updateResult = await fs_service_1.fsService.updateNotebookMetadata(filePath, {
363
+ widgets: { 'application/vnd.jupyter.widget-state+json': widget_state },
364
+ });
365
+ if (!updateResult.success) {
366
+ return reply.code(500).send({ detail: updateResult.error || 'Failed to update notebook metadata' });
367
+ }
368
+ return reply.send({ status: 'ok', notebook_path: filePath, mtime: updateResult.mtime });
369
+ }
370
+ catch (err) {
371
+ const message = err instanceof Error ? err.message : 'Unknown error';
372
+ return reply.code(500).send({ detail: message });
373
+ }
374
+ });
341
375
  }
@@ -2,7 +2,6 @@
2
2
  * Python Discovery API Routes
3
3
  */
4
4
  import { FastifyInstance } from 'fastify';
5
- import { PythonDiscoveryService } from '../discovery/discovery-service';
6
- declare const discoveryService: PythonDiscoveryService;
5
+ declare const discoveryService: import("../discovery/discovery-service").PythonDiscoveryService;
7
6
  export default function pythonRoutes(fastify: FastifyInstance): Promise<void>;
8
7
  export { discoveryService };
@@ -11,7 +11,10 @@ const kernelspec_1 = require("../kernel/kernelspec");
11
11
  const default_kernel_1 = require("../kernel/default-kernel");
12
12
  const server_registry_1 = require("../cluster/server-registry");
13
13
  const kernel_1 = require("./kernel");
14
- const discoveryService = new discovery_service_1.PythonDiscoveryService();
14
+ // The shared singleton — kernel-service preflight/labels and per-env
15
+ // kernelspec scanning read its in-memory cache, so routes must write through
16
+ // the SAME instance (a second instance would leave the singleton stale).
17
+ const discoveryService = discovery_service_1.pythonDiscovery;
15
18
  exports.discoveryService = discoveryService;
16
19
  async function pythonRoutes(fastify) {
17
20
  /**
@@ -41,6 +44,9 @@ async function pythonRoutes(fastify) {
41
44
  const data = await response.json();
42
45
  return reply.send(data);
43
46
  }
47
+ // Discover Python environments FIRST: kernelspec discovery also scans
48
+ // each known env's share/jupyter/kernels, so it must see the fresh list.
49
+ const environments = await discoveryService.discover({ forceRefresh: refresh });
44
50
  // Get Jupyter kernelspecs and transform to snake_case
45
51
  const kernelspecs = (0, kernelspec_1.discoverKernelSpecs)(refresh).map(k => ({
46
52
  name: k.name,
@@ -50,8 +56,6 @@ async function pythonRoutes(fastify) {
50
56
  python_path: k.argv?.[0] || null, // First element of argv is typically the Python executable
51
57
  }));
52
58
  const kernelspecNames = new Set(kernelspecs.map(k => k.name));
53
- // Get discovered Python environments
54
- const environments = await discoveryService.discover({ forceRefresh: refresh });
55
59
  // Convert to snake_case and match with kernelspecs
56
60
  const envObjects = environments.map(env => ({
57
61
  path: env.path,
@@ -75,6 +79,138 @@ async function pythonRoutes(fastify) {
75
79
  return reply.code(500).send({ detail: message });
76
80
  }
77
81
  });
82
+ /**
83
+ * Install ipykernel into an environment (no kernelspec registration — env
84
+ * kernels raw-launch). One installer chosen up front (conda → uv → pip).
85
+ *
86
+ * Responds with an NDJSON stream so the UI can show installer output LIVE:
87
+ * {"type":"output","data":"…"} — stdout/stderr chunks as they arrive
88
+ * {"type":"done","installer":…} — success terminator
89
+ * {"type":"error","detail":…,"code":…,"install_hint":…} — failure terminator
90
+ * Pre-stream validation failures are plain JSON error responses.
91
+ */
92
+ fastify.post('/python/install-ipykernel', async (request, reply) => {
93
+ const { python_path, server_id } = request.body;
94
+ if (!python_path) {
95
+ return reply.code(400).send({ detail: 'python_path is required' });
96
+ }
97
+ const localServerId = server_registry_1.serverRegistry.getLocalServerId();
98
+ if (server_id && server_id !== localServerId && server_id !== 'local') {
99
+ const server = server_registry_1.serverRegistry.getServer(server_id);
100
+ if (!server) {
101
+ return reply.code(404).send({ detail: `Server not found: ${server_id}` });
102
+ }
103
+ if (server.status !== 'online') {
104
+ return reply.code(503).send({ detail: `Server is offline: ${server_id}` });
105
+ }
106
+ const response = await fetch(`${server.url}/api/python/install-ipykernel`, {
107
+ method: 'POST',
108
+ headers: {
109
+ 'Content-Type': 'application/json',
110
+ ...(process.env.NEBULA_CLUSTER_SECRET ? { 'X-Nebula-Cluster-Secret': process.env.NEBULA_CLUSTER_SECRET } : {}),
111
+ },
112
+ body: JSON.stringify({ python_path }),
113
+ });
114
+ if (!response.ok || !response.body) {
115
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
116
+ return reply.code(response.status).send({
117
+ detail: error.detail || 'Failed to install ipykernel',
118
+ code: error.code,
119
+ install_hint: error.install_hint,
120
+ });
121
+ }
122
+ // Pipe the remote NDJSON stream through untouched.
123
+ reply.hijack();
124
+ reply.raw.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' });
125
+ try {
126
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
127
+ for await (const chunk of response.body) {
128
+ reply.raw.write(chunk);
129
+ }
130
+ }
131
+ catch { /* upstream died — terminate what we have */ }
132
+ reply.raw.end();
133
+ return reply;
134
+ }
135
+ // Local install: stream output as it happens, then a terminator event.
136
+ reply.hijack();
137
+ reply.raw.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' });
138
+ const send = (obj) => reply.raw.write(`${JSON.stringify(obj)}\n`);
139
+ try {
140
+ const result = await discoveryService.installIpykernel(python_path, (chunk) => send({ type: 'output', data: chunk }));
141
+ send({ type: 'done', installer: result.installer, message: result.message, python_path });
142
+ }
143
+ catch (err) {
144
+ if (err instanceof types_1.KernelProvisionError) {
145
+ send({ type: 'error', detail: err.message, code: err.code, install_hint: err.installHint });
146
+ }
147
+ else {
148
+ send({ type: 'error', detail: err instanceof Error ? err.message : 'Unknown error' });
149
+ }
150
+ }
151
+ reply.raw.end();
152
+ return reply;
153
+ });
154
+ /**
155
+ * Probe a manually-entered interpreter path ("Enter interpreter path…"):
156
+ * validate it runs, classify + enrich it, persist it into the discovery
157
+ * cache, and return it in the same snake_case shape as /python/environments.
158
+ */
159
+ fastify.post('/python/probe', async (request, reply) => {
160
+ try {
161
+ const { python_path, server_id } = request.body;
162
+ if (!python_path) {
163
+ return reply.code(400).send({ detail: 'python_path is required' });
164
+ }
165
+ const localServerId = server_registry_1.serverRegistry.getLocalServerId();
166
+ if (server_id && server_id !== localServerId && server_id !== 'local') {
167
+ const server = server_registry_1.serverRegistry.getServer(server_id);
168
+ if (!server) {
169
+ return reply.code(404).send({ detail: `Server not found: ${server_id}` });
170
+ }
171
+ if (server.status !== 'online') {
172
+ return reply.code(503).send({ detail: `Server is offline: ${server_id}` });
173
+ }
174
+ const response = await fetch(`${server.url}/api/python/probe`, {
175
+ method: 'POST',
176
+ headers: {
177
+ 'Content-Type': 'application/json',
178
+ ...(process.env.NEBULA_CLUSTER_SECRET ? { 'X-Nebula-Cluster-Secret': process.env.NEBULA_CLUSTER_SECRET } : {}),
179
+ },
180
+ body: JSON.stringify({ python_path }),
181
+ });
182
+ if (!response.ok) {
183
+ const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
184
+ return reply.code(response.status).send({
185
+ detail: error.detail || 'Failed to probe interpreter',
186
+ code: error.code,
187
+ install_hint: error.install_hint,
188
+ });
189
+ }
190
+ return reply.send(await response.json());
191
+ }
192
+ const env = await discoveryService.probeAndRemember(python_path);
193
+ return reply.send({
194
+ path: env.path,
195
+ version: env.version,
196
+ display_name: env.displayName,
197
+ env_type: env.envType,
198
+ env_name: env.envName,
199
+ has_ipykernel: env.hasIpykernel,
200
+ externally_managed: env.externallyManaged,
201
+ install_hint: env.installHint,
202
+ kernel_name: env.kernelName,
203
+ });
204
+ }
205
+ catch (err) {
206
+ if (err instanceof types_1.KernelProvisionError) {
207
+ const status = err.code === 'python_not_found' ? 404 : 500;
208
+ return reply.code(status).send({ detail: err.message, code: err.code, install_hint: err.installHint });
209
+ }
210
+ const message = err instanceof Error ? err.message : 'Unknown error';
211
+ return reply.code(500).send({ detail: message });
212
+ }
213
+ });
78
214
  /**
79
215
  * Install ipykernel and register a Python environment as a Jupyter kernel
80
216
  */
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Allocation service — owns the lifecycle a compute allocation has *before* and
3
+ * *around* the cluster registry: submit a job, follow it through the queue, and
4
+ * correlate the client-server's registration (by one-time token) back to the
5
+ * allocation. Once correlated, the allocation is a normal registered server and
6
+ * kernels run on it through the existing cluster path.
7
+ *
8
+ * Phase-1 MVP: in-memory allocations, direct transport (no SSH tunnel — not
9
+ * needed where compute↔login is directly reachable).
10
+ */
11
+ import type { Scheduler, JobSpec } from './types';
12
+ import { type LaunchContext } from './job-template';
13
+ export type AllocationState = 'pending' | 'running' | 'active' | 'ended' | 'failed' | 'cancelled';
14
+ export interface Allocation {
15
+ id: string;
16
+ jobId?: string;
17
+ token: string;
18
+ spec: JobSpec;
19
+ state: AllocationState;
20
+ serverId?: string;
21
+ nodes?: string[];
22
+ reason?: string;
23
+ createdAt: number;
24
+ walltimeEndsAt?: number;
25
+ }
26
+ declare class AllocationService {
27
+ private scheduler;
28
+ private ctx;
29
+ private allocations;
30
+ private pollTimer;
31
+ private enabled;
32
+ private lastPollAt;
33
+ private lostListenerRegistered;
34
+ init(scheduler: Scheduler, ctx: LaunchContext): void;
35
+ /** Poll immediately (debounced to 2s so error bursts don't hammer squeue). */
36
+ pollNow(): void;
37
+ private stateFile;
38
+ /**
39
+ * Allocations survive head-server restarts: persisted on every change,
40
+ * reloaded on init. A reloaded 'active' allocation is demoted to 'running'
41
+ * with its serverId cleared — the registry is empty after a restart, and
42
+ * the compute node's client-server re-registers itself (heartbeat -> 404
43
+ * -> re-register with its allocation token) within ~30s, at which point
44
+ * poll() re-correlates and promotes it back to 'active'. The SLURM job
45
+ * itself is re-checked by jobId on the next poll, so jobs that died while
46
+ * we were down are marked ended/failed instead of lingering.
47
+ */
48
+ private persist;
49
+ private loadPersisted;
50
+ /** Pick the poll cadence from what we're actually waiting for. */
51
+ private nextPollDelay;
52
+ private scheduleNextPoll;
53
+ isEnabled(): boolean;
54
+ getScheduler(): Scheduler | null;
55
+ list(): Allocation[];
56
+ get(id: string): Allocation | undefined;
57
+ create(spec: JobSpec): Promise<Allocation>;
58
+ cancel(id: string): Promise<boolean>;
59
+ private poll;
60
+ shutdown(): void;
61
+ }
62
+ export declare const allocationService: AllocationService;
63
+ export {};