nebula-notebook 0.1.1 → 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 (78) hide show
  1. package/README.md +90 -11
  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 -6
  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/headless-handler.d.ts +2 -0
  48. package/node-server/dist/notebook/headless-handler.js +70 -24
  49. package/node-server/dist/notebook/operation-router.js +7 -2
  50. package/node-server/dist/notebook/undoRedoManager.d.ts +4 -1
  51. package/node-server/dist/notebook/undoRedoManager.js +10 -2
  52. package/node-server/dist/output/display-data.js +2 -0
  53. package/node-server/dist/routes/cluster.js +2 -2
  54. package/node-server/dist/routes/compute.d.ts +8 -0
  55. package/node-server/dist/routes/compute.js +136 -0
  56. package/node-server/dist/routes/fs.js +2 -2
  57. package/node-server/dist/routes/kernel.js +111 -1
  58. package/node-server/dist/routes/notebook.js +29 -1
  59. package/node-server/dist/scheduler/allocation-service.d.ts +43 -0
  60. package/node-server/dist/scheduler/allocation-service.js +169 -0
  61. package/node-server/dist/scheduler/job-template.d.ts +30 -0
  62. package/node-server/dist/scheduler/job-template.js +85 -0
  63. package/node-server/dist/scheduler/mock-scheduler.d.ts +30 -0
  64. package/node-server/dist/scheduler/mock-scheduler.js +121 -0
  65. package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
  66. package/node-server/dist/scheduler/slurm-scheduler.js +393 -0
  67. package/node-server/dist/scheduler/types.d.ts +117 -0
  68. package/node-server/dist/scheduler/types.js +8 -0
  69. package/node-server/dist/scheduler/util.d.ts +7 -0
  70. package/node-server/dist/scheduler/util.js +20 -0
  71. package/node-server/dist/terminal/pty-manager.js +8 -0
  72. package/node-server/dist/terminal/server.js +43 -2
  73. package/node-server/dist/update-check.d.ts +20 -0
  74. package/node-server/dist/update-check.js +114 -0
  75. package/node-server/package.json +1 -0
  76. package/package.json +2 -1
  77. package/dist/assets/index-BvrHu37J.js +0 -648
  78. package/dist/assets/index-Dfj_xsKU.css +0 -32
