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
@@ -0,0 +1,393 @@
1
+ "use strict";
2
+ /**
3
+ * SLURM implementation of the Scheduler interface.
4
+ *
5
+ * Everything shells out to the standard SLURM client CLIs (sbatch, squeue,
6
+ * scancel, sinfo, sacctmgr, sacct). The Nebula main server runs on the login
7
+ * node where these are available; detection is capability-based.
8
+ */
9
+ Object.defineProperty(exports, "__esModule", { value: true });
10
+ exports.SlurmScheduler = void 0;
11
+ const child_process_1 = require("child_process");
12
+ const util_1 = require("util");
13
+ const util_2 = require("./util");
14
+ const execFileP = (0, util_1.promisify)(child_process_1.execFile);
15
+ async function run(cmd, args, timeoutMs = 15_000) {
16
+ const { stdout, stderr } = await execFileP(cmd, args, {
17
+ timeout: timeoutMs,
18
+ maxBuffer: 16 * 1024 * 1024,
19
+ });
20
+ return { stdout: stdout.toString(), stderr: stderr.toString() };
21
+ }
22
+ function toInt(v) {
23
+ const n = parseInt((v ?? '').trim(), 10);
24
+ return Number.isFinite(n) ? n : 0;
25
+ }
26
+ /** Pull `Key=value` (value up to next space) out of a `scontrol -o` line. */
27
+ function scontrolField(line, key) {
28
+ const m = line.match(new RegExp(`(?:^|\\s)${key}=(\\S+)`));
29
+ return m ? m[1] : undefined;
30
+ }
31
+ /** Map a SLURM state code or name to our normalized JobState. */
32
+ function mapState(raw) {
33
+ const s = (raw || '').trim().toUpperCase();
34
+ if (s === 'PD' || s === 'PENDING' || s === 'CF' || s === 'CONFIGURING')
35
+ return 'pending';
36
+ if (s === 'R' || s === 'RUNNING')
37
+ return 'running';
38
+ if (s === 'CG' || s === 'COMPLETING')
39
+ return 'completing';
40
+ if (s === 'CD' || s === 'COMPLETED')
41
+ return 'completed';
42
+ if (s === 'CA' || s === 'CANCELLED' || s.startsWith('CANCELLED'))
43
+ return 'cancelled';
44
+ if (['F', 'FAILED', 'TO', 'TIMEOUT', 'NF', 'NODE_FAIL', 'OOM', 'OUT_OF_MEMORY', 'BF', 'BOOT_FAIL', 'DL', 'DEADLINE'].includes(s)) {
45
+ return 'failed';
46
+ }
47
+ return 'unknown';
48
+ }
49
+ async function expandNodes(nodelist) {
50
+ const nl = (nodelist || '').trim();
51
+ if (!nl || nl === '(null)' || nl === 'None' || nl === 'n/a')
52
+ return [];
53
+ try {
54
+ const { stdout } = await run('scontrol', ['show', 'hostnames', nl], 5_000);
55
+ return stdout.split('\n').map((s) => s.trim()).filter(Boolean);
56
+ }
57
+ catch {
58
+ return [nl];
59
+ }
60
+ }
61
+ class SlurmScheduler {
62
+ name = 'slurm';
63
+ /** Cache of per-partition required-QoS (site job-submit filters change rarely). */
64
+ qosCache = new Map();
65
+ // Short-TTL caches + in-flight de-duplication so the dashboard's 15s poll, the
66
+ // allocation modal, and manual refresh don't each re-run the (potentially slow)
67
+ // sinfo/scontrol/squeue/sacctmgr queries against the scheduler.
68
+ static LOAD_TTL_MS = 10_000;
69
+ static ASSOC_TTL_MS = 60_000;
70
+ loadCache = null;
71
+ loadInflight = null;
72
+ assocCache = new Map();
73
+ assocInflight = new Map();
74
+ async detect() {
75
+ try {
76
+ await run('sbatch', ['--version'], 5_000);
77
+ await run('squeue', ['--version'], 5_000);
78
+ return true;
79
+ }
80
+ catch {
81
+ return false;
82
+ }
83
+ }
84
+ async associations(user) {
85
+ const cached = this.assocCache.get(user);
86
+ if (cached && Date.now() - cached.at < SlurmScheduler.ASSOC_TTL_MS)
87
+ return cached.data;
88
+ const inflight = this.assocInflight.get(user);
89
+ if (inflight)
90
+ return inflight;
91
+ const p = this.associationsFresh(user)
92
+ .then((data) => { this.assocCache.set(user, { data, at: Date.now() }); return data; })
93
+ .finally(() => { this.assocInflight.delete(user); });
94
+ this.assocInflight.set(user, p);
95
+ return p;
96
+ }
97
+ async associationsFresh(user) {
98
+ let account;
99
+ const partitions = new Set();
100
+ const qoses = new Set();
101
+ let defaultQos;
102
+ try {
103
+ const { stdout } = await run('sacctmgr', [
104
+ '-nP', 'show', 'assoc', `user=${user}`,
105
+ 'format=Account,Partition,QOS,DefaultQOS',
106
+ ]);
107
+ for (const line of stdout.split('\n')) {
108
+ if (!line.trim())
109
+ continue;
110
+ const [acct, part, qos, defQos] = line.split('|');
111
+ if (acct && !account)
112
+ account = acct.trim();
113
+ if (part && part.trim())
114
+ partitions.add(part.trim());
115
+ if (qos)
116
+ qos.split(',').forEach((q) => q.trim() && qoses.add(q.trim()));
117
+ if (defQos && defQos.trim() && !defaultQos)
118
+ defaultQos = defQos.trim();
119
+ }
120
+ }
121
+ catch {
122
+ /* sacctmgr may be unavailable / accounting disabled */
123
+ }
124
+ // Account-level associations often leave Partition blank (== not restricted).
125
+ // Fall back to the full partition list so the user can still pick one;
126
+ // an actually-disallowed choice is caught at submit / estimate time.
127
+ if (partitions.size === 0) {
128
+ try {
129
+ const { stdout } = await run('sinfo', ['-h', '-o', '%R']);
130
+ stdout.split('\n').forEach((p) => {
131
+ const t = p.trim();
132
+ if (t)
133
+ partitions.add(t);
134
+ });
135
+ }
136
+ catch {
137
+ /* ignore */
138
+ }
139
+ }
140
+ return {
141
+ account,
142
+ partitions: [...partitions],
143
+ qoses: [...qoses],
144
+ defaultQos,
145
+ };
146
+ }
147
+ async load() {
148
+ if (this.loadCache && Date.now() - this.loadCache.at < SlurmScheduler.LOAD_TTL_MS) {
149
+ return this.loadCache.data;
150
+ }
151
+ if (this.loadInflight)
152
+ return this.loadInflight;
153
+ const p = this.loadFresh()
154
+ .then((data) => { this.loadCache = { data, at: Date.now() }; return data; })
155
+ .finally(() => { this.loadInflight = null; });
156
+ this.loadInflight = p;
157
+ return p;
158
+ }
159
+ async loadFresh() {
160
+ const partitions = new Map();
161
+ // Fire all four scheduler queries concurrently — the wall time is the slowest
162
+ // one, not the sum (scontrol/squeue dominate on big/busy clusters). Each is
163
+ // independent and optional; a failed query just leaves its slice of data empty.
164
+ const [sinfoRes, nodeRes, squeueRes, qosRes] = await Promise.allSettled([
165
+ run('sinfo', ['-h', '-o', '%R|%a|%l|%C|%D|%T']),
166
+ run('scontrol', ['show', 'node', '-o'], 20_000),
167
+ run('squeue', ['-h', '-r', '-o', '%P|%q|%t']),
168
+ run('sacctmgr', ['-nP', 'show', 'qos', 'format=Name,Priority,MaxWall,Preempt']),
169
+ ]);
170
+ // sinfo emits one line per (partition, node-state) group; aggregate per partition.
171
+ if (sinfoRes.status === 'fulfilled') {
172
+ for (const line of sinfoRes.value.stdout.split('\n')) {
173
+ if (!line.trim())
174
+ continue;
175
+ const [name, avail, timeLimit, cpus, nodeCount, stateName] = line.split('|');
176
+ const [ca, ci, co, ct] = (cpus || '').split('/').map(toInt);
177
+ let p = partitions.get(name);
178
+ if (!p) {
179
+ p = {
180
+ name,
181
+ up: (avail || '').trim().toLowerCase() === 'up',
182
+ timeLimit: (timeLimit || '').trim(),
183
+ cpus: { alloc: 0, idle: 0, other: 0, total: 0 },
184
+ nodes: { idle: 0, mixed: 0, alloc: 0, down: 0, total: 0 },
185
+ jobs: { pending: 0, running: 0 },
186
+ };
187
+ partitions.set(name, p);
188
+ }
189
+ p.cpus.alloc += ca;
190
+ p.cpus.idle += ci;
191
+ p.cpus.other += co;
192
+ p.cpus.total += ct;
193
+ const nc = toInt(nodeCount);
194
+ p.nodes.total += nc;
195
+ const st = (stateName || '').trim().toLowerCase();
196
+ if (st.startsWith('idle'))
197
+ p.nodes.idle += nc;
198
+ else if (st.startsWith('mix') || st.startsWith('alloc'))
199
+ p.nodes.mixed += nc;
200
+ else if (st.startsWith('down') || st.startsWith('drain') || st.startsWith('fail'))
201
+ p.nodes.down += nc;
202
+ }
203
+ }
204
+ // GPU capacity per partition, from per-node TRES: configured (CfgTRES) vs
205
+ // allocated (AllocTRES) `gres/gpu`, so we can report *idle* (available) GPUs
206
+ // rather than a per-node count. Generic — no site-specific node/gres names.
207
+ if (nodeRes.status === 'fulfilled') {
208
+ const agg = new Map();
209
+ for (const line of nodeRes.value.stdout.split('\n')) {
210
+ if (!line.trim())
211
+ continue;
212
+ const cfg = scontrolField(line, 'CfgTRES') || '';
213
+ const cfgGpu = toInt((cfg.match(/gres\/gpu=(\d+)/) || [])[1]);
214
+ if (cfgGpu === 0)
215
+ continue; // node has no GPUs
216
+ const parts = scontrolField(line, 'Partitions');
217
+ if (!parts)
218
+ continue;
219
+ const allocGpu = toInt((scontrolField(line, 'AllocTRES')?.match(/gres\/gpu=(\d+)/) || [])[1]);
220
+ const type = (scontrolField(line, 'Gres')?.match(/gpu:([^:(]+)/) || [])[1] || 'gpu';
221
+ for (const part of parts.split(',')) {
222
+ const e = agg.get(part) || { type, total: 0, used: 0 };
223
+ e.total += cfgGpu;
224
+ e.used += allocGpu;
225
+ if (!e.type || e.type === 'gpu')
226
+ e.type = type;
227
+ agg.set(part, e);
228
+ }
229
+ }
230
+ for (const [part, g] of agg) {
231
+ const p = partitions.get(part);
232
+ if (p && g.total > 0)
233
+ p.gpus = { type: g.type, total: g.total, idle: Math.max(0, g.total - g.used) };
234
+ }
235
+ }
236
+ // Queue depth from squeue, aggregated per partition AND per QoS.
237
+ const qosUsage = new Map();
238
+ if (squeueRes.status === 'fulfilled') {
239
+ for (const line of squeueRes.value.stdout.split('\n')) {
240
+ if (!line.trim())
241
+ continue;
242
+ const [part, qos, state] = line.split('|');
243
+ const s = (state || '').trim().toUpperCase();
244
+ const p = partitions.get((part || '').trim());
245
+ if (p) {
246
+ if (s === 'PD')
247
+ p.jobs.pending += 1;
248
+ else if (s === 'R')
249
+ p.jobs.running += 1;
250
+ }
251
+ const qn = (qos || '').trim();
252
+ if (qn) {
253
+ const u = qosUsage.get(qn) || { running: 0, pending: 0 };
254
+ if (s === 'PD')
255
+ u.pending += 1;
256
+ else if (s === 'R')
257
+ u.running += 1;
258
+ qosUsage.set(qn, u);
259
+ }
260
+ }
261
+ }
262
+ // QoS definitions + preemption graph. In SLURM a QoS's `Preempt` column lists
263
+ // the QoS's it can preempt; a QoS is therefore *preemptible* (can be
264
+ // interrupted) exactly when some other QoS lists it as a preempt target.
265
+ // This is derived entirely from the scheduler — no site-specific QoS names.
266
+ const qoses = [];
267
+ if (qosRes.status === 'fulfilled') {
268
+ const rows = [];
269
+ for (const line of qosRes.value.stdout.split('\n')) {
270
+ if (!line.trim())
271
+ continue;
272
+ const [name, priority, maxWall, preempt] = line.split('|');
273
+ rows.push({
274
+ name: (name || '').trim(),
275
+ priority: toInt(priority),
276
+ maxWall: maxWall && maxWall.trim() ? maxWall.trim() : undefined,
277
+ preempts: (preempt || '').split(',').map((s) => s.trim()).filter(Boolean),
278
+ });
279
+ }
280
+ const preemptedBy = new Set();
281
+ for (const r of rows)
282
+ for (const target of r.preempts)
283
+ preemptedBy.add(target);
284
+ for (const r of rows) {
285
+ qoses.push({
286
+ name: r.name,
287
+ priority: r.priority,
288
+ maxWall: r.maxWall,
289
+ preemptible: preemptedBy.has(r.name),
290
+ preempts: r.preempts,
291
+ jobs: qosUsage.get(r.name) || { running: 0, pending: 0 },
292
+ });
293
+ }
294
+ }
295
+ return { partitions: [...partitions.values()], qoses, fetchedAt: Date.now() };
296
+ }
297
+ async allowedQos(partition) {
298
+ const cached = this.qosCache.get(partition);
299
+ if (cached && Date.now() - cached.at < 300_000)
300
+ return cached.allowed;
301
+ let allowed = null;
302
+ try {
303
+ // A no-QoS dry run: succeeds when the partition accepts any QoS. A site
304
+ // job-submit filter that requires an explicit QoS rejects it and names the
305
+ // acceptable set in the message ("... Allowed: a, b").
306
+ await run('sbatch', [
307
+ '--test-only',
308
+ `--partition=${partition}`,
309
+ '--cpus-per-task=1', '--mem=1G', '--time=00:10:00', '--wrap=true',
310
+ ], 15_000);
311
+ allowed = null;
312
+ }
313
+ catch (e) {
314
+ const text = String(e?.stderr || e?.message || '');
315
+ const m = text.match(/requires explicit --qos\.?\s*Allowed:\s*([^\n]+)/i);
316
+ allowed = m ? m[1].split(',').map((s) => s.trim()).filter(Boolean) : null;
317
+ }
318
+ this.qosCache.set(partition, { allowed, at: Date.now() });
319
+ return allowed;
320
+ }
321
+ async estimateStart(spec) {
322
+ const args = [
323
+ '--test-only',
324
+ `--partition=${spec.partition}`,
325
+ `--cpus-per-task=${spec.cpus}`,
326
+ `--mem=${spec.memGb}G`,
327
+ `--time=${(0, util_2.formatWalltime)(spec.walltimeMinutes)}`,
328
+ ];
329
+ if (spec.qos)
330
+ args.push(`--qos=${spec.qos}`);
331
+ if (spec.account)
332
+ args.push(`--account=${spec.account}`);
333
+ if (spec.gpus)
334
+ args.push(`--gres=gpu:${spec.gpuType ? `${spec.gpuType}:` : ''}${spec.gpus}`);
335
+ args.push('--wrap=true');
336
+ // `sbatch --test-only` writes "Job N to start at <time> ... on nodes <n>" to stderr.
337
+ try {
338
+ const { stdout, stderr } = await run('sbatch', args, 15_000);
339
+ const text = `${stderr}\n${stdout}`;
340
+ const m = text.match(/to start at (\S+)(?:.*?on nodes? (\S+))?/i);
341
+ if (m)
342
+ return { startsAt: m[1], nodes: m[2] ? [m[2]] : undefined };
343
+ return { reason: text.trim() || 'no estimate available' };
344
+ }
345
+ catch (e) {
346
+ const reason = (e?.stderr || e?.message || 'test-only failed').toString().trim();
347
+ return { reason };
348
+ }
349
+ }
350
+ async submit(scriptPath) {
351
+ const { stdout } = await run('sbatch', ['--parsable', scriptPath]);
352
+ const jobId = stdout.trim().split(';')[0].trim();
353
+ if (!/^\d+$/.test(jobId)) {
354
+ throw new Error(`Unexpected sbatch output: ${stdout.trim()}`);
355
+ }
356
+ return { jobId };
357
+ }
358
+ async query(jobId) {
359
+ // Active jobs: squeue.
360
+ try {
361
+ const { stdout } = await run('squeue', ['-h', '-j', jobId, '-o', '%T|%N|%r']);
362
+ const line = stdout.split('\n').find((l) => l.trim());
363
+ if (line) {
364
+ const [state, nodelist, reason] = line.split('|');
365
+ return {
366
+ state: mapState(state),
367
+ nodes: await expandNodes(nodelist),
368
+ reason: reason && reason.trim() && reason.trim() !== 'None' ? reason.trim() : undefined,
369
+ };
370
+ }
371
+ }
372
+ catch {
373
+ /* fall through to sacct */
374
+ }
375
+ // Finished jobs: sacct.
376
+ try {
377
+ const { stdout } = await run('sacct', ['-nXP', '-j', jobId, '-o', 'State,NodeList']);
378
+ const line = stdout.split('\n').find((l) => l.trim());
379
+ if (line) {
380
+ const [state, nodelist] = line.split('|');
381
+ return { state: mapState(state), nodes: await expandNodes(nodelist) };
382
+ }
383
+ }
384
+ catch {
385
+ /* ignore */
386
+ }
387
+ return { state: 'unknown', nodes: [] };
388
+ }
389
+ async cancel(jobId) {
390
+ await run('scancel', [jobId]);
391
+ }
392
+ }
393
+ exports.SlurmScheduler = SlurmScheduler;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Scheduler abstraction — a thin interface over an HPC batch scheduler.
3
+ *
4
+ * SLURM is the first implementation; PBS/LSF can implement the same interface
5
+ * later without touching the allocation service or routes.
6
+ */
7
+ export type JobState = 'pending' | 'running' | 'completing' | 'completed' | 'failed' | 'cancelled' | 'unknown';
8
+ /** The resource request for one compute allocation. */
9
+ export interface JobSpec {
10
+ partition: string;
11
+ qos?: string;
12
+ account?: string;
13
+ cpus: number;
14
+ memGb: number;
15
+ gpus?: number;
16
+ /** Specific GPU model to request (e.g. from the scheduler's gres names). Optional. */
17
+ gpuType?: string;
18
+ walltimeMinutes: number;
19
+ jobName: string;
20
+ /**
21
+ * Opt-in idle auto-release: the client server on the compute node exits its
22
+ * own process after this many minutes without kernel/terminal activity, so
23
+ * the batch job completes and the allocation ends naturally. Omitted = never.
24
+ */
25
+ idleTimeoutMinutes?: number;
26
+ }
27
+ export interface JobStatus {
28
+ state: JobState;
29
+ nodes: string[];
30
+ reason?: string;
31
+ }
32
+ /** Result of a dry-run start-time estimate (SLURM `sbatch --test-only`). */
33
+ export interface StartEstimate {
34
+ startsAt?: string;
35
+ nodes?: string[];
36
+ reason?: string;
37
+ }
38
+ /** What a given user is allowed to submit to. */
39
+ export interface Associations {
40
+ account?: string;
41
+ partitions: string[];
42
+ qoses: string[];
43
+ defaultQos?: string;
44
+ }
45
+ export interface PartitionLoad {
46
+ name: string;
47
+ up: boolean;
48
+ timeLimit: string;
49
+ cpus: {
50
+ alloc: number;
51
+ idle: number;
52
+ other: number;
53
+ total: number;
54
+ };
55
+ /** GPU capacity for the partition: total configured vs currently idle (available). */
56
+ gpus?: {
57
+ type: string;
58
+ total: number;
59
+ idle: number;
60
+ };
61
+ nodes: {
62
+ idle: number;
63
+ mixed: number;
64
+ alloc: number;
65
+ down: number;
66
+ total: number;
67
+ };
68
+ jobs: {
69
+ pending: number;
70
+ running: number;
71
+ };
72
+ }
73
+ export interface QosLoad {
74
+ name: string;
75
+ priority: number;
76
+ maxWall?: string;
77
+ /** True if some other QoS is configured to preempt this one (jobs may be interrupted). */
78
+ preemptible: boolean;
79
+ /** QoS names this QoS can preempt (empty for most). */
80
+ preempts: string[];
81
+ /** Live usage under this QoS across the cluster. */
82
+ jobs: {
83
+ running: number;
84
+ pending: number;
85
+ };
86
+ }
87
+ /** A point-in-time snapshot of cluster busyness for the launcher's load monitor. */
88
+ export interface QueueLoad {
89
+ partitions: PartitionLoad[];
90
+ qoses: QosLoad[];
91
+ fetchedAt: number;
92
+ }
93
+ export interface Scheduler {
94
+ readonly name: string;
95
+ /** Is this scheduler available on this host (are its CLIs present)? */
96
+ detect(): Promise<boolean>;
97
+ /** Partitions/QoS the user may submit to, and their default QoS. */
98
+ associations(user: string): Promise<Associations>;
99
+ /** Per-partition / per-QoS busyness snapshot. */
100
+ load(): Promise<QueueLoad>;
101
+ /**
102
+ * QoS names a partition will actually accept, or null when it accepts any
103
+ * (no explicit-QoS requirement). Lets the UI avoid offering a QoS the
104
+ * scheduler would reject. Discovered from the scheduler, not configured.
105
+ */
106
+ allowedQos(partition: string): Promise<string[] | null>;
107
+ /** Dry-run estimated start time for a spec, without submitting. */
108
+ estimateStart(spec: JobSpec): Promise<StartEstimate>;
109
+ /** Submit a rendered job script; returns the scheduler job id. */
110
+ submit(scriptPath: string): Promise<{
111
+ jobId: string;
112
+ }>;
113
+ /** Current state of a submitted job. */
114
+ query(jobId: string): Promise<JobStatus>;
115
+ /** Cancel a submitted job. */
116
+ cancel(jobId: string): Promise<void>;
117
+ }
@@ -0,0 +1,8 @@
1
+ "use strict";
2
+ /**
3
+ * Scheduler abstraction — a thin interface over an HPC batch scheduler.
4
+ *
5
+ * SLURM is the first implementation; PBS/LSF can implement the same interface
6
+ * later without touching the allocation service or routes.
7
+ */
8
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Small shared helpers for the scheduler module.
3
+ */
4
+ /** Format minutes as a SLURM walltime string (`D-HH:MM:SS` or `HH:MM:SS`). */
5
+ export declare function formatWalltime(minutes: number): string;
6
+ /** POSIX single-quote a string so it is safe to embed in a shell script. */
7
+ export declare function shellQuote(s: string): string;
@@ -0,0 +1,20 @@
1
+ "use strict";
2
+ /**
3
+ * Small shared helpers for the scheduler module.
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.formatWalltime = formatWalltime;
7
+ exports.shellQuote = shellQuote;
8
+ /** Format minutes as a SLURM walltime string (`D-HH:MM:SS` or `HH:MM:SS`). */
9
+ function formatWalltime(minutes) {
10
+ const total = Math.max(1, Math.floor(minutes));
11
+ const d = Math.floor(total / 1440);
12
+ const h = Math.floor((total % 1440) / 60);
13
+ const m = total % 60;
14
+ const hhmmss = `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}:00`;
15
+ return d > 0 ? `${d}-${hhmmss}` : hhmmss;
16
+ }
17
+ /** POSIX single-quote a string so it is safe to embed in a shell script. */
18
+ function shellQuote(s) {
19
+ return `'${String(s).replace(/'/g, `'\\''`)}'`;
20
+ }
@@ -37,6 +37,7 @@ var __importStar = (this && this.__importStar) || (function () {
37
37
  })();
38
38
  Object.defineProperty(exports, "__esModule", { value: true });
39
39
  exports.ptyManager = exports.PtyManager = void 0;
40
+ const path = __importStar(require("path"));
40
41
  const pty = __importStar(require("@homebridge/node-pty-prebuilt-multiarch"));
41
42
  const uuid_1 = require("uuid");
42
43
  const types_1 = require("./types");
@@ -76,6 +77,11 @@ class PtyManager {
76
77
  const cwd = options?.cwd || process.cwd();
77
78
  const cols = options?.cols || 80;
78
79
  const rows = options?.rows || 24;
80
+ // Make the `nebula` agent CLI resolvable in every Nebula terminal, and
81
+ // pre-point it at this server. The CLI is a thin layer over the same
82
+ // client the MCP uses (packages/mcp) — agents in the built-in terminal
83
+ // can drive notebooks without any MCP registration.
84
+ const nebulaCliBinDir = path.resolve(__dirname, '..', '..', '..', 'packages', 'mcp', 'bin');
79
85
  // Spawn PTY process
80
86
  const ptyProcess = pty.spawn(shell, [], {
81
87
  name: 'xterm-256color',
@@ -86,6 +92,8 @@ class PtyManager {
86
92
  ...process.env,
87
93
  TERM: 'xterm-256color',
88
94
  COLORTERM: 'truecolor',
95
+ NEBULA_URL: process.env.NEBULA_URL ?? `http://localhost:${process.env.PORT || 3000}`,
96
+ PATH: `${nebulaCliBinDir}:${process.env.PATH ?? ''}`,
89
97
  },
90
98
  });
