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
@@ -182,7 +182,10 @@ export declare class HeadlessUndoRedoManager {
182
182
  /**
183
183
  * Clear state for a notebook (e.g., when closing).
184
184
  */
185
- clearState(notebookPath: string): void;
185
+ /** Drop in-memory state so the next access rebuilds from the persisted
186
+ * history file (the file/history may have changed underneath us: UI save,
187
+ * external-edit reconciliation). Omit the path to clear all. */
188
+ clearState(notebookPath?: string): void;
186
189
  /**
187
190
  * Get updates since a timestamp (for agent awareness).
188
191
  * Returns human-readable summaries of operations made between agent sessions.
@@ -142,7 +142,7 @@ class HeadlessUndoRedoManager {
142
142
  // Try to load existing history
143
143
  let loadedHistory = [];
144
144
  try {
145
- loadedHistory = this.fsService.loadHistory(notebookPath);
145
+ loadedHistory = this.fsService.loadHistorySync(notebookPath);
146
146
  }
147
147
  catch (err) {
148
148
  console.warn(`[UndoRedoManager] Failed to load history for ${notebookPath}:`, err);
@@ -357,8 +357,16 @@ class HeadlessUndoRedoManager {
357
357
  /**
358
358
  * Clear state for a notebook (e.g., when closing).
359
359
  */
360
+ /** Drop in-memory state so the next access rebuilds from the persisted
361
+ * history file (the file/history may have changed underneath us: UI save,
362
+ * external-edit reconciliation). Omit the path to clear all. */
360
363
  clearState(notebookPath) {
361
- this.states.delete(notebookPath);
364
+ if (notebookPath) {
365
+ this.states.delete(notebookPath);
366
+ }
367
+ else {
368
+ this.states.clear();
369
+ }
362
370
  }
363
371
  /**
364
372
  * Get updates since a timestamp (for agent awareness).
@@ -8,6 +8,8 @@ exports.buildDisplayOutput = buildDisplayOutput;
8
8
  exports.convertMimeBundleToJupyter = convertMimeBundleToJupyter;
9
9
  const PREFERRED_MIME_TYPES = [
10
10
  'application/vnd.nebula.web+json',
11
+ // Widget views must win over plotly/html when both are present
12
+ 'application/vnd.jupyter.widget-view+json',
11
13
  'application/vnd.plotly.v1+json',
12
14
  'text/html',
13
15
  'image/png',
@@ -27,11 +27,11 @@ async function clusterRoutes(fastify) {
27
27
  * Body can include { resources } for initial resource info
28
28
  */
29
29
  fastify.post('/servers/register', async (request, reply) => {
30
- const { host, port, name, secret, resources } = request.body;
30
+ const { host, port, name, secret, resources, allocationToken } = request.body;
31
31
  if (!host || !port) {
32
32
  return reply.code(400).send({ error: 'host and port are required' });
33
33
  }
34
- const result = server_registry_1.serverRegistry.register({ host, port, name, secret, resources });
34
+ const result = server_registry_1.serverRegistry.register({ host, port, name, secret, resources, allocationToken });
35
35
  if (!result.success) {
36
36
  return reply.code(403).send({ error: result.error });
37
37
  }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Compute allocation API — submit and manage scheduler-backed compute.
3
+ *
4
+ * All routes live under /api/compute and are auth-protected like the rest of the
5
+ * API. They are no-ops (return `enabled: false`) when no scheduler is detected.
6
+ */
7
+ import { FastifyInstance } from 'fastify';
8
+ export default function computeRoutes(fastify: FastifyInstance): Promise<void>;
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ /**
3
+ * Compute allocation API — submit and manage scheduler-backed compute.
4
+ *
5
+ * All routes live under /api/compute and are auth-protected like the rest of the
6
+ * API. They are no-ops (return `enabled: false`) when no scheduler is detected.
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.default = computeRoutes;
43
+ const os = __importStar(require("os"));
44
+ const allocation_service_1 = require("../scheduler/allocation-service");
45
+ function currentUser() {
46
+ return process.env.USER || process.env.LOGNAME || os.userInfo().username;
47
+ }
48
+ /** Build a JobSpec from request input, applying MVP defaults. */
49
+ function parseSpec(x) {
50
+ const partition = String(x?.partition || '').trim();
51
+ const qos = x?.qos ? String(x.qos).trim() : undefined;
52
+ const account = x?.account ? String(x.account).trim() : undefined;
53
+ const cpus = Math.max(1, Math.floor(Number(x?.cpus) || 1));
54
+ const memGb = Math.max(1, Math.floor(Number(x?.memGb) || 4));
55
+ const gpusRaw = Math.floor(Number(x?.gpus) || 0);
56
+ const gpus = gpusRaw > 0 ? gpusRaw : undefined;
57
+ const gpuType = gpus && x?.gpuType ? String(x.gpuType).trim() || undefined : undefined;
58
+ const walltimeMinutes = Math.max(1, Math.floor(Number(x?.walltimeMinutes) || 120));
59
+ // Opt-in idle auto-release (minutes). Absent/invalid/<=0 = disabled.
60
+ const idleRaw = Math.floor(Number(x?.idleTimeoutMinutes) || 0);
61
+ const idleTimeoutMinutes = idleRaw > 0 ? idleRaw : undefined;
62
+ const jobName = (x?.jobName ? String(x.jobName) : `nebula-${partition || 'compute'}`)
63
+ .replace(/[^A-Za-z0-9._-]/g, '-')
64
+ .slice(0, 60) || 'nebula';
65
+ return { partition, qos, account, cpus, memGb, gpus, gpuType, walltimeMinutes, jobName, idleTimeoutMinutes };
66
+ }
67
+ async function computeRoutes(fastify) {
68
+ /** Whether scheduler-backed compute is available on this server. */
69
+ fastify.get('/compute/status', async (_req, reply) => {
70
+ return reply.send({
71
+ enabled: allocation_service_1.allocationService.isEnabled(),
72
+ scheduler: allocation_service_1.allocationService.getScheduler()?.name ?? null,
73
+ });
74
+ });
75
+ /** Allowed partitions/QoS for the current user + a live load snapshot. */
76
+ fastify.get('/compute/partitions', async (_req, reply) => {
77
+ const sched = allocation_service_1.allocationService.getScheduler();
78
+ if (!sched)
79
+ return reply.send({ enabled: false, associations: null, load: null });
80
+ const [associations, load] = await Promise.all([
81
+ sched.associations(currentUser()).catch(() => null),
82
+ sched.load().catch(() => null),
83
+ ]);
84
+ return reply.send({ enabled: true, associations, load });
85
+ });
86
+ /** QoS names a partition will accept (null = any). Drives the modal's QoS filter. */
87
+ fastify.get('/compute/partition-qos', async (req, reply) => {
88
+ const sched = allocation_service_1.allocationService.getScheduler();
89
+ if (!sched)
90
+ return reply.send({ allowed: null });
91
+ const partition = String(req.query?.partition || '').trim();
92
+ if (!partition)
93
+ return reply.send({ allowed: null });
94
+ const allowed = await sched.allowedQos(partition).catch(() => null);
95
+ return reply.send({ allowed });
96
+ });
97
+ /** Dry-run estimated start time for a prospective allocation. */
98
+ fastify.get('/compute/estimate', async (req, reply) => {
99
+ const sched = allocation_service_1.allocationService.getScheduler();
100
+ if (!sched)
101
+ return reply.code(400).send({ error: 'scheduler not available' });
102
+ const spec = parseSpec(req.query);
103
+ if (!spec.partition)
104
+ return reply.code(400).send({ error: 'partition is required' });
105
+ const estimate = await sched.estimateStart(spec);
106
+ return reply.send(estimate);
107
+ });
108
+ /** List current allocations (pending / running / active / ended). */
109
+ fastify.get('/compute/allocations', async (_req, reply) => {
110
+ return reply.send({ allocations: allocation_service_1.allocationService.list() });
111
+ });
112
+ /** Submit a new compute allocation. */
113
+ fastify.post('/compute/allocations', async (req, reply) => {
114
+ if (!allocation_service_1.allocationService.isEnabled()) {
115
+ return reply.code(400).send({ error: 'scheduler not available' });
116
+ }
117
+ const spec = parseSpec(req.body);
118
+ if (!spec.partition)
119
+ return reply.code(400).send({ error: 'partition is required' });
120
+ try {
121
+ const alloc = await allocation_service_1.allocationService.create(spec);
122
+ return reply.send(alloc);
123
+ }
124
+ catch (e) {
125
+ return reply.code(500).send({ error: e?.message || 'submit failed' });
126
+ }
127
+ });
128
+ /** Cancel an allocation (scancel + evict its server). */
129
+ fastify.delete('/compute/allocations/:id', async (req, reply) => {
130
+ const { id } = req.params;
131
+ const ok = await allocation_service_1.allocationService.cancel(id);
132
+ if (!ok)
133
+ return reply.code(404).send({ error: 'allocation not found' });
134
+ return reply.send({ cancelled: true });
135
+ });
136
+ }
@@ -121,7 +121,7 @@ async function fsRoutes(fastify) {
121
121
  if (!filePath) {
122
122
  return reply.code(400).send({ detail: 'path query parameter is required' });
123
123
  }
124
- const result = fs_service_1.fsService.getFileMtime(filePath);
124
+ const result = await fs_service_1.fsService.getFileMtime(filePath);
125
125
  return reply.send(result);
126
126
  }
127
127
  catch (err) {
@@ -143,7 +143,7 @@ async function fsRoutes(fastify) {
143
143
  if (!filePath) {
144
144
  return reply.code(400).send({ detail: 'path query parameter is required' });
145
145
  }
146
- const result = fs_service_1.fsService.readFile(filePath);
146
+ const result = await fs_service_1.fsService.readFile(filePath);
147
147
  return reply.send(result);
148
148
  }
149
149
  catch (err) {
@@ -49,6 +49,7 @@ const fs_service_1 = require("../fs/fs-service");
49
49
  const operation_router_1 = require("../notebook/operation-router");
50
50
  const kernel_proxy_1 = require("../cluster/kernel-proxy");
51
51
  const server_registry_1 = require("../cluster/server-registry");
52
+ const allocation_service_1 = require("../scheduler/allocation-service");
52
53
  // Track all WebSocket connections per kernel session for broadcasting
53
54
  const sessionWebSockets = new Map();
54
55
  // Only send streaming outputs to sockets after they've performed an initial output sync.
@@ -58,6 +59,51 @@ const outputSubscribedSockets = new WeakSet();
58
59
  // Shared kernel service instance - exported for use by headless handler
59
60
  const kernelService = new kernel_service_1.KernelService();
60
61
  exports.kernelService = kernelService;
62
+ // When a kernel dies (crash, OOM, reattached PID gone), push the status to
63
+ // every client immediately — otherwise the UI shows 'busy' until the next
64
+ // poll or failed execute.
65
+ kernelService.onSessionDead((sessionId) => {
66
+ const sockets = sessionWebSockets.get(sessionId);
67
+ if (!sockets || sockets.size === 0)
68
+ return;
69
+ const msg = JSON.stringify({ type: 'status', status: 'dead' });
70
+ for (const socket of sockets) {
71
+ try {
72
+ if (socket.readyState === ws_1.WebSocket.OPEN)
73
+ socket.send(msg);
74
+ }
75
+ catch {
76
+ // Socket already closing — the reconnect loop will handle it
77
+ }
78
+ }
79
+ });
80
+ // Broadcast kernel-originated comm messages (ipywidgets state sync, etc.) to
81
+ // every client attached to the session. This includes idle-time traffic from
82
+ // widget observers — comm messages are demuxed regardless of parent request.
83
+ kernelService.onComm((sessionId, comm) => {
84
+ const sockets = sessionWebSockets.get(sessionId);
85
+ if (!sockets || sockets.size === 0)
86
+ return;
87
+ const msg = JSON.stringify({
88
+ type: 'comm',
89
+ comm: {
90
+ msg_type: comm.msgType,
91
+ comm_id: comm.commId,
92
+ ...(comm.targetName !== undefined && { target_name: comm.targetName }),
93
+ data: comm.data,
94
+ ...(comm.buffers && { buffers: comm.buffers }),
95
+ },
96
+ });
97
+ for (const socket of sockets) {
98
+ try {
99
+ if (socket.readyState === ws_1.WebSocket.OPEN)
100
+ socket.send(msg);
101
+ }
102
+ catch {
103
+ // Socket already closing — the reconnect loop will handle it
104
+ }
105
+ }
106
+ });
61
107
  // Initialize kernel service on module load
62
108
  kernelService.initialize().catch(err => {
63
109
  console.error('Failed to initialize kernel service:', err);
@@ -190,6 +236,41 @@ function setupKernelWebSocket(wss) {
190
236
  const result = await kernelService.complete(sessionId, code, cursorPos);
191
237
  ws.send(JSON.stringify({ type: 'complete_reply', result }));
192
238
  }
239
+ else if (message.type === 'comm') {
240
+ // Client -> kernel comm message (ipywidgets interaction):
241
+ // { type: 'comm', comm: { msg_type, comm_id, target_name?, data, buffers? } }
242
+ const comm = (message.comm && typeof message.comm === 'object') ? message.comm : {};
243
+ const msgType = comm.msg_type;
244
+ if (msgType !== 'comm_open' && msgType !== 'comm_msg' && msgType !== 'comm_close') {
245
+ ws.send(JSON.stringify({ type: 'error', error: `Invalid comm msg_type: ${String(msgType)}` }));
246
+ return;
247
+ }
248
+ if (typeof comm.comm_id !== 'string' || comm.comm_id.length === 0) {
249
+ ws.send(JSON.stringify({ type: 'error', error: 'comm.comm_id is required' }));
250
+ return;
251
+ }
252
+ const content = {
253
+ comm_id: comm.comm_id,
254
+ data: (comm.data && typeof comm.data === 'object') ? comm.data : {},
255
+ };
256
+ if (msgType === 'comm_open') {
257
+ content.target_name = typeof comm.target_name === 'string' ? comm.target_name : '';
258
+ }
259
+ const buffers = Array.isArray(comm.buffers)
260
+ ? comm.buffers.filter((b) => typeof b === 'string')
261
+ : undefined;
262
+ await kernelService.sendCommMessage(sessionId, msgType, content, buffers);
263
+ }
264
+ else if (message.type === 'comm_info') {
265
+ // Late-joiner support: list open comms known for this session.
266
+ // Reply only to the requesting socket.
267
+ const openComms = kernelService.getOpenComms(sessionId);
268
+ const comms = {};
269
+ for (const [commId, info] of Object.entries(openComms)) {
270
+ comms[commId] = { target_name: info.targetName };
271
+ }
272
+ ws.send(JSON.stringify({ type: 'comm_info_reply', comms }));
273
+ }
193
274
  }
194
275
  catch (err) {
195
276
  const errMessage = err instanceof Error ? err.message : 'Unknown error';
@@ -340,6 +421,10 @@ async function kernelRoutes(fastify) {
340
421
  return reply.code(500).send({ detail: message });
341
422
  }
342
423
  }
424
+ // Confirmed-dead rows (process gone) are auto-removed without user
425
+ // confirmation — only ambiguous leftovers (PID still alive) are reported
426
+ // for the explicit Clean Up flow.
427
+ await kernelService.autoCleanupDeadSessions().catch(() => { });
343
428
  const deadSessions = kernelService.getDeadSessions();
344
429
  const sessions = deadSessions.map(s => ({
345
430
  session_id: s.sessionId,
@@ -390,7 +475,7 @@ async function kernelRoutes(fastify) {
390
475
  const metadataResult = await persistNotebookKernelMetadata(file_path, kernel_name);
391
476
  notebookMtime = metadataResult.mtime;
392
477
  if (client_origin !== 'ui') {
393
- operation_router_1.operationRouter.notifyKernelChanged(file_path, { kernelName: kernel_name, serverId: server_id });
478
+ operation_router_1.operationRouter.notifyKernelChanged(file_path, { kernelName: kernel_name, serverId: server_id, mtime: metadataResult.mtime });
394
479
  }
395
480
  }
396
481
  return reply.send({ session_id: result.sessionId, kernel_name, server_id, mtime: notebookMtime });
@@ -407,7 +492,7 @@ async function kernelRoutes(fastify) {
407
492
  const metadataResult = await persistNotebookKernelMetadata(file_path, kernel_name);
408
493
  notebookMtime = metadataResult.mtime;
409
494
  if (client_origin !== 'ui') {
410
- operation_router_1.operationRouter.notifyKernelChanged(file_path, { kernelName: kernel_name, serverId: localServerId });
495
+ operation_router_1.operationRouter.notifyKernelChanged(file_path, { kernelName: kernel_name, serverId: localServerId, mtime: metadataResult.mtime });
411
496
  }
412
497
  }
413
498
  return reply.send({ session_id: sessionId, kernel_name, server_id: localServerId, mtime: notebookMtime });
@@ -473,6 +558,7 @@ async function kernelRoutes(fastify) {
473
558
  operation_router_1.operationRouter.notifyKernelChanged(normalizedFilePath, {
474
559
  kernelName: effectiveKernelName,
475
560
  serverId: effectiveServerId,
561
+ mtime: metadataResult.mtime,
476
562
  });
477
563
  }
478
564
  return reply.send({
@@ -494,6 +580,7 @@ async function kernelRoutes(fastify) {
494
580
  operation_router_1.operationRouter.notifyKernelChanged(normalizedFilePath, {
495
581
  kernelName: effectiveKernelName,
496
582
  serverId: localServerId,
583
+ mtime: metadataResult.mtime,
497
584
  });
498
585
  }
499
586
  return reply.send({
@@ -610,10 +697,35 @@ async function kernelRoutes(fastify) {
610
697
  if ((0, kernel_proxy_1.isProxiedSession)(sessionId)) {
611
698
  try {
612
699
  const remoteStatus = await (0, kernel_proxy_1.getRemoteKernelStatus)(sessionId);
613
- return reply.send(remoteStatus);
700
+ // Return the COMPOSITE session id (serverId::remoteId) — the remote
701
+ // server reports its own local id, and leaking that to clients makes
702
+ // them reference a session the main server doesn't recognize.
703
+ return reply.send({ ...remoteStatus, id: sessionId });
614
704
  }
615
705
  catch (err) {
616
706
  const message = err instanceof Error ? err.message : 'Unknown error';
707
+ // "Server is offline / not found" from the registry is a HEARTBEAT
708
+ // signal, not ground truth: a compute node saturated by a heavy
709
+ // execution can miss heartbeats for minutes while its SLURM job (and
710
+ // kernel) are perfectly alive — then recover. Treating that as
711
+ // terminal permanently killed live kernels (red kernel, reconnect
712
+ // loop stopped, only a page refresh recovered).
713
+ //
714
+ // The scheduler is the authority: return 410 Gone (terminal) only
715
+ // when the session's allocation is confirmed ended/failed/cancelled
716
+ // by squeue polling. Everything else is 503 (transient) so the
717
+ // client's reconnect loop keeps trying and revives the kernel when
718
+ // the node recovers.
719
+ if (/server (is offline|not found)/i.test(message)) {
720
+ const { serverId } = (0, kernel_proxy_1.parseSessionId)(sessionId);
721
+ const allocation = serverId
722
+ ? allocation_service_1.allocationService.list().find(a => a.serverId === serverId)
723
+ : undefined;
724
+ if (allocation && ['ended', 'failed', 'cancelled'].includes(allocation.state)) {
725
+ return reply.code(410).send({ detail: `Allocation ${allocation.state} — kernel stopped`, reason: 'server_gone' });
726
+ }
727
+ return reply.code(503).send({ detail: `${message} (may be transient — compute node busy or re-registering)`, reason: 'server_unreachable' });
728
+ }
617
729
  return reply.code(500).send({ detail: message });
618
730
  }
619
731
  }
@@ -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
+ // The UI just wrote newer content than whatever the headless handler may
91
+ // have cached. Drop the cache so headless ops (an agent working after the
92
+ // user closes the tab) reload from disk instead of serving — or worse,
93
+ // OCC-validating against — stale cells.
94
+ headlessHandler.invalidate(filePath);
95
+ headlessHandler.invalidate(fs_service_1.fsService.normalizePath(filePath));
90
96
  return reply.send({ status: 'ok', path: filePath, mtime: result.mtime });
91
97
  }
92
98
  catch (err) {
@@ -103,7 +109,7 @@ async function notebookRoutes(fastify) {
103
109
  if (!notebookPath) {
104
110
  return reply.code(400).send({ detail: 'notebook_path query parameter is required' });
105
111
  }
106
- const history = fs_service_1.fsService.loadHistory(notebookPath);
112
+ const history = await fs_service_1.fsService.loadHistory(notebookPath);
107
113
  return reply.send({ notebook_path: notebookPath, history });
108
114
  }
109
115
  catch (err) {
@@ -178,6 +184,7 @@ async function notebookRoutes(fastify) {
178
184
  return reply.send({
179
185
  status: 'ok',
180
186
  notebook_path,
187
+ mtime: result.mtime,
181
188
  ...(result.status || fs_service_1.fsService.getAgentPermissionStatus(notebook_path)),
182
189
  });
183
190
  }
@@ -332,4 +339,31 @@ async function notebookRoutes(fastify) {
332
339
  return reply.code(500).send({ detail: message });
333
340
  }
334
341
  });
342
+ /**
343
+ * Persist live ipywidgets state into notebook metadata.widgets
344
+ * (application/vnd.jupyter.widget-state+json), making saved notebooks
345
+ * render static widgets in Jupyter/nbviewer.
346
+ */
347
+ fastify.post('/notebook/widget-state', async (request, reply) => {
348
+ try {
349
+ const { path: filePath, widget_state } = request.body;
350
+ if (!filePath) {
351
+ return reply.code(400).send({ detail: 'path is required' });
352
+ }
353
+ if (!widget_state || typeof widget_state !== 'object') {
354
+ return reply.code(400).send({ detail: 'widget_state must be an object' });
355
+ }
356
+ const updateResult = await fs_service_1.fsService.updateNotebookMetadata(filePath, {
357
+ widgets: { 'application/vnd.jupyter.widget-state+json': widget_state },
358
+ });
359
+ if (!updateResult.success) {
360
+ return reply.code(500).send({ detail: updateResult.error || 'Failed to update notebook metadata' });
361
+ }
362
+ return reply.send({ status: 'ok', notebook_path: filePath, mtime: updateResult.mtime });
363
+ }
364
+ catch (err) {
365
+ const message = err instanceof Error ? err.message : 'Unknown error';
366
+ return reply.code(500).send({ detail: message });
367
+ }
368
+ });
335
369
  }
@@ -0,0 +1,43 @@
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
+ init(scheduler: Scheduler, ctx: LaunchContext): void;
33
+ isEnabled(): boolean;
34
+ getScheduler(): Scheduler | null;
35
+ list(): Allocation[];
36
+ get(id: string): Allocation | undefined;
37
+ create(spec: JobSpec): Promise<Allocation>;
38
+ cancel(id: string): Promise<boolean>;
39
+ private poll;
40
+ shutdown(): void;
41
+ }
42
+ export declare const allocationService: AllocationService;
43
+ export {};