Add Jupyter workspace runtime
This commit is contained in:
+73
-2
@@ -40,6 +40,11 @@ from src.agent_types import (
|
||||
)
|
||||
from src.bundled_skills import ALWAYS_ENABLED_HIDDEN_SKILL_NAMES, get_bundled_skills
|
||||
from src.data_agent_inputs import DataAgentInputError, load_input_sources
|
||||
from src.jupyter_runtime import (
|
||||
DEFAULT_JUPYTER_WORKSPACE_ROOT,
|
||||
JupyterRuntimeError,
|
||||
JupyterRuntimeManager,
|
||||
)
|
||||
from src.mcp_runtime import MCPRuntime, MCPServerProfile
|
||||
from src.openai_compat import OpenAICompatClient, OpenAICompatError
|
||||
from src.session_store import (
|
||||
@@ -498,6 +503,7 @@ class AgentState:
|
||||
self._run_locks: dict[str, Lock] = {}
|
||||
self._account_configs: dict[str, AgentInstanceConfig] = {}
|
||||
self.run_manager = RunManager()
|
||||
self.jupyter_runtime_manager = JupyterRuntimeManager()
|
||||
self._default_config = AgentInstanceConfig(
|
||||
cwd=cwd.resolve(),
|
||||
model=model,
|
||||
@@ -914,6 +920,14 @@ class ModelListRequest(BaseModel):
|
||||
account_id: str | None = None
|
||||
|
||||
|
||||
class JupyterWorkspaceBindRequest(BaseModel):
|
||||
session_id: str = Field(min_length=1)
|
||||
base_url: str = Field(min_length=1)
|
||||
password: str = Field(min_length=1)
|
||||
workspace_root: str = DEFAULT_JUPYTER_WORKSPACE_ROOT
|
||||
account_id: str | None = None
|
||||
|
||||
|
||||
class SkillPreferenceUpdate(BaseModel):
|
||||
skill: str | None = None
|
||||
enabled: bool
|
||||
@@ -940,6 +954,11 @@ class FeishuOnlineDocRequest(BaseModel):
|
||||
account_id: str | None = None
|
||||
|
||||
|
||||
def _append_runtime_context(current: str | None, addition: str) -> str:
|
||||
parts = [part.strip() for part in (current, addition) if part and part.strip()]
|
||||
return '\n\n'.join(parts)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# App factory
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -971,6 +990,46 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return state.snapshot(payload.account_id)
|
||||
|
||||
@app.get('/api/jupyter/session')
|
||||
async def get_jupyter_session(
|
||||
session_id: str,
|
||||
account_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
safe_session_id = _safe_session_id(session_id)
|
||||
if not safe_session_id:
|
||||
raise HTTPException(status_code=400, detail='session_id is required')
|
||||
account_key = state._account_key(account_id)
|
||||
return state.jupyter_runtime_manager.session_payload(
|
||||
account_key,
|
||||
safe_session_id,
|
||||
)
|
||||
|
||||
@app.post('/api/jupyter/bind-session')
|
||||
async def bind_jupyter_session(
|
||||
payload: JupyterWorkspaceBindRequest,
|
||||
) -> dict[str, Any]:
|
||||
safe_session_id = _safe_session_id(payload.session_id)
|
||||
if not safe_session_id:
|
||||
raise HTTPException(status_code=400, detail='session_id is required')
|
||||
account_key = state._account_key(payload.account_id)
|
||||
try:
|
||||
runtime = await asyncio.to_thread(
|
||||
state.jupyter_runtime_manager.bind_session,
|
||||
account_id=account_key,
|
||||
session_id=safe_session_id,
|
||||
base_url=payload.base_url,
|
||||
password=payload.password,
|
||||
workspace_root=payload.workspace_root,
|
||||
)
|
||||
except JupyterRuntimeError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=502,
|
||||
detail=f'Unable to connect Jupyter runtime: {exc}',
|
||||
) from exc
|
||||
return runtime.to_dict()
|
||||
|
||||
@app.get('/api/slash-commands')
|
||||
async def list_slash_commands() -> list[dict[str, Any]]:
|
||||
commands: list[dict[str, Any]] = []
|
||||
@@ -1312,6 +1371,16 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
agent = state.agent_for(request.account_id, requested_session_id)
|
||||
config = state.config_for(request.account_id)
|
||||
session_directory = state.account_paths(request.account_id)['sessions']
|
||||
jupyter_runtime = state.jupyter_runtime_manager.get(
|
||||
account_key,
|
||||
requested_session_id,
|
||||
)
|
||||
runtime_context = request.runtime_context
|
||||
if jupyter_runtime is not None:
|
||||
runtime_context = _append_runtime_context(
|
||||
runtime_context,
|
||||
jupyter_runtime.render_context(),
|
||||
)
|
||||
|
||||
def emit_agent_event(event: dict[str, object]) -> None:
|
||||
stage = _runtime_event_stage(event)
|
||||
@@ -1371,6 +1440,7 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
agent.tool_context,
|
||||
cancel_event=run_record.cancel_event,
|
||||
process_registry=run_record.process_registry,
|
||||
jupyter_runtime=jupyter_runtime,
|
||||
)
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
@@ -1390,14 +1460,14 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
result = agent.resume(
|
||||
prompt,
|
||||
stored,
|
||||
runtime_context=request.runtime_context,
|
||||
runtime_context=runtime_context,
|
||||
event_sink=emit_agent_event,
|
||||
)
|
||||
else:
|
||||
result = agent.run(
|
||||
prompt,
|
||||
session_id=requested_session_id,
|
||||
runtime_context=request.runtime_context,
|
||||
runtime_context=runtime_context,
|
||||
event_sink=emit_agent_event,
|
||||
)
|
||||
except Exception as exc:
|
||||
@@ -1426,6 +1496,7 @@ def create_app(state: AgentState) -> FastAPI:
|
||||
agent.tool_context,
|
||||
cancel_event=None,
|
||||
process_registry=None,
|
||||
jupyter_runtime=None,
|
||||
)
|
||||
elapsed_ms = max(0, int((time.perf_counter() - started_at) * 1000))
|
||||
payload = _serialize_run_result(result)
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { getCurrentAccount } from "@/lib/claw-auth";
|
||||
|
||||
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
|
||||
|
||||
type JupyterBindPayload = {
|
||||
session_id?: string;
|
||||
base_url?: string;
|
||||
password?: string;
|
||||
workspace_root?: string;
|
||||
};
|
||||
|
||||
export async function GET(req: Request) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const url = new URL(req.url);
|
||||
const sessionId = url.searchParams.get("session_id");
|
||||
if (!sessionId)
|
||||
return Response.json({ error: "缺少 session_id" }, { status: 400 });
|
||||
|
||||
try {
|
||||
const target = new URL(`${CLAW_API_URL}/api/jupyter/session`);
|
||||
target.searchParams.set("account_id", account.id);
|
||||
target.searchParams.set("session_id", sessionId);
|
||||
const response = await fetch(target, { cache: "no-store" });
|
||||
const body = await response.text();
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
err instanceof Error
|
||||
? `Unable to reach Claw backend: ${err.message}`
|
||||
: "Unable to reach Claw backend",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const account = await getCurrentAccount();
|
||||
if (!account)
|
||||
return Response.json({ error: "请先登录账号" }, { status: 401 });
|
||||
|
||||
const payload = (await req.json()) as JupyterBindPayload;
|
||||
try {
|
||||
const response = await fetch(`${CLAW_API_URL}/api/jupyter/bind-session`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
...payload,
|
||||
account_id: account.id,
|
||||
}),
|
||||
cache: "no-store",
|
||||
});
|
||||
const body = await response.text();
|
||||
return new Response(body, {
|
||||
status: response.status,
|
||||
headers: {
|
||||
"content-type":
|
||||
response.headers.get("content-type") ?? "application/json",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{
|
||||
error:
|
||||
err instanceof Error
|
||||
? `Unable to reach Claw backend: ${err.message}`
|
||||
: "Unable to reach Claw backend",
|
||||
},
|
||||
{ status: 502 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,14 @@ import {
|
||||
} from "lucide-react";
|
||||
import { Popover as PopoverPrimitive } from "radix-ui";
|
||||
import type { ComponentProps, FC, KeyboardEvent, ReactNode } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useId,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import TextareaAutosize from "react-textarea-autosize";
|
||||
import { useActivityPanel } from "@/components/assistant-ui/activity-panel";
|
||||
import {
|
||||
@@ -53,10 +60,13 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -279,9 +289,13 @@ async function cancelLatestRun(sessionId: string, runId?: string | null) {
|
||||
|
||||
const ChatTopActions: FC = () => {
|
||||
const { openFiles } = useActivityPanel();
|
||||
const [workspaceOpen, setWorkspaceOpen] = useState(false);
|
||||
const latestSessionId = useAuiState((s) =>
|
||||
findLatestMessageMetadataValue(s.thread.messages, "sessionId"),
|
||||
);
|
||||
const normalizedSessionId = normalizeSessionId(
|
||||
typeof latestSessionId === "string" ? latestSessionId : null,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none sticky top-3 z-20 mr-4 flex justify-end">
|
||||
@@ -316,13 +330,194 @@ const ChatTopActions: FC = () => {
|
||||
<FileTextIcon className="size-4 text-muted-foreground" />
|
||||
聊天中的文件
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left text-sm transition-colors hover:bg-muted"
|
||||
onClick={() => setWorkspaceOpen(true)}
|
||||
>
|
||||
<WrenchIcon className="size-4 text-muted-foreground" />
|
||||
切换工作区
|
||||
</button>
|
||||
</PopoverPrimitive.Content>
|
||||
</PopoverPrimitive.Portal>
|
||||
</PopoverPrimitive.Root>
|
||||
<WorkspaceSwitchDialog
|
||||
open={workspaceOpen}
|
||||
onOpenChange={setWorkspaceOpen}
|
||||
sessionId={normalizedSessionId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type JupyterWorkspaceStatus = {
|
||||
connected?: boolean;
|
||||
session_id?: string;
|
||||
workspace_cwd?: string;
|
||||
jupyter_tree_url?: string;
|
||||
detail?: string;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
function WorkspaceSwitchDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
sessionId,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
sessionId: string | null;
|
||||
}) {
|
||||
const [jupyterUrl, setJupyterUrl] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [workspaceRoot, setWorkspaceRoot] = useState(
|
||||
"/root/zk_agent_workspaces",
|
||||
);
|
||||
const [status, setStatus] = useState<JupyterWorkspaceStatus | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [message, setMessage] = useState("");
|
||||
const fieldId = useId();
|
||||
const effectiveSessionId =
|
||||
sessionId ??
|
||||
(typeof window !== "undefined"
|
||||
? normalizeSessionId(readActiveSessionId())
|
||||
: null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !effectiveSessionId) return;
|
||||
const sessionIdForRequest = effectiveSessionId;
|
||||
let cancelled = false;
|
||||
async function refreshStatus() {
|
||||
const response = await fetch(
|
||||
`/api/claw/jupyter?session_id=${encodeURIComponent(sessionIdForRequest)}`,
|
||||
{ cache: "no-store" },
|
||||
);
|
||||
const payload = (await response.json().catch(() => ({}))) as
|
||||
| JupyterWorkspaceStatus
|
||||
| Record<string, never>;
|
||||
if (!cancelled) setStatus(payload as JupyterWorkspaceStatus);
|
||||
}
|
||||
void refreshStatus().catch(() => undefined);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, effectiveSessionId]);
|
||||
|
||||
async function submit() {
|
||||
setMessage("");
|
||||
if (!effectiveSessionId) {
|
||||
setMessage("请先发送一条消息创建 session,再切换工作区。");
|
||||
return;
|
||||
}
|
||||
if (!jupyterUrl.trim() || !password) {
|
||||
setMessage("请填写 Jupyter 地址和密码。");
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const response = await fetch("/api/claw/jupyter", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
session_id: effectiveSessionId,
|
||||
base_url: jupyterUrl.trim(),
|
||||
password,
|
||||
workspace_root: workspaceRoot.trim() || "/root/zk_agent_workspaces",
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json().catch(() => ({}))) as
|
||||
| JupyterWorkspaceStatus
|
||||
| { detail?: string; error?: string };
|
||||
if (!response.ok) {
|
||||
setMessage(payload.detail ?? payload.error ?? "切换工作区失败");
|
||||
return;
|
||||
}
|
||||
setStatus(payload as JupyterWorkspaceStatus);
|
||||
setMessage("工作区切换成功,后续工具会在远端 Jupyter 工作区执行。");
|
||||
setPassword("");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="pointer-events-auto sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>切换工作区</DialogTitle>
|
||||
<DialogDescription>
|
||||
把当前 session 的工具执行切换到远端 Jupyter 开发机。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 text-sm">
|
||||
{status?.connected ? (
|
||||
<div className="rounded-md border bg-muted/40 p-3 text-muted-foreground">
|
||||
<div className="font-medium text-foreground">当前已连接</div>
|
||||
<div className="mt-1 break-all">
|
||||
工作目录:{status.workspace_cwd}
|
||||
</div>
|
||||
{status.jupyter_tree_url ? (
|
||||
<a
|
||||
className="mt-1 block break-all text-primary underline-offset-4 hover:underline"
|
||||
href={status.jupyter_tree_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
打开 Jupyter 工作区
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<label className="grid gap-1.5" htmlFor={`${fieldId}-url`}>
|
||||
<span className="text-muted-foreground">Jupyter 地址</span>
|
||||
<Input
|
||||
id={`${fieldId}-url`}
|
||||
value={jupyterUrl}
|
||||
onChange={(event) => setJupyterUrl(event.target.value)}
|
||||
placeholder="https://...-jupyter.../lab"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5" htmlFor={`${fieldId}-password`}>
|
||||
<span className="text-muted-foreground">密码</span>
|
||||
<Input
|
||||
id={`${fieldId}-password`}
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Jupyter 登录密码"
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-1.5" htmlFor={`${fieldId}-root`}>
|
||||
<span className="text-muted-foreground">远端工作区根目录</span>
|
||||
<Input
|
||||
id={`${fieldId}-root`}
|
||||
value={workspaceRoot}
|
||||
onChange={(event) => setWorkspaceRoot(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
{message ? (
|
||||
<div className="rounded-md bg-muted px-3 py-2 text-muted-foreground">
|
||||
{message}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button type="button" disabled={submitting} onClick={submit}>
|
||||
{submitting ? "连接中..." : "切换"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const ThreadMessage: FC = () => {
|
||||
const role = useAuiState((s) => s.message.role);
|
||||
const isEditing = useAuiState((s) => s.message.composer.isEditing);
|
||||
|
||||
@@ -38,6 +38,8 @@ dependencies = [
|
||||
"pydantic>=2.5",
|
||||
"openpyxl>=3.1",
|
||||
"pyarrow>=15",
|
||||
"requests>=2.31",
|
||||
"websocket-client>=1.7",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
|
||||
from .config_runtime import ConfigRuntime
|
||||
from .lsp_runtime import LSPRuntime
|
||||
from .mcp_runtime import MCPRuntime
|
||||
from .jupyter_runtime import JupyterRuntimeSession
|
||||
from .plan_runtime import PlanRuntime
|
||||
from .remote_runtime import RemoteRuntime
|
||||
from .remote_trigger_runtime import RemoteTriggerRuntime
|
||||
@@ -49,6 +50,7 @@ class ToolExecutionContext:
|
||||
config_runtime: 'ConfigRuntime | None' = None
|
||||
lsp_runtime: 'LSPRuntime | None' = None
|
||||
mcp_runtime: 'MCPRuntime | None' = None
|
||||
jupyter_runtime: 'JupyterRuntimeSession | None' = None
|
||||
remote_runtime: 'RemoteRuntime | None' = None
|
||||
remote_trigger_runtime: 'RemoteTriggerRuntime | None' = None
|
||||
plan_runtime: 'PlanRuntime | None' = None
|
||||
@@ -138,6 +140,7 @@ def build_tool_context(
|
||||
config_runtime: 'ConfigRuntime | None' = None,
|
||||
lsp_runtime: 'LSPRuntime | None' = None,
|
||||
mcp_runtime: 'MCPRuntime | None' = None,
|
||||
jupyter_runtime: 'JupyterRuntimeSession | None' = None,
|
||||
remote_runtime: 'RemoteRuntime | None' = None,
|
||||
remote_trigger_runtime: 'RemoteTriggerRuntime | None' = None,
|
||||
plan_runtime: 'PlanRuntime | None' = None,
|
||||
@@ -169,6 +172,7 @@ def build_tool_context(
|
||||
config_runtime=config_runtime,
|
||||
lsp_runtime=lsp_runtime,
|
||||
mcp_runtime=mcp_runtime,
|
||||
jupyter_runtime=jupyter_runtime,
|
||||
remote_runtime=remote_runtime,
|
||||
remote_trigger_runtime=remote_trigger_runtime,
|
||||
plan_runtime=plan_runtime,
|
||||
|
||||
+201
-2
@@ -1683,6 +1683,8 @@ def _display_path(path: Path, context: ToolExecutionContext) -> str:
|
||||
|
||||
|
||||
def _execution_cwd(context: ToolExecutionContext) -> Path:
|
||||
if context.jupyter_runtime is not None:
|
||||
return Path(context.jupyter_runtime.binding.workspace_cwd)
|
||||
if context.scratchpad_directory is not None:
|
||||
context.scratchpad_directory.mkdir(parents=True, exist_ok=True)
|
||||
return context.scratchpad_directory
|
||||
@@ -1781,6 +1783,12 @@ def _list_dir(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
if not isinstance(raw_path, str):
|
||||
raise ToolExecutionError('path must be a string')
|
||||
max_entries = _coerce_int(arguments, 'max_entries', 200)
|
||||
if context.jupyter_runtime is not None:
|
||||
return context.jupyter_runtime.list_dir(
|
||||
raw_path,
|
||||
max_entries=max_entries,
|
||||
max_output_chars=context.max_output_chars,
|
||||
)
|
||||
target = _resolve_path(raw_path, context, allow_outside_root=True)
|
||||
if not target.exists():
|
||||
raise ToolExecutionError(f'Path not found: {raw_path}')
|
||||
@@ -1798,6 +1806,23 @@ def _list_dir(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
|
||||
|
||||
def _read_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
if context.jupyter_runtime is not None:
|
||||
start_line = arguments.get('start_line')
|
||||
end_line = arguments.get('end_line')
|
||||
if start_line is not None and (
|
||||
isinstance(start_line, bool) or not isinstance(start_line, int) or start_line < 1
|
||||
):
|
||||
raise ToolExecutionError('start_line must be an integer >= 1')
|
||||
if end_line is not None and (
|
||||
isinstance(end_line, bool) or not isinstance(end_line, int) or end_line < 1
|
||||
):
|
||||
raise ToolExecutionError('end_line must be an integer >= 1')
|
||||
return context.jupyter_runtime.read_text(
|
||||
_require_string(arguments, 'path'),
|
||||
start_line=start_line,
|
||||
end_line=end_line,
|
||||
max_output_chars=context.max_output_chars,
|
||||
)
|
||||
target = _resolve_path(
|
||||
_require_string(arguments, 'path'),
|
||||
context,
|
||||
@@ -1825,8 +1850,6 @@ def _read_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
|
||||
def _write_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
_ensure_write_allowed(context)
|
||||
target = _resolve_path(_require_string(arguments, 'path'), context)
|
||||
_ensure_not_platform_code_write(target, context)
|
||||
content = _coerce_write_file_content(arguments)
|
||||
append = arguments.get('append', False)
|
||||
if not isinstance(append, bool):
|
||||
@@ -1834,6 +1857,25 @@ def _write_file(arguments: dict[str, Any], context: ToolExecutionContext) -> str
|
||||
newline_at_end = arguments.get('newline_at_end', False)
|
||||
if not isinstance(newline_at_end, bool):
|
||||
raise ToolExecutionError('newline_at_end must be a boolean')
|
||||
if context.jupyter_runtime is not None:
|
||||
message = context.jupyter_runtime.write_text(
|
||||
_require_string(arguments, 'path'),
|
||||
content,
|
||||
append=append,
|
||||
newline_at_end=newline_at_end,
|
||||
max_output_chars=context.max_output_chars,
|
||||
)
|
||||
return (
|
||||
message,
|
||||
{
|
||||
'action': 'remote_append_file' if append else 'remote_write_file',
|
||||
'path': _require_string(arguments, 'path'),
|
||||
'remote_workspace': context.jupyter_runtime.binding.workspace_cwd,
|
||||
'content_length': len(content),
|
||||
},
|
||||
)
|
||||
target = _resolve_path(_require_string(arguments, 'path'), context)
|
||||
_ensure_not_platform_code_write(target, context)
|
||||
if newline_at_end and content and not content.endswith('\n'):
|
||||
content += '\n'
|
||||
previous_text: str | None = None
|
||||
@@ -2068,6 +2110,34 @@ def _notebook_edit(arguments: dict[str, Any], context: ToolExecutionContext) ->
|
||||
|
||||
def _glob_search(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
pattern = _require_string(arguments, 'pattern')
|
||||
if context.jupyter_runtime is not None:
|
||||
code = r'''
|
||||
from pathlib import Path
|
||||
import glob
|
||||
pattern = PATTERN
|
||||
workspace = WORKSPACE
|
||||
if pattern.startswith("/"):
|
||||
matches = sorted(glob.glob(pattern, recursive=True))
|
||||
else:
|
||||
matches = sorted(glob.glob(str(Path(workspace) / pattern), recursive=True))
|
||||
print("\n".join(matches) if matches else "(no matches)")
|
||||
'''
|
||||
script = (
|
||||
code.replace('PATTERN', repr(pattern))
|
||||
.replace('WORKSPACE', repr(context.jupyter_runtime.binding.workspace_cwd))
|
||||
)
|
||||
result = context.jupyter_runtime.run_python(
|
||||
code=script,
|
||||
script_path=None,
|
||||
args=[],
|
||||
stdin=None,
|
||||
timeout_seconds=20.0,
|
||||
max_output_chars=context.max_output_chars,
|
||||
cancel_event=context.cancel_event,
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
raise ToolExecutionError(result.stdout.strip() or 'remote glob_search failed')
|
||||
return result.stdout.strip() or '(no matches)'
|
||||
matches = sorted(context.root.glob(pattern))
|
||||
if not matches:
|
||||
return '(no matches)'
|
||||
@@ -2093,6 +2163,53 @@ def _grep_search(arguments: dict[str, Any], context: ToolExecutionContext) -> st
|
||||
if not isinstance(literal, bool):
|
||||
raise ToolExecutionError('literal must be a boolean')
|
||||
max_matches = _coerce_int(arguments, 'max_matches', 100)
|
||||
if context.jupyter_runtime is not None:
|
||||
code = r'''
|
||||
from pathlib import Path
|
||||
import re
|
||||
pattern = PATTERN
|
||||
literal = LITERAL
|
||||
max_matches = MAX_MATCHES
|
||||
root = Path(ROOT)
|
||||
if not root.exists():
|
||||
raise SystemExit(f"Path not found: {root}")
|
||||
regex = re.compile(re.escape(pattern) if literal else pattern)
|
||||
hits = []
|
||||
files = root.rglob("*") if root.is_dir() else [root]
|
||||
for file_path in files:
|
||||
if not file_path.is_file():
|
||||
continue
|
||||
try:
|
||||
text = file_path.read_text(encoding="utf-8", errors="replace")
|
||||
except OSError:
|
||||
continue
|
||||
for line_no, line in enumerate(text.splitlines(), start=1):
|
||||
if regex.search(line):
|
||||
hits.append(f"{file_path}:{line_no}: {line[:500]}")
|
||||
if len(hits) >= max_matches:
|
||||
print("\n".join(hits + [f"... truncated at {max_matches} matches ..."]))
|
||||
raise SystemExit(0)
|
||||
print("\n".join(hits) if hits else "(no matches)")
|
||||
'''
|
||||
root = context.jupyter_runtime.resolve_workspace_path(raw_path)
|
||||
script = (
|
||||
code.replace('PATTERN', repr(pattern))
|
||||
.replace('LITERAL', repr(literal))
|
||||
.replace('MAX_MATCHES', str(max_matches))
|
||||
.replace('ROOT', repr(root))
|
||||
)
|
||||
result = context.jupyter_runtime.run_python(
|
||||
code=script,
|
||||
script_path=None,
|
||||
args=[],
|
||||
stdin=None,
|
||||
timeout_seconds=30.0,
|
||||
max_output_chars=context.max_output_chars,
|
||||
cancel_event=context.cancel_event,
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
raise ToolExecutionError(result.stdout.strip() or 'remote grep_search failed')
|
||||
return result.stdout.strip() or '(no matches)'
|
||||
root = _resolve_path(raw_path, context, allow_outside_root=True)
|
||||
if not root.exists():
|
||||
raise ToolExecutionError(f'Path not found: {raw_path}')
|
||||
@@ -2180,6 +2297,46 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
|
||||
context.command_timeout_seconds,
|
||||
)
|
||||
max_output_chars = _coerce_int(arguments, 'max_output_chars', context.max_output_chars)
|
||||
if context.jupyter_runtime is not None:
|
||||
result = context.jupyter_runtime.run_python(
|
||||
code=code,
|
||||
script_path=(
|
||||
context.jupyter_runtime.resolve_workspace_path(script_path)
|
||||
if script_path
|
||||
else None
|
||||
),
|
||||
args=raw_args,
|
||||
stdin=stdin,
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_output_chars=max_output_chars,
|
||||
cancel_event=context.cancel_event,
|
||||
)
|
||||
payload = [
|
||||
f'exit_code={result.exit_code}',
|
||||
'interpreter=remote:python3',
|
||||
f'remote_workspace={context.jupyter_runtime.binding.workspace_cwd}',
|
||||
'[stdout]',
|
||||
result.stdout.rstrip(),
|
||||
'[stderr]',
|
||||
result.stderr.rstrip(),
|
||||
]
|
||||
if result.timed_out:
|
||||
payload.insert(0, f'timed_out=true\ntimeout_seconds={timeout_seconds:g}')
|
||||
return (
|
||||
_truncate_output('\n'.join(payload).strip(), max_output_chars),
|
||||
{
|
||||
'action': 'remote_python_exec',
|
||||
'mode': 'code' if code else 'script',
|
||||
'interpreter': 'remote:python3',
|
||||
'remote_workspace': context.jupyter_runtime.binding.workspace_cwd,
|
||||
'timed_out': result.timed_out,
|
||||
'timeout_seconds': timeout_seconds,
|
||||
'exit_code': result.exit_code,
|
||||
'stdout_preview': _snapshot_text(result.stdout),
|
||||
'stderr_preview': _snapshot_text(result.stderr),
|
||||
'output_preview': _snapshot_text('\n'.join(payload).strip()),
|
||||
},
|
||||
)
|
||||
interpreter = _resolve_python_interpreter(context)
|
||||
if code:
|
||||
command = [interpreter, '-c', code]
|
||||
@@ -2484,6 +2641,34 @@ def _resolve_python_interpreter(context: ToolExecutionContext) -> str:
|
||||
def _run_bash(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
|
||||
command = _require_string(arguments, 'command')
|
||||
_ensure_shell_allowed(command, context)
|
||||
if context.jupyter_runtime is not None:
|
||||
result = context.jupyter_runtime.run_command(
|
||||
command,
|
||||
timeout_seconds=context.command_timeout_seconds,
|
||||
max_output_chars=context.max_output_chars,
|
||||
cancel_event=context.cancel_event,
|
||||
)
|
||||
payload = [
|
||||
f'exit_code={result.exit_code}',
|
||||
f'remote_workspace={context.jupyter_runtime.binding.workspace_cwd}',
|
||||
'[stdout]',
|
||||
result.stdout.rstrip(),
|
||||
'[stderr]',
|
||||
result.stderr.rstrip(),
|
||||
]
|
||||
return (
|
||||
_truncate_output('\n'.join(payload).strip(), context.max_output_chars),
|
||||
{
|
||||
'action': 'remote_bash',
|
||||
'command': command,
|
||||
'exit_code': result.exit_code,
|
||||
'remote_workspace': context.jupyter_runtime.binding.workspace_cwd,
|
||||
'timed_out': result.timed_out,
|
||||
'stdout_preview': _snapshot_text(result.stdout),
|
||||
'stderr_preview': _snapshot_text(result.stderr),
|
||||
'output_preview': _snapshot_text('\n'.join(payload).strip()),
|
||||
},
|
||||
)
|
||||
completed = subprocess.run(
|
||||
command,
|
||||
shell=True,
|
||||
@@ -3784,6 +3969,20 @@ def _stream_bash(
|
||||
arguments: dict[str, Any],
|
||||
context: ToolExecutionContext,
|
||||
) -> Iterator[ToolStreamUpdate]:
|
||||
if context.jupyter_runtime is not None:
|
||||
result = _run_bash(arguments, context)
|
||||
content, metadata = result if isinstance(result, tuple) else (result, {})
|
||||
tool_result = ToolExecutionResult(
|
||||
name='bash',
|
||||
ok=True,
|
||||
content=content,
|
||||
metadata=metadata,
|
||||
)
|
||||
if content:
|
||||
yield from _stream_static_text_result(tool_result)
|
||||
else:
|
||||
yield ToolStreamUpdate(kind='result', result=tool_result)
|
||||
return
|
||||
try:
|
||||
command = _require_string(arguments, 'command')
|
||||
_ensure_shell_allowed(command, context)
|
||||
|
||||
@@ -0,0 +1,648 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import ssl
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from html import unescape
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any
|
||||
from urllib.parse import quote, urlparse, urlunparse
|
||||
|
||||
try: # 依赖在部署环境安装;这里延迟报错,方便无网络环境做静态检查。
|
||||
import requests
|
||||
except ImportError: # pragma: no cover
|
||||
requests = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
import websocket
|
||||
except ImportError: # pragma: no cover
|
||||
websocket = None # type: ignore[assignment]
|
||||
|
||||
|
||||
DEFAULT_JUPYTER_WORKSPACE_ROOT = '/root/zk_agent_workspaces'
|
||||
|
||||
|
||||
class JupyterRuntimeError(RuntimeError):
|
||||
"""Raised when a remote Jupyter runtime cannot complete a request."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JupyterWorkspaceBinding:
|
||||
account_id: str
|
||||
session_id: str
|
||||
base_url: str
|
||||
workspace_root: str
|
||||
workspace_cwd: str
|
||||
workspace_api_path: str
|
||||
jupyter_tree_url: str
|
||||
created_at: float
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
'account_id': self.account_id,
|
||||
'session_id': self.session_id,
|
||||
'base_url': self.base_url,
|
||||
'workspace_root': self.workspace_root,
|
||||
'workspace_cwd': self.workspace_cwd,
|
||||
'workspace_api_path': self.workspace_api_path,
|
||||
'jupyter_tree_url': self.jupyter_tree_url,
|
||||
'created_at': self.created_at,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RemoteCommandResult:
|
||||
exit_code: int
|
||||
stdout: str
|
||||
stderr: str = ''
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
class JupyterRuntimeSession:
|
||||
"""A small terminal-backed execution bridge for one account/session pair."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
binding: JupyterWorkspaceBinding,
|
||||
http_session: Any,
|
||||
xsrf_token: str,
|
||||
) -> None:
|
||||
self.binding = binding
|
||||
self._session = http_session
|
||||
self._xsrf_token = xsrf_token
|
||||
self._terminal_name: str | None = None
|
||||
self._lock = threading.RLock()
|
||||
|
||||
@classmethod
|
||||
def connect(
|
||||
cls,
|
||||
*,
|
||||
account_id: str,
|
||||
session_id: str,
|
||||
base_url: str,
|
||||
password: str,
|
||||
workspace_root: str = DEFAULT_JUPYTER_WORKSPACE_ROOT,
|
||||
timeout_seconds: float = 20.0,
|
||||
) -> 'JupyterRuntimeSession':
|
||||
if requests is None:
|
||||
raise JupyterRuntimeError(
|
||||
'Missing dependency: requests. Please install project dependencies.'
|
||||
)
|
||||
normalized_base_url = normalize_jupyter_base_url(base_url)
|
||||
http_session = requests.Session()
|
||||
xsrf_token = _login_jupyter(
|
||||
http_session,
|
||||
normalized_base_url,
|
||||
password,
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
workspace_root = normalize_posix_path(workspace_root)
|
||||
workspace_cwd = f'{workspace_root}/{sanitize_remote_path_part(session_id)}'
|
||||
workspace_api_path = api_path_for_absolute_path(workspace_cwd)
|
||||
binding = JupyterWorkspaceBinding(
|
||||
account_id=account_id,
|
||||
session_id=session_id,
|
||||
base_url=normalized_base_url,
|
||||
workspace_root=workspace_root,
|
||||
workspace_cwd=workspace_cwd,
|
||||
workspace_api_path=workspace_api_path,
|
||||
jupyter_tree_url=(
|
||||
f'{normalized_base_url}/lab/tree/{quote(workspace_api_path)}'
|
||||
),
|
||||
created_at=time.time(),
|
||||
)
|
||||
runtime = cls(
|
||||
binding=binding,
|
||||
http_session=http_session,
|
||||
xsrf_token=xsrf_token,
|
||||
)
|
||||
runtime.bootstrap_workspace(timeout_seconds=timeout_seconds)
|
||||
return runtime
|
||||
|
||||
def bootstrap_workspace(self, *, timeout_seconds: float = 20.0) -> None:
|
||||
result = self.run_command(
|
||||
(
|
||||
'mkdir -p '
|
||||
f'{shlex.quote(self.binding.workspace_cwd)}/output '
|
||||
f'{shlex.quote(self.binding.workspace_cwd)}/scratchpad'
|
||||
),
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_output_chars=4000,
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
raise JupyterRuntimeError(
|
||||
result.stdout.strip() or 'Unable to bootstrap Jupyter workspace.'
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
payload = self.binding.to_dict()
|
||||
payload['connected'] = True
|
||||
return payload
|
||||
|
||||
def render_context(self) -> str:
|
||||
return '\n'.join(
|
||||
[
|
||||
'[远端 Jupyter 工作区]',
|
||||
'当前 session 已切换到远端 Jupyter 运行时。',
|
||||
f'- 默认工作目录:{self.binding.workspace_cwd}',
|
||||
f'- 输出文件优先写入:{self.binding.workspace_cwd}/output',
|
||||
'- 线上挂载目录(例如 /mnt/...)可以通过绝对路径读取和分析。',
|
||||
'- 除非用户明确要求,不要修改平台代码目录;数据产物应留在当前 session 工作区。',
|
||||
f'- Jupyter 工作区入口:{self.binding.jupyter_tree_url}',
|
||||
]
|
||||
)
|
||||
|
||||
def run_command(
|
||||
self,
|
||||
command: str,
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
max_output_chars: int,
|
||||
cancel_event: Any | None = None,
|
||||
) -> RemoteCommandResult:
|
||||
if websocket is None:
|
||||
raise JupyterRuntimeError(
|
||||
'Missing dependency: websocket-client. Please install project dependencies.'
|
||||
)
|
||||
if not command.strip():
|
||||
raise JupyterRuntimeError('Remote command is empty.')
|
||||
marker = f'__ZK_AGENT_EXIT_{uuid.uuid4().hex}__'
|
||||
workspace = shlex.quote(self.binding.workspace_cwd)
|
||||
wrapped = (
|
||||
f'mkdir -p {workspace} && '
|
||||
f'cd {workspace} && '
|
||||
f'bash -lc {shlex.quote(command)}; '
|
||||
f'printf "\\n{marker}:%s\\n" "$?"'
|
||||
)
|
||||
with self._lock:
|
||||
name = self._ensure_terminal()
|
||||
ws_url = self._terminal_websocket_url(name)
|
||||
ws = websocket.create_connection(
|
||||
ws_url,
|
||||
timeout=min(timeout_seconds, 30.0),
|
||||
cookie=self._cookie_header(),
|
||||
origin=self.binding.base_url,
|
||||
sslopt={'cert_reqs': ssl.CERT_NONE},
|
||||
header=[
|
||||
f'X-XSRFToken: {self._xsrf_token}',
|
||||
f'Referer: {self.binding.base_url}/lab',
|
||||
],
|
||||
)
|
||||
try:
|
||||
self._drain_initial_messages(ws, max_wait_seconds=1.0)
|
||||
ws.send(json.dumps(['stdin', 'stty -echo\n']))
|
||||
self._drain_initial_messages(ws, max_wait_seconds=0.3)
|
||||
ws.send(json.dumps(['stdin', f'{wrapped}\n']))
|
||||
return self._collect_until_marker(
|
||||
ws,
|
||||
marker=marker,
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_output_chars=max_output_chars,
|
||||
cancel_event=cancel_event,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def run_python(
|
||||
self,
|
||||
*,
|
||||
code: str | None,
|
||||
script_path: str | None,
|
||||
args: list[str],
|
||||
stdin: str | None,
|
||||
timeout_seconds: float,
|
||||
max_output_chars: int,
|
||||
cancel_event: Any | None = None,
|
||||
) -> RemoteCommandResult:
|
||||
if bool(code) == bool(script_path):
|
||||
raise JupyterRuntimeError('code and script_path must specify exactly one value')
|
||||
temp_dir = f'{self.binding.workspace_cwd}/scratchpad/.python_exec'
|
||||
command_parts = [f'mkdir -p {shlex.quote(temp_dir)}']
|
||||
if code is not None:
|
||||
script_path = f'{temp_dir}/snippet_{uuid.uuid4().hex}.py'
|
||||
command_parts.append(
|
||||
self._remote_write_text_command(script_path, code)
|
||||
)
|
||||
assert script_path is not None
|
||||
quoted_args = ' '.join(shlex.quote(arg) for arg in args)
|
||||
python_command = f'python3 {shlex.quote(script_path)}'
|
||||
if quoted_args:
|
||||
python_command = f'{python_command} {quoted_args}'
|
||||
if stdin is not None:
|
||||
stdin_path = f'{temp_dir}/stdin_{uuid.uuid4().hex}.txt'
|
||||
command_parts.append(
|
||||
self._remote_write_text_command(stdin_path, stdin)
|
||||
)
|
||||
python_command = f'{python_command} < {shlex.quote(stdin_path)}'
|
||||
command_parts.append(python_command)
|
||||
return self.run_command(
|
||||
' && '.join(command_parts),
|
||||
timeout_seconds=timeout_seconds,
|
||||
max_output_chars=max_output_chars,
|
||||
cancel_event=cancel_event,
|
||||
)
|
||||
|
||||
def list_dir(self, path: str, *, max_entries: int, max_output_chars: int) -> str:
|
||||
target = self.resolve_workspace_path(path)
|
||||
code = r'''
|
||||
import json
|
||||
from pathlib import Path
|
||||
target = Path(PATH)
|
||||
if not target.exists():
|
||||
raise SystemExit(f"Path not found: {target}")
|
||||
if not target.is_dir():
|
||||
raise SystemExit(f"Path is not a directory: {target}")
|
||||
entries = []
|
||||
for item in sorted(target.iterdir(), key=lambda p: (not p.is_dir(), p.name.lower()))[:MAX_ENTRIES]:
|
||||
entries.append(("dir" if item.is_dir() else "file") + "\t" + str(item))
|
||||
print("\n".join(entries) if entries else "(empty directory)")
|
||||
'''
|
||||
script = (
|
||||
code.replace('PATH', python_literal(target))
|
||||
.replace('MAX_ENTRIES', str(max_entries))
|
||||
)
|
||||
result = self.run_python(
|
||||
code=script,
|
||||
script_path=None,
|
||||
args=[],
|
||||
stdin=None,
|
||||
timeout_seconds=20.0,
|
||||
max_output_chars=max_output_chars,
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
raise JupyterRuntimeError(result.stdout.strip() or 'remote list_dir failed')
|
||||
return result.stdout.strip()
|
||||
|
||||
def read_text(
|
||||
self,
|
||||
path: str,
|
||||
*,
|
||||
start_line: int | None,
|
||||
end_line: int | None,
|
||||
max_output_chars: int,
|
||||
) -> str:
|
||||
target = self.resolve_workspace_path(path)
|
||||
code = r'''
|
||||
from pathlib import Path
|
||||
target = Path(PATH)
|
||||
if not target.is_file():
|
||||
raise SystemExit(f"Path is not a file: {target}")
|
||||
text = target.read_text(encoding="utf-8", errors="replace")
|
||||
start_line = START_LINE
|
||||
end_line = END_LINE
|
||||
if start_line is None and end_line is None:
|
||||
print(text, end="")
|
||||
else:
|
||||
lines = text.splitlines()
|
||||
start_idx = max((start_line or 1) - 1, 0)
|
||||
end_idx = end_line or len(lines)
|
||||
print("\n".join(f"{start_idx + idx + 1}: {line}" for idx, line in enumerate(lines[start_idx:end_idx])), end="")
|
||||
'''
|
||||
script = (
|
||||
code.replace('PATH', python_literal(target))
|
||||
.replace('START_LINE', python_literal(start_line))
|
||||
.replace('END_LINE', python_literal(end_line))
|
||||
)
|
||||
result = self.run_python(
|
||||
code=script,
|
||||
script_path=None,
|
||||
args=[],
|
||||
stdin=None,
|
||||
timeout_seconds=20.0,
|
||||
max_output_chars=max_output_chars,
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
raise JupyterRuntimeError(result.stdout.strip() or 'remote read_file failed')
|
||||
return result.stdout[:max_output_chars]
|
||||
|
||||
def write_text(
|
||||
self,
|
||||
path: str,
|
||||
content: str,
|
||||
*,
|
||||
append: bool,
|
||||
newline_at_end: bool,
|
||||
max_output_chars: int,
|
||||
) -> str:
|
||||
target = self.resolve_workspace_path(path)
|
||||
if newline_at_end and content and not content.endswith('\n'):
|
||||
content += '\n'
|
||||
mode = 'a' if append else 'w'
|
||||
code = r'''
|
||||
from pathlib import Path
|
||||
target = Path(PATH)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
content = CONTENT
|
||||
with target.open(MODE, encoding="utf-8") as fh:
|
||||
fh.write(content)
|
||||
print(f"{'appended' if MODE == 'a' else 'wrote'} {target} ({len(content)} chars)")
|
||||
'''
|
||||
script = (
|
||||
code.replace('PATH', python_literal(target))
|
||||
.replace('CONTENT', python_literal(content))
|
||||
.replace('MODE', python_literal(mode))
|
||||
)
|
||||
result = self.run_python(
|
||||
code=script,
|
||||
script_path=None,
|
||||
args=[],
|
||||
stdin=None,
|
||||
timeout_seconds=20.0,
|
||||
max_output_chars=max_output_chars,
|
||||
)
|
||||
if result.exit_code != 0:
|
||||
raise JupyterRuntimeError(result.stdout.strip() or 'remote write_file failed')
|
||||
return result.stdout.strip()
|
||||
|
||||
def resolve_workspace_path(self, raw_path: str) -> str:
|
||||
value = raw_path.strip() or '.'
|
||||
if value.startswith('/'):
|
||||
return normalize_posix_path(value)
|
||||
path = PurePosixPath(value)
|
||||
if path.parts and path.parts[0] in {'output', 'outputs'}:
|
||||
tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath()
|
||||
return normalize_posix_path(f'{self.binding.workspace_cwd}/output/{tail}')
|
||||
if path.parts and path.parts[0] in {'scratchpad', 'scratch'}:
|
||||
tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath()
|
||||
return normalize_posix_path(f'{self.binding.workspace_cwd}/scratchpad/{tail}')
|
||||
return normalize_posix_path(f'{self.binding.workspace_cwd}/{value}')
|
||||
|
||||
def _ensure_terminal(self) -> str:
|
||||
if self._terminal_name:
|
||||
return self._terminal_name
|
||||
response = self._session.post(
|
||||
f'{self.binding.base_url}/api/terminals',
|
||||
headers={'X-XSRFToken': self._xsrf_token},
|
||||
timeout=20,
|
||||
)
|
||||
if response.status_code >= 400:
|
||||
raise JupyterRuntimeError(
|
||||
f'Unable to create Jupyter terminal: HTTP {response.status_code} {response.text[:500]}'
|
||||
)
|
||||
payload = response.json()
|
||||
name = str(payload.get('name') or '').strip()
|
||||
if not name:
|
||||
raise JupyterRuntimeError('Jupyter terminal response did not contain a terminal name.')
|
||||
self._terminal_name = name
|
||||
return name
|
||||
|
||||
def _terminal_websocket_url(self, name: str) -> str:
|
||||
parsed = urlparse(self.binding.base_url)
|
||||
scheme = 'wss' if parsed.scheme == 'https' else 'ws'
|
||||
base_path = parsed.path.rstrip('/')
|
||||
path = f'{base_path}/terminals/websocket/{quote(name)}'
|
||||
return urlunparse((scheme, parsed.netloc, path, '', '', ''))
|
||||
|
||||
def _cookie_header(self) -> str:
|
||||
return '; '.join(
|
||||
f'{cookie.name}={cookie.value}'
|
||||
for cookie in self._session.cookies
|
||||
)
|
||||
|
||||
def _drain_initial_messages(self, ws: Any, *, max_wait_seconds: float) -> None:
|
||||
deadline = time.monotonic() + max_wait_seconds
|
||||
old_timeout = ws.gettimeout()
|
||||
try:
|
||||
while time.monotonic() < deadline:
|
||||
ws.settimeout(max(0.05, deadline - time.monotonic()))
|
||||
try:
|
||||
ws.recv()
|
||||
except Exception:
|
||||
break
|
||||
finally:
|
||||
ws.settimeout(old_timeout)
|
||||
|
||||
def _collect_until_marker(
|
||||
self,
|
||||
ws: Any,
|
||||
*,
|
||||
marker: str,
|
||||
timeout_seconds: float,
|
||||
max_output_chars: int,
|
||||
cancel_event: Any | None,
|
||||
) -> RemoteCommandResult:
|
||||
deadline = time.monotonic() + timeout_seconds
|
||||
transcript: list[str] = []
|
||||
exit_code: int | None = None
|
||||
marker_pattern = re.compile(rf'{re.escape(marker)}:(\d+)')
|
||||
while time.monotonic() < deadline:
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
try:
|
||||
ws.send(json.dumps(['stdin', '\x03']))
|
||||
except Exception:
|
||||
pass
|
||||
raise JupyterRuntimeError('Run cancelled by user')
|
||||
ws.settimeout(max(0.1, min(1.0, deadline - time.monotonic())))
|
||||
try:
|
||||
raw = ws.recv()
|
||||
except Exception:
|
||||
continue
|
||||
for chunk in parse_terminal_payload(raw):
|
||||
match = marker_pattern.search(chunk)
|
||||
if match:
|
||||
exit_code = int(match.group(1))
|
||||
chunk = marker_pattern.sub('', chunk)
|
||||
if chunk:
|
||||
transcript.append(chunk)
|
||||
break
|
||||
transcript.append(chunk)
|
||||
if exit_code is not None:
|
||||
break
|
||||
if exit_code is None:
|
||||
try:
|
||||
ws.send(json.dumps(['stdin', '\x03']))
|
||||
except Exception:
|
||||
pass
|
||||
return RemoteCommandResult(
|
||||
exit_code=124,
|
||||
stdout=truncate_text(''.join(transcript), max_output_chars),
|
||||
timed_out=True,
|
||||
)
|
||||
return RemoteCommandResult(
|
||||
exit_code=exit_code,
|
||||
stdout=truncate_text(clean_terminal_output(''.join(transcript)), max_output_chars),
|
||||
)
|
||||
|
||||
def _remote_write_text_command(self, path: str, content: str) -> str:
|
||||
encoded = base64.b64encode(content.encode('utf-8')).decode('ascii')
|
||||
script = '; '.join(
|
||||
[
|
||||
'import base64',
|
||||
'from pathlib import Path',
|
||||
f'path = Path({json.dumps(path)})',
|
||||
'path.parent.mkdir(parents=True, exist_ok=True)',
|
||||
f'path.write_bytes(base64.b64decode({json.dumps(encoded)}))',
|
||||
]
|
||||
)
|
||||
return f'python3 -c {shlex.quote(script)}'
|
||||
|
||||
|
||||
class JupyterRuntimeManager:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._sessions: dict[tuple[str, str], JupyterRuntimeSession] = {}
|
||||
|
||||
def bind_session(
|
||||
self,
|
||||
*,
|
||||
account_id: str,
|
||||
session_id: str,
|
||||
base_url: str,
|
||||
password: str,
|
||||
workspace_root: str = DEFAULT_JUPYTER_WORKSPACE_ROOT,
|
||||
) -> JupyterRuntimeSession:
|
||||
runtime = JupyterRuntimeSession.connect(
|
||||
account_id=account_id,
|
||||
session_id=session_id,
|
||||
base_url=base_url,
|
||||
password=password,
|
||||
workspace_root=workspace_root,
|
||||
)
|
||||
with self._lock:
|
||||
self._sessions[(account_id, session_id)] = runtime
|
||||
return runtime
|
||||
|
||||
def get(self, account_id: str, session_id: str) -> JupyterRuntimeSession | None:
|
||||
with self._lock:
|
||||
return self._sessions.get((account_id, session_id))
|
||||
|
||||
def session_payload(self, account_id: str, session_id: str) -> dict[str, Any]:
|
||||
runtime = self.get(account_id, session_id)
|
||||
if runtime is None:
|
||||
return {
|
||||
'connected': False,
|
||||
'account_id': account_id,
|
||||
'session_id': session_id,
|
||||
}
|
||||
return runtime.to_dict()
|
||||
|
||||
|
||||
def normalize_jupyter_base_url(value: str) -> str:
|
||||
raw = value.strip()
|
||||
if not raw:
|
||||
raise JupyterRuntimeError('Jupyter URL is empty.')
|
||||
parsed = urlparse(raw)
|
||||
if not parsed.scheme:
|
||||
parsed = urlparse(f'https://{raw}')
|
||||
if parsed.scheme not in {'http', 'https'} or not parsed.netloc:
|
||||
raise JupyterRuntimeError(f'Invalid Jupyter URL: {value}')
|
||||
path = parsed.path.rstrip('/')
|
||||
for marker in ('/lab', '/tree', '/notebooks', '/login'):
|
||||
index = path.find(marker)
|
||||
if index >= 0:
|
||||
path = path[:index]
|
||||
break
|
||||
return urlunparse((parsed.scheme, parsed.netloc, path.rstrip('/'), '', '', '')).rstrip('/')
|
||||
|
||||
|
||||
def normalize_posix_path(value: str) -> str:
|
||||
path = PurePosixPath(value)
|
||||
if not str(path).startswith('/'):
|
||||
path = PurePosixPath('/') / path
|
||||
normalized = str(path)
|
||||
normalized = re.sub(r'/+', '/', normalized)
|
||||
return normalized.rstrip('/') or '/'
|
||||
|
||||
|
||||
def sanitize_remote_path_part(value: str) -> str:
|
||||
return re.sub(r'[^A-Za-z0-9_.-]+', '_', value).strip('._') or uuid.uuid4().hex
|
||||
|
||||
|
||||
def api_path_for_absolute_path(path: str) -> str:
|
||||
normalized = normalize_posix_path(path)
|
||||
if normalized == '/root':
|
||||
return ''
|
||||
if normalized.startswith('/root/'):
|
||||
return normalized[len('/root/') :]
|
||||
return normalized.lstrip('/')
|
||||
|
||||
|
||||
def parse_terminal_payload(raw: str) -> list[str]:
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
return [raw]
|
||||
if isinstance(payload, list) and len(payload) >= 2:
|
||||
kind, content = payload[0], payload[1]
|
||||
if kind in {'stdout', 'stderr'} and isinstance(content, str):
|
||||
return [content]
|
||||
return []
|
||||
|
||||
|
||||
def clean_terminal_output(text: str) -> str:
|
||||
# 去掉少量 ANSI 控制符,避免输出里带终端颜色和光标控制。
|
||||
cleaned = re.sub(r'\x1b\[[0-?]*[ -/]*[@-~]', '', text)
|
||||
lines = [
|
||||
line.rstrip('\r')
|
||||
for line in cleaned.splitlines()
|
||||
if line.strip() not in {'#', '>'} and not re.fullmatch(r'(>\s*)+', line.strip())
|
||||
]
|
||||
return '\n'.join(lines).strip('\r\n')
|
||||
|
||||
|
||||
def truncate_text(text: str, limit: int) -> str:
|
||||
if limit <= 0 or len(text) <= limit:
|
||||
return text
|
||||
omitted = len(text) - limit
|
||||
return f'{text[:limit]}\n... [truncated, {omitted} chars omitted]'
|
||||
|
||||
|
||||
def python_literal(value: object) -> str:
|
||||
return repr(value)
|
||||
|
||||
|
||||
def _login_jupyter(
|
||||
http_session: Any,
|
||||
base_url: str,
|
||||
password: str,
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
) -> str:
|
||||
login_url = f'{base_url}/login?next=%2Flab'
|
||||
response = http_session.get(login_url, timeout=timeout_seconds)
|
||||
if response.status_code >= 400:
|
||||
raise JupyterRuntimeError(
|
||||
f'Unable to open Jupyter login page: HTTP {response.status_code}'
|
||||
)
|
||||
xsrf_token = _extract_xsrf_token(response.text) or http_session.cookies.get('_xsrf')
|
||||
if not xsrf_token:
|
||||
raise JupyterRuntimeError('Unable to find Jupyter _xsrf token on login page.')
|
||||
post = http_session.post(
|
||||
f'{base_url}/login',
|
||||
data={'_xsrf': xsrf_token, 'password': password},
|
||||
headers={'Referer': login_url},
|
||||
timeout=timeout_seconds,
|
||||
allow_redirects=True,
|
||||
)
|
||||
if post.status_code >= 400:
|
||||
raise JupyterRuntimeError(
|
||||
f'Jupyter login failed: HTTP {post.status_code} {post.text[:500]}'
|
||||
)
|
||||
probe = http_session.get(f'{base_url}/api/contents', timeout=timeout_seconds)
|
||||
if probe.status_code >= 400:
|
||||
raise JupyterRuntimeError(
|
||||
f'Jupyter contents API probe failed after login: HTTP {probe.status_code} {probe.text[:500]}'
|
||||
)
|
||||
return str(xsrf_token)
|
||||
|
||||
|
||||
def _extract_xsrf_token(html: str) -> str | None:
|
||||
patterns = [
|
||||
r'name=["\']_xsrf["\']\s+value=["\']([^"\']+)["\']',
|
||||
r'value=["\']([^"\']+)["\']\s+name=["\']_xsrf["\']',
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, html)
|
||||
if match:
|
||||
return unescape(match.group(1))
|
||||
return None
|
||||
Reference in New Issue
Block a user