Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -1174,7 +1174,7 @@
"en": "Jupyter Notebook",
"zh-CN": "Jupyter Notebook"
},
"version": "0.2.0",
"version": "0.2.1",
"source": "./plugins/jupyter-notebook",
"description": "在对话侧栏打开 Jupyter Notebook:连接已有的 Jupyter Server 或为工作区启动一个,Agent 与用户一起编辑和运行 cell。",
"description_i18n": {
Expand Down
2 changes: 1 addition & 1 deletion plugins/jupyter-notebook/.zcode-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "jupyter-notebook",
"version": "0.2.0",
"version": "0.2.1",
"description": "在对话侧栏打开 Jupyter Notebook:连接已有的 Jupyter Server 或为工作区启动一个,Agent 与用户一起编辑和运行 cell。",
"author": {
"name": "ZCode"
Expand Down
2 changes: 1 addition & 1 deletion plugins/jupyter-notebook/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ You can also open the panel from the side pane "+" menu.
### Servers

- **Running servers**: JupyterLab, Notebook 7 and `jupyter server` instances on this machine are found automatically and connect without a token. Other servers, including remote ones, connect with a URL and token (`connect_server`, or the form in the panel). The token stays inside the plugin process and is never shown to the agent or the panel.
- **A server for the workspace**: `start_server` (or the panel's start button) runs `python -m jupyter_server` bound to `127.0.0.1` with a random token and the workspace as its root. It uses the first Python with `jupyter-server` and `ipykernel`: the plugin setting *Python interpreter*, the workspace `.venv` / `venv`, the active virtualenv or conda environment, then `python3` on PATH. Nothing is installed automatically; `start_server` with `useUv: true` runs it through `uv run --with jupyter-server --with ipykernel`. The server stops when the session ends.
- **A server for the workspace**: `start_server` (or the panel's start button) runs `python -m jupyter_server` bound to `127.0.0.1` with a random token and the workspace as its root. It uses the first Python with `jupyter-server` and `ipykernel`: the plugin setting *Python interpreter*, the workspace `.venv` / `venv`, the active virtualenv or conda environment, then `python3` on PATH. Nothing is installed automatically; `start_server` with `useUv: true` runs it through `uv run --with jupyter-server --with ipykernel`. The server stops when the session ends, including when startup is still in progress.
- If a notebook is already open in JupyterLab on the same server, the panel joins its kernel.

### Panel
Expand Down
2 changes: 1 addition & 1 deletion plugins/jupyter-notebook/README_CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Git 市场安装包已包含编译好的 MCP server 和面板,无需自行构
### Server

- **已在运行的 server**:本机运行的 JupyterLab、Notebook 7 和 `jupyter server` 会被自动发现,无需 token 即可连接。其他 server(包括远程的)通过 URL 和 token 连接(`connect_server` 或面板中的表单)。token 只保存在插件进程内,不会出现在 Agent 或面板中。
- **为工作区启动 server**:`start_server`(或面板中的启动按钮)运行 `python -m jupyter_server`,只绑定 `127.0.0.1`,使用随机 token,根目录为工作区。按顺序使用第一个装有 `jupyter-server` 与 `ipykernel` 的 Python:插件设置“Python 解释器”、工作区 `.venv` / `venv`、当前激活的 virtualenv 或 conda 环境、PATH 中的 `python3`。插件不会自动安装任何东西;`start_server` 传 `useUv: true` 时改用 `uv run --with jupyter-server --with ipykernel` 运行。会话结束时 server 随之停止。
- **为工作区启动 server**:`start_server`(或面板中的启动按钮)运行 `python -m jupyter_server`,只绑定 `127.0.0.1`,使用随机 token,根目录为工作区。按顺序使用第一个装有 `jupyter-server` 与 `ipykernel` 的 Python:插件设置“Python 解释器”、工作区 `.venv` / `venv`、当前激活的 virtualenv 或 conda 环境、PATH 中的 `python3`。插件不会自动安装任何东西;`start_server` 传 `useUv: true` 时改用 `uv run --with jupyter-server --with ipykernel` 运行。会话结束时 server 随之停止,包括尚未完成启动的 server。
- 若同一 server 上的 JupyterLab 已打开该 notebook,面板会加入它的 kernel。

### 面板
Expand Down
2 changes: 1 addition & 1 deletion ui-plugins/jupyter-notebook/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@zcode/plugin-jupyter-notebook",
"version": "0.2.0",
"version": "0.2.1",
"private": true,
"license": "MIT",
"type": "module",
Expand Down
2 changes: 1 addition & 1 deletion ui-plugins/jupyter-notebook/src/CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
## 分层

- `jupyter/`:Jupyter Server REST(status、kernelspecs、sessions、kernels、contents)与 kernel WebSocket(旧版 JSON 帧,不协商子协议)。只认 `{baseUrl, token}`,不区分 server 来源。
- `servers/`:`ServerRegistry` 是连接唯一 owner。`attach(url, token)` 连接已有 server;`launch()` 找到装有 `jupyter_server` + `ipykernel` 的 Python,起 `python -m jupyter_server`(仅 `127.0.0.1`、随机端口、`JUPYTER_TOKEN`、root_dir = 工作区),就绪后同样 attach。`discover()` 读 Jupyter runtime 目录的 `jpserver-*.json`。只停止自己 launch 的 server;进程退出时一并停止。
- `servers/`:`ServerRegistry` 是连接唯一 owner。`attach(url, token)` 连接已有 server;`launch()` 找到装有 `jupyter_server` + `ipykernel` 的 Python,起 `python -m jupyter_server`(仅 `127.0.0.1`、随机端口、`JUPYTER_TOKEN`、root_dir = 工作区),就绪后同样 attach。`discover()` 读 Jupyter runtime 目录的 `jpserver-*.json`。只停止自己 launch 的 server;进程退出时一并停止。`closeAll()` 是终止操作:先拒绝新的连接与启动,等待在途启动完成并回收其进程;并发关闭复用同一次清理,不停止外部 attached server。
- `servers/environment.ts`:启动前的环境检查(`JupyterEnvironment`):与 `start_server` 相同的解释器顺序,目标解释器中两个包的版本,环境是否由 uv 管理(pyvenv.cfg 的 `uv =` 或项目的 uv.lock),本机的 uv,以及安装命令(uv 项目 `uv add --dev`,没有 pyproject.toml 的 uv 环境 `uv pip install --python`,其余 pip)。`start_server` 失败时的提示用同一个命令。
- `domain/`:nbformat 规范化(补 cell id、source 归一为字符串)、编辑操作、输出归并(stream 合并、`clear_output(wait)`、`update_display_data`)、模型摘要。纯函数。
- `app/notebooks.ts`:`NotebookOwner` 是已打开 notebook 的唯一 owner:文档、revision、kernel session、执行、自动保存与冲突。
Expand Down
2 changes: 1 addition & 1 deletion ui-plugins/jupyter-notebook/src/contract.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
export const PLUGIN_NAME = "jupyter-notebook";
export const PLUGIN_VERSION = "0.2.0";
export const PLUGIN_VERSION = "0.2.1";
/** manifest `mcpServers` 键、`ui.surfaces[].id` 与资源命名空间保持一致。 */
export const SERVER_NAME = "jupyter-notebook";
export const SURFACE_ID = "jupyter-notebook";
Expand Down
24 changes: 19 additions & 5 deletions ui-plugins/jupyter-notebook/src/servers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export function createServerRegistry(deps: RegistryDeps) {
const clientFactory = deps.clientFactory ?? createJupyterClient;
const discover = deps.discover ?? (() => discoverServers());
let launching: Promise<ServerInfo> | null = null;
let closing: Promise<void> | null = null;
let environment: JupyterEnvironment = UNKNOWN_ENVIRONMENT;
let checkingEnvironment: Promise<JupyterEnvironment> | null = null;

Expand Down Expand Up @@ -154,9 +155,11 @@ export function createServerRegistry(deps: RegistryDeps) {

/** 连接已有 server。未给 token 时从 URL 的 `?token=` 或本机 runtime 文件取。 */
async attach({ url, token }: { url: string; token?: string | null }): Promise<ServerInfo> {
if (closing) throw new JupyterError("invalid_operation", "The Jupyter Server registry is closed.");
const normalized = normalizeBaseUrl(url);
let secret = token?.trim() || normalized.token;
const runtime = (await discover()).find((server) => server.baseUrl === normalized.baseUrl);
if (closing) throw new JupyterError("invalid_operation", "The Jupyter Server registry is closed.");
if (!secret && runtime) secret = runtime.token;
const id = serverIdFor(normalized.baseUrl);
const existing = entries.get(id);
Expand All @@ -176,6 +179,7 @@ export function createServerRegistry(deps: RegistryDeps) {
);
throw error;
}
if (closing) throw new JupyterError("invalid_operation", "The Jupyter Server registry is closed.");
const entry: Entry = {
info: {
id,
Expand Down Expand Up @@ -209,6 +213,7 @@ export function createServerRegistry(deps: RegistryDeps) {

/** 启动本插件自己的 server;已有运行中的 launched server 时直接返回它。 */
launch({ python, useUv }: { python?: string; useUv?: boolean } = {}): Promise<ServerInfo> {
if (closing) return Promise.reject(new JupyterError("invalid_operation", "The Jupyter Server registry is closed."));
const running = [...entries.values()].find(
(entry) => entry.process && entry.info.status === "ready",
);
Expand Down Expand Up @@ -259,6 +264,11 @@ export function createServerRegistry(deps: RegistryDeps) {
.version()
.then((value) => value.version)
.catch(() => null);
// closeAll 必须覆盖尚未登记的启动;关闭已开始时先回收新进程,不能重新登记。
if (closing) {
await proc.stop();
throw new JupyterError("invalid_operation", "The Jupyter Server registry is closed.");
}
proc.child.once("exit", () => {
if (entries.get(id) !== entry) return;
entry.info.status = entry.info.status === "stopped" ? "stopped" : "error";
Expand Down Expand Up @@ -325,11 +335,15 @@ export function createServerRegistry(deps: RegistryDeps) {
return { info: snapshot(entry), client: entry.client };
},

async closeAll() {
await Promise.allSettled(
[...entries.values()].filter((entry) => entry.process).map((entry) => entry.process!.stop()),
);
entries.clear();
closeAll(): Promise<void> {
closing ??= (async () => {
await launching?.catch(() => undefined);
await Promise.allSettled(
[...entries.values()].filter((entry) => entry.process).map((entry) => entry.process!.stop()),
);
entries.clear();
})();
return closing;
},
};
}
Expand Down
92 changes: 91 additions & 1 deletion ui-plugins/jupyter-notebook/src/servers/servers.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { EventEmitter } from "node:events";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { normalizeBaseUrl } from "#nb/jupyter/rest.ts";
import { discoverServers, parseRuntimeFile, runtimeDirs } from "#nb/servers/discover.ts";
import { findJupyterPython, pythonCandidates } from "#nb/servers/launch.ts";
import { createServerRegistry, serverIdFor, toServerPath } from "#nb/servers/registry.ts";

const deferred = <T,>() => {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
};

let dir: string;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), "jnb-unit-"));
Expand Down Expand Up @@ -126,6 +134,88 @@ describe("python discovery", () => {
});

describe("registry", () => {
it.each(["launch", "version"])("waits for a pending %s before completing shutdown", async (phase) => {
const entered = deferred<void>();
const resume = deferred<void>();
const stop = vi.fn(async () => undefined);
const registry = createServerRegistry({
workspaceRoot: dir,
pythonPath: null,
launch: async () => {
if (phase === "launch") { entered.resolve(); await resume.promise; }
return { baseUrl: "http://127.0.0.1:9/", token: "secret", python: "python", pid: 1,
child: new EventEmitter(), logTail: () => "", stop } as never;
},
clientFactory: (() => ({ version: async () => {
if (phase === "version") { entered.resolve(); await resume.promise; }
return { version: "2" };
} })) as never,
});
const launch = registry.launch({ useUv: true });
const failedLaunch = expect(launch).rejects.toMatchObject({ code: "invalid_operation" });
await entered.promise;
const closing = registry.closeAll();
const closingAgain = registry.closeAll();
resume.resolve();
await Promise.all([closing, closingAgain, failedLaunch]);
expect(stop).toHaveBeenCalledTimes(1);
expect(registry.list()).toEqual([]);
await expect(registry.launch({ useUv: true })).rejects.toMatchObject({ code: "invalid_operation" });
await expect(registry.attach({ url: "http://127.0.0.1:9/" })).rejects.toMatchObject({ code: "invalid_operation" });
});

it("finishes shutdown when the pending launch fails", async () => {
const entered = deferred<void>();
const resume = deferred<void>();
const error = new Error("startup failed");
const registry = createServerRegistry({
workspaceRoot: dir, pythonPath: null,
launch: async () => { entered.resolve(); await resume.promise; throw error; },
});
const launch = registry.launch({ useUv: true });
const failedLaunch = expect(launch).rejects.toBe(error);
await entered.promise;
const closing = registry.closeAll();
resume.resolve();
await Promise.all([closing, failedLaunch]);
expect(registry.list()).toEqual([]);
});

it("does not register an attachment that finishes after shutdown", async () => {
const entered = deferred<void>();
const resume = deferred<void>();
const registry = createServerRegistry({
workspaceRoot: dir, pythonPath: null, discover: async () => [],
clientFactory: (() => ({
status: async () => { entered.resolve(); await resume.promise; },
version: async () => ({ version: "2" }),
})) as never,
});
const attach = registry.attach({ url: "http://127.0.0.1:9/" });
const failedAttach = expect(attach).rejects.toMatchObject({ code: "invalid_operation" });
await entered.promise;
await registry.closeAll();
resume.resolve();
await failedAttach;
expect(registry.list()).toEqual([]);
});

it("stops only owned servers and shares concurrent shutdown", async () => {
const stop = vi.fn(async () => undefined);
const registry = createServerRegistry({
workspaceRoot: dir, pythonPath: null, discover: async () => [],
launch: async () => ({ baseUrl: "http://127.0.0.1:9/", token: "secret", python: "python", pid: 1,
child: new EventEmitter(), logTail: () => "", stop }) as never,
clientFactory: (() => ({ status: async () => ({}), version: async () => ({ version: "2" }) })) as never,
});
await registry.launch({ useUv: true });
await registry.attach({ url: "http://127.0.0.1:10/" });
expect(registry.list()).toHaveLength(2);
await Promise.all([registry.closeAll(), registry.closeAll()]);
expect(stop).toHaveBeenCalledTimes(1);
expect(registry.list()).toEqual([]);
});

it("requires a server before resolving and reports it clearly", () => {
const registry = createServerRegistry({ workspaceRoot: dir, pythonPath: null, discover: async () => [] });
expect(() => registry.resolve()).toThrow(expect.objectContaining({ code: "no_server" }));
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, it } from "vitest";
import { ensureJupyterPython } from "../scripts/jupyter-env.mjs";
import { launchJupyterServer, type LaunchedProcess } from "#nb/servers/launch.ts";
import { createServerRegistry } from "#nb/servers/registry.ts";

it("stops a real Jupyter process that becomes ready during registry shutdown", async () => {
const python = await ensureJupyterPython();
const workspace = await mkdtemp(join(tmpdir(), "jupyter-nb-shutdown-"));
let ready!: () => void;
const started = new Promise<void>((resolve) => { ready = resolve; });
let resume!: () => void;
const released = new Promise<void>((resolve) => { resume = resolve; });
let proc: LaunchedProcess | undefined;
const registry = createServerRegistry({
workspaceRoot: workspace, pythonPath: python,
launch: async (options) => {
proc = await launchJupyterServer(options);
ready();
await released;
return proc;
},
});
try {
const launch = registry.launch();
const result = launch.then(() => null, (error: unknown) => error);
await started;
expect(proc!.child.exitCode).toBeNull();
const closing = registry.closeAll();
resume();
await closing;
const completion = await result;
expect(proc!.child.exitCode !== null || proc!.child.signalCode !== null).toBe(true);
expect(completion).toMatchObject({ code: "invalid_operation" });
expect(registry.list()).toEqual([]);
await expect(fetch(new URL("api/status", proc!.baseUrl))).rejects.toThrow();
} finally {
resume();
await proc?.stop();
await registry.closeAll();
await rm(workspace, { recursive: true, force: true });
}
});