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.
- package/README.md +124 -24
- package/dist/assets/errorwidget-C4r2j2DQ.js +5 -0
- package/dist/assets/fa-brands-400-CEJbCg16.woff +0 -0
- package/dist/assets/fa-brands-400-CSYNqBb_.ttf +0 -0
- package/dist/assets/fa-brands-400-DnkPfk3o.eot +0 -0
- package/dist/assets/fa-brands-400-UxlILjvJ.woff2 +0 -0
- package/dist/assets/fa-brands-400-cH1MgKbP.svg +3717 -0
- package/dist/assets/fa-regular-400-BhTwtT8w.eot +0 -0
- package/dist/assets/fa-regular-400-D1vz6WBx.ttf +0 -0
- package/dist/assets/fa-regular-400-DFnMcJPd.woff +0 -0
- package/dist/assets/fa-regular-400-DGzu1beS.woff2 +0 -0
- package/dist/assets/fa-regular-400-gwj8Pxq-.svg +801 -0
- package/dist/assets/fa-solid-900-B4ZZ7kfP.svg +5034 -0
- package/dist/assets/fa-solid-900-B6Axprfb.eot +0 -0
- package/dist/assets/fa-solid-900-BUswJgRo.woff2 +0 -0
- package/dist/assets/fa-solid-900-DOXgCApm.woff +0 -0
- package/dist/assets/fa-solid-900-mxuxnBEa.ttf +0 -0
- package/dist/assets/index-7-YBurka.js +716 -0
- package/dist/assets/index-BtWv4MIT.css +7 -0
- package/dist/assets/index-CFBUnxSZ.css +32 -0
- package/dist/assets/index-CsHoPQy-.js +1 -0
- package/dist/assets/index-D5w21_Z8.js +81 -0
- package/dist/assets/index-Day3QcNs.js +1 -0
- package/dist/assets/services-shim-D6p_A67v.js +33 -0
- package/dist/assets/viewlist-uomDf7I7.js +1 -0
- package/dist/assets/widgets-X7J3NxEn.css +1 -0
- package/dist/index.html +2 -2
- package/node-server/dist/cluster/client-registration.js +3 -0
- package/node-server/dist/cluster/kernel-proxy.js +24 -9
- package/node-server/dist/cluster/server-registry.d.ts +8 -0
- package/node-server/dist/cluster/server-registry.js +31 -7
- package/node-server/dist/fs/fs-service.d.ts +55 -7
- package/node-server/dist/fs/fs-service.js +489 -80
- package/node-server/dist/fs/notebook-formats/percent.d.ts +25 -0
- package/node-server/dist/fs/notebook-formats/percent.js +286 -0
- package/node-server/dist/fs/notebook-formats/qmd.d.ts +29 -0
- package/node-server/dist/fs/notebook-formats/qmd.js +307 -0
- package/node-server/dist/fs/notebook-formats/registry.d.ts +12 -0
- package/node-server/dist/fs/notebook-formats/registry.js +77 -0
- package/node-server/dist/fs/notebook-formats/types.d.ts +37 -0
- package/node-server/dist/fs/notebook-formats/types.js +13 -0
- package/node-server/dist/idle-exit.d.ts +52 -0
- package/node-server/dist/idle-exit.js +83 -0
- package/node-server/dist/index.js +129 -9
- package/node-server/dist/kernel/kernel-service.d.ts +113 -2
- package/node-server/dist/kernel/kernel-service.js +762 -60
- package/node-server/dist/notebook/cell-hash.d.ts +13 -0
- package/node-server/dist/notebook/cell-hash.js +26 -0
- package/node-server/dist/notebook/headless-handler.d.ts +9 -0
- package/node-server/dist/notebook/headless-handler.js +124 -23
- package/node-server/dist/notebook/operation-router.d.ts +36 -0
- package/node-server/dist/notebook/operation-router.js +224 -9
- package/node-server/dist/notebook/undoRedoManager.d.ts +4 -1
- package/node-server/dist/notebook/undoRedoManager.js +10 -2
- package/node-server/dist/output/display-data.js +2 -0
- package/node-server/dist/routes/cluster.js +2 -2
- package/node-server/dist/routes/compute.d.ts +8 -0
- package/node-server/dist/routes/compute.js +136 -0
- package/node-server/dist/routes/fs.js +2 -2
- package/node-server/dist/routes/kernel.js +115 -3
- package/node-server/dist/routes/notebook.js +35 -1
- package/node-server/dist/scheduler/allocation-service.d.ts +43 -0
- package/node-server/dist/scheduler/allocation-service.js +169 -0
- package/node-server/dist/scheduler/job-template.d.ts +30 -0
- package/node-server/dist/scheduler/job-template.js +85 -0
- package/node-server/dist/scheduler/mock-scheduler.d.ts +30 -0
- package/node-server/dist/scheduler/mock-scheduler.js +121 -0
- package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
- package/node-server/dist/scheduler/slurm-scheduler.js +393 -0
- package/node-server/dist/scheduler/types.d.ts +117 -0
- package/node-server/dist/scheduler/types.js +8 -0
- package/node-server/dist/scheduler/util.d.ts +7 -0
- package/node-server/dist/scheduler/util.js +20 -0
- package/node-server/dist/terminal/pty-manager.js +8 -0
- package/node-server/dist/terminal/server.js +43 -2
- package/node-server/dist/update-check.d.ts +20 -0
- package/node-server/dist/update-check.js +114 -0
- package/node-server/package.json +1 -3
- package/package.json +3 -5
- package/dist/assets/index-C1h_sArD.css +0 -32
- package/dist/assets/index-CDSTBon8.js +0 -658
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Registry for plain-text notebook format adapters.
|
|
4
|
+
*
|
|
5
|
+
* .ipynb is intentionally NOT registered here: dispatch sites check .ipynb
|
|
6
|
+
* first and fall through to the existing Jupyter JSON code path untouched.
|
|
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.getFormatAdapter = getFormatAdapter;
|
|
43
|
+
exports.defaultKernelForPath = defaultKernelForPath;
|
|
44
|
+
exports.isTextNotebookPath = isTextNotebookPath;
|
|
45
|
+
exports.isNotebookPath = isNotebookPath;
|
|
46
|
+
const path = __importStar(require("path"));
|
|
47
|
+
const percent_1 = require("./percent");
|
|
48
|
+
const qmd_1 = require("./qmd");
|
|
49
|
+
const ADAPTERS = [percent_1.percentAdapter, qmd_1.qmdAdapter];
|
|
50
|
+
const BY_EXTENSION = new Map();
|
|
51
|
+
for (const adapter of ADAPTERS) {
|
|
52
|
+
for (const ext of adapter.extensions)
|
|
53
|
+
BY_EXTENSION.set(ext, adapter);
|
|
54
|
+
}
|
|
55
|
+
function getFormatAdapter(filePath) {
|
|
56
|
+
return BY_EXTENSION.get(path.extname(filePath).toLowerCase()) ?? null;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Default kernel for a text-notebook file that does not declare one in its
|
|
60
|
+
* header, keyed by extension. Falls back to python3 for unknown extensions.
|
|
61
|
+
* (.qmd resolves its kernel from the first code fence's language instead.)
|
|
62
|
+
*/
|
|
63
|
+
const DEFAULT_KERNEL_BY_EXTENSION = {
|
|
64
|
+
'.py': 'python3',
|
|
65
|
+
'.r': 'ir',
|
|
66
|
+
'.jl': 'julia',
|
|
67
|
+
};
|
|
68
|
+
function defaultKernelForPath(filePath) {
|
|
69
|
+
return DEFAULT_KERNEL_BY_EXTENSION[path.extname(filePath).toLowerCase()] ?? 'python3';
|
|
70
|
+
}
|
|
71
|
+
function isTextNotebookPath(filePath) {
|
|
72
|
+
return BY_EXTENSION.has(path.extname(filePath).toLowerCase());
|
|
73
|
+
}
|
|
74
|
+
/** True for any path Nebula can treat as a notebook (.ipynb or text format). */
|
|
75
|
+
function isNotebookPath(filePath) {
|
|
76
|
+
return path.extname(filePath).toLowerCase() === '.ipynb' || isTextNotebookPath(filePath);
|
|
77
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plain-text notebook format adapters (jupytext percent .py, Quarto .qmd).
|
|
3
|
+
*
|
|
4
|
+
* These formats are "notebooks that don't serialize outputs": the in-memory
|
|
5
|
+
* model is the same NebulaCell[] the rest of the system uses; only the
|
|
6
|
+
* on-disk representation differs. Outputs, execution counts, and UI state
|
|
7
|
+
* are never written to disk for these formats — that is the point of them.
|
|
8
|
+
*
|
|
9
|
+
* .ipynb is deliberately NOT an adapter: every dispatch site checks .ipynb
|
|
10
|
+
* first and falls into the existing, unmodified Jupyter JSON code path.
|
|
11
|
+
*/
|
|
12
|
+
import { NebulaCell } from '../types';
|
|
13
|
+
export interface ParsedTextNotebook {
|
|
14
|
+
/** Cells with outputs: [], executionCount: null. Ids come from in-file
|
|
15
|
+
* markers when present, else the positional `cell-${i}` fallback (the
|
|
16
|
+
* same degraded mode legacy .ipynb files without nebula_id use). */
|
|
17
|
+
cells: NebulaCell[];
|
|
18
|
+
/** Normalized notebook metadata: kernelspec?, nebula?, plus opaque
|
|
19
|
+
* format-specific keys the serializer round-trips. */
|
|
20
|
+
metadata: Record<string, unknown>;
|
|
21
|
+
/** Kernelspec name if the file declares one, else null (caller falls back
|
|
22
|
+
* to the existing env-default resolution). */
|
|
23
|
+
kernelspecName: string | null;
|
|
24
|
+
}
|
|
25
|
+
export interface NotebookFormatAdapter {
|
|
26
|
+
name: 'percent' | 'qmd';
|
|
27
|
+
/** Lowercased extensions including the dot, e.g. ['.py']. */
|
|
28
|
+
extensions: string[];
|
|
29
|
+
capabilities: {
|
|
30
|
+
storesOutputs: false;
|
|
31
|
+
/** Whether cell ids can persist in the file for ALL cell types.
|
|
32
|
+
* percent: true. qmd: code cells only (prose has no metadata slot). */
|
|
33
|
+
storesCellIds: boolean;
|
|
34
|
+
};
|
|
35
|
+
parse(text: string): ParsedTextNotebook;
|
|
36
|
+
serialize(cells: NebulaCell[], metadata: Record<string, unknown>, kernelName?: string): string;
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Plain-text notebook format adapters (jupytext percent .py, Quarto .qmd).
|
|
4
|
+
*
|
|
5
|
+
* These formats are "notebooks that don't serialize outputs": the in-memory
|
|
6
|
+
* model is the same NebulaCell[] the rest of the system uses; only the
|
|
7
|
+
* on-disk representation differs. Outputs, execution counts, and UI state
|
|
8
|
+
* are never written to disk for these formats — that is the point of them.
|
|
9
|
+
*
|
|
10
|
+
* .ipynb is deliberately NOT an adapter: every dispatch site checks .ipynb
|
|
11
|
+
* first and falls into the existing, unmodified Jupyter JSON code path.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idle auto-release for scheduler-launched client servers.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in per allocation: when the compute-node client server is started with
|
|
5
|
+
* NEBULA_IDLE_EXIT_MINUTES=<n>, it watches its own kernel and terminal
|
|
6
|
+
* activity and exits its process after n idle minutes. The batch job then
|
|
7
|
+
* completes and the allocation ends naturally — no scancel plumbing, no
|
|
8
|
+
* scheduler-side state.
|
|
9
|
+
*
|
|
10
|
+
* "Idle" means all of:
|
|
11
|
+
* - no kernel session is busy (or still starting up), AND
|
|
12
|
+
* - the most recent kernel activity (execution start/finish, restart) is
|
|
13
|
+
* older than the timeout, AND
|
|
14
|
+
* - the most recent terminal (pty) activity is older than the timeout.
|
|
15
|
+
*
|
|
16
|
+
* The monitor's own start time is the initial activity baseline, so a fresh
|
|
17
|
+
* allocation gets the full timeout before anything has run on it.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Pure idleness decision — should the client server exit now?
|
|
21
|
+
*
|
|
22
|
+
* @param nowMs current time (ms since epoch)
|
|
23
|
+
* @param lastKernelActivityMs most recent kernel activity (ms), or null if no signal
|
|
24
|
+
* @param anyKernelBusy true when any kernel session is 'busy' or 'starting'
|
|
25
|
+
* @param lastPtyActivityMs most recent terminal activity (ms), or null if no signal
|
|
26
|
+
* @param timeoutMinutes idle timeout; <= 0 disables (never exit)
|
|
27
|
+
*/
|
|
28
|
+
export declare function shouldIdleExit(nowMs: number, lastKernelActivityMs: number | null, anyKernelBusy: boolean, lastPtyActivityMs: number | null, timeoutMinutes: number): boolean;
|
|
29
|
+
export interface IdleExitMonitorOptions {
|
|
30
|
+
/** Idle minutes before self-exit (from NEBULA_IDLE_EXIT_MINUTES). */
|
|
31
|
+
timeoutMinutes: number;
|
|
32
|
+
/** Kernel-side activity snapshot (kernelService.getIdleSnapshot). */
|
|
33
|
+
getKernelSnapshot: () => {
|
|
34
|
+
anyBusy: boolean;
|
|
35
|
+
lastActivityMs: number | null;
|
|
36
|
+
};
|
|
37
|
+
/** Most recent terminal activity across pty sessions (null = none). */
|
|
38
|
+
getLastPtyActivityMs: () => number | null;
|
|
39
|
+
/** Called once when ~WARN_LEAD_MINUTES remain (re-armed if activity resumes). */
|
|
40
|
+
onWarn: (minutesLeft: number) => void;
|
|
41
|
+
/** Called once when the idle timeout is reached. */
|
|
42
|
+
onIdleExit: () => void | Promise<void>;
|
|
43
|
+
/** Check interval; default 60s. Exposed for tests. */
|
|
44
|
+
intervalMs?: number;
|
|
45
|
+
/** Activity baseline; default Date.now() (start of monitoring). */
|
|
46
|
+
startedAtMs?: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Start the periodic idleness check. Returns a stop function.
|
|
50
|
+
* The interval is unref()ed so it never keeps an exiting process alive.
|
|
51
|
+
*/
|
|
52
|
+
export declare function startIdleExitMonitor(opts: IdleExitMonitorOptions): () => void;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Idle auto-release for scheduler-launched client servers.
|
|
4
|
+
*
|
|
5
|
+
* Opt-in per allocation: when the compute-node client server is started with
|
|
6
|
+
* NEBULA_IDLE_EXIT_MINUTES=<n>, it watches its own kernel and terminal
|
|
7
|
+
* activity and exits its process after n idle minutes. The batch job then
|
|
8
|
+
* completes and the allocation ends naturally — no scancel plumbing, no
|
|
9
|
+
* scheduler-side state.
|
|
10
|
+
*
|
|
11
|
+
* "Idle" means all of:
|
|
12
|
+
* - no kernel session is busy (or still starting up), AND
|
|
13
|
+
* - the most recent kernel activity (execution start/finish, restart) is
|
|
14
|
+
* older than the timeout, AND
|
|
15
|
+
* - the most recent terminal (pty) activity is older than the timeout.
|
|
16
|
+
*
|
|
17
|
+
* The monitor's own start time is the initial activity baseline, so a fresh
|
|
18
|
+
* allocation gets the full timeout before anything has run on it.
|
|
19
|
+
*/
|
|
20
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
21
|
+
exports.shouldIdleExit = shouldIdleExit;
|
|
22
|
+
exports.startIdleExitMonitor = startIdleExitMonitor;
|
|
23
|
+
/** How far before the exit we log the "run anything to keep it" warning. */
|
|
24
|
+
const WARN_LEAD_MINUTES = 5;
|
|
25
|
+
/**
|
|
26
|
+
* Pure idleness decision — should the client server exit now?
|
|
27
|
+
*
|
|
28
|
+
* @param nowMs current time (ms since epoch)
|
|
29
|
+
* @param lastKernelActivityMs most recent kernel activity (ms), or null if no signal
|
|
30
|
+
* @param anyKernelBusy true when any kernel session is 'busy' or 'starting'
|
|
31
|
+
* @param lastPtyActivityMs most recent terminal activity (ms), or null if no signal
|
|
32
|
+
* @param timeoutMinutes idle timeout; <= 0 disables (never exit)
|
|
33
|
+
*/
|
|
34
|
+
function shouldIdleExit(nowMs, lastKernelActivityMs, anyKernelBusy, lastPtyActivityMs, timeoutMinutes) {
|
|
35
|
+
if (!Number.isFinite(timeoutMinutes) || timeoutMinutes <= 0)
|
|
36
|
+
return false;
|
|
37
|
+
if (anyKernelBusy)
|
|
38
|
+
return false;
|
|
39
|
+
const last = Math.max(lastKernelActivityMs ?? 0, lastPtyActivityMs ?? 0);
|
|
40
|
+
if (last <= 0)
|
|
41
|
+
return false; // no baseline at all — never guess
|
|
42
|
+
return nowMs - last >= timeoutMinutes * 60_000;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Start the periodic idleness check. Returns a stop function.
|
|
46
|
+
* The interval is unref()ed so it never keeps an exiting process alive.
|
|
47
|
+
*/
|
|
48
|
+
function startIdleExitMonitor(opts) {
|
|
49
|
+
const intervalMs = opts.intervalMs ?? 60_000;
|
|
50
|
+
const startedAt = opts.startedAtMs ?? Date.now();
|
|
51
|
+
let warned = false;
|
|
52
|
+
let exiting = false;
|
|
53
|
+
const tick = () => {
|
|
54
|
+
if (exiting)
|
|
55
|
+
return;
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const kernel = opts.getKernelSnapshot();
|
|
58
|
+
// The monitor start counts as activity so a fresh, never-used allocation
|
|
59
|
+
// still gets the full timeout before releasing itself.
|
|
60
|
+
const lastKernel = Math.max(kernel.lastActivityMs ?? 0, startedAt);
|
|
61
|
+
const lastPty = opts.getLastPtyActivityMs();
|
|
62
|
+
if (shouldIdleExit(now, lastKernel, kernel.anyBusy, lastPty, opts.timeoutMinutes)) {
|
|
63
|
+
exiting = true;
|
|
64
|
+
clearInterval(timer);
|
|
65
|
+
void opts.onIdleExit();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
// Warn once when within WARN_LEAD_MINUTES of the cutoff; re-arm on activity.
|
|
69
|
+
const last = Math.max(lastKernel, lastPty ?? 0);
|
|
70
|
+
const idleMinutes = (now - last) / 60_000;
|
|
71
|
+
const warnAt = opts.timeoutMinutes - WARN_LEAD_MINUTES;
|
|
72
|
+
if (kernel.anyBusy || idleMinutes < warnAt) {
|
|
73
|
+
warned = false;
|
|
74
|
+
}
|
|
75
|
+
else if (!warned && warnAt > 0) {
|
|
76
|
+
warned = true;
|
|
77
|
+
opts.onWarn(Math.max(1, Math.round(opts.timeoutMinutes - idleMinutes)));
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
const timer = setInterval(tick, intervalMs);
|
|
81
|
+
timer.unref?.();
|
|
82
|
+
return () => clearInterval(timer);
|
|
83
|
+
}
|
|
@@ -61,24 +61,32 @@ const crypto = __importStar(require("crypto"));
|
|
|
61
61
|
const crypto_1 = require("crypto");
|
|
62
62
|
// Import routes
|
|
63
63
|
const kernel_1 = __importStar(require("./routes/kernel"));
|
|
64
|
-
const llm_1 = __importStar(require("./routes/llm"));
|
|
65
64
|
const fs_1 = __importDefault(require("./routes/fs"));
|
|
66
65
|
const notebook_1 = __importDefault(require("./routes/notebook"));
|
|
67
66
|
const python_1 = __importDefault(require("./routes/python"));
|
|
68
67
|
const auth_1 = __importDefault(require("./routes/auth"));
|
|
69
68
|
const cluster_1 = __importDefault(require("./routes/cluster"));
|
|
70
69
|
const resources_1 = __importDefault(require("./routes/resources"));
|
|
70
|
+
const compute_1 = __importDefault(require("./routes/compute"));
|
|
71
71
|
// Import cluster
|
|
72
72
|
const server_registry_1 = require("./cluster/server-registry");
|
|
73
73
|
const cluster_secret_1 = require("./cluster/cluster-secret");
|
|
74
74
|
const client_registration_1 = require("./cluster/client-registration");
|
|
75
|
+
// Import scheduler (SLURM-backed compute allocations)
|
|
76
|
+
const allocation_service_1 = require("./scheduler/allocation-service");
|
|
77
|
+
const slurm_scheduler_1 = require("./scheduler/slurm-scheduler");
|
|
78
|
+
const mock_scheduler_1 = require("./scheduler/mock-scheduler");
|
|
75
79
|
// Import auth
|
|
76
80
|
const auth_2 = require("./auth");
|
|
77
81
|
const fs_service_1 = require("./fs/fs-service");
|
|
82
|
+
const update_check_1 = require("./update-check");
|
|
78
83
|
// Import terminal routes (existing)
|
|
79
84
|
const server_1 = require("./terminal/server");
|
|
80
85
|
// Import notebook WebSocket
|
|
81
86
|
const notebook_websocket_1 = require("./notebook/notebook-websocket");
|
|
87
|
+
// Idle auto-release (client mode, opt-in via NEBULA_IDLE_EXIT_MINUTES)
|
|
88
|
+
const idle_exit_1 = require("./idle-exit");
|
|
89
|
+
const pty_manager_1 = require("./terminal/pty-manager");
|
|
82
90
|
const PORT = process.env.PORT || process.env.NODE_SERVER_PORT || 3000;
|
|
83
91
|
const DEV_MODE = process.env.DEV_MODE === 'true' || process.argv.includes('--dev');
|
|
84
92
|
const BODY_LIMIT = process.env.NEBULA_BODY_LIMIT ||
|
|
@@ -272,12 +280,13 @@ async function createApp() {
|
|
|
272
280
|
fastify.get('/api/health', async (_request, reply) => {
|
|
273
281
|
// Use configured root directory (from .nebula-config.json or fallback to cwd)
|
|
274
282
|
const rootDir = fs_service_1.fsService.normalizePath('~');
|
|
283
|
+
const update = (0, update_check_1.getUpdateInfo)();
|
|
275
284
|
return reply.send({
|
|
276
285
|
status: 'ok',
|
|
277
|
-
version:
|
|
286
|
+
version: update.current,
|
|
278
287
|
ready: kernel_1.kernelService.isReady,
|
|
279
|
-
llm_providers: Object.keys(llm_1.llmService.getAvailableProviders()),
|
|
280
288
|
cwd: rootDir,
|
|
289
|
+
update,
|
|
281
290
|
});
|
|
282
291
|
});
|
|
283
292
|
fastify.get('/api/ready', async (_request, reply) => {
|
|
@@ -309,11 +318,11 @@ async function createApp() {
|
|
|
309
318
|
});
|
|
310
319
|
// API routes (protected)
|
|
311
320
|
await fastify.register(kernel_1.default, { prefix: '/api' });
|
|
312
|
-
await fastify.register(llm_1.default, { prefix: '/api' });
|
|
313
321
|
await fastify.register(fs_1.default, { prefix: '/api' });
|
|
314
322
|
await fastify.register(notebook_1.default, { prefix: '/api' });
|
|
315
323
|
await fastify.register(python_1.default, { prefix: '/api' });
|
|
316
324
|
await fastify.register(cluster_1.default, { prefix: '/api' });
|
|
325
|
+
await fastify.register(compute_1.default, { prefix: '/api' });
|
|
317
326
|
await fastify.register(resources_1.default, { prefix: '/api/resources' });
|
|
318
327
|
// Terminal routes (registered directly on the app, not under /api prefix)
|
|
319
328
|
await fastify.register(server_1.setupTerminalRoutes);
|
|
@@ -449,8 +458,9 @@ async function main() {
|
|
|
449
458
|
else {
|
|
450
459
|
console.log('[Auth] 2FA configured and ready');
|
|
451
460
|
}
|
|
452
|
-
// Set local server ID from hostname
|
|
453
|
-
|
|
461
|
+
// Set local server ID from hostname (NEBULA_HOST overrides the advertised name
|
|
462
|
+
// when the OS hostname isn't the reachable/desired identity — e.g. demos).
|
|
463
|
+
const localServerId = `${process.env.NEBULA_HOST || os.hostname()}:${PORT}`;
|
|
454
464
|
server_registry_1.serverRegistry.setLocalServerId(localServerId);
|
|
455
465
|
console.log(`[Cluster] Local server ID: ${localServerId}`);
|
|
456
466
|
const serverInstanceId = (0, crypto_1.randomUUID)();
|
|
@@ -471,6 +481,35 @@ async function main() {
|
|
|
471
481
|
(0, server_1.setupTerminalWebSocket)(server);
|
|
472
482
|
// Setup notebook operations WebSocket
|
|
473
483
|
(0, notebook_websocket_1.setupNotebookWebSocket)(server);
|
|
484
|
+
// Scheduler-backed compute allocations (main server only; detection-gated)
|
|
485
|
+
let schedulerDetected = false;
|
|
486
|
+
if (!CLIENT_MODE) {
|
|
487
|
+
try {
|
|
488
|
+
// NEBULA_SCHEDULER=mock enables a fabricated-data scheduler (no real cluster)
|
|
489
|
+
// for demos/tests; otherwise use the real SLURM scheduler (detection-gated).
|
|
490
|
+
const scheduler = process.env.NEBULA_SCHEDULER === 'mock' ? new mock_scheduler_1.MockScheduler() : new slurm_scheduler_1.SlurmScheduler();
|
|
491
|
+
if (await scheduler.detect()) {
|
|
492
|
+
schedulerDetected = true;
|
|
493
|
+
const ctx = {
|
|
494
|
+
mainUrl: process.env.NEBULA_SCHEDULER_MAIN_URL || `http://${os.hostname()}:${PORT}`,
|
|
495
|
+
secret: process.env.NEBULA_CLUSTER_SECRET,
|
|
496
|
+
nodeBin: process.execPath,
|
|
497
|
+
execArgv: process.execArgv,
|
|
498
|
+
scriptPath: process.argv[1],
|
|
499
|
+
cwd: process.cwd(),
|
|
500
|
+
stateDir: process.env.NEBULA_SCHEDULER_STATE_DIR || path.join(os.homedir(), '.nebula', 'allocations'),
|
|
501
|
+
};
|
|
502
|
+
allocation_service_1.allocationService.init(scheduler, ctx);
|
|
503
|
+
console.log('[Scheduler] SLURM detected — compute allocations enabled');
|
|
504
|
+
}
|
|
505
|
+
else {
|
|
506
|
+
console.log('[Scheduler] No batch scheduler detected — compute allocations disabled');
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch (err) {
|
|
510
|
+
console.warn('[Scheduler] Init failed:', err instanceof Error ? err.message : String(err));
|
|
511
|
+
}
|
|
512
|
+
}
|
|
474
513
|
if (REATTACH_KERNELS) {
|
|
475
514
|
try {
|
|
476
515
|
const result = await kernel_1.kernelService.reattachOrphanedSessions();
|
|
@@ -483,6 +522,41 @@ async function main() {
|
|
|
483
522
|
console.warn(`[Kernel] Reattach failed: ${message}`);
|
|
484
523
|
}
|
|
485
524
|
}
|
|
525
|
+
// Final "you're up" banner — the one part of startup written for humans.
|
|
526
|
+
// Everything a first-time user needs: where to point the browser, where the
|
|
527
|
+
// files live, whether cluster compute is on, and how to reach a remote
|
|
528
|
+
// server from a laptop. Client-mode servers skip it (no user is watching).
|
|
529
|
+
if (!CLIENT_MODE) {
|
|
530
|
+
const host = os.hostname();
|
|
531
|
+
let rootDir = '~';
|
|
532
|
+
try {
|
|
533
|
+
rootDir = fs_service_1.fsService.getRootDirectory();
|
|
534
|
+
}
|
|
535
|
+
catch { /* keep default */ }
|
|
536
|
+
const lines = [
|
|
537
|
+
'',
|
|
538
|
+
' ──────────────────────────────────────────────────────',
|
|
539
|
+
' Nebula Notebook is running',
|
|
540
|
+
'',
|
|
541
|
+
` Open: http://localhost:${PORT}`,
|
|
542
|
+
` Network: http://${host}:${PORT}`,
|
|
543
|
+
` Files: ${rootDir}`,
|
|
544
|
+
` Compute: ${schedulerDetected
|
|
545
|
+
? 'SLURM detected — allocate compute nodes from the kernel menu'
|
|
546
|
+
: 'no batch scheduler here — kernels run on this machine'}`,
|
|
547
|
+
...(setupNeeded ? [' Sign-in: scan the QR code above with an authenticator app'] : []),
|
|
548
|
+
'',
|
|
549
|
+
' On a remote machine? From your laptop:',
|
|
550
|
+
` ssh -L ${PORT}:localhost:${PORT} ${host} then open http://localhost:${PORT}`,
|
|
551
|
+
' Cluster guide: https://github.com/jzthree/nebula-notebook/blob/main/docs/CLUSTER_SETUP.md',
|
|
552
|
+
' ──────────────────────────────────────────────────────',
|
|
553
|
+
'',
|
|
554
|
+
];
|
|
555
|
+
console.log(lines.join('\n'));
|
|
556
|
+
}
|
|
557
|
+
// Daily notify-only update check (NEBULA_NO_UPDATE_CHECK=1 to disable)
|
|
558
|
+
if (!CLIENT_MODE)
|
|
559
|
+
(0, update_check_1.startUpdateChecker)();
|
|
486
560
|
// Graceful shutdown
|
|
487
561
|
let shuttingDown = false;
|
|
488
562
|
const shutdown = async () => {
|
|
@@ -510,10 +584,11 @@ async function main() {
|
|
|
510
584
|
catch (err) {
|
|
511
585
|
console.error('[Server] Error during kernel cleanup:', err);
|
|
512
586
|
}
|
|
513
|
-
// Cleanup cluster registration
|
|
587
|
+
// Cleanup cluster registration + scheduler polling
|
|
514
588
|
try {
|
|
515
589
|
await client_registration_1.clientRegistration.shutdown();
|
|
516
590
|
server_registry_1.serverRegistry.shutdown();
|
|
591
|
+
allocation_service_1.allocationService.shutdown();
|
|
517
592
|
}
|
|
518
593
|
catch (err) {
|
|
519
594
|
console.error('[Server] Error during cluster cleanup:', err);
|
|
@@ -544,9 +619,54 @@ async function main() {
|
|
|
544
619
|
console.log(`[Server] Notebook WebSocket: ${wsProtocol}://localhost:${PORT}/api/notebook/{path}/ws`);
|
|
545
620
|
console.log(`[Server] Terminal WebSocket: ${wsProtocol}://localhost:${PORT}/ws?id={terminal_id}`);
|
|
546
621
|
console.log(`[Server] Root directory: ${fs_service_1.fsService.getRootDirectory()} (change with --workdir)`);
|
|
547
|
-
// Initialize client registration (explicit client mode only)
|
|
622
|
+
// Initialize client registration (explicit client mode only). With PORT=0
|
|
623
|
+
// (scheduler-launched jobs pick an ephemeral port), register the *actual* bound
|
|
624
|
+
// port so the main server proxies kernels to the right place.
|
|
548
625
|
if (CLIENT_MODE) {
|
|
549
|
-
|
|
626
|
+
const addr = fastify.server.address();
|
|
627
|
+
const boundPort = addr && typeof addr === 'object' && addr ? addr.port : Number(PORT);
|
|
628
|
+
const clientServerId = `${os.hostname()}:${boundPort}`;
|
|
629
|
+
server_registry_1.serverRegistry.setLocalServerId(clientServerId);
|
|
630
|
+
kernel_1.kernelService.setServerIdentity(clientServerId, serverInstanceId);
|
|
631
|
+
client_registration_1.clientRegistration.initFromEnv(boundPort);
|
|
632
|
+
// Opt-in idle auto-release: exit this process after N idle minutes so the
|
|
633
|
+
// batch job completes and the allocation ends naturally (no scancel needed).
|
|
634
|
+
const idleExitMinutes = Math.floor(Number(process.env.NEBULA_IDLE_EXIT_MINUTES) || 0);
|
|
635
|
+
if (idleExitMinutes > 0) {
|
|
636
|
+
console.log(`[IdleExit] Enabled — allocation auto-ends after ${idleExitMinutes} idle minutes`);
|
|
637
|
+
(0, idle_exit_1.startIdleExitMonitor)({
|
|
638
|
+
timeoutMinutes: idleExitMinutes,
|
|
639
|
+
getKernelSnapshot: () => kernel_1.kernelService.getIdleSnapshot(),
|
|
640
|
+
getLastPtyActivityMs: () => {
|
|
641
|
+
const terminals = pty_manager_1.ptyManager.list();
|
|
642
|
+
if (terminals.length === 0)
|
|
643
|
+
return null;
|
|
644
|
+
return Math.max(...terminals.map((t) => t.lastActivity));
|
|
645
|
+
},
|
|
646
|
+
onWarn: (minutesLeft) => {
|
|
647
|
+
console.warn(`[IdleExit] Idle — will end allocation in ${minutesLeft}m; run anything to keep it`);
|
|
648
|
+
},
|
|
649
|
+
onIdleExit: async () => {
|
|
650
|
+
console.log(`[IdleExit] Idle for ${idleExitMinutes} minutes — releasing allocation (exiting)`);
|
|
651
|
+
// Shut kernels down cleanly WITHOUT preservation so nothing tries to
|
|
652
|
+
// resurrect them, then drop the cluster registration and exit 0 —
|
|
653
|
+
// the batch job completes and the allocation ends.
|
|
654
|
+
(0, server_1.cleanupTerminals)();
|
|
655
|
+
try {
|
|
656
|
+
await kernel_1.kernelService.shutdown({ preserveKernels: false });
|
|
657
|
+
}
|
|
658
|
+
catch (err) {
|
|
659
|
+
console.error('[IdleExit] Error during kernel cleanup:', err);
|
|
660
|
+
}
|
|
661
|
+
try {
|
|
662
|
+
await client_registration_1.clientRegistration.shutdown();
|
|
663
|
+
}
|
|
664
|
+
catch { /* best effort — we are exiting anyway */ }
|
|
665
|
+
console.log('[IdleExit] Allocation released — exiting');
|
|
666
|
+
process.exit(0);
|
|
667
|
+
},
|
|
668
|
+
});
|
|
669
|
+
}
|
|
550
670
|
}
|
|
551
671
|
}
|
|
552
672
|
// Run
|
|
@@ -7,6 +7,19 @@
|
|
|
7
7
|
import { KernelOutput, ExecutionResult, ExecutionQueueInfo, StartKernelOptions, SessionInfo, KernelServiceConfig } from './types';
|
|
8
8
|
import { SessionStore } from './session-store';
|
|
9
9
|
import { KernelSpec } from './kernelspec';
|
|
10
|
+
/** Kernel-originated comm event surfaced to onComm listeners. */
|
|
11
|
+
export interface CommEvent {
|
|
12
|
+
msgType: 'comm_open' | 'comm_msg' | 'comm_close';
|
|
13
|
+
commId: string;
|
|
14
|
+
targetName?: string;
|
|
15
|
+
data: Record<string, unknown>;
|
|
16
|
+
/** Binary buffer frames, base64-encoded. Present only when non-empty. */
|
|
17
|
+
buffers?: string[];
|
|
18
|
+
}
|
|
19
|
+
/** Resolved by BoundedAsyncQueue.next() when the queue has been closed. */
|
|
20
|
+
export declare const IOPUB_QUEUE_CLOSED: unique symbol;
|
|
21
|
+
/** Resolved by BoundedAsyncQueue.next(timeoutMs) when the wait timed out. */
|
|
22
|
+
export declare const IOPUB_QUEUE_TIMEOUT: unique symbol;
|
|
10
23
|
export declare class KernelService {
|
|
11
24
|
private sessions;
|
|
12
25
|
private fileToSession;
|
|
@@ -28,7 +41,23 @@ export declare class KernelService {
|
|
|
28
41
|
private cellOutputBuffers;
|
|
29
42
|
private cellOutputTracking;
|
|
30
43
|
private executingCellIds;
|
|
44
|
+
private iopubReaders;
|
|
45
|
+
private iopubParentSubscribers;
|
|
46
|
+
private iopubCatchAllSubscribers;
|
|
47
|
+
private commStates;
|
|
48
|
+
private shellReaders;
|
|
49
|
+
private shellReplyWaiters;
|
|
31
50
|
constructor(config?: KernelServiceConfig, sessionStore?: SessionStore);
|
|
51
|
+
private deadListeners;
|
|
52
|
+
onSessionDead(cb: (sessionId: string) => void): void;
|
|
53
|
+
private notifySessionDead;
|
|
54
|
+
private commListeners;
|
|
55
|
+
onComm(cb: (sessionId: string, comm: CommEvent) => void): void;
|
|
56
|
+
private notifyComm;
|
|
57
|
+
private livenessTimer;
|
|
58
|
+
private startLivenessSweep;
|
|
59
|
+
/** One liveness pass (exposed for tests; normally driven by the timer). */
|
|
60
|
+
sweepLiveness(): void;
|
|
32
61
|
setServerIdentity(serverId: string, serverInstanceId?: string): void;
|
|
33
62
|
/**
|
|
34
63
|
* Check if a PID is still alive.
|
|
@@ -109,6 +138,57 @@ export declare class KernelService {
|
|
|
109
138
|
* @param pid Optional kernel PID — used to distinguish "busy" from "dead" on timeout
|
|
110
139
|
*/
|
|
111
140
|
private waitForReady;
|
|
141
|
+
private startIopubReader;
|
|
142
|
+
private stopIopubReader;
|
|
143
|
+
private closeIopubSubscribers;
|
|
144
|
+
private dispatchIopubMessage;
|
|
145
|
+
/**
|
|
146
|
+
* Subscribe to iopub messages parented by a specific msg_id. Must be called
|
|
147
|
+
* BEFORE sending the request so no reply can slip past the demux. Callers
|
|
148
|
+
* must unsubscribe in a finally block.
|
|
149
|
+
*/
|
|
150
|
+
private subscribeIopubParent;
|
|
151
|
+
private unsubscribeIopubParent;
|
|
152
|
+
/** Subscribe to ALL iopub messages for a session (any parent). */
|
|
153
|
+
private subscribeIopubCatchAll;
|
|
154
|
+
private unsubscribeIopubCatchAll;
|
|
155
|
+
private startShellReader;
|
|
156
|
+
private stopShellReader;
|
|
157
|
+
private closeShellReplyWaiters;
|
|
158
|
+
/**
|
|
159
|
+
* Register a one-shot waiter for the shell reply parented by msgId. Must be
|
|
160
|
+
* called BEFORE sending the request so the reply cannot slip past the
|
|
161
|
+
* dispatch. Resolves 'closed' immediately if no reader loop is alive, or
|
|
162
|
+
* later when the reader stops (kernel stop/restart/cleanup).
|
|
163
|
+
*/
|
|
164
|
+
private registerShellReplyWaiter;
|
|
165
|
+
private removeShellReplyWaiter;
|
|
166
|
+
/**
|
|
167
|
+
* Await a previously registered shell reply with a timeout. The waiter is
|
|
168
|
+
* always deregistered on the way out (timeout, reply, or error), so a late
|
|
169
|
+
* reply is dropped by the reader instead of leaking a waiter.
|
|
170
|
+
*/
|
|
171
|
+
private waitForShellReply;
|
|
172
|
+
private handleKernelCommMessage;
|
|
173
|
+
private rememberOpenComm;
|
|
174
|
+
/**
|
|
175
|
+
* Open comms known for a session (for late-joining clients).
|
|
176
|
+
* Returns comm_id -> { targetName, openData } where openData is the last
|
|
177
|
+
* state-carrying comm_open payload observed for that comm.
|
|
178
|
+
*/
|
|
179
|
+
getOpenComms(sessionId: string): Record<string, {
|
|
180
|
+
targetName: string;
|
|
181
|
+
openData: Record<string, unknown>;
|
|
182
|
+
}>;
|
|
183
|
+
/**
|
|
184
|
+
* Send a comm message (comm_open / comm_msg / comm_close) to the kernel on
|
|
185
|
+
* the shell channel. Comm messages produce NO shell reply, so the queued
|
|
186
|
+
* slot releases as soon as the send completes — we never block waiting for
|
|
187
|
+
* a reply that will not come.
|
|
188
|
+
*
|
|
189
|
+
* @param buffers Optional binary buffer frames, base64-encoded.
|
|
190
|
+
*/
|
|
191
|
+
sendCommMessage(sessionId: string, msgType: 'comm_open' | 'comm_msg' | 'comm_close', content: Record<string, unknown>, buffers?: string[]): Promise<void>;
|
|
112
192
|
/**
|
|
113
193
|
* Monitor a busy reattached kernel on iopub. When its current execution
|
|
114
194
|
* finishes (status: idle on iopub), verify shell connectivity and update
|
|
@@ -128,10 +208,16 @@ export declare class KernelService {
|
|
|
128
208
|
* Get or create kernel for a file (one notebook = one kernel).
|
|
129
209
|
* Returns whether a new session was created.
|
|
130
210
|
*/
|
|
211
|
+
/** In-flight create/attach per normalized file path (single-flight).
|
|
212
|
+
* Without this, two near-simultaneous requests (UI re-render + agent op,
|
|
213
|
+
* double-click) both miss the fileToSession check and spawn TWO kernel
|
|
214
|
+
* processes — the second overwrites the mapping and the first leaks. */
|
|
215
|
+
private inflightKernelCreates;
|
|
131
216
|
getOrCreateKernel(filePath: string, kernelName?: string): Promise<{
|
|
132
217
|
sessionId: string;
|
|
133
218
|
created: boolean;
|
|
134
219
|
}>;
|
|
220
|
+
private getOrCreateKernelInternal;
|
|
135
221
|
/**
|
|
136
222
|
* Get existing kernel session ID for a notebook file (if any).
|
|
137
223
|
* Returns null if no live session is associated with the file.
|
|
@@ -156,7 +242,11 @@ export declare class KernelService {
|
|
|
156
242
|
executeCode(sessionId: string, code: string, onOutput: (output: KernelOutput, cellId?: string | null) => Promise<void>, onQueueInfo?: (info: ExecutionQueueInfo) => void, cellId?: string | null): Promise<ExecutionResult>;
|
|
157
243
|
private enqueueExecution;
|
|
158
244
|
/**
|
|
159
|
-
*
|
|
245
|
+
* Serialize shell socket SENDS. Receives are owned by the unified shell
|
|
246
|
+
* reader, so tasks queued here must be send-only and release the slot as
|
|
247
|
+
* soon as the send completes (register any reply waiter BEFORE enqueueing,
|
|
248
|
+
* await the reply AFTER the slot is released). Holding the slot across a
|
|
249
|
+
* reply wait would let one slow request delay every later send.
|
|
160
250
|
*/
|
|
161
251
|
private enqueueShellRequest;
|
|
162
252
|
private reserveExecutionSlot;
|
|
@@ -174,7 +264,10 @@ export declare class KernelService {
|
|
|
174
264
|
cursor_end: number;
|
|
175
265
|
}>;
|
|
176
266
|
/**
|
|
177
|
-
* Internal completion implementation
|
|
267
|
+
* Internal completion implementation. The send is serialized through the
|
|
268
|
+
* shell queue; the reply is awaited via the unified shell reader, so a
|
|
269
|
+
* timeout here simply drops the waiter and can never block or swallow a
|
|
270
|
+
* later request's reply.
|
|
178
271
|
*/
|
|
179
272
|
private completeInternal;
|
|
180
273
|
/**
|
|
@@ -247,6 +340,15 @@ export declare class KernelService {
|
|
|
247
340
|
*/
|
|
248
341
|
getSessionStatusFast(sessionId: string): SessionInfo | null;
|
|
249
342
|
getSessionStatus(sessionId: string): Promise<SessionInfo | null>;
|
|
343
|
+
/**
|
|
344
|
+
* Activity snapshot for the idle auto-release monitor (client mode):
|
|
345
|
+
* whether any kernel is busy/starting, and the most recent kernel activity
|
|
346
|
+
* across sessions in ms since epoch (sessions track it in seconds).
|
|
347
|
+
*/
|
|
348
|
+
getIdleSnapshot(): {
|
|
349
|
+
anyBusy: boolean;
|
|
350
|
+
lastActivityMs: number | null;
|
|
351
|
+
};
|
|
250
352
|
/**
|
|
251
353
|
* Get all sessions
|
|
252
354
|
*/
|
|
@@ -261,6 +363,15 @@ export declare class KernelService {
|
|
|
261
363
|
status: string;
|
|
262
364
|
lastHeartbeat: number;
|
|
263
365
|
}[];
|
|
366
|
+
/**
|
|
367
|
+
* Auto-delete dead session rows whose kernel process is CONFIRMED gone
|
|
368
|
+
* (no PID, PID not running, or PID reused by another process). These are
|
|
369
|
+
* pure bookkeeping — nothing to kill, nothing to lose — so they need no
|
|
370
|
+
* user confirmation. Rows whose PID is still alive are kept for the
|
|
371
|
+
* explicit "Clean Up" flow (killing a process should stay a user action,
|
|
372
|
+
* and legacy rows without a start-time fingerprint can't be verified).
|
|
373
|
+
*/
|
|
374
|
+
autoCleanupDeadSessions(): Promise<number>;
|
|
264
375
|
/**
|
|
265
376
|
* Cleanup dead sessions by deleting them from the database
|
|
266
377
|
*/
|