nebula-notebook 0.2.0 → 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 +35 -20
- package/dist/assets/{errorwidget-C4r2j2DQ.js → errorwidget-Wy5ajPiM.js} +1 -1
- package/dist/assets/{index-Day3QcNs.js → index-B_Bs5koo.js} +1 -1
- package/dist/assets/index-DK4nS74r.js +829 -0
- package/dist/assets/{index-D5w21_Z8.js → index-DeQN03nW.js} +1 -1
- package/dist/assets/index-DzjpmUak.css +32 -0
- package/dist/assets/{index-CsHoPQy-.js → index-g6MjT-VG.js} +1 -1
- package/dist/assets/{services-shim-D6p_A67v.js → services-shim-BirHVsih.js} +1 -1
- 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 +35 -2
- package/node-server/dist/cluster/kernel-proxy.js +19 -3
- package/node-server/dist/cluster/server-registry.d.ts +9 -0
- package/node-server/dist/cluster/server-registry.js +26 -0
- 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 +31 -1
- package/node-server/dist/fs/fs-service.js +160 -28
- package/node-server/dist/fs/types.d.ts +9 -0
- package/node-server/dist/fs/types.js +8 -0
- package/node-server/dist/index.js +71 -4
- package/node-server/dist/kernel/kernel-service.d.ts +17 -0
- package/node-server/dist/kernel/kernel-service.js +72 -4
- 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 +10 -0
- package/node-server/dist/notebook/headless-handler.js +55 -5
- package/node-server/dist/notebook/undoRedoManager.d.ts +8 -0
- package/node-server/dist/notebook/undoRedoManager.js +39 -16
- 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/fs.js +21 -4
- package/node-server/dist/routes/kernel.js +45 -2
- package/node-server/dist/routes/notebook.js +8 -2
- 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 +20 -0
- package/node-server/dist/scheduler/allocation-service.js +124 -8
- package/node-server/dist/scheduler/mock-scheduler.js +7 -3
- package/node-server/dist/scheduler/slurm-scheduler.js +21 -8
- package/node-server/dist/scheduler/types.d.ts +3 -2
- 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 +130 -19
- package/node-server/dist/terminal/server.js +112 -21
- package/node-server/dist/terminal/types.d.ts +2 -0
- package/node-server/package.json +1 -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 +17 -5
- package/scripts/postinstall.cjs +25 -5
- package/dist/assets/index-7-YBurka.js +0 -716
- package/dist/assets/index-CFBUnxSZ.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,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
|
+
}
|
|
@@ -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) {
|
|
@@ -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);
|
|
@@ -45,6 +45,8 @@ const path = __importStar(require("path"));
|
|
|
45
45
|
const os = __importStar(require("os"));
|
|
46
46
|
const kernel_service_1 = require("../kernel/kernel-service");
|
|
47
47
|
const kernelspec_1 = require("../kernel/kernelspec");
|
|
48
|
+
const discovery_service_1 = require("../discovery/discovery-service");
|
|
49
|
+
const types_1 = require("../discovery/types");
|
|
48
50
|
const fs_service_1 = require("../fs/fs-service");
|
|
49
51
|
const operation_router_1 = require("../notebook/operation-router");
|
|
50
52
|
const kernel_proxy_1 = require("../cluster/kernel-proxy");
|
|
@@ -109,7 +111,11 @@ kernelService.initialize().catch(err => {
|
|
|
109
111
|
console.error('Failed to initialize kernel service:', err);
|
|
110
112
|
});
|
|
111
113
|
function buildKernelMetadata(kernelName) {
|
|
112
|
-
|
|
114
|
+
// env: kernels have no on-disk spec — synthesize one, preferring the
|
|
115
|
+
// discovery cache's label ("Python 3.10 (conda: hypir)") over the raw path.
|
|
116
|
+
const envPython = (0, kernelspec_1.envKernelPythonPath)(kernelName);
|
|
117
|
+
const cachedLabel = envPython ? discovery_service_1.pythonDiscovery.getFromCache()?.[envPython]?.displayName : undefined;
|
|
118
|
+
const spec = (0, kernelspec_1.resolveKernelSpec)(kernelName, cachedLabel);
|
|
113
119
|
return {
|
|
114
120
|
kernelspec: {
|
|
115
121
|
name: kernelName,
|
|
@@ -118,6 +124,20 @@ function buildKernelMetadata(kernelName) {
|
|
|
118
124
|
},
|
|
119
125
|
};
|
|
120
126
|
}
|
|
127
|
+
/**
|
|
128
|
+
* Map provisioning failures to structured responses (matching routes/python.ts)
|
|
129
|
+
* so the UI can branch on `code` — e.g. open the ipykernel install prompt —
|
|
130
|
+
* instead of parsing error prose. Returns true if the error was handled.
|
|
131
|
+
*/
|
|
132
|
+
function sendProvisionError(reply, err) {
|
|
133
|
+
if (!(err instanceof types_1.KernelProvisionError))
|
|
134
|
+
return false;
|
|
135
|
+
const status = err.code === 'python_not_found' ? 404
|
|
136
|
+
: err.code === 'externally_managed' || err.code === 'needs_ipykernel' ? 422
|
|
137
|
+
: 500;
|
|
138
|
+
reply.code(status).send({ detail: err.message, code: err.code, install_hint: err.installHint });
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
121
141
|
async function persistNotebookKernelMetadata(filePath, kernelName) {
|
|
122
142
|
// Best-effort: persisting the kernel choice into the notebook's metadata is a
|
|
123
143
|
// secondary side-effect. If it fails (e.g. the notebook file doesn't exist yet,
|
|
@@ -498,6 +518,8 @@ async function kernelRoutes(fastify) {
|
|
|
498
518
|
return reply.send({ session_id: sessionId, kernel_name, server_id: localServerId, mtime: notebookMtime });
|
|
499
519
|
}
|
|
500
520
|
catch (err) {
|
|
521
|
+
if (sendProvisionError(reply, err))
|
|
522
|
+
return reply;
|
|
501
523
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
502
524
|
return reply.code(500).send({ detail: message });
|
|
503
525
|
}
|
|
@@ -526,7 +548,7 @@ async function kernelRoutes(fastify) {
|
|
|
526
548
|
*/
|
|
527
549
|
fastify.post('/kernels/for-file', async (request, reply) => {
|
|
528
550
|
try {
|
|
529
|
-
const { file_path, kernel_name
|
|
551
|
+
const { file_path, kernel_name, server_id, client_origin } = request.body;
|
|
530
552
|
if (!file_path) {
|
|
531
553
|
return reply.code(400).send({ detail: 'file_path is required' });
|
|
532
554
|
}
|
|
@@ -548,6 +570,25 @@ async function kernelRoutes(fastify) {
|
|
|
548
570
|
}
|
|
549
571
|
}
|
|
550
572
|
}
|
|
573
|
+
// No explicit kernel and no saved preference: resolve from the
|
|
574
|
+
// notebook's own kernelspec metadata (headless callers — CLI/MCP —
|
|
575
|
+
// don't resolve it client-side the way the UI does). Only then fall
|
|
576
|
+
// back to python3.
|
|
577
|
+
if (!effectiveKernelName) {
|
|
578
|
+
try {
|
|
579
|
+
const metadata = await fs_service_1.fsService.getNotebookMetadataAsync(normalizedFilePath);
|
|
580
|
+
const specName = metadata.kernelspec?.name;
|
|
581
|
+
if (specName) {
|
|
582
|
+
effectiveKernelName = specName;
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
// fall through to the default
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
if (!effectiveKernelName) {
|
|
590
|
+
effectiveKernelName = 'python3';
|
|
591
|
+
}
|
|
551
592
|
// Check if we should start on a remote server
|
|
552
593
|
if (effectiveServerId && effectiveServerId !== localServerId && effectiveServerId !== 'local') {
|
|
553
594
|
// Start on remote server
|
|
@@ -594,6 +635,8 @@ async function kernelRoutes(fastify) {
|
|
|
594
635
|
});
|
|
595
636
|
}
|
|
596
637
|
catch (err) {
|
|
638
|
+
if (sendProvisionError(reply, err))
|
|
639
|
+
return reply;
|
|
597
640
|
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
598
641
|
return reply.code(500).send({ detail: message });
|
|
599
642
|
}
|
|
@@ -87,6 +87,12 @@ async function notebookRoutes(fastify) {
|
|
|
87
87
|
return reply.code(400).send({ detail: 'cells is required' });
|
|
88
88
|
}
|
|
89
89
|
const result = await fs_service_1.fsService.saveNotebookBundle(filePath, cells, kernel_name, history);
|
|
90
|
+
// Outputs-unchanged sentinel couldn't be resolved against the file on
|
|
91
|
+
// disk (first save, changed ids, external rewrite…) — tell the client
|
|
92
|
+
// to resend the full payload.
|
|
93
|
+
if (!result.success && result.needsFull) {
|
|
94
|
+
return reply.code(409).send({ detail: 'unchanged-outputs sentinel unresolved; resend full payload', code: 'needs_full' });
|
|
95
|
+
}
|
|
90
96
|
// The UI just wrote newer content than whatever the headless handler may
|
|
91
97
|
// have cached. Drop the cache so headless ops (an agent working after the
|
|
92
98
|
// user closes the tab) reload from disk instead of serving — or worse,
|
|
@@ -185,7 +191,7 @@ async function notebookRoutes(fastify) {
|
|
|
185
191
|
status: 'ok',
|
|
186
192
|
notebook_path,
|
|
187
193
|
mtime: result.mtime,
|
|
188
|
-
...(result.status || fs_service_1.fsService.
|
|
194
|
+
...(result.status || await fs_service_1.fsService.getAgentPermissionStatusAsync(notebook_path)),
|
|
189
195
|
});
|
|
190
196
|
}
|
|
191
197
|
catch (err) {
|
|
@@ -202,7 +208,7 @@ async function notebookRoutes(fastify) {
|
|
|
202
208
|
if (!filePath) {
|
|
203
209
|
return reply.code(400).send({ detail: 'path query parameter is required' });
|
|
204
210
|
}
|
|
205
|
-
const status = fs_service_1.fsService.
|
|
211
|
+
const status = await fs_service_1.fsService.getAgentPermissionStatusAsync(filePath);
|
|
206
212
|
return reply.send({
|
|
207
213
|
notebook_path: filePath,
|
|
208
214
|
...status,
|
|
@@ -2,7 +2,6 @@
|
|
|
2
2
|
* Python Discovery API Routes
|
|
3
3
|
*/
|
|
4
4
|
import { FastifyInstance } from 'fastify';
|
|
5
|
-
|
|
6
|
-
declare const discoveryService: PythonDiscoveryService;
|
|
5
|
+
declare const discoveryService: import("../discovery/discovery-service").PythonDiscoveryService;
|
|
7
6
|
export default function pythonRoutes(fastify: FastifyInstance): Promise<void>;
|
|
8
7
|
export { discoveryService };
|
|
@@ -11,7 +11,10 @@ const kernelspec_1 = require("../kernel/kernelspec");
|
|
|
11
11
|
const default_kernel_1 = require("../kernel/default-kernel");
|
|
12
12
|
const server_registry_1 = require("../cluster/server-registry");
|
|
13
13
|
const kernel_1 = require("./kernel");
|
|
14
|
-
|
|
14
|
+
// The shared singleton — kernel-service preflight/labels and per-env
|
|
15
|
+
// kernelspec scanning read its in-memory cache, so routes must write through
|
|
16
|
+
// the SAME instance (a second instance would leave the singleton stale).
|
|
17
|
+
const discoveryService = discovery_service_1.pythonDiscovery;
|
|
15
18
|
exports.discoveryService = discoveryService;
|
|
16
19
|
async function pythonRoutes(fastify) {
|
|
17
20
|
/**
|
|
@@ -41,6 +44,9 @@ async function pythonRoutes(fastify) {
|
|
|
41
44
|
const data = await response.json();
|
|
42
45
|
return reply.send(data);
|
|
43
46
|
}
|
|
47
|
+
// Discover Python environments FIRST: kernelspec discovery also scans
|
|
48
|
+
// each known env's share/jupyter/kernels, so it must see the fresh list.
|
|
49
|
+
const environments = await discoveryService.discover({ forceRefresh: refresh });
|
|
44
50
|
// Get Jupyter kernelspecs and transform to snake_case
|
|
45
51
|
const kernelspecs = (0, kernelspec_1.discoverKernelSpecs)(refresh).map(k => ({
|
|
46
52
|
name: k.name,
|
|
@@ -50,8 +56,6 @@ async function pythonRoutes(fastify) {
|
|
|
50
56
|
python_path: k.argv?.[0] || null, // First element of argv is typically the Python executable
|
|
51
57
|
}));
|
|
52
58
|
const kernelspecNames = new Set(kernelspecs.map(k => k.name));
|
|
53
|
-
// Get discovered Python environments
|
|
54
|
-
const environments = await discoveryService.discover({ forceRefresh: refresh });
|
|
55
59
|
// Convert to snake_case and match with kernelspecs
|
|
56
60
|
const envObjects = environments.map(env => ({
|
|
57
61
|
path: env.path,
|
|
@@ -75,6 +79,138 @@ async function pythonRoutes(fastify) {
|
|
|
75
79
|
return reply.code(500).send({ detail: message });
|
|
76
80
|
}
|
|
77
81
|
});
|
|
82
|
+
/**
|
|
83
|
+
* Install ipykernel into an environment (no kernelspec registration — env
|
|
84
|
+
* kernels raw-launch). One installer chosen up front (conda → uv → pip).
|
|
85
|
+
*
|
|
86
|
+
* Responds with an NDJSON stream so the UI can show installer output LIVE:
|
|
87
|
+
* {"type":"output","data":"…"} — stdout/stderr chunks as they arrive
|
|
88
|
+
* {"type":"done","installer":…} — success terminator
|
|
89
|
+
* {"type":"error","detail":…,"code":…,"install_hint":…} — failure terminator
|
|
90
|
+
* Pre-stream validation failures are plain JSON error responses.
|
|
91
|
+
*/
|
|
92
|
+
fastify.post('/python/install-ipykernel', async (request, reply) => {
|
|
93
|
+
const { python_path, server_id } = request.body;
|
|
94
|
+
if (!python_path) {
|
|
95
|
+
return reply.code(400).send({ detail: 'python_path is required' });
|
|
96
|
+
}
|
|
97
|
+
const localServerId = server_registry_1.serverRegistry.getLocalServerId();
|
|
98
|
+
if (server_id && server_id !== localServerId && server_id !== 'local') {
|
|
99
|
+
const server = server_registry_1.serverRegistry.getServer(server_id);
|
|
100
|
+
if (!server) {
|
|
101
|
+
return reply.code(404).send({ detail: `Server not found: ${server_id}` });
|
|
102
|
+
}
|
|
103
|
+
if (server.status !== 'online') {
|
|
104
|
+
return reply.code(503).send({ detail: `Server is offline: ${server_id}` });
|
|
105
|
+
}
|
|
106
|
+
const response = await fetch(`${server.url}/api/python/install-ipykernel`, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
headers: {
|
|
109
|
+
'Content-Type': 'application/json',
|
|
110
|
+
...(process.env.NEBULA_CLUSTER_SECRET ? { 'X-Nebula-Cluster-Secret': process.env.NEBULA_CLUSTER_SECRET } : {}),
|
|
111
|
+
},
|
|
112
|
+
body: JSON.stringify({ python_path }),
|
|
113
|
+
});
|
|
114
|
+
if (!response.ok || !response.body) {
|
|
115
|
+
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
|
116
|
+
return reply.code(response.status).send({
|
|
117
|
+
detail: error.detail || 'Failed to install ipykernel',
|
|
118
|
+
code: error.code,
|
|
119
|
+
install_hint: error.install_hint,
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
// Pipe the remote NDJSON stream through untouched.
|
|
123
|
+
reply.hijack();
|
|
124
|
+
reply.raw.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' });
|
|
125
|
+
try {
|
|
126
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
127
|
+
for await (const chunk of response.body) {
|
|
128
|
+
reply.raw.write(chunk);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch { /* upstream died — terminate what we have */ }
|
|
132
|
+
reply.raw.end();
|
|
133
|
+
return reply;
|
|
134
|
+
}
|
|
135
|
+
// Local install: stream output as it happens, then a terminator event.
|
|
136
|
+
reply.hijack();
|
|
137
|
+
reply.raw.writeHead(200, { 'Content-Type': 'application/x-ndjson', 'Cache-Control': 'no-cache' });
|
|
138
|
+
const send = (obj) => reply.raw.write(`${JSON.stringify(obj)}\n`);
|
|
139
|
+
try {
|
|
140
|
+
const result = await discoveryService.installIpykernel(python_path, (chunk) => send({ type: 'output', data: chunk }));
|
|
141
|
+
send({ type: 'done', installer: result.installer, message: result.message, python_path });
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
if (err instanceof types_1.KernelProvisionError) {
|
|
145
|
+
send({ type: 'error', detail: err.message, code: err.code, install_hint: err.installHint });
|
|
146
|
+
}
|
|
147
|
+
else {
|
|
148
|
+
send({ type: 'error', detail: err instanceof Error ? err.message : 'Unknown error' });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
reply.raw.end();
|
|
152
|
+
return reply;
|
|
153
|
+
});
|
|
154
|
+
/**
|
|
155
|
+
* Probe a manually-entered interpreter path ("Enter interpreter path…"):
|
|
156
|
+
* validate it runs, classify + enrich it, persist it into the discovery
|
|
157
|
+
* cache, and return it in the same snake_case shape as /python/environments.
|
|
158
|
+
*/
|
|
159
|
+
fastify.post('/python/probe', async (request, reply) => {
|
|
160
|
+
try {
|
|
161
|
+
const { python_path, server_id } = request.body;
|
|
162
|
+
if (!python_path) {
|
|
163
|
+
return reply.code(400).send({ detail: 'python_path is required' });
|
|
164
|
+
}
|
|
165
|
+
const localServerId = server_registry_1.serverRegistry.getLocalServerId();
|
|
166
|
+
if (server_id && server_id !== localServerId && server_id !== 'local') {
|
|
167
|
+
const server = server_registry_1.serverRegistry.getServer(server_id);
|
|
168
|
+
if (!server) {
|
|
169
|
+
return reply.code(404).send({ detail: `Server not found: ${server_id}` });
|
|
170
|
+
}
|
|
171
|
+
if (server.status !== 'online') {
|
|
172
|
+
return reply.code(503).send({ detail: `Server is offline: ${server_id}` });
|
|
173
|
+
}
|
|
174
|
+
const response = await fetch(`${server.url}/api/python/probe`, {
|
|
175
|
+
method: 'POST',
|
|
176
|
+
headers: {
|
|
177
|
+
'Content-Type': 'application/json',
|
|
178
|
+
...(process.env.NEBULA_CLUSTER_SECRET ? { 'X-Nebula-Cluster-Secret': process.env.NEBULA_CLUSTER_SECRET } : {}),
|
|
179
|
+
},
|
|
180
|
+
body: JSON.stringify({ python_path }),
|
|
181
|
+
});
|
|
182
|
+
if (!response.ok) {
|
|
183
|
+
const error = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
|
184
|
+
return reply.code(response.status).send({
|
|
185
|
+
detail: error.detail || 'Failed to probe interpreter',
|
|
186
|
+
code: error.code,
|
|
187
|
+
install_hint: error.install_hint,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
return reply.send(await response.json());
|
|
191
|
+
}
|
|
192
|
+
const env = await discoveryService.probeAndRemember(python_path);
|
|
193
|
+
return reply.send({
|
|
194
|
+
path: env.path,
|
|
195
|
+
version: env.version,
|
|
196
|
+
display_name: env.displayName,
|
|
197
|
+
env_type: env.envType,
|
|
198
|
+
env_name: env.envName,
|
|
199
|
+
has_ipykernel: env.hasIpykernel,
|
|
200
|
+
externally_managed: env.externallyManaged,
|
|
201
|
+
install_hint: env.installHint,
|
|
202
|
+
kernel_name: env.kernelName,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
if (err instanceof types_1.KernelProvisionError) {
|
|
207
|
+
const status = err.code === 'python_not_found' ? 404 : 500;
|
|
208
|
+
return reply.code(status).send({ detail: err.message, code: err.code, install_hint: err.installHint });
|
|
209
|
+
}
|
|
210
|
+
const message = err instanceof Error ? err.message : 'Unknown error';
|
|
211
|
+
return reply.code(500).send({ detail: message });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
78
214
|
/**
|
|
79
215
|
* Install ipykernel and register a Python environment as a Jupyter kernel
|
|
80
216
|
*/
|