@@ -52,6 +52,8 @@ export declare class HeadlessOperationHandler {
52
52
  * Persist dirty notebooks to disk.
53
53
  */
54
54
  flush(notebookPath?: string): Promise<void>;
55
+ /** In-flight persist per notebook path (single-flight). */
56
+ private persisting;
55
57
  private asyncPersist;
56
58
  private schedulePersist;
57
59
  /**
@@ -45,6 +45,7 @@ const cell_hash_1 = require("./cell-hash");
45
45
  const path = __importStar(require("path"));
46
46
  const os = __importStar(require("os"));
47
47
  const uuid_1 = require("uuid");
48
+ const registry_1 = require("../fs/notebook-formats/registry");
48
49
  const cell_metadata_1 = require("./cell-metadata");
49
50
  const undoRedoManager_1 = require("./undoRedoManager");
50
51
  const kernelspec_1 = require("../kernel/kernelspec");
@@ -80,7 +81,7 @@ class HeadlessOperationHandler {
80
81
  */
81
82
  getCachedNotebook(notebookPath) {
82
83
  if (!this.cache.has(notebookPath)) {
83
- const result = this.fsService.getNotebookCells(notebookPath);
84
+ const result = this.fsService.getNotebookCellsSync(notebookPath);
84
85
  this.cache.set(notebookPath, {
85
86
  cells: result.cells || [],
86
87
  metadata: result.metadata || {},
@@ -109,23 +110,41 @@ class HeadlessOperationHandler {
109
110
  async flush(notebookPath) {
110
111
  const paths = notebookPath ? [notebookPath] : Array.from(this.cache.keys());
111
112
  for (const p of paths) {
112
- const notebook = this.cache.get(p);
113
- if (notebook?.dirty) {
113
+ // A scheduled persist may have already cleared the dirty flag but not
114
+ // finished writing — flush must wait for it, or callers read a stale
115
+ // file. The persist loop keeps writing while dirty, so awaiting the
116
+ // in-flight run covers later mutations too.
117
+ const inFlight = this.persisting.get(p);
118
+ if (inFlight) {
119
+ await inFlight;
120
+ }
121
+ if (this.cache.get(p)?.dirty) {
114
122
  await this.asyncPersist(p);
115
123
  }
116
124
  }
117
125
  }
118
- async asyncPersist(notebookPath) {
119
- const notebook = this.cache.get(notebookPath);
120
- if (!notebook)
121
- return;
122
- // Keep writing while dirty
123
- while (notebook.dirty) {
124
- const cells = JSON.parse(JSON.stringify(notebook.cells));
125
- notebook.dirty = false;
126
- const history = this.undoRedoManager.getHistory(notebookPath, cells);
127
- await this.fsService.saveNotebookBundle(notebookPath, cells, undefined, history);
128
- }
126
+ /** In-flight persist per notebook path (single-flight). */
127
+ persisting = new Map();
128
+ asyncPersist(notebookPath) {
129
+ const existing = this.persisting.get(notebookPath);
130
+ if (existing)
131
+ return existing;
132
+ const run = (async () => {
133
+ const notebook = this.cache.get(notebookPath);
134
+ if (!notebook)
135
+ return;
136
+ // Keep writing while dirty
137
+ while (notebook.dirty) {
138
+ const cells = JSON.parse(JSON.stringify(notebook.cells));
139
+ notebook.dirty = false;
140
+ const history = this.undoRedoManager.getHistory(notebookPath, cells);
141
+ await this.fsService.saveNotebookBundle(notebookPath, cells, undefined, history);
142
+ }
143
+ })().finally(() => {
144
+ this.persisting.delete(notebookPath);
145
+ });
146
+ this.persisting.set(notebookPath, run);
147
+ return run;
129
148
  }
130
149
  schedulePersist(notebookPath) {
131
150
  // Schedule async persist
@@ -143,6 +162,10 @@ class HeadlessOperationHandler {
143
162
  else {
144
163
  this.cache.clear();
145
164
  }
165
+ // Undo/redo state is derived from the persisted history file; when the
166
+ // notebook may have changed underneath us (UI save, external edit
167
+ // reconciliation), rebuild it from disk on next access.
168
+ this.undoRedoManager.clearState(notebookPath);
146
169
  }
147
170
  /**
148
171
  * Check if notebook has unsaved changes.
@@ -155,21 +178,22 @@ class HeadlessOperationHandler {
155
178
  */
156
179
  checkAgentPermission(notebookPath, _operationType) {
157
180
  const status = this.fsService.getAgentPermissionStatus(notebookPath);
158
- if (status.agent_created) {
181
+ // can_agent_modify already encodes the tri-state rule (agent-created notebooks
182
+ // are permitted unless explicitly revoked; user notebooks need permission +
183
+ // history), so an explicit user revoke wins even for agent-created notebooks.
184
+ if (status.can_agent_modify) {
159
185
  return null;
160
186
  }
161
- if (status.agent_permitted) {
162
- if (!status.has_history) {
163
- return {
164
- success: false,
165
- error: `Agent cannot modify "${notebookPath}": notebook is user-permitted but history is not enabled. Open the notebook in the UI first to enable history tracking, or the agent can create a new notebook.`,
166
- };
167
- }
168
- return null;
187
+ // User-permitted but history not yet enabled (soft, fixable) vs. not permitted.
188
+ if (status.agent_permitted && !status.has_history && !status.agent_created) {
189
+ return {
190
+ success: false,
191
+ error: `Agent cannot modify "${notebookPath}": notebook is user-permitted but history is not enabled. Open the notebook in the UI first to enable history tracking, or the agent can create a new notebook.`,
192
+ };
169
193
  }
170
194
  return {
171
195
  success: false,
172
- error: `Agent cannot modify "${notebookPath}": notebook is not agent-permitted. Either open the notebook in Nebula UI and grant agent permission, or the agent can create a new notebook which will be automatically permitted.`,
196
+ error: `Agent cannot modify "${notebookPath}": notebook is not agent-permitted (agent access may have been revoked). Either open the notebook in Nebula UI and grant agent permission, or the agent can create a new notebook which will be automatically permitted.`,
173
197
  };
174
198
  }
175
199
  /**
@@ -781,6 +805,28 @@ class HeadlessOperationHandler {
781
805
  error: `Notebook already exists: ${notebookPath}. Use overwrite=true to replace.`,
782
806
  };
783
807
  }
808
+ // Text notebook formats (.py percent / .qmd): create through the format
809
+ // adapter — agent flags live in the file's own metadata like .ipynb.
810
+ const formatAdapter = (0, registry_1.getFormatAdapter)(normalizedPath);
811
+ if (formatAdapter) {
812
+ const dir = path.dirname(normalizedPath);
813
+ if (!fs.existsSync(dir)) {
814
+ fs.mkdirSync(dir, { recursive: true });
815
+ }
816
+ const metadata = {
817
+ kernelspec: { name: kernelName, display_name: kernelDisplayName },
818
+ nebula: { agent_created: true, agent_permitted: true },
819
+ };
820
+ if (formatAdapter.name === 'qmd')
821
+ metadata.__qmd_language = 'python';
822
+ fs.writeFileSync(normalizedPath, formatAdapter.serialize([], metadata), 'utf-8');
823
+ this.invalidate(notebookPath);
824
+ return {
825
+ success: true,
826
+ path: notebookPath,
827
+ mtime: fs.statSync(normalizedPath).mtimeMs / 1000,
828
+ };
829
+ }
784
830
  const notebook = {
785
831
  nbformat: 4,
786
832
  nbformat_minor: 5,
@@ -48,6 +48,7 @@ const fs = __importStar(require("fs"));
48
48
  const os = __importStar(require("os"));
49
49
  const ws_1 = require("ws");
50
50
  const cell_hash_1 = require("./cell-hash");
51
+ const registry_1 = require("../fs/notebook-formats/registry");
51
52
  const AGENT_LOCK_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
52
53
  const UI_STALE_TIMEOUT_MS = 45 * 1000; // 45 seconds
53
54
  function normalizeNotebookPath(notebookPath) {
@@ -320,9 +321,13 @@ class OperationRouter {
320
321
  const opType = operation.type;
321
322
  const agentId = operation.agentId;
322
323
  // For createNotebook, route to ANY connected UI (not path-specific)
323
- // This allows the UI to open the new notebook in a new tab
324
+ // This allows the UI to open the new notebook in a new tab.
325
+ // EXCEPT text notebook formats (.py/.qmd): the UI's create handler only
326
+ // builds Jupyter JSON, so those are always created headless through the
327
+ // format adapters.
324
328
  const isCreateNotebook = opType === 'createNotebook';
325
- const anyUI = isCreateNotebook ? this.getAnyUIConnection() : null;
329
+ const isTextFormatCreate = isCreateNotebook && (0, registry_1.isTextNotebookPath)(operation.notebookPath || '');
330
+ const anyUI = isCreateNotebook && !isTextFormatCreate ? this.getAnyUIConnection() : null;
326
331
  const hasUI = isCreateNotebook ? (anyUI !== null) : (this.getResponsiveUIConnection(normalizedPath) !== null);
327
332
  const backend = hasUI ? 'ui' : 'headless';
328
333
  // Clean up expired locks
@@ -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,
@@ -612,10 +697,35 @@ async function kernelRoutes(fastify) {
612
697
  if ((0, kernel_proxy_1.isProxiedSession)(sessionId)) {
613
698
  try {
614
699
  const remoteStatus = await (0, kernel_proxy_1.getRemoteKernelStatus)(sessionId);
615
- 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 });
616
704
  }
617
705
  catch (err) {
618
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
+ }
619
729
  return reply.code(500).send({ detail: message });
620
730
  }
621
731
  }
@@ -109,7 +109,7 @@ async function notebookRoutes(fastify) {
109
109
  if (!notebookPath) {
110
110
  return reply.code(400).send({ detail: 'notebook_path query parameter is required' });
111
111
  }
112
- const history = fs_service_1.fsService.loadHistory(notebookPath);
112
+ const history = await fs_service_1.fsService.loadHistory(notebookPath);
113
113
  return reply.send({ notebook_path: notebookPath, history });
114
114
  }
115
115
  catch (err) {
@@ -184,6 +184,7 @@ async function notebookRoutes(fastify) {
184
184
  return reply.send({
185
185
  status: 'ok',
186
186
  notebook_path,
187
+ mtime: result.mtime,
187
188
  ...(result.status || fs_service_1.fsService.getAgentPermissionStatus(notebook_path)),
188
189
  });
189
190
  }
@@ -338,4 +339,31 @@ async function notebookRoutes(fastify) {
338
339
  return reply.code(500).send({ detail: message });
339
340
  }
340
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
+ });
341
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 {};