Improve Jupyter workspace bootstrap

This commit is contained in:
wuyang6
2026-05-12 22:33:15 +08:00
parent 295d5d1ea1
commit 86f49134a4
5 changed files with 858 additions and 44 deletions
+156 -4
View File
@@ -23,10 +23,11 @@ from pathlib import Path
from threading import Lock, RLock from threading import Lock, RLock
from typing import Any from typing import Any
from urllib import error, request from urllib import error, request
from urllib.parse import quote, unquote, urlparse
from uuid import uuid4 from uuid import uuid4
from fastapi import FastAPI, HTTPException from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse from fastapi.responses import FileResponse, JSONResponse, Response, StreamingResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -1004,6 +1005,66 @@ def create_app(state: AgentState) -> FastAPI:
safe_session_id, safe_session_id,
) )
@app.get('/api/jupyter/files')
async def list_jupyter_files(
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')
runtime = state.jupyter_runtime_manager.get(
state._account_key(account_id),
safe_session_id,
)
if runtime is None:
return {
'connected': False,
'session_id': safe_session_id,
'input': [],
'output': [],
}
try:
return {
'connected': True,
'session_id': safe_session_id,
'workspace_cwd': runtime.binding.workspace_cwd,
'input': runtime.list_files('input', kind='input'),
'output': runtime.list_files('output', kind='output'),
}
except JupyterRuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@app.get('/api/jupyter/file')
async def download_jupyter_file(
session_id: str,
path: str,
account_id: str | None = None,
) -> Response:
safe_session_id = _safe_session_id(session_id)
if not safe_session_id:
raise HTTPException(status_code=400, detail='session_id is required')
runtime = state.jupyter_runtime_manager.get(
state._account_key(account_id),
safe_session_id,
)
if runtime is None:
raise HTTPException(status_code=404, detail='Jupyter runtime is not connected')
try:
data, filename = runtime.read_file_bytes(path)
except JupyterRuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return Response(
data,
media_type=_content_type_for_filename(filename),
headers={
'content-disposition': (
f'attachment; filename="{_ascii_download_filename(filename)}"; '
f"filename*=UTF-8''{_url_quote_filename(filename)}"
)
},
)
@app.post('/api/jupyter/bind-session') @app.post('/api/jupyter/bind-session')
async def bind_jupyter_session( async def bind_jupyter_session(
payload: JupyterWorkspaceBindRequest, payload: JupyterWorkspaceBindRequest,
@@ -1020,6 +1081,7 @@ def create_app(state: AgentState) -> FastAPI:
base_url=payload.base_url, base_url=payload.base_url,
password=payload.password, password=payload.password,
workspace_root=payload.workspace_root, workspace_root=payload.workspace_root,
project_root=state.config_for(payload.account_id).cwd,
) )
except JupyterRuntimeError as exc: except JupyterRuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -1092,6 +1154,13 @@ def create_app(state: AgentState) -> FastAPI:
except (ValueError, subprocess.TimeoutExpired) as exc: except (ValueError, subprocess.TimeoutExpired) as exc:
raise HTTPException(status_code=400, detail=str(exc)) raise HTTPException(status_code=400, detail=str(exc))
result['skills'] = await list_skills(payload.account_id) result['skills'] = await list_skills(payload.account_id)
account_key = state._account_key(payload.account_id)
config = state.config_for(payload.account_id)
result['remote_runtime_sync'] = await asyncio.to_thread(
state.jupyter_runtime_manager.sync_account,
account_key,
config.cwd,
)
return result return result
@app.get('/api/models') @app.get('/api/models')
@@ -1127,7 +1196,11 @@ def create_app(state: AgentState) -> FastAPI:
@app.post('/api/files/online-doc') @app.post('/api/files/online-doc')
async def create_feishu_online_doc(payload: FeishuOnlineDocRequest) -> dict[str, Any]: async def create_feishu_online_doc(payload: FeishuOnlineDocRequest) -> dict[str, Any]:
file_path = _resolve_account_file(state, payload.account_id, payload.path) file_path, online_doc_key = _resolve_feishu_source_file(
state,
payload.account_id,
payload.path,
)
suffix = file_path.suffix.lower() suffix = file_path.suffix.lower()
if suffix in FEISHU_UNSUPPORTED_DOCUMENT_SUFFIXES: if suffix in FEISHU_UNSUPPORTED_DOCUMENT_SUFFIXES:
raise HTTPException( raise HTTPException(
@@ -1186,6 +1259,7 @@ def create_app(state: AgentState) -> FastAPI:
state, state,
payload.account_id, payload.account_id,
file_path=file_path, file_path=file_path,
map_key=online_doc_key,
title=title, title=title,
url=url, url=url,
kind=kind, kind=kind,
@@ -2165,6 +2239,28 @@ def _join_url(base_url: str, suffix: str) -> str:
return f'{base_url.rstrip("/")}/{suffix.lstrip("/")}' return f'{base_url.rstrip("/")}/{suffix.lstrip("/")}'
def _content_type_for_filename(filename: str) -> str:
suffix = Path(filename).suffix.lower()
if suffix == '.json':
return 'application/json; charset=utf-8'
if suffix in {'.jsonl', '.txt', '.md', '.csv'}:
return 'text/plain; charset=utf-8'
if suffix == '.xlsx':
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
if suffix == '.docx':
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
return 'application/octet-stream'
def _ascii_download_filename(filename: str) -> str:
safe = re.sub(r'[^A-Za-z0-9._-]+', '_', filename).strip('._')
return safe[:120] or 'download'
def _url_quote_filename(filename: str) -> str:
return quote(filename, safe='')
def _annotate_transcript_elapsed(payload: dict[str, Any], elapsed_ms: int) -> None: def _annotate_transcript_elapsed(payload: dict[str, Any], elapsed_ms: int) -> None:
transcript = payload.get('transcript') transcript = payload.get('transcript')
if not isinstance(transcript, list): if not isinstance(transcript, list):
@@ -2651,6 +2747,7 @@ def _record_feishu_online_doc(
account_id: str | None, account_id: str | None,
*, *,
file_path: Path, file_path: Path,
map_key: str | None = None,
title: str, title: str,
url: str, url: str,
kind: str = 'doc', kind: str = 'doc',
@@ -2667,17 +2764,19 @@ def _record_feishu_online_doc(
files = payload.get('files') if isinstance(payload, dict) else None files = payload.get('files') if isinstance(payload, dict) else None
if not isinstance(files, dict): if not isinstance(files, dict):
files = {} files = {}
previous = files.get(str(file_path)) key = map_key or str(file_path)
previous = files.get(key)
created_at = ( created_at = (
previous.get('created_at') previous.get('created_at')
if isinstance(previous, dict) and isinstance(previous.get('created_at'), int) if isinstance(previous, dict) and isinstance(previous.get('created_at'), int)
else now else now
) )
files[str(file_path)] = { files[key] = {
'url': clean_url, 'url': clean_url,
'title': title, 'title': title,
'kind': kind, 'kind': kind,
'file_path': str(file_path), 'file_path': str(file_path),
'source_path': key,
'created_at': created_at, 'created_at': created_at,
'updated_at': now, 'updated_at': now,
} }
@@ -2687,6 +2786,59 @@ def _record_feishu_online_doc(
) )
def _resolve_feishu_source_file(
state: AgentState,
account_id: str | None,
raw_path: str,
) -> tuple[Path, str | None]:
if raw_path.startswith('jupyter://'):
return _materialize_jupyter_file_for_feishu(state, account_id, raw_path), raw_path
return _resolve_account_file(state, account_id, raw_path), None
def _materialize_jupyter_file_for_feishu(
state: AgentState,
account_id: str | None,
raw_uri: str,
) -> Path:
session_id, remote_path = _parse_jupyter_file_uri(raw_uri)
runtime = state.jupyter_runtime_manager.get(
state._account_key(account_id),
session_id,
)
if runtime is None:
raise HTTPException(status_code=404, detail='Jupyter runtime is not connected')
try:
data, filename = runtime.read_file_bytes(remote_path)
except JupyterRuntimeError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
target_dir = (
state.account_paths(account_id)['base']
/ 'integrations'
/ 'feishu'
/ 'remote-files'
/ session_id
)
target_dir.mkdir(parents=True, exist_ok=True)
target = target_dir / _safe_uploaded_filename(filename)
target.write_bytes(data)
return target
def _parse_jupyter_file_uri(raw_uri: str) -> tuple[str, str]:
parsed = urlparse(raw_uri)
session_id = _safe_session_id(parsed.netloc)
remote_path = unquote(parsed.path or '')
if not session_id or not remote_path.startswith('/'):
raise HTTPException(status_code=400, detail='Invalid Jupyter file URI')
return session_id, remote_path
def _safe_uploaded_filename(filename: str) -> str:
cleaned = re.sub(r'[\x00-\x1f/\\]+', '_', filename).strip(' ._')
return cleaned[:180] or 'remote-file'
def _resolve_account_file(state: AgentState, account_id: str | None, raw_path: str) -> Path: def _resolve_account_file(state: AgentState, account_id: str | None, raw_path: str) -> Path:
path = Path(raw_path).expanduser().resolve() path = Path(raw_path).expanduser().resolve()
account_base = state.account_paths(account_id)['base'].resolve() account_base = state.account_paths(account_id)['base'].resolve()
+151 -7
View File
@@ -8,6 +8,7 @@ import {
} from "@/lib/claw-auth"; } from "@/lib/claw-auth";
export const runtime = "nodejs"; export const runtime = "nodejs";
const CLAW_API_URL = process.env.CLAW_API_URL ?? "http://127.0.0.1:8765";
export async function GET(req: Request) { export async function GET(req: Request) {
const account = await getCurrentAccount(); const account = await getCurrentAccount();
@@ -19,15 +20,26 @@ export async function GET(req: Request) {
const sessionId = normalizeSessionId(url.searchParams.get("session_id")); const sessionId = normalizeSessionId(url.searchParams.get("session_id"));
const onlineDocs = await readOnlineDocMap(account.id); const onlineDocs = await readOnlineDocMap(account.id);
if (!requestedPath && sessionId) { if (!requestedPath && sessionId) {
const remoteFiles = await listRemoteSessionFiles(
account.id,
sessionId,
onlineDocs,
);
return Response.json({ return Response.json({
session_id: sessionId, session_id: sessionId,
input: await listSessionFiles(account.id, sessionId, "input", onlineDocs), input: [
output: await listSessionFiles( ...(await listSessionFiles(account.id, sessionId, "input", onlineDocs)),
...remoteFiles.input,
],
output: [
...(await listSessionFiles(
account.id, account.id,
sessionId, sessionId,
"output", "output",
onlineDocs, onlineDocs,
), )),
...remoteFiles.output,
],
}); });
} }
if (!requestedPath) { if (!requestedPath) {
@@ -35,23 +47,38 @@ export async function GET(req: Request) {
if (!latestSessionId) { if (!latestSessionId) {
return Response.json({ session_id: null, input: [], output: [] }); return Response.json({ session_id: null, input: [], output: [] });
} }
const remoteFiles = await listRemoteSessionFiles(
account.id,
latestSessionId,
onlineDocs,
);
return Response.json({ return Response.json({
session_id: latestSessionId, session_id: latestSessionId,
input: await listSessionFiles( input: [
...(await listSessionFiles(
account.id, account.id,
latestSessionId, latestSessionId,
"input", "input",
onlineDocs, onlineDocs,
), )),
output: await listSessionFiles( ...remoteFiles.input,
],
output: [
...(await listSessionFiles(
account.id, account.id,
latestSessionId, latestSessionId,
"output", "output",
onlineDocs, onlineDocs,
), )),
...remoteFiles.output,
],
}); });
} }
if (requestedPath.startsWith("jupyter://")) {
return downloadRemoteFile(account.id, requestedPath);
}
const accountRoot = path.resolve(accountBaseRoot(account.id)); const accountRoot = path.resolve(accountBaseRoot(account.id));
const filePath = path.resolve(requestedPath); const filePath = path.resolve(requestedPath);
if (!filePath.startsWith(`${accountRoot}${path.sep}`)) { if (!filePath.startsWith(`${accountRoot}${path.sep}`)) {
@@ -75,6 +102,123 @@ export async function GET(req: Request) {
} }
} }
async function listRemoteSessionFiles(
accountId: string,
sessionId: string,
onlineDocs: Record<string, OnlineDocRecord>,
) {
try {
const target = new URL(`${CLAW_API_URL}/api/jupyter/files`);
target.searchParams.set("account_id", accountId);
target.searchParams.set("session_id", sessionId);
const response = await fetch(target, { cache: "no-store" });
if (!response.ok) return { input: [], output: [] };
const payload = (await response.json()) as {
input?: RemoteSessionFile[];
output?: RemoteSessionFile[];
};
return {
input: mapRemoteFiles(payload.input, sessionId, "input", onlineDocs),
output: mapRemoteFiles(payload.output, sessionId, "output", onlineDocs),
};
} catch {
return { input: [], output: [] };
}
}
type RemoteSessionFile = {
name?: unknown;
path?: unknown;
kind?: unknown;
size?: unknown;
modified_at?: unknown;
};
function mapRemoteFiles(
files: RemoteSessionFile[] | undefined,
sessionId: string,
kind: "input" | "output",
onlineDocs: Record<string, OnlineDocRecord>,
) {
if (!Array.isArray(files)) return [];
return files
.map((file) => {
const remotePath =
typeof file.path === "string" && file.path.startsWith("/")
? file.path
: null;
if (!remotePath) return null;
const name =
typeof file.name === "string" && file.name.trim()
? file.name.trim()
: path.basename(remotePath);
const uri = toJupyterFileUri(sessionId, remotePath);
const onlineDoc = onlineDocs[uri];
return {
name,
path: uri,
kind,
source: "jupyter",
size: typeof file.size === "number" ? file.size : 0,
modified_at:
typeof file.modified_at === "string"
? file.modified_at
: new Date().toISOString(),
download_url: `/api/claw/files?path=${encodeURIComponent(uri)}`,
online_doc_url: cleanOnlineDocUrl(onlineDoc?.url) ?? null,
online_doc_title: onlineDoc?.title ?? null,
online_doc_kind: onlineDoc?.kind ?? null,
online_doc_updated_at: onlineDoc?.updated_at ?? null,
};
})
.filter((file): file is NonNullable<typeof file> => Boolean(file));
}
async function downloadRemoteFile(accountId: string, uri: string) {
const parsed = parseJupyterFileUri(uri);
if (!parsed) {
return Response.json({ error: "无效的远端文件路径" }, { status: 400 });
}
const target = new URL(`${CLAW_API_URL}/api/jupyter/file`);
target.searchParams.set("account_id", accountId);
target.searchParams.set("session_id", parsed.sessionId);
target.searchParams.set("path", parsed.remotePath);
const response = await fetch(target, { cache: "no-store" });
if (!response.ok) {
const payload = await response.text();
return new Response(payload, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/json",
},
});
}
return new Response(response.body, {
status: response.status,
headers: {
"content-type":
response.headers.get("content-type") ?? "application/octet-stream",
"content-disposition":
response.headers.get("content-disposition") ??
`attachment; filename="${encodeURIComponent(path.basename(parsed.remotePath))}"`,
},
});
}
function toJupyterFileUri(sessionId: string, remotePath: string) {
return `jupyter://${sessionId}${remotePath}`;
}
function parseJupyterFileUri(uri: string) {
const match = uri.match(/^jupyter:\/\/([^/]+)(\/.*)$/u);
if (!match) return null;
return {
sessionId: match[1],
remotePath: match[2],
};
}
async function listSessionFiles( async function listSessionFiles(
accountId: string, accountId: string,
sessionId: string, sessionId: string,
@@ -32,7 +32,14 @@ import {
WrenchIcon, WrenchIcon,
} from "lucide-react"; } from "lucide-react";
import { Popover as PopoverPrimitive } from "radix-ui"; import { Popover as PopoverPrimitive } from "radix-ui";
import type { ComponentProps, FC, KeyboardEvent, ReactNode } from "react"; import type {
ComponentProps,
Dispatch,
FC,
KeyboardEvent,
ReactNode,
SetStateAction,
} from "react";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -359,6 +366,20 @@ type JupyterWorkspaceStatus = {
error?: string; error?: string;
}; };
type WorkspaceSwitchProgress = {
percent: number;
label: string;
};
const WORKSPACE_SWITCH_STEPS = [
"登录 Jupyter",
"初始化目录",
"创建 Python 环境",
"配置 pip 源",
"同步 Skill",
"链接工作区",
];
function WorkspaceSwitchDialog({ function WorkspaceSwitchDialog({
open, open,
onOpenChange, onOpenChange,
@@ -376,6 +397,9 @@ function WorkspaceSwitchDialog({
const [status, setStatus] = useState<JupyterWorkspaceStatus | null>(null); const [status, setStatus] = useState<JupyterWorkspaceStatus | null>(null);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
const [progress, setProgress] = useState<WorkspaceSwitchProgress | null>(
null,
);
const fieldId = useId(); const fieldId = useId();
const effectiveSessionId = const effectiveSessionId =
sessionId ?? sessionId ??
@@ -405,21 +429,19 @@ function WorkspaceSwitchDialog({
async function submit() { async function submit() {
setMessage(""); setMessage("");
if (!effectiveSessionId) { const sessionIdForRequest = ensureWorkspaceSessionId(effectiveSessionId);
setMessage("请先发送一条消息创建 session,再切换工作区。");
return;
}
if (!jupyterUrl.trim() || !password) { if (!jupyterUrl.trim() || !password) {
setMessage("请填写 Jupyter 地址和密码。"); setMessage("请填写 Jupyter 地址和密码。");
return; return;
} }
setSubmitting(true); setSubmitting(true);
const progressTimer = startWorkspaceProgress(setProgress);
try { try {
const response = await fetch("/api/claw/jupyter", { const response = await fetch("/api/claw/jupyter", {
method: "POST", method: "POST",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
session_id: effectiveSessionId, session_id: sessionIdForRequest,
base_url: jupyterUrl.trim(), base_url: jupyterUrl.trim(),
password, password,
workspace_root: workspaceRoot.trim() || "/root/zk_agent_workspaces", workspace_root: workspaceRoot.trim() || "/root/zk_agent_workspaces",
@@ -432,11 +454,15 @@ function WorkspaceSwitchDialog({
setMessage(payload.detail ?? payload.error ?? "切换工作区失败"); setMessage(payload.detail ?? payload.error ?? "切换工作区失败");
return; return;
} }
writeActiveSessionId(sessionIdForRequest);
setStatus(payload as JupyterWorkspaceStatus); setStatus(payload as JupyterWorkspaceStatus);
setProgress({ percent: 100, label: "切换完成" });
setMessage("工作区切换成功,后续工具会在远端 Jupyter 工作区执行。"); setMessage("工作区切换成功,后续工具会在远端 Jupyter 工作区执行。");
setPassword(""); setPassword("");
} finally { } finally {
window.clearInterval(progressTimer);
setSubmitting(false); setSubmitting(false);
window.setTimeout(() => setProgress(null), 1200);
} }
} }
@@ -475,6 +501,7 @@ function WorkspaceSwitchDialog({
value={jupyterUrl} value={jupyterUrl}
onChange={(event) => setJupyterUrl(event.target.value)} onChange={(event) => setJupyterUrl(event.target.value)}
placeholder="https://...-jupyter.../lab" placeholder="https://...-jupyter.../lab"
disabled={submitting}
/> />
</label> </label>
<label className="grid gap-1.5" htmlFor={`${fieldId}-password`}> <label className="grid gap-1.5" htmlFor={`${fieldId}-password`}>
@@ -485,6 +512,7 @@ function WorkspaceSwitchDialog({
value={password} value={password}
onChange={(event) => setPassword(event.target.value)} onChange={(event) => setPassword(event.target.value)}
placeholder="Jupyter 登录密码" placeholder="Jupyter 登录密码"
disabled={submitting}
/> />
</label> </label>
<label className="grid gap-1.5" htmlFor={`${fieldId}-root`}> <label className="grid gap-1.5" htmlFor={`${fieldId}-root`}>
@@ -493,8 +521,25 @@ function WorkspaceSwitchDialog({
id={`${fieldId}-root`} id={`${fieldId}-root`}
value={workspaceRoot} value={workspaceRoot}
onChange={(event) => setWorkspaceRoot(event.target.value)} onChange={(event) => setWorkspaceRoot(event.target.value)}
disabled={submitting}
/> />
</label> </label>
{progress ? (
<div className="rounded-md border bg-muted/30 px-3 py-2">
<div className="mb-2 flex items-center justify-between gap-3 text-xs">
<span className="text-muted-foreground">{progress.label}</span>
<span className="font-medium tabular-nums">
{Math.round(progress.percent)}%
</span>
</div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-all duration-500"
style={{ width: `${progress.percent}%` }}
/>
</div>
</div>
) : null}
{message ? ( {message ? (
<div className="rounded-md bg-muted px-3 py-2 text-muted-foreground"> <div className="rounded-md bg-muted px-3 py-2 text-muted-foreground">
{message} {message}
@@ -518,6 +563,43 @@ function WorkspaceSwitchDialog({
); );
} }
function ensureWorkspaceSessionId(current: string | null) {
if (current) return current;
const generated =
typeof crypto !== "undefined" && "randomUUID" in crypto
? `__LOCALID_${crypto.randomUUID().replace(/-/g, "").slice(0, 8)}`
: `__LOCALID_${Math.random().toString(36).slice(2, 10)}`;
writeActiveSessionId(generated);
scheduleSessionListRefresh();
return generated;
}
function startWorkspaceProgress(
setProgress: Dispatch<SetStateAction<WorkspaceSwitchProgress | null>>,
) {
setProgress({ percent: 6, label: WORKSPACE_SWITCH_STEPS[0] });
return window.setInterval(() => {
setProgress((current) => {
const previous = current ?? {
percent: 6,
label: WORKSPACE_SWITCH_STEPS[0],
};
const nextPercent = Math.min(
92,
previous.percent + (previous.percent < 55 ? 9 : 4),
);
const stepIndex = Math.min(
WORKSPACE_SWITCH_STEPS.length - 1,
Math.floor((nextPercent / 100) * WORKSPACE_SWITCH_STEPS.length),
);
return {
percent: nextPercent,
label: WORKSPACE_SWITCH_STEPS[stepIndex],
};
});
}, 1200);
}
const ThreadMessage: FC = () => { const ThreadMessage: FC = () => {
const role = useAuiState((s) => s.message.role); const role = useAuiState((s) => s.message.role);
const isEditing = useAuiState((s) => s.message.composer.isEditing); const isEditing = useAuiState((s) => s.message.composer.isEditing);
+54 -3
View File
@@ -2313,7 +2313,7 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
) )
payload = [ payload = [
f'exit_code={result.exit_code}', f'exit_code={result.exit_code}',
'interpreter=remote:python3', f'interpreter={context.jupyter_runtime.python_interpreter}',
f'remote_workspace={context.jupyter_runtime.binding.workspace_cwd}', f'remote_workspace={context.jupyter_runtime.binding.workspace_cwd}',
'[stdout]', '[stdout]',
result.stdout.rstrip(), result.stdout.rstrip(),
@@ -2327,7 +2327,7 @@ def _run_python_exec(arguments: dict[str, Any], context: ToolExecutionContext) -
{ {
'action': 'remote_python_exec', 'action': 'remote_python_exec',
'mode': 'code' if code else 'script', 'mode': 'code' if code else 'script',
'interpreter': 'remote:python3', 'interpreter': context.jupyter_runtime.python_interpreter,
'remote_workspace': context.jupyter_runtime.binding.workspace_cwd, 'remote_workspace': context.jupyter_runtime.binding.workspace_cwd,
'timed_out': result.timed_out, 'timed_out': result.timed_out,
'timeout_seconds': timeout_seconds, 'timeout_seconds': timeout_seconds,
@@ -2537,13 +2537,64 @@ def _communicate_after_stop(process: subprocess.Popen[str]) -> tuple[str, str]:
def _run_python_package(arguments: dict[str, Any], context: ToolExecutionContext) -> str: def _run_python_package(arguments: dict[str, Any], context: ToolExecutionContext) -> str:
_ensure_process_execution_allowed(context, 'Python package management') _ensure_process_execution_allowed(context, 'Python package management')
action = _require_string(arguments, 'action') action = _require_string(arguments, 'action')
interpreter = _resolve_python_interpreter(context)
timeout_seconds = _coerce_float( timeout_seconds = _coerce_float(
arguments, arguments,
'timeout_seconds', 'timeout_seconds',
min(context.command_timeout_seconds * 4, 120.0), min(context.command_timeout_seconds * 4, 120.0),
) )
max_output_chars = _coerce_int(arguments, 'max_output_chars', context.max_output_chars) max_output_chars = _coerce_int(arguments, 'max_output_chars', context.max_output_chars)
if context.jupyter_runtime is not None:
interpreter = context.jupyter_runtime.python_interpreter
if action == 'show':
result = context.jupyter_runtime.run_command(
f'{shlex.quote(interpreter)} -m pip --version',
timeout_seconds=timeout_seconds,
max_output_chars=max_output_chars,
cancel_event=context.cancel_event,
)
elif action == 'install':
packages = arguments.get('packages')
if not isinstance(packages, list) or not packages:
raise ToolExecutionError('packages must be a non-empty array for action=install')
package_args = [str(package).strip() for package in packages if str(package).strip()]
if not package_args:
raise ToolExecutionError('packages must contain at least one non-empty package name')
result = context.jupyter_runtime.install_python_packages(
package_args,
timeout_seconds=timeout_seconds,
max_output_chars=max_output_chars,
cancel_event=context.cancel_event,
)
else:
raise ToolExecutionError('action must be "show" or "install"')
payload = [
f'exit_code={result.exit_code}',
f'interpreter={interpreter}',
f'python_env={context.jupyter_runtime.python_env_path}',
'[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_package',
'package_action': action,
'interpreter': interpreter,
'python_env_dir': context.jupyter_runtime.python_env_path,
'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 action == 'show': if action == 'show':
command = [interpreter, '-m', 'pip', '--version'] command = [interpreter, '-m', 'pip', '--version']
elif action == 'install': elif action == 'install':
+392 -7
View File
@@ -1,16 +1,19 @@
from __future__ import annotations from __future__ import annotations
import base64 import base64
import hashlib
import io
import json import json
import re import re
import shlex import shlex
import ssl import ssl
import tarfile
import threading import threading
import time import time
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from html import unescape from html import unescape
from pathlib import PurePosixPath from pathlib import Path, PurePosixPath
from typing import Any from typing import Any
from urllib.parse import quote, urlparse, urlunparse from urllib.parse import quote, urlparse, urlunparse
@@ -26,6 +29,8 @@ except ImportError: # pragma: no cover
DEFAULT_JUPYTER_WORKSPACE_ROOT = '/root/zk_agent_workspaces' DEFAULT_JUPYTER_WORKSPACE_ROOT = '/root/zk_agent_workspaces'
REMOTE_PIP_INDEX_URL = 'https://pypi.tuna.tsinghua.edu.cn/simple'
REMOTE_PIP_TRUSTED_HOST = 'pypi.tuna.tsinghua.edu.cn'
class JupyterRuntimeError(RuntimeError): class JupyterRuntimeError(RuntimeError):
@@ -79,6 +84,15 @@ class JupyterRuntimeSession:
self._xsrf_token = xsrf_token self._xsrf_token = xsrf_token
self._terminal_name: str | None = None self._terminal_name: str | None = None
self._lock = threading.RLock() self._lock = threading.RLock()
account_part = sanitize_remote_path_part(binding.account_id)
self.account_runtime_root = (
f'{binding.workspace_root}/.runtime/accounts/{account_part}'
)
self.python_env_path = f'{self.account_runtime_root}/python/.venv'
self.platform_root = ''
self.skills_root = ''
self.bundle_digest = ''
self.synced_at: float | None = None
@classmethod @classmethod
def connect( def connect(
@@ -90,6 +104,7 @@ class JupyterRuntimeSession:
password: str, password: str,
workspace_root: str = DEFAULT_JUPYTER_WORKSPACE_ROOT, workspace_root: str = DEFAULT_JUPYTER_WORKSPACE_ROOT,
timeout_seconds: float = 20.0, timeout_seconds: float = 20.0,
project_root: Path | None = None,
) -> 'JupyterRuntimeSession': ) -> 'JupyterRuntimeSession':
if requests is None: if requests is None:
raise JupyterRuntimeError( raise JupyterRuntimeError(
@@ -123,15 +138,27 @@ class JupyterRuntimeSession:
http_session=http_session, http_session=http_session,
xsrf_token=xsrf_token, xsrf_token=xsrf_token,
) )
runtime.bootstrap_workspace(timeout_seconds=timeout_seconds) runtime.bootstrap_workspace(
timeout_seconds=timeout_seconds,
project_root=project_root,
)
return runtime return runtime
def bootstrap_workspace(self, *, timeout_seconds: float = 20.0) -> None: def bootstrap_workspace(
self,
*,
timeout_seconds: float = 20.0,
project_root: Path | None = None,
) -> None:
result = self.run_command( result = self.run_command(
( (
'mkdir -p ' 'mkdir -p '
f'{shlex.quote(self.binding.workspace_cwd)}/output ' f'{shlex.quote(self.binding.workspace_cwd)}/output '
f'{shlex.quote(self.binding.workspace_cwd)}/input '
f'{shlex.quote(self.binding.workspace_cwd)}/scratchpad ' f'{shlex.quote(self.binding.workspace_cwd)}/scratchpad '
f'{shlex.quote(self.binding.workspace_root)}/.runtime/uploads '
f'{shlex.quote(self.binding.workspace_root)}/runtime_uploads '
f'{shlex.quote(self.account_runtime_root)}/python'
), ),
timeout_seconds=timeout_seconds, timeout_seconds=timeout_seconds,
max_output_chars=4000, max_output_chars=4000,
@@ -140,24 +167,190 @@ class JupyterRuntimeSession:
raise JupyterRuntimeError( raise JupyterRuntimeError(
result.stdout.strip() or 'Unable to bootstrap Jupyter workspace.' result.stdout.strip() or 'Unable to bootstrap Jupyter workspace.'
) )
self.ensure_python_environment(timeout_seconds=max(timeout_seconds, 300.0))
if project_root is not None:
self.sync_runtime_bundle(project_root, timeout_seconds=max(timeout_seconds, 180.0))
def to_dict(self) -> dict[str, Any]: def to_dict(self) -> dict[str, Any]:
payload = self.binding.to_dict() payload = self.binding.to_dict()
payload['connected'] = True payload['connected'] = True
payload['account_runtime_root'] = self.account_runtime_root
payload['python_env_path'] = self.python_env_path
payload['python_interpreter'] = self.python_interpreter
payload['platform_root'] = self.platform_root
payload['skills_root'] = self.skills_root
payload['bundle_digest'] = self.bundle_digest
payload['synced_at'] = self.synced_at
return payload return payload
def render_context(self) -> str: def render_context(self) -> str:
return '\n'.join( lines = [
[
'[远端 Jupyter 工作区]', '[远端 Jupyter 工作区]',
'当前 session 已切换到远端 Jupyter 运行时。', '当前 session 已切换到远端 Jupyter 运行时。',
f'- 默认工作目录:{self.binding.workspace_cwd}', f'- 默认工作目录:{self.binding.workspace_cwd}',
f'- 输出文件优先写入:{self.binding.workspace_cwd}/output', f'- 输出文件优先写入:{self.binding.workspace_cwd}/output',
f'- Python 环境:{self.python_env_path}',
'- Python 环境默认只初始化 venv 和 pip 清华源;缺包时使用 python_package 按需安装。',
'- 线上挂载目录(例如 /mnt/...)可以通过绝对路径读取和分析。', '- 线上挂载目录(例如 /mnt/...)可以通过绝对路径读取和分析。',
]
if self.platform_root:
lines.extend(
[
f'- 平台运行包:{self.platform_root}',
f'- Skill 运行包:{self.skills_root}',
'- 读取 skills/...、src/...、标签定义/... 时会使用远端同步后的运行包。',
]
)
lines.extend(
[
'- 除非用户明确要求,不要修改平台代码目录;数据产物应留在当前 session 工作区。', '- 除非用户明确要求,不要修改平台代码目录;数据产物应留在当前 session 工作区。',
f'- Jupyter 工作区入口:{self.binding.jupyter_tree_url}', f'- Jupyter 工作区入口:{self.binding.jupyter_tree_url}',
] ]
) )
return '\n'.join(lines)
@property
def python_interpreter(self) -> str:
return f'{self.python_env_path}/bin/python'
def ensure_python_environment(self, *, timeout_seconds: float = 300.0) -> None:
python_env = shlex.quote(self.python_env_path)
pip_conf = shlex.quote(f'{self.python_env_path}/pip.conf')
marker = shlex.quote(f'{self.python_env_path}/.zk-agent-python-env-v2')
command = f'''
set -e
if [ ! -x {python_env}/bin/python ]; then
python3 -m venv {python_env}
fi
cat > {pip_conf} <<'EOF'
[global]
index-url = {REMOTE_PIP_INDEX_URL}
trusted-host = {REMOTE_PIP_TRUSTED_HOST}
disable-pip-version-check = true
EOF
touch {marker}
{python_env}/bin/python -m pip --version
{python_env}/bin/python - <<'PY'
import sys
print(sys.executable)
PY
'''
result = self.run_command(
command,
timeout_seconds=timeout_seconds,
max_output_chars=12000,
)
if result.exit_code != 0:
raise JupyterRuntimeError(
result.stdout.strip() or 'Unable to initialize remote Python env.'
)
def install_python_packages(
self,
packages: list[str],
*,
timeout_seconds: float,
max_output_chars: int,
cancel_event: Any | None = None,
) -> RemoteCommandResult:
package_args = ' '.join(shlex.quote(package) for package in packages)
return self.run_command(
f'{shlex.quote(self.python_interpreter)} -m pip install {package_args}',
timeout_seconds=timeout_seconds,
max_output_chars=max_output_chars,
cancel_event=cancel_event,
)
def sync_runtime_bundle(
self,
project_root: Path,
*,
timeout_seconds: float = 180.0,
) -> dict[str, Any]:
bundle_bytes, digest = build_runtime_bundle(project_root)
platform_root = f'{self.binding.workspace_root}/.runtime/platform/{digest[:16]}'
upload_path = f'{self.binding.workspace_root}/runtime_uploads/platform-{digest}.tar.gz'
digest_file = f'{platform_root}/.bundle_digest'
probe = self.run_command(
(
f'test -f {shlex.quote(digest_file)} '
f'&& cat {shlex.quote(digest_file)} || true'
),
timeout_seconds=20.0,
max_output_chars=2000,
)
if digest not in probe.stdout:
self.put_file_bytes(
api_path_for_absolute_path(upload_path),
bundle_bytes,
)
tmp_root = f'{platform_root}.tmp-{uuid.uuid4().hex[:8]}'
result = self.run_command(
(
f'rm -rf {shlex.quote(tmp_root)} && '
f'mkdir -p {shlex.quote(tmp_root)} && '
f'tar -xzf {shlex.quote(upload_path)} -C {shlex.quote(tmp_root)} && '
f'printf %s {shlex.quote(digest)} > {shlex.quote(tmp_root)}/.bundle_digest && '
f'rm -rf {shlex.quote(platform_root)} && '
f'mv {shlex.quote(tmp_root)} {shlex.quote(platform_root)}'
),
timeout_seconds=timeout_seconds,
max_output_chars=12000,
)
if result.exit_code != 0:
raise JupyterRuntimeError(
result.stdout.strip() or 'Unable to extract remote runtime bundle.'
)
self.platform_root = platform_root
self.skills_root = f'{platform_root}/skills'
self.bundle_digest = digest
self.synced_at = time.time()
self.link_platform_resources()
return {
'bundle_digest': digest,
'platform_root': self.platform_root,
'skills_root': self.skills_root,
'uploaded_bytes': len(bundle_bytes),
}
def link_platform_resources(self) -> None:
if not self.platform_root:
return
workspace = shlex.quote(self.binding.workspace_cwd)
platform = shlex.quote(self.platform_root)
command = (
f'ln -sfn {platform}/skills {workspace}/skills && '
f'ln -sfn {platform}/src {workspace}/src && '
f'ln -sfn {platform}/标签定义 {workspace}/标签定义 && '
f'ln -sfn {platform}/pyproject.toml {workspace}/pyproject.toml'
)
result = self.run_command(
command,
timeout_seconds=20.0,
max_output_chars=4000,
)
if result.exit_code != 0:
raise JupyterRuntimeError(
result.stdout.strip() or 'Unable to link runtime resources into workspace.'
)
def put_file_bytes(self, api_path: str, data: bytes) -> None:
encoded = base64.b64encode(data).decode('ascii')
url = f'{self.binding.base_url}/api/contents/{quote(api_path, safe="/")}'
response = self._session.put(
url,
headers={'X-XSRFToken': self._xsrf_token},
json={
'type': 'file',
'format': 'base64',
'content': encoded,
},
timeout=60,
)
if response.status_code >= 400:
raise JupyterRuntimeError(
f'Unable to upload file to Jupyter contents API: HTTP {response.status_code} {response.text[:500]}'
)
def run_command( def run_command(
self, self,
@@ -175,10 +368,12 @@ class JupyterRuntimeSession:
raise JupyterRuntimeError('Remote command is empty.') raise JupyterRuntimeError('Remote command is empty.')
marker = f'__ZK_AGENT_EXIT_{uuid.uuid4().hex}__' marker = f'__ZK_AGENT_EXIT_{uuid.uuid4().hex}__'
workspace = shlex.quote(self.binding.workspace_cwd) workspace = shlex.quote(self.binding.workspace_cwd)
env_prefix = self._remote_env_prefix()
inner_command = f'{env_prefix}{command}'
wrapped = ( wrapped = (
f'mkdir -p {workspace} && ' f'mkdir -p {workspace} && '
f'cd {workspace} && ' f'cd {workspace} && '
f'bash -lc {shlex.quote(command)}; ' f'bash -lc {shlex.quote(inner_command)}; '
f'printf "\\n{marker}:%s\\n" "$?"' f'printf "\\n{marker}:%s\\n" "$?"'
) )
with self._lock: with self._lock:
@@ -235,7 +430,7 @@ class JupyterRuntimeSession:
) )
assert script_path is not None assert script_path is not None
quoted_args = ' '.join(shlex.quote(arg) for arg in args) quoted_args = ' '.join(shlex.quote(arg) for arg in args)
python_command = f'python3 {shlex.quote(script_path)}' python_command = f'{shlex.quote(self.python_interpreter)} {shlex.quote(script_path)}'
if quoted_args: if quoted_args:
python_command = f'{python_command} {quoted_args}' python_command = f'{python_command} {quoted_args}'
if stdin is not None: if stdin is not None:
@@ -283,6 +478,86 @@ print("\n".join(entries) if entries else "(empty directory)")
raise JupyterRuntimeError(result.stdout.strip() or 'remote list_dir failed') raise JupyterRuntimeError(result.stdout.strip() or 'remote list_dir failed')
return result.stdout.strip() return result.stdout.strip()
def list_files(
self,
path: str,
*,
kind: str,
max_entries: int = 200,
max_output_chars: int = 20000,
) -> list[dict[str, Any]]:
target = self.resolve_workspace_path(path)
code = r'''
import json
from datetime import datetime, timezone
from pathlib import Path
target = Path(PATH)
entries = []
if target.exists() and target.is_dir():
for item in sorted(target.iterdir(), key=lambda p: p.stat().st_mtime, reverse=True)[:MAX_ENTRIES]:
if not item.is_file():
continue
stat = item.stat()
entries.append({
"name": item.name,
"path": str(item),
"kind": KIND,
"size": stat.st_size,
"modified_at": datetime.fromtimestamp(stat.st_mtime, timezone.utc).isoformat().replace("+00:00", "Z"),
})
print(json.dumps(entries, ensure_ascii=False))
'''
script = (
code.replace('PATH', python_literal(target))
.replace('MAX_ENTRIES', str(max_entries))
.replace('KIND', python_literal(kind))
)
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_files failed')
try:
payload = json.loads(result.stdout.strip() or '[]')
except json.JSONDecodeError as exc:
raise JupyterRuntimeError('remote list_files returned invalid JSON') from exc
if not isinstance(payload, list):
raise JupyterRuntimeError('remote list_files returned invalid payload')
return [item for item in payload if isinstance(item, dict)]
def read_file_bytes(self, path: str) -> tuple[bytes, str]:
target = self.resolve_workspace_path(path)
api_path = api_path_for_absolute_path(target)
url = f'{self.binding.base_url}/api/contents/{quote(api_path, safe="/")}'
response = self._session.get(
url,
params={'content': 1},
headers={'X-XSRFToken': self._xsrf_token},
timeout=60,
)
if response.status_code >= 400:
raise JupyterRuntimeError(
f'Unable to read remote file: HTTP {response.status_code} {response.text[:500]}'
)
payload = response.json()
if payload.get('type') != 'file':
raise JupyterRuntimeError('Remote path is not a file.')
content = payload.get('content')
file_format = payload.get('format')
if not isinstance(content, str):
raise JupyterRuntimeError('Remote file response did not contain content.')
if file_format == 'base64':
data = base64.b64decode(content)
else:
data = content.encode('utf-8')
name = str(payload.get('name') or PurePosixPath(target).name or 'remote-file')
return data, name
def read_text( def read_text(
self, self,
path: str, path: str,
@@ -369,6 +644,10 @@ print(f"{'appended' if MODE == 'a' else 'wrote'} {target} ({len(content)} chars)
if value.startswith('/'): if value.startswith('/'):
return normalize_posix_path(value) return normalize_posix_path(value)
path = PurePosixPath(value) path = PurePosixPath(value)
if self.platform_root and path.parts:
platform_heads = {'skills', 'src', '标签定义'}
if path.parts[0] in platform_heads or value == 'pyproject.toml':
return normalize_posix_path(f'{self.platform_root}/{value}')
if path.parts and path.parts[0] in {'output', 'outputs'}: if path.parts and path.parts[0] in {'output', 'outputs'}:
tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath() tail = PurePosixPath(*path.parts[1:]) if len(path.parts) > 1 else PurePosixPath()
return normalize_posix_path(f'{self.binding.workspace_cwd}/output/{tail}') return normalize_posix_path(f'{self.binding.workspace_cwd}/output/{tail}')
@@ -377,6 +656,33 @@ print(f"{'appended' if MODE == 'a' else 'wrote'} {target} ({len(content)} chars)
return normalize_posix_path(f'{self.binding.workspace_cwd}/scratchpad/{tail}') return normalize_posix_path(f'{self.binding.workspace_cwd}/scratchpad/{tail}')
return normalize_posix_path(f'{self.binding.workspace_cwd}/{value}') return normalize_posix_path(f'{self.binding.workspace_cwd}/{value}')
def _remote_env_prefix(self) -> str:
exports = {
'ZK_AGENT_WORKSPACE': self.binding.workspace_cwd,
'ZK_AGENT_OUTPUT': f'{self.binding.workspace_cwd}/output',
'ZK_AGENT_SCRATCHPAD': f'{self.binding.workspace_cwd}/scratchpad',
'ZK_AGENT_PYTHON_ENV': self.python_env_path,
}
if self.platform_root:
exports['ZK_AGENT_PLATFORM_ROOT'] = self.platform_root
exports['ZK_AGENT_SKILLS_ROOT'] = self.skills_root
lines = [f'export {key}={shlex.quote(value)}' for key, value in exports.items()]
lines.append(f'export PATH={shlex.quote(self.python_env_path + "/bin")}:$PATH')
# CloudML 的 Jupyter Python 可能来自 Nix,pip 安装的科学计算轮子会依赖系统库。
# 只在库文件存在时预加载,避免 pandas/numpy/pyarrow 因 libz/libstdc++ 找不到而失败。
lines.extend(
[
'if [ -f /lib/x86_64-linux-gnu/libz.so.1 ] && '
'[ -f /lib/x86_64-linux-gnu/libstdc++.so.6 ]; then '
'export LD_PRELOAD=/lib/x86_64-linux-gnu/libz.so.1:'
'/lib/x86_64-linux-gnu/libstdc++.so.6${LD_PRELOAD:+:$LD_PRELOAD}; '
'fi'
]
)
if self.platform_root:
lines.append(f'export PYTHONPATH={shlex.quote(self.platform_root)}:${{PYTHONPATH:-}}')
return '; '.join(lines) + '; '
def _ensure_terminal(self) -> str: def _ensure_terminal(self) -> str:
if self._terminal_name: if self._terminal_name:
return self._terminal_name return self._terminal_name
@@ -500,6 +806,7 @@ class JupyterRuntimeManager:
base_url: str, base_url: str,
password: str, password: str,
workspace_root: str = DEFAULT_JUPYTER_WORKSPACE_ROOT, workspace_root: str = DEFAULT_JUPYTER_WORKSPACE_ROOT,
project_root: Path | None = None,
) -> JupyterRuntimeSession: ) -> JupyterRuntimeSession:
runtime = JupyterRuntimeSession.connect( runtime = JupyterRuntimeSession.connect(
account_id=account_id, account_id=account_id,
@@ -507,6 +814,7 @@ class JupyterRuntimeManager:
base_url=base_url, base_url=base_url,
password=password, password=password,
workspace_root=workspace_root, workspace_root=workspace_root,
project_root=project_root,
) )
with self._lock: with self._lock:
self._sessions[(account_id, session_id)] = runtime self._sessions[(account_id, session_id)] = runtime
@@ -526,6 +834,24 @@ class JupyterRuntimeManager:
} }
return runtime.to_dict() return runtime.to_dict()
def sync_account(self, account_id: str, project_root: Path) -> list[dict[str, Any]]:
with self._lock:
runtimes = [
runtime
for (runtime_account, _), runtime in self._sessions.items()
if runtime_account == account_id
]
results: list[dict[str, Any]] = []
for runtime in runtimes:
payload = runtime.sync_runtime_bundle(project_root)
results.append(
{
'session_id': runtime.binding.session_id,
**payload,
}
)
return results
def normalize_jupyter_base_url(value: str) -> str: def normalize_jupyter_base_url(value: str) -> str:
raw = value.strip() raw = value.strip()
@@ -601,6 +927,65 @@ def python_literal(value: object) -> str:
return repr(value) return repr(value)
def build_runtime_bundle(project_root: Path) -> tuple[bytes, str]:
root = project_root.resolve()
digest = calculate_runtime_bundle_digest(root)
buffer = io.BytesIO()
with tarfile.open(fileobj=buffer, mode='w:gz') as archive:
for name in ('src', 'skills', '标签定义', 'pyproject.toml'):
source = root / name
if not source.exists():
continue
archive.add(source, arcname=name, filter=_runtime_bundle_filter)
data = buffer.getvalue()
return data, digest
def calculate_runtime_bundle_digest(project_root: Path) -> str:
digest = hashlib.sha256()
for name in ('src', 'skills', '标签定义', 'pyproject.toml'):
source = project_root / name
if not source.exists():
continue
if source.is_file():
_update_digest_for_file(digest, source, Path(name))
continue
for path in sorted(source.rglob('*')):
rel = Path(name) / path.relative_to(source)
if _should_skip_bundle_path(rel) or not path.is_file():
continue
_update_digest_for_file(digest, path, rel)
return digest.hexdigest()
def _update_digest_for_file(digest: 'hashlib._Hash', path: Path, rel: Path) -> None:
digest.update(rel.as_posix().encode('utf-8'))
digest.update(b'\0')
digest.update(path.read_bytes())
digest.update(b'\0')
def _should_skip_bundle_path(path: Path | PurePosixPath) -> bool:
parts = path.parts
skipped_dirs = {
'.git',
'.venv',
'.port_sessions',
'.next',
'node_modules',
'__pycache__',
}
if any(part in skipped_dirs for part in parts):
return True
return str(path).endswith(('.pyc', '.pyo', '.DS_Store'))
def _runtime_bundle_filter(info: tarfile.TarInfo) -> tarfile.TarInfo | None:
if _should_skip_bundle_path(PurePosixPath(info.name)):
return None
return info
def _login_jupyter( def _login_jupyter(
http_session: Any, http_session: Any,
base_url: str, base_url: str,