91
99
  const session = {
@@ -40,6 +40,8 @@ exports.setupTerminalRoutes = setupTerminalRoutes;
40
40
  exports.setupTerminalWebSocket = setupTerminalWebSocket;
41
41
  exports.cleanupTerminals = cleanupTerminals;
42
42
  const ws_1 = require("ws");
43
+ const net = __importStar(require("net"));
44
+ const os = __importStar(require("os"));
43
45
  const path = __importStar(require("path"));
44
46
  const pty_manager_1 = require("./pty-manager");
45
47
  const fs_service_1 = require("../fs/fs-service");
@@ -59,9 +61,48 @@ const wsConnections = new Map();
59
61
  */
60
62
  async function setupTerminalRoutes(fastify) {
61
63
  // Terminal health check (includes terminal count and repo root for the
62
- // path-qualified MCP setup hint shown in the agent terminal UI)
64
+ // path-qualified MCP setup hint shown in the agent terminal UI, plus
65
+ // hostname/port so the UI can compose an exact SSH tunnel command)
63
66
  fastify.get('/api/terminals/health', async (_request, reply) => {
64
- return reply.send({ status: 'ok', terminals: pty_manager_1.ptyManager.list().length, repo_root: NEBULA_REPO_ROOT });
67
+ return reply.send({
68
+ status: 'ok',
69
+ terminals: pty_manager_1.ptyManager.list().length,
70
+ repo_root: NEBULA_REPO_ROOT,
71
+ hostname: os.hostname(),
72
+ port: Number(process.env.PORT) || 3000,
73
+ });
74
+ });
75
+ // Probe a loopback port on THIS host — used by remote-agent mode to detect
76
+ // whether the user's reverse SSH channel (ssh -R <port>:localhost:22) is up.
77
+ // Loopback-only by construction; the port is user-chosen and random.
78
+ fastify.get('/api/terminals/reverse-check', async (request, reply) => {
79
+ const port = Number(request.query.port);
80
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) {
81
+ return reply.status(400).send({ error: 'port must be an integer in 1024-65535' });
82
+ }
83
+ // Two-signal probe at the cost of one connection: `up` = the listener
84
+ // accepted (fast path, unchanged); `ssh` = an SSH banner arrived within
85
+ // 700ms of connecting (an sshd greets immediately with "SSH-2.0-...").
86
+ // ssh=false means the port is forwarded but nothing SSH answers — the
87
+ // classic Remote-Login-off case; ssh=null means banner unknown (slow
88
+ // network) and MUST NOT be treated as a failure.
89
+ const result = await new Promise((resolve) => {
90
+ const sock = net.connect({ host: '127.0.0.1', port, timeout: 1200 });
91
+ let bannerTimer = null;
92
+ const finish = (up, ssh) => {
93
+ if (bannerTimer)
94
+ clearTimeout(bannerTimer);
95
+ sock.destroy();
96
+ resolve({ up, ssh });
97
+ };
98
+ sock.once('connect', () => {
99
+ bannerTimer = setTimeout(() => finish(true, null), 700);
100
+ sock.once('data', (buf) => finish(true, buf.toString('latin1').startsWith('SSH-')));
101
+ });
102
+ sock.once('timeout', () => finish(false, null));
103
+ sock.once('error', () => finish(false, null));
104
+ });
105
+ return reply.send({ up: result.up, ssh: result.ssh });
65
106
  });
66
107
  // List all terminals
67
108
  fastify.get('/api/terminals', async (_request, reply) => {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Update check — notify-only, never auto-installs.
3
+ *
4
+ * Once at startup (and daily after), asks the npm registry for the latest
5
+ * published nebula-notebook version and compares it to the running one.
6
+ * Result is surfaced in /api/health (`update`) for the UI pill and logged
7
+ * once to the server console. Opt out with NEBULA_NO_UPDATE_CHECK=1.
8
+ * Network failures are silent: an update check must never affect operation.
9
+ */
10
+ export interface UpdateInfo {
11
+ current: string;
12
+ latest: string | null;
13
+ update_available: boolean;
14
+ checked_at: number | null;
15
+ }
16
+ /** Numeric semver compare on MAJOR.MINOR.PATCH; ignores prerelease tags. */
17
+ export declare function isNewerVersion(latest: string, current: string): boolean;
18
+ export declare function startUpdateChecker(): void;
19
+ export declare function stopUpdateChecker(): void;
20
+ export declare function getUpdateInfo(): UpdateInfo;