nebula-notebook 0.1.1 → 0.2.1
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 +110 -16
- package/dist/assets/errorwidget-Wy5ajPiM.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-B_Bs5koo.js +1 -0
- package/dist/assets/index-BtWv4MIT.css +7 -0
- package/dist/assets/index-DK4nS74r.js +829 -0
- package/dist/assets/index-DeQN03nW.js +81 -0
- package/dist/assets/index-DzjpmUak.css +32 -0
- package/dist/assets/index-g6MjT-VG.js +1 -0
- package/dist/assets/services-shim-BirHVsih.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/auth/auth-middleware.js +22 -4
- package/node-server/dist/auth/auth-service.js +37 -7
- package/node-server/dist/cluster/client-registration.d.ts +12 -1
- package/node-server/dist/cluster/client-registration.js +38 -2
- package/node-server/dist/cluster/kernel-proxy.js +43 -12
- package/node-server/dist/cluster/server-registry.d.ts +17 -0
- package/node-server/dist/cluster/server-registry.js +57 -7
- package/node-server/dist/discovery/conda-locations.d.ts +73 -0
- package/node-server/dist/discovery/conda-locations.js +427 -0
- package/node-server/dist/discovery/discovery-service.d.ts +73 -4
- package/node-server/dist/discovery/discovery-service.js +211 -127
- package/node-server/dist/discovery/types.d.ts +0 -1
- package/node-server/dist/discovery/types.js +1 -2
- package/node-server/dist/environment.d.ts +12 -0
- package/node-server/dist/environment.js +104 -0
- package/node-server/dist/fs/fs-service.d.ts +86 -8
- package/node-server/dist/fs/fs-service.js +640 -99
- 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/fs/types.d.ts +9 -0
- package/node-server/dist/fs/types.js +8 -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 +198 -8
- package/node-server/dist/kernel/kernel-service.d.ts +130 -2
- package/node-server/dist/kernel/kernel-service.js +834 -64
- package/node-server/dist/kernel/kernelspec.d.ts +27 -0
- package/node-server/dist/kernel/kernelspec.js +75 -4
- package/node-server/dist/notebook/headless-handler.d.ts +12 -0
- package/node-server/dist/notebook/headless-handler.js +125 -29
- package/node-server/dist/notebook/operation-router.js +7 -2
- package/node-server/dist/notebook/undoRedoManager.d.ts +12 -1
- package/node-server/dist/notebook/undoRedoManager.js +49 -18
- package/node-server/dist/output/display-data.js +2 -0
- package/node-server/dist/private-tmp.d.ts +14 -0
- package/node-server/dist/private-tmp.js +70 -0
- package/node-server/dist/routes/autocomplete.d.ts +19 -0
- package/node-server/dist/routes/autocomplete.js +294 -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 +23 -6
- package/node-server/dist/routes/kernel.js +156 -3
- package/node-server/dist/routes/notebook.js +37 -3
- package/node-server/dist/routes/python.d.ts +1 -2
- package/node-server/dist/routes/python.js +139 -3
- package/node-server/dist/scheduler/allocation-service.d.ts +63 -0
- package/node-server/dist/scheduler/allocation-service.js +285 -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 +125 -0
- package/node-server/dist/scheduler/slurm-scheduler.d.ts +31 -0
- package/node-server/dist/scheduler/slurm-scheduler.js +406 -0
- package/node-server/dist/scheduler/types.d.ts +118 -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/agent-registry.d.ts +87 -0
- package/node-server/dist/terminal/agent-registry.js +246 -0
- package/node-server/dist/terminal/binding-store.d.ts +57 -0
- package/node-server/dist/terminal/binding-store.js +0 -0
- package/node-server/dist/terminal/pty-manager.d.ts +23 -6
- package/node-server/dist/terminal/pty-manager.js +138 -19
- package/node-server/dist/terminal/server.js +155 -23
- package/node-server/dist/terminal/types.d.ts +2 -0
- package/node-server/dist/update-check.d.ts +20 -0
- package/node-server/dist/update-check.js +114 -0
- package/node-server/package.json +2 -0
- package/node_modules/nebula-autocomplete/README.md +145 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.d.ts +69 -0
- package/node_modules/nebula-autocomplete/dist/backends/claude.js +449 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.d.ts +45 -0
- package/node_modules/nebula-autocomplete/dist/backends/codex.js +108 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.d.ts +16 -0
- package/node_modules/nebula-autocomplete/dist/client/fetcher.js +41 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.d.ts +43 -0
- package/node_modules/nebula-autocomplete/dist/codemirror/ghostText.js +175 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.d.ts +17 -0
- package/node_modules/nebula-autocomplete/dist/core/engine.js +135 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/core/lru.js +26 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.d.ts +12 -0
- package/node_modules/nebula-autocomplete/dist/core/prompt.js +0 -0
- package/node_modules/nebula-autocomplete/dist/core/text.d.ts +53 -0
- package/node_modules/nebula-autocomplete/dist/core/text.js +169 -0
- package/node_modules/nebula-autocomplete/dist/index.d.ts +7 -0
- package/node_modules/nebula-autocomplete/dist/index.js +5 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.d.ts +40 -0
- package/node_modules/nebula-autocomplete/dist/server/fastify.js +52 -0
- package/node_modules/nebula-autocomplete/dist/transport.d.ts +55 -0
- package/node_modules/nebula-autocomplete/dist/transport.js +66 -0
- package/node_modules/nebula-autocomplete/dist/types.d.ts +103 -0
- package/node_modules/nebula-autocomplete/dist/types.js +1 -0
- package/node_modules/nebula-autocomplete/package.json +58 -0
- package/node_modules/nebula-autocomplete/server.d.ts +3 -0
- package/node_modules/nebula-autocomplete/server.js +3 -0
- package/package.json +18 -5
- package/scripts/postinstall.cjs +25 -5
- package/dist/assets/index-BvrHu37J.js +0 -648
- package/dist/assets/index-Dfj_xsKU.css +0 -32
- package/node-server/dist/llm/index.d.ts +0 -5
- package/node-server/dist/llm/index.js +0 -21
- package/node-server/dist/llm/llm-service.d.ts +0 -77
- package/node-server/dist/llm/llm-service.js +0 -454
- package/node-server/dist/llm/types.d.ts +0 -40
- package/node-server/dist/llm/types.js +0 -15
- package/node-server/dist/routes/llm.d.ts +0 -8
- package/node-server/dist/routes/llm.js +0 -105
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AI autocomplete API — inline code completions for notebook cells.
|
|
3
|
+
*
|
|
4
|
+
* Routes live under /api and are auth-protected like the rest of the API.
|
|
5
|
+
* Backed by the nebula-autocomplete engine, which drives a Claude Code / Codex
|
|
6
|
+
* CLI using the user's own subscription. The CLI can run in one of two places:
|
|
7
|
+
*
|
|
8
|
+
* - on THIS server (default) — when the Nebula host itself has claude/codex
|
|
9
|
+
* (a local install, or a login node where the CLIs are available);
|
|
10
|
+
* - on the USER'S machine over the reverse SSH tunnel ("run on my machine") —
|
|
11
|
+
* for remote Nebula where the user would rather use their own laptop's CLI +
|
|
12
|
+
* subscription. See the `transport` field on the completion request.
|
|
13
|
+
*
|
|
14
|
+
* Engines are created lazily and cached per (backend, transport) so a warm pool
|
|
15
|
+
* is reused across turns; disposed on server shutdown.
|
|
16
|
+
*/
|
|
17
|
+
import { FastifyInstance } from 'fastify';
|
|
18
|
+
export declare function disposeAutocompleteEngines(): void;
|
|
19
|
+
export default function autocompleteRoutes(fastify: FastifyInstance): Promise<void>;
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* AI autocomplete API — inline code completions for notebook cells.
|
|
4
|
+
*
|
|
5
|
+
* Routes live under /api and are auth-protected like the rest of the API.
|
|
6
|
+
* Backed by the nebula-autocomplete engine, which drives a Claude Code / Codex
|
|
7
|
+
* CLI using the user's own subscription. The CLI can run in one of two places:
|
|
8
|
+
*
|
|
9
|
+
* - on THIS server (default) — when the Nebula host itself has claude/codex
|
|
10
|
+
* (a local install, or a login node where the CLIs are available);
|
|
11
|
+
* - on the USER'S machine over the reverse SSH tunnel ("run on my machine") —
|
|
12
|
+
* for remote Nebula where the user would rather use their own laptop's CLI +
|
|
13
|
+
* subscription. See the `transport` field on the completion request.
|
|
14
|
+
*
|
|
15
|
+
* Engines are created lazily and cached per (backend, transport) so a warm pool
|
|
16
|
+
* is reused across turns; disposed on server shutdown.
|
|
17
|
+
*/
|
|
18
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
19
|
+
exports.disposeAutocompleteEngines = disposeAutocompleteEngines;
|
|
20
|
+
exports.default = autocompleteRoutes;
|
|
21
|
+
const child_process_1 = require("child_process");
|
|
22
|
+
const environment_1 = require("../environment");
|
|
23
|
+
const nebula_autocomplete_1 = require("nebula-autocomplete");
|
|
24
|
+
const server_1 = require("nebula-autocomplete/server");
|
|
25
|
+
const engines = new Map();
|
|
26
|
+
/**
|
|
27
|
+
* Async command runner. NEVER use spawnSync here: these probes run ssh calls
|
|
28
|
+
* that can take 10+ seconds, and spawnSync freezes Node's event loop — which
|
|
29
|
+
* stalls EVERYTHING the server does (terminal WS input, API responses), felt
|
|
30
|
+
* as "the whole app is unresponsive for ~10s after a refresh".
|
|
31
|
+
*/
|
|
32
|
+
function runCmd(cmd, args, timeoutMs) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
let out = '';
|
|
35
|
+
let done = false;
|
|
36
|
+
const finish = (status) => { if (!done) {
|
|
37
|
+
done = true;
|
|
38
|
+
resolve({ status, stdout: out });
|
|
39
|
+
} };
|
|
40
|
+
let proc;
|
|
41
|
+
try {
|
|
42
|
+
proc = (0, child_process_1.spawn)(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return finish(null);
|
|
46
|
+
}
|
|
47
|
+
const timer = setTimeout(() => { try {
|
|
48
|
+
proc.kill('SIGKILL');
|
|
49
|
+
}
|
|
50
|
+
catch { /* */ } finish(null); }, timeoutMs);
|
|
51
|
+
proc.stdout?.on('data', (d) => (out += d.toString()));
|
|
52
|
+
proc.on('error', () => { clearTimeout(timer); finish(null); });
|
|
53
|
+
proc.on('exit', (code) => { clearTimeout(timer); finish(code); });
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async function binaryAvailable(name) {
|
|
57
|
+
return (await runCmd('which', [name], 3000)).status === 0;
|
|
58
|
+
}
|
|
59
|
+
/** Parse + validate a client-supplied transport (untrusted input). */
|
|
60
|
+
function parseTransport(raw) {
|
|
61
|
+
if (!raw || typeof raw !== 'object')
|
|
62
|
+
return null;
|
|
63
|
+
const t = raw;
|
|
64
|
+
const port = Number(t.port);
|
|
65
|
+
const user = typeof t.user === 'string' ? t.user.trim() : '';
|
|
66
|
+
const bin = typeof t.bin === 'string' ? t.bin.trim() : '';
|
|
67
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535)
|
|
68
|
+
return null;
|
|
69
|
+
if (!user || !/^[A-Za-z0-9._-]+$/.test(user))
|
|
70
|
+
return null;
|
|
71
|
+
if (!bin.startsWith('/'))
|
|
72
|
+
return null; // must be an absolute path
|
|
73
|
+
const host = typeof t.host === 'string' && t.host.trim() ? t.host.trim() : 'localhost';
|
|
74
|
+
return { port, user, bin, host };
|
|
75
|
+
}
|
|
76
|
+
function toSsh(t) {
|
|
77
|
+
return { kind: 'ssh', host: t.host ?? 'localhost', port: t.port, user: t.user, remoteBin: t.bin };
|
|
78
|
+
}
|
|
79
|
+
function sanitizeTuning(name, raw) {
|
|
80
|
+
// Experimental tuning surface: any shell-safe model id is allowed (it's the
|
|
81
|
+
// user's own CLI/subscription; a bad id just fails the worker's warmup
|
|
82
|
+
// visibly). Pattern-gated so nothing hostile reaches the spawn argv.
|
|
83
|
+
let model = typeof raw.model === 'string' ? raw.model.trim().toLowerCase() : '';
|
|
84
|
+
if (name === 'claude') {
|
|
85
|
+
// Default sonnet (was haiku): BENCHMARKED (eval/bench.mjs, 54 samples/config,
|
|
86
|
+
// interleaved) — sonnet ttfb p50 1086ms vs haiku 987ms (+10%, fixed API
|
|
87
|
+
// overhead dominates short completions) while fixing haiku's context
|
|
88
|
+
// failures (100% vs 94% ctx-accuracy). Thinking: on haiku it engages
|
|
89
|
+
// every turn (probed: ~1-1.5k chars) = 3-8s of non-streaming dead air; on
|
|
90
|
+
// sonnet MAX_THINKING_TOKENS never engages at all for completion-sized
|
|
91
|
+
// prompts (probed: 0 thinking chars, 4/4), so it can't help there either.
|
|
92
|
+
model = /^[a-z0-9][a-z0-9._:-]{0,63}$/.test(model) ? model : 'sonnet';
|
|
93
|
+
}
|
|
94
|
+
else
|
|
95
|
+
model = ''; // codex: fixed model
|
|
96
|
+
const rawTokens = Number(raw.thinkingTokens);
|
|
97
|
+
const thinkingTokens = Number.isFinite(rawTokens)
|
|
98
|
+
? Math.min(16_000, Math.max(0, Math.round(rawTokens)))
|
|
99
|
+
: raw.thinking === true ? 4096 : 0;
|
|
100
|
+
return { model, thinkingTokens };
|
|
101
|
+
}
|
|
102
|
+
function engineKey(name, t, tune) {
|
|
103
|
+
const base = t ? `${name}:ssh:${t.user}@${t.host ?? 'localhost'}:${t.port}:${t.bin}` : name;
|
|
104
|
+
return `${base}:${tune.model}:${tune.thinkingTokens}`;
|
|
105
|
+
}
|
|
106
|
+
function getEngine(name, t, tune) {
|
|
107
|
+
const key = engineKey(name, t, tune);
|
|
108
|
+
let engine = engines.get(key);
|
|
109
|
+
if (!engine) {
|
|
110
|
+
const backend = name === 'codex'
|
|
111
|
+
? new nebula_autocomplete_1.CodexBackend({
|
|
112
|
+
codexHome: t ? undefined : process.env.NEBULA_AUTOCOMPLETE_CODEX_HOME,
|
|
113
|
+
transport: t ? toSsh(t) : { kind: 'local' },
|
|
114
|
+
})
|
|
115
|
+
: new nebula_autocomplete_1.ClaudeBackend({
|
|
116
|
+
transport: t ? toSsh(t) : { kind: 'local' },
|
|
117
|
+
...(tune.model ? { model: tune.model } : {}),
|
|
118
|
+
maxThinkingTokens: tune.thinkingTokens,
|
|
119
|
+
});
|
|
120
|
+
// contextBudget 20000: field testing showed 20k chars of context leaves
|
|
121
|
+
// latency essentially unchanged (worker history is prefix-cached; only the
|
|
122
|
+
// new message is uncached) — an earlier small A/B suggesting otherwise was
|
|
123
|
+
// cache/variance artifact. 20k covers the whole notebook in most cases;
|
|
124
|
+
// per-request overrides (Advanced settings) are clamped inside the engine.
|
|
125
|
+
engine = new nebula_autocomplete_1.AutocompleteEngine({ backend, contextBudget: 20_000 });
|
|
126
|
+
engines.set(key, engine);
|
|
127
|
+
}
|
|
128
|
+
return engine;
|
|
129
|
+
}
|
|
130
|
+
function disposeAutocompleteEngines() {
|
|
131
|
+
for (const engine of engines.values())
|
|
132
|
+
engine.dispose();
|
|
133
|
+
engines.clear();
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Discover the absolute path of a provider CLI on the user's machine over the
|
|
137
|
+
* reverse tunnel (PATH isn't set for a non-interactive ssh, so running the CLI
|
|
138
|
+
* needs an absolute path). Uses a login shell + a sentinel so rc-file noise
|
|
139
|
+
* doesn't corrupt the result. Best-effort; returns null on any failure.
|
|
140
|
+
*/
|
|
141
|
+
async function discoverRemoteBin(port, user, provider, host = 'localhost') {
|
|
142
|
+
if (!Number.isInteger(port) || !/^[A-Za-z0-9._-]+$/.test(user))
|
|
143
|
+
return null;
|
|
144
|
+
// `whence -p` FIRST: on the user's machine `claude`/`codex` is typically a zsh
|
|
145
|
+
// FUNCTION, so `command -v` returns the function name (not a path) and would
|
|
146
|
+
// short-circuit. `whence -p` (zsh) resolves the actual binary; `command -v` is
|
|
147
|
+
// the fallback for non-zsh login shells (where `whence` doesn't exist).
|
|
148
|
+
const remote = `$SHELL -lic 'printf "NB_BIN=%s\\n" "$(whence -p ${provider} 2>/dev/null || command -v ${provider} 2>/dev/null)"' 2>/dev/null`;
|
|
149
|
+
const res = await runCmd('ssh', ['-p', String(port), '-o', 'ProxyCommand=none', '-o', 'StrictHostKeyChecking=accept-new',
|
|
150
|
+
'-o', 'BatchMode=yes', '-o', 'ConnectTimeout=8', `${user}@${host}`, remote], 12000);
|
|
151
|
+
if (res.status !== 0 || !res.stdout)
|
|
152
|
+
return null;
|
|
153
|
+
const line = res.stdout.split('\n').find((l) => l.startsWith('NB_BIN='));
|
|
154
|
+
const bin = line ? line.slice('NB_BIN='.length).trim() : '';
|
|
155
|
+
return bin.startsWith('/') ? bin : null;
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Actually RUN a provider CLI on THIS server with a trivial prompt to tell
|
|
159
|
+
* "installed" from "usable" (logged in). Bounded + best-effort. Returns a status
|
|
160
|
+
* the diagnostics UI can render.
|
|
161
|
+
*/
|
|
162
|
+
async function checkCliUsable(name) {
|
|
163
|
+
if (!(await binaryAvailable(name)))
|
|
164
|
+
return { installed: false, usable: false, detail: 'not installed on this server' };
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
const cfg = `/tmp/nebula-diag-${name}-${process.pid}-${Math.round(process.hrtime()[1])}`;
|
|
167
|
+
const args = name === 'claude'
|
|
168
|
+
? ['-p', 'reply with just: ok', '--model', 'haiku']
|
|
169
|
+
: ['exec', '--json', '--skip-git-repo-check', '--sandbox', 'read-only', '--color', 'never', '--ephemeral', '-m', 'gpt-5.6-luna', 'reply ok'];
|
|
170
|
+
const env = { ...process.env, CLAUDE_CODE_MAX_OUTPUT_TOKENS: '64', CLAUDE_CONFIG_DIR: cfg };
|
|
171
|
+
let out = '', err = '', done = false;
|
|
172
|
+
const finish = (r) => { if (!done) {
|
|
173
|
+
done = true;
|
|
174
|
+
resolve(r);
|
|
175
|
+
} };
|
|
176
|
+
let proc;
|
|
177
|
+
try {
|
|
178
|
+
proc = (0, child_process_1.spawn)(name, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
return finish({ installed: true, usable: false, detail: 'failed to launch' });
|
|
182
|
+
}
|
|
183
|
+
const timer = setTimeout(() => { try {
|
|
184
|
+
proc.kill('SIGKILL');
|
|
185
|
+
}
|
|
186
|
+
catch { /* */ } finish({ installed: true, usable: false, detail: 'timed out (no response in 25s)' }); }, 25000);
|
|
187
|
+
proc.stdout?.on('data', (d) => (out += d.toString()));
|
|
188
|
+
proc.stderr?.on('data', (d) => (err += d.toString()));
|
|
189
|
+
proc.on('error', () => { clearTimeout(timer); finish({ installed: true, usable: false, detail: 'failed to launch' }); });
|
|
190
|
+
proc.on('exit', (code) => {
|
|
191
|
+
clearTimeout(timer);
|
|
192
|
+
const blob = (out + '\n' + err).toLowerCase();
|
|
193
|
+
const authIssue = /log ?in|logged in|authenticat|unauthor|oauth|api key|credit balance|subscription|setup-token|not authenticated/.test(blob);
|
|
194
|
+
if (authIssue)
|
|
195
|
+
return finish({ installed: true, usable: false, detail: 'not logged in on this server' });
|
|
196
|
+
if (code === 0 && out.trim())
|
|
197
|
+
return finish({ installed: true, usable: true, detail: 'logged in' });
|
|
198
|
+
return finish({ installed: true, usable: false, detail: err.trim().slice(-160) || `exited ${code}` });
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
async function autocompleteRoutes(fastify) {
|
|
203
|
+
/**
|
|
204
|
+
* Which CLI backends are usable on THIS server (for the settings UI and the
|
|
205
|
+
* editor's ghost-text gate). "Usable" means an actual trivial turn succeeds
|
|
206
|
+
* (logged in), not merely that the binary exists — a binary without
|
|
207
|
+
* credentials previously reported true here, so the UI enabled ghost text
|
|
208
|
+
* that could only ever produce "Not logged in" errors. The real probe costs
|
|
209
|
+
* a CLI round trip, so results are cached with a TTL.
|
|
210
|
+
*/
|
|
211
|
+
let statusCache = null;
|
|
212
|
+
let statusProbe = null;
|
|
213
|
+
const STATUS_TTL_MS = 5 * 60_000;
|
|
214
|
+
fastify.get('/autocomplete/status', async (_req, reply) => {
|
|
215
|
+
if (!statusCache || Date.now() - statusCache.at > STATUS_TTL_MS) {
|
|
216
|
+
if (!statusProbe) {
|
|
217
|
+
statusProbe = Promise.all([checkCliUsable('claude'), checkCliUsable('codex')])
|
|
218
|
+
.then(([claude, codex]) => {
|
|
219
|
+
const backends = { claude: claude.usable, codex: codex.usable };
|
|
220
|
+
statusCache = { at: Date.now(), backends };
|
|
221
|
+
return backends;
|
|
222
|
+
})
|
|
223
|
+
.finally(() => { statusProbe = null; });
|
|
224
|
+
}
|
|
225
|
+
await statusProbe;
|
|
226
|
+
}
|
|
227
|
+
return reply.send({
|
|
228
|
+
enabled: true,
|
|
229
|
+
backends: statusCache.backends,
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
/**
|
|
233
|
+
* Resolve the user's local CLI paths over the reverse tunnel, so the client
|
|
234
|
+
* can enable "run on my machine". Body: { port, user, host? }.
|
|
235
|
+
* Returns { ok, reachable, claude, codex }.
|
|
236
|
+
*/
|
|
237
|
+
fastify.post('/autocomplete/probe-remote', async (req, reply) => {
|
|
238
|
+
const body = (req.body ?? {});
|
|
239
|
+
const port = Number(body.port);
|
|
240
|
+
const user = typeof body.user === 'string' ? body.user.trim() : '';
|
|
241
|
+
const host = typeof body.host === 'string' && body.host.trim() ? body.host.trim() : 'localhost';
|
|
242
|
+
if (!Number.isInteger(port) || port <= 0 || !user) {
|
|
243
|
+
return reply.code(400).send({ ok: false, error: 'port and user required' });
|
|
244
|
+
}
|
|
245
|
+
const [claude, codex] = await Promise.all([
|
|
246
|
+
discoverRemoteBin(port, user, 'claude', host),
|
|
247
|
+
discoverRemoteBin(port, user, 'codex', host),
|
|
248
|
+
]);
|
|
249
|
+
return reply.send({ ok: true, reachable: claude !== null || codex !== null, claude, codex });
|
|
250
|
+
});
|
|
251
|
+
/**
|
|
252
|
+
* Comprehensive diagnostics for the settings "AI" tab. Body: { port?, user? }.
|
|
253
|
+
* Reports the environment, whether this server's CLIs are usable (logged in),
|
|
254
|
+
* and — if tunnel params are given — whether the user's machine is reachable
|
|
255
|
+
* and which CLIs resolve there.
|
|
256
|
+
*/
|
|
257
|
+
fastify.post('/autocomplete/diagnose', async (req, reply) => {
|
|
258
|
+
const body = (req.body ?? {});
|
|
259
|
+
const port = Number(body.port);
|
|
260
|
+
const user = typeof body.user === 'string' ? body.user.trim() : '';
|
|
261
|
+
const host = typeof body.host === 'string' && body.host.trim() ? body.host.trim() : 'localhost';
|
|
262
|
+
const hasTunnel = Number.isInteger(port) && port > 0 && !!user;
|
|
263
|
+
const [claude, codex] = await Promise.all([checkCliUsable('claude'), checkCliUsable('codex')]);
|
|
264
|
+
let tunnel = null;
|
|
265
|
+
if (hasTunnel) {
|
|
266
|
+
const [rc, rx] = await Promise.all([
|
|
267
|
+
discoverRemoteBin(port, user, 'claude', host),
|
|
268
|
+
discoverRemoteBin(port, user, 'codex', host),
|
|
269
|
+
]);
|
|
270
|
+
tunnel = { configured: true, reachable: rc !== null || rx !== null, claude: rc, codex: rx };
|
|
271
|
+
}
|
|
272
|
+
return reply.send({ environment: (0, environment_1.getEnvironment)(), server: { claude, codex }, tunnel });
|
|
273
|
+
});
|
|
274
|
+
/** POST /autocomplete — SSE stream. Body may include `transport` to run the
|
|
275
|
+
* CLI on the user's machine instead of this server. */
|
|
276
|
+
(0, server_1.registerAutocompleteRoute)(fastify, (req) => {
|
|
277
|
+
const raw = req;
|
|
278
|
+
const name = req.backend === 'codex' ? 'codex' : 'claude';
|
|
279
|
+
const t = parseTransport(raw.transport);
|
|
280
|
+
return getEngine(name, t, sanitizeTuning(name, raw));
|
|
281
|
+
});
|
|
282
|
+
// Pre-warm the default local engine at server start: the first completion
|
|
283
|
+
// after a server (re)start otherwise pays engine creation + pool spawn +
|
|
284
|
+
// warmup turns on the user's keystroke (measured ~19s cold). Booting it
|
|
285
|
+
// here moves that entirely off the request path. Best-effort; claude only
|
|
286
|
+
// (the default backend — codex spawns per-request by design).
|
|
287
|
+
void binaryAvailable('claude').then((ok) => {
|
|
288
|
+
if (ok)
|
|
289
|
+
getEngine('claude', null, { model: 'sonnet', thinkingTokens: 0 });
|
|
290
|
+
}).catch(() => { });
|
|
291
|
+
fastify.addHook('onClose', async () => {
|
|
292
|
+
disposeAutocompleteEngines();
|
|
293
|
+
});
|
|
294
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -41,7 +41,7 @@ exports.default = fsRoutes;
|
|
|
41
41
|
const fs_service_1 = require("../fs/fs-service");
|
|
42
42
|
Object.defineProperty(exports, "fsService", { enumerable: true, get: function () { return fs_service_1.fsService; } });
|
|
43
43
|
const path = __importStar(require("path"));
|
|
44
|
-
const
|
|
44
|
+
const private_tmp_1 = require("../private-tmp");
|
|
45
45
|
const nodeFs = __importStar(require("fs"));
|
|
46
46
|
async function fsRoutes(fastify) {
|
|
47
47
|
/**
|
|
@@ -50,7 +50,7 @@ async function fsRoutes(fastify) {
|
|
|
50
50
|
fastify.get('/fs/list', async (request, reply) => {
|
|
51
51
|
try {
|
|
52
52
|
const dirPath = request.query.path || '~';
|
|
53
|
-
const result = fs_service_1.fsService.listDirectory(dirPath);
|
|
53
|
+
const result = await fs_service_1.fsService.listDirectory(dirPath);
|
|
54
54
|
return reply.send(result);
|
|
55
55
|
}
|
|
56
56
|
catch (err) {
|
|
@@ -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) {
|
|
@@ -319,10 +319,27 @@ async function fsRoutes(fastify) {
|
|
|
319
319
|
'.zip': 'application/zip',
|
|
320
320
|
};
|
|
321
321
|
const contentType = mimeTypes[extension] || 'application/octet-stream';
|
|
322
|
+
// Inline viewing (?inline=1): hand the file to the browser's own viewer
|
|
323
|
+
// in a new tab instead of forcing a download. Restricted to an allowlist
|
|
324
|
+
// of types the browser renders SAFELY as data (PDF/images/media/text).
|
|
325
|
+
// HTML is intentionally excluded — served inline same-origin, its JS
|
|
326
|
+
// could read the Nebula auth token; it goes through the trust-gated
|
|
327
|
+
// /?html= view instead. nosniff stops the browser from re-interpreting a
|
|
328
|
+
// mislabeled file as something executable.
|
|
329
|
+
const wantInline = request.query.inline === '1' || request.query.inline === 'true';
|
|
330
|
+
const INLINE_OK = new Set([
|
|
331
|
+
'.pdf', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico', '.avif',
|
|
332
|
+
'.mp4', '.webm', '.mov', '.m4v', '.ogv',
|
|
333
|
+
'.mp3', '.wav', '.ogg', '.oga', '.flac', '.m4a', '.aac',
|
|
334
|
+
'.txt', '.csv', '.tsv', '.log', '.json', '.md',
|
|
335
|
+
]);
|
|
336
|
+
const inline = wantInline && INLINE_OK.has(extension);
|
|
337
|
+
const disposition = inline ? 'inline' : 'attachment';
|
|
322
338
|
const stream = nodeFs.createReadStream(normalizedPath);
|
|
323
339
|
return reply
|
|
324
340
|
.header('Content-Type', contentType)
|
|
325
|
-
.header('Content-
|
|
341
|
+
.header('X-Content-Type-Options', 'nosniff')
|
|
342
|
+
.header('Content-Disposition', `${disposition}; filename="${filename}"`)
|
|
326
343
|
.header('Content-Length', stat.size)
|
|
327
344
|
.send(stream);
|
|
328
345
|
}
|
|
@@ -357,7 +374,7 @@ async function fsRoutes(fastify) {
|
|
|
357
374
|
return reply.code(400).send({ detail: 'path is required' });
|
|
358
375
|
}
|
|
359
376
|
// Save the uploaded file to a temp location first
|
|
360
|
-
const tmpPath = path.join(
|
|
377
|
+
const tmpPath = path.join((0, private_tmp_1.privateTmpDir)('uploads'), `upload-${Date.now()}-${path.basename(data.filename || 'file')}`);
|
|
361
378
|
const writeStream = nodeFs.createWriteStream(tmpPath);
|
|
362
379
|
await new Promise((resolve, reject) => {
|
|
363
380
|
data.file.pipe(writeStream);
|