nebula-notebook 0.2.15 → 0.2.16

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.
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ /**
3
+ * Multi-arch launch support for compute allocations.
4
+ *
5
+ * An allocation re-launches THIS Nebula install on the compute node, so the
6
+ * node binary and node_modules must match the compute node's CPU arch. On
7
+ * heterogeneous clusters (CRI: x86_64 login nodes, aarch64 ghq/pearsonq) a
8
+ * per-arch runtime is configured via env:
9
+ *
10
+ * NEBULA_ARM64_NODE_BIN=/shared/node22-arm64/bin/node
11
+ * NEBULA_ARM64_DIR=/shared/nebula-notebook-arm64 # checkout with arm64 node_modules
12
+ *
13
+ * Both must live on storage the compute nodes share with the login node.
14
+ */
15
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
16
+ if (k2 === undefined) k2 = k;
17
+ var desc = Object.getOwnPropertyDescriptor(m, k);
18
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
19
+ desc = { enumerable: true, get: function() { return m[k]; } };
20
+ }
21
+ Object.defineProperty(o, k2, desc);
22
+ }) : (function(o, m, k, k2) {
23
+ if (k2 === undefined) k2 = k;
24
+ o[k2] = m[k];
25
+ }));
26
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
27
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
28
+ }) : function(o, v) {
29
+ o["default"] = v;
30
+ });
31
+ var __importStar = (this && this.__importStar) || (function () {
32
+ var ownKeys = function(o) {
33
+ ownKeys = Object.getOwnPropertyNames || function (o) {
34
+ var ar = [];
35
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
36
+ return ar;
37
+ };
38
+ return ownKeys(o);
39
+ };
40
+ return function (mod) {
41
+ if (mod && mod.__esModule) return mod;
42
+ var result = {};
43
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
44
+ __setModuleDefault(result, mod);
45
+ return result;
46
+ };
47
+ })();
48
+ Object.defineProperty(exports, "__esModule", { value: true });
49
+ exports.normalizeArch = normalizeArch;
50
+ exports.archOverridesFromEnv = archOverridesFromEnv;
51
+ exports.pickLaunchContext = pickLaunchContext;
52
+ const path = __importStar(require("path"));
53
+ /** SLURM spellings (uname -m) → node's process.arch identifiers. */
54
+ function normalizeArch(raw) {
55
+ const a = (raw || '').trim().toLowerCase();
56
+ if (a === 'x86_64' || a === 'amd64' || a === 'x64')
57
+ return 'x64';
58
+ if (a === 'aarch64' || a === 'arm64')
59
+ return 'arm64';
60
+ return a;
61
+ }
62
+ const CONFIGURABLE_ARCHES = ['arm64', 'x64'];
63
+ /**
64
+ * Read per-arch runtime overrides from the environment. An arch is configured
65
+ * only when BOTH its vars are present — a node binary without its matching
66
+ * node_modules tree (or vice versa) would just fail later and worse.
67
+ */
68
+ function archOverridesFromEnv(env = process.env) {
69
+ const overrides = {};
70
+ for (const arch of CONFIGURABLE_ARCHES) {
71
+ const prefix = `NEBULA_${arch.toUpperCase()}_`;
72
+ const nodeBin = env[`${prefix}NODE_BIN`]?.trim();
73
+ const dir = env[`${prefix}DIR`]?.trim();
74
+ if (!nodeBin || !dir)
75
+ continue;
76
+ overrides[arch] = {
77
+ nodeBin,
78
+ cwd: path.join(dir, 'node-server'),
79
+ scriptPath: path.join(dir, 'node-server', 'src', 'index.ts'),
80
+ };
81
+ }
82
+ return overrides;
83
+ }
84
+ /**
85
+ * Pick the launch context for a partition's arch: the server's own install
86
+ * when arches match (or the arch is unknown — the status quo), the configured
87
+ * override when they differ, and a loud, actionable refusal otherwise. The
88
+ * refusal is the point: without it the job dies on the compute node with
89
+ * "Exec format error" in a log nobody reads.
90
+ */
91
+ function pickLaunchContext(ctx, targetArchRaw, serverArch = process.arch // undefined → the running process's arch
92
+ ) {
93
+ if (!targetArchRaw)
94
+ return ctx;
95
+ const target = normalizeArch(targetArchRaw);
96
+ if (target === normalizeArch(serverArch))
97
+ return ctx;
98
+ const override = ctx.archOverrides?.[target];
99
+ if (!override) {
100
+ const prefix = `NEBULA_${target.toUpperCase()}_`;
101
+ throw new Error(`this partition runs ${targetArchRaw} nodes but this Nebula server is ${serverArch} — ` +
102
+ `its runtime cannot execute there. Install a ${targetArchRaw} Node.js and Nebula checkout ` +
103
+ `on shared storage and set ${prefix}NODE_BIN and ${prefix}DIR on the server.`);
104
+ }
105
+ return {
106
+ ...ctx,
107
+ nodeBin: override.nodeBin,
108
+ cwd: override.cwd,
109
+ scriptPath: override.scriptPath,
110
+ // Never let a client restart itself mid-job on a source change.
111
+ execArgv: ctx.execArgv.filter((a) => a !== '--watch'),
112
+ };
113
+ }
@@ -26,5 +26,16 @@ export interface LaunchContext {
26
26
  cwd: string;
27
27
  /** Directory (on shared storage) for job scripts + logs */
28
28
  stateDir: string;
29
+ /**
30
+ * Per-arch runtime substitutions (node's process.arch keys, e.g. 'arm64')
31
+ * for partitions whose CPU arch differs from this server's — see arch.ts.
32
+ */
33
+ archOverrides?: Record<string, {
34
+ nodeBin: string;
35
+ cwd: string;
36
+ scriptPath: string;
37
+ }>;
38
+ /** Arch of nodeBin itself. Defaults to process.arch; overridable for tests. */
39
+ serverArch?: string;
29
40
  }
30
41
  export declare function renderJobScript(spec: JobSpec, ctx: LaunchContext, allocId: string, token: string): string;
@@ -21,6 +21,7 @@ export declare class MockScheduler implements Scheduler {
21
21
  associations(_user: string): Promise<Associations>;
22
22
  load(): Promise<QueueLoad>;
23
23
  allowedQos(partition: string): Promise<string[] | null>;
24
+ partitionArch(_partition: string): Promise<string | null>;
24
25
  estimateStart(): Promise<StartEstimate>;
25
26
  submit(scriptPath: string): Promise<{
26
27
  jobId: string;
@@ -73,6 +73,9 @@ class MockScheduler {
73
73
  return ['priority', 'opportunistic'];
74
74
  return null;
75
75
  }
76
+ async partitionArch(_partition) {
77
+ return null; // mock cluster is arch-homogeneous
78
+ }
76
79
  async estimateStart() {
77
80
  // The launcher uses capacity-based availability, not this dry-run estimate.
78
81
  return {};
@@ -21,6 +21,7 @@ export declare class SlurmScheduler implements Scheduler {
21
21
  private associationsFresh;
22
22
  load(): Promise<QueueLoad>;
23
23
  private loadFresh;
24
+ partitionArch(partition: string): Promise<string | null>;
24
25
  allowedQos(partition: string): Promise<string[] | null>;
25
26
  estimateStart(spec: JobSpec): Promise<StartEstimate>;
26
27
  submit(scriptPath: string): Promise<{
@@ -11,6 +11,7 @@ exports.SlurmScheduler = void 0;
11
11
  const child_process_1 = require("child_process");
12
12
  const util_1 = require("util");
13
13
  const util_2 = require("./util");
14
+ const arch_1 = require("./arch");
14
15
  const execFileP = (0, util_1.promisify)(child_process_1.execFile);
15
16
  async function run(cmd, args, timeoutMs = 15_000) {
16
17
  const { stdout, stderr } = await execFileP(cmd, args, {
@@ -210,7 +211,26 @@ class SlurmScheduler {
210
211
  // GPU capacity per partition, from per-node TRES: configured (CfgTRES) vs
211
212
  // allocated (AllocTRES) `gres/gpu`, so we can report *idle* (available) GPUs
212
213
  // rather than a per-node count. Generic — no site-specific node/gres names.
214
+ // The same per-node dump also carries each node's CPU arch (`Arch=`), which
215
+ // multi-arch clusters (x86_64 login + aarch64 queues) need at submit time.
213
216
  if (nodeRes.status === 'fulfilled') {
217
+ const archAgg = new Map();
218
+ for (const line of nodeRes.value.stdout.split('\n')) {
219
+ if (!line.trim())
220
+ continue;
221
+ const arch = scontrolField(line, 'Arch');
222
+ const nodeParts = scontrolField(line, 'Partitions');
223
+ if (arch && nodeParts) {
224
+ for (const part of nodeParts.split(',')) {
225
+ (archAgg.get(part) ?? archAgg.set(part, new Set()).get(part)).add(arch);
226
+ }
227
+ }
228
+ }
229
+ for (const [part, archs] of archAgg) {
230
+ const p = partitions.get(part);
231
+ if (p)
232
+ p.archs = [...archs].sort();
233
+ }
214
234
  // Aggregate per (partition, GPU model) — heterogeneous queues mix cards.
215
235
  const agg = new Map();
216
236
  for (const line of nodeRes.value.stdout.split('\n')) {
@@ -307,6 +327,20 @@ class SlurmScheduler {
307
327
  }
308
328
  return { partitions: [...partitions.values()], qoses, fetchedAt: Date.now() };
309
329
  }
330
+ async partitionArch(partition) {
331
+ try {
332
+ const load = await this.load();
333
+ const archs = load.partitions.find((p) => p.name === partition)?.archs;
334
+ // Mixed-arch partitions have no safe pick — the scheduler decides the
335
+ // node, so treat them as unknown rather than guessing wrong half the time.
336
+ if (!archs || archs.length !== 1)
337
+ return null;
338
+ return (0, arch_1.normalizeArch)(archs[0]);
339
+ }
340
+ catch {
341
+ return null;
342
+ }
343
+ }
310
344
  async allowedQos(partition) {
311
345
  const cached = this.qosCache.get(partition);
312
346
  if (cached && Date.now() - cached.at < 300_000)
@@ -70,6 +70,9 @@ export interface PartitionLoad {
70
70
  pending: number;
71
71
  running: number;
72
72
  };
73
+ /** Distinct CPU arches of the partition's nodes, SLURM spelling (e.g.
74
+ * 'x86_64', 'aarch64'), sorted. Absent when no node reported one. */
75
+ archs?: string[];
73
76
  }
74
77
  export interface QosLoad {
75
78
  name: string;
@@ -105,6 +108,12 @@ export interface Scheduler {
105
108
  * scheduler would reject. Discovered from the scheduler, not configured.
106
109
  */
107
110
  allowedQos(partition: string): Promise<string[] | null>;
111
+ /**
112
+ * The partition's CPU arch as a node process.arch identifier ('x64',
113
+ * 'arm64'), or null when unknown or mixed — null means "assume the
114
+ * server's own arch", preserving pre-multi-arch behavior.
115
+ */
116
+ partitionArch(partition: string): Promise<string | null>;
108
117
  /** Dry-run estimated start time for a spec, without submitting. */
109
118
  estimateStart(spec: JobSpec): Promise<StartEstimate>;
110
119
  /** Submit a rendered job script; returns the scheduler job id. */
@@ -5,3 +5,9 @@
5
5
  export declare function formatWalltime(minutes: number): string;
6
6
  /** POSIX single-quote a string so it is safe to embed in a shell script. */
7
7
  export declare function shellQuote(s: string): string;
8
+ /**
9
+ * Compress a job log's tail into a one-line failure reason: the last few
10
+ * non-empty lines (the error is at the end), capped so a pathological log
11
+ * can't flood the UI. Null when there is nothing to say.
12
+ */
13
+ export declare function summarizeLogTail(content: string): string | null;
@@ -5,6 +5,7 @@
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.formatWalltime = formatWalltime;
7
7
  exports.shellQuote = shellQuote;
8
+ exports.summarizeLogTail = summarizeLogTail;
8
9
  /** Format minutes as a SLURM walltime string (`D-HH:MM:SS` or `HH:MM:SS`). */
9
10
  function formatWalltime(minutes) {
10
11
  const total = Math.max(1, Math.floor(minutes));
@@ -18,3 +19,18 @@ function formatWalltime(minutes) {
18
19
  function shellQuote(s) {
19
20
  return `'${String(s).replace(/'/g, `'\\''`)}'`;
20
21
  }
22
+ /**
23
+ * Compress a job log's tail into a one-line failure reason: the last few
24
+ * non-empty lines (the error is at the end), capped so a pathological log
25
+ * can't flood the UI. Null when there is nothing to say.
26
+ */
27
+ function summarizeLogTail(content) {
28
+ const lines = content
29
+ .split('\n')
30
+ .map((l) => l.trimEnd())
31
+ .filter((l) => l.trim().length > 0);
32
+ if (lines.length === 0)
33
+ return null;
34
+ const joined = lines.slice(-3).join(' | ');
35
+ return joined.length > 300 ? `…${joined.slice(-300)}` : joined;
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nebula-notebook",
3
- "version": "0.2.15",
3
+ "version": "0.2.16",
4
4
  "description": "AI-native notebook computing environment — real Jupyter kernels, real filesystem, built to be driven by agents (Claude Code / Codex) via MCP",
5
5
  "type": "module",
6
6
  "license": "MIT",