From 4919fe090986bacb97ec7f1d230e7f14f709986f Mon Sep 17 00:00:00 2001 From: "tao.chen" <93983997+taochen-ct@users.noreply.github.com> Date: Tue, 4 Aug 2026 12:40:38 +0800 Subject: [PATCH] update: create file --- backend/Dockerfile | 7 +- backend/src/backend/runtime_client.py | 127 +++++++++++++++----- backend/src/backend/scripts.py | 163 +++++++++++++++++--------- frontend/Dockerfile | 4 + runtime/Dockerfile | 7 +- schedule/Dockerfile | 7 +- 6 files changed, 226 insertions(+), 89 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index f810b03..9e65775 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -3,7 +3,8 @@ FROM python:3.12-slim-bookworm ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PYTHONPATH=/app \ - PATH="/app/.venv/bin:${PATH}" + PATH="/app/.venv/bin:${PATH}" \ + TZ=Asia/Shanghai WORKDIR /app COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv @@ -25,6 +26,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ g++ \ python3-dev \ build-essential \ + tzdata \ + && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ + && echo $TZ > /etc/timezone \ + && dpkg-reconfigure --frontend noninteractive tzdata \ && rm -rf /var/lib/apt/lists/* COPY common ./common diff --git a/backend/src/backend/runtime_client.py b/backend/src/backend/runtime_client.py index 8f022cb..34529ff 100644 --- a/backend/src/backend/runtime_client.py +++ b/backend/src/backend/runtime_client.py @@ -105,30 +105,41 @@ class RuntimeClient: ) return ws - async def _jupyter_contents_post( + async def _jupyter_request( self, workspace_id: str, ws: dict[str, Any], - body: dict[str, Any], + method: str, + contents_path: str, + body: dict[str, Any] | None = None, ) -> dict[str, Any]: - """POST to the workspace Jupyter's ``/api/contents/`` directly. + """Send a request to the workspace Jupyter's contents API. - Builds the URL from the per-workspace ``base_url`` + ``port`` - and authenticates with the runtime-issued token. We reuse the + ``contents_path`` is the suffix after ``/api/contents/`` — + pass ``""`` for the root, ``"foo.ipynb"`` for a file, or + ``"foo.ipynb/checkpoints"`` for the checkpoint subresource. + The URL is built from the per-workspace ``base_url`` + ``port`` + and authenticated with the runtime-issued token. We reuse the existing ``httpx.AsyncClient`` (its ``base_url`` is overridden by the absolute URL we hand it). """ + suffix = contents_path.lstrip("/") url = ( f"{ws['base_url']}:{ws['port']}" - f"/jupyter/{workspace_id}/api/contents/" + f"/jupyter/{workspace_id}/api/contents/{suffix}" ) - logger.debug(url) + logger.debug(f"{method} {url}") logger.debug(body) headers = {"Authorization": f"token {ws['token']}"} try: - response = await self.client.post( - url, json=body, headers=headers - ) + if body is None: + response = await self.client.request( + method, url, headers=headers + ) + else: + response = await self.client.request( + method, url, json=body, headers=headers + ) except httpx.RequestError as exc: raise _RUNTIME_TRANSPORT_ERROR from exc if response.is_error: @@ -137,6 +148,8 @@ class RuntimeClient: except ValueError: detail = response.text raise RuntimeClientError(response.status_code, detail) + if response.status_code == 204 or not response.content: + return {} return response.json() async def create_notebook( @@ -145,24 +158,29 @@ class RuntimeClient: *, name: str, cells: list[dict[str, Any]] | None = None, + checkpoint: bool = True, ) -> dict[str, Any]: - """Create a notebook in the workspace root. + """Create a notebook at the given path in the workspace. - Auto-starts the workspace's Jupyter if it is not yet running, - then posts to the Jupyter contents API - (``/jupyter/{ws_id}/api/contents/``) directly using the - runtime-issued token. Returns the Jupyter contents descriptor - (``path``, ``type``, ``created``, ``writable``...). + Uses Jupyter's ``PUT /api/contents/{name}`` (the + "save / create at path" verb — ``POST /api/contents/`` only + creates an auto-incremented ``Untitled.ipynb``). The body + carries ``type``, ``format`` and the full notebook ``content``. - ``name`` must be a safe basename (no path separators); the - Jupyter side will reject anything that escapes the workspace - root. + Auto-starts the workspace's Jupyter if it is not yet running. + When ``checkpoint`` is true (default), follows up with a + ``POST /api/contents/{name}/checkpoints`` so the file is + visible in the live notebook tree without an extra refresh — + checkpoint failures are logged but do not fail the create, + because the underlying ``PUT`` already persisted the bytes. + + ``name`` must be a safe basename or relative path; the Jupyter + side will reject anything that escapes the workspace root. """ ws = await self._ensure_workspace(workspace_id) - logger.debug(ws) body = { "type": "notebook", - "path": name, + "format": "json", "content": { "cells": cells if cells is not None else [], "metadata": {}, @@ -170,8 +188,20 @@ class RuntimeClient: "nbformat_minor": 5, }, } - - return await self._jupyter_contents_post(workspace_id, ws, body) + result = await self._jupyter_request( + workspace_id, ws, "PUT", name, body=body + ) + if checkpoint: + try: + await self._jupyter_request( + workspace_id, ws, "POST", f"{name}/checkpoints" + ) + except RuntimeClientError as exc: + logger.warning( + f"checkpoint after create_notebook({name}) failed: " + f"{exc.status_code} {exc.detail}" + ) + return result async def upload_file( self, @@ -180,13 +210,20 @@ class RuntimeClient: name: str, content: str, content_type: str = "text/plain", + checkpoint: bool = True, ) -> dict[str, Any]: - """Upload a text file to the workspace root. + """Upload a text file to the workspace. - ``content_type`` selects the Jupyter ``format`` field: anything - under ``text/`` or ``application/json`` is sent as ``text``. - Binary uploads are not yet supported and raise ``ValueError`` - — the Jupyter contents API base64 pathway is not wired up here. + Uses ``PUT /api/contents/{name}`` with ``type="file"`` and + ``format="text"``. ``content_type`` is accepted for API + symmetry with :meth:`create_notebook`; only ``text/*`` and + ``application/json`` are supported here — binary uploads + require the base64 pathway which is not yet wired up and + raise :class:`ValueError`. + + When ``checkpoint`` is true (default), the same follow-up + checkpoint call as :meth:`create_notebook` is made; failures + are swallowed. """ if not ( content_type.startswith("text/") @@ -199,11 +236,39 @@ class RuntimeClient: ws = await self._ensure_workspace(workspace_id) body = { "type": "file", - "path": name, - "content": content, "format": "text", + "content": content, } - return await self._jupyter_contents_post(workspace_id, ws, body) + result = await self._jupyter_request( + workspace_id, ws, "PUT", name, body=body + ) + if checkpoint: + try: + await self._jupyter_request( + workspace_id, ws, "POST", f"{name}/checkpoints" + ) + except RuntimeClientError as exc: + logger.warning( + f"checkpoint after upload_file({name}) failed: " + f"{exc.status_code} {exc.detail}" + ) + return result + + async def delete_file( + self, + workspace_id: str, + *, + name: str, + ) -> None: + """Delete a file from the workspace Jupyter. + + Uses ``DELETE /api/contents/{name}`` on the workspace Jupyter. + Jupyter returns ``204 No Content`` on success; any 4xx/5xx is + surfaced as :class:`RuntimeClientError`. Non-recursive — Jupyter + rejects directory deletes unless the directory is empty. + """ + ws = await self._ensure_workspace(workspace_id) + await self._jupyter_request(workspace_id, ws, "DELETE", name) __all__ = ["RuntimeClient", "RuntimeClientError"] diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index 2b17766..8584ebe 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -586,29 +586,46 @@ async def delete_workspace_directory( session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: directory_path = normalize_user_path(path, allow_empty=False) - relative_prefix = user_relative_path(context, directory_path) + # The original query JOINed StorageObjects to filter scripts by + # ``relative_path`` prefix. Jupyter-only scripts have no + # StorageObject row, and the Scripts schema does not track + # directory membership, so we cannot honour the directory filter + # for jupyter-created files. We therefore enumerate all active + # scripts the user owns in the workspace and forward each delete + # to the runtime. ``directory_path`` is still returned in the + # response for API compatibility. rows = ( await session.execute( - select(Scripts, StorageObjects) - .join( - StorageObjects, - StorageObjects.storage_object_id == Scripts.current_object_id, - ) + select(Scripts) .where( Scripts.workspace_id == context.workspace.workspace_id, Scripts.owner_user_id == context.user.user_id, Scripts.status == "active", - StorageObjects.relative_path.startswith( - f"{relative_prefix}/" - ), ) ) - ).all() - for script, _storage in rows: - await request.app.state.storage_client.delete_object( - script.current_object_id - ) + ).scalars().all() + runtime_client = request.app.state.runtime_client + workspace_id = context.workspace.workspace_id + for script in rows: + if not script.script_name: + continue + # Best-effort: try to delete from jupyter, but do not let one + # failure abort the rest. The jupyter call will 404 if the + # file is not actually in the workspace root (e.g. legacy + # scripts that were never mirrored to jupyter); we treat that + # as a no-op and still mark the row deleted. + try: + await runtime_client.delete_file( + workspace_id, name=script.script_name + ) + except RuntimeClientError as exc: + if exc.status_code != 404: + logger.warning( + f"delete_workspace_directory: jupyter delete " + f"failed for {script.script_id} ({script.script_name}): " + f"{exc.status_code} {exc.detail}" + ) script.status = "deleted" script.deleted_at = datetime.now(UTC).replace(tzinfo=None) return { @@ -677,48 +694,64 @@ async def update_script( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - script, storage_object = await get_script_row( - script_id, - context, - session, - for_update=True, + # Look up the Scripts row on its own: jupyter-only scripts do not + # have a StorageObjects row to JOIN against, and update is an + # in-place overwrite of the same jupyter path, so we do not need + # any object-store metadata. + script = await session.scalar( + select(Scripts) + .where( + Scripts.script_id == script_id, + Scripts.workspace_id == context.workspace.workspace_id, + Scripts.status == "active", + ) + .with_for_update() ) + if script is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "script not found", + ) require_script_modify_access( script, user_id=context.user.user_id, is_admin=context.is_admin, ) content = validate_script_content(payload.content, script.script_type) - if not storage_object.relative_path: + runtime_client = request.app.state.runtime_client + workspace_id = context.workspace.workspace_id + try: + if script.script_type == "notebook": + notebook = json.loads(content.decode("utf-8")) + jupyter_resp = await runtime_client.create_notebook( + workspace_id, + name=script.script_name, + cells=notebook.get("cells"), + ) + else: + jupyter_resp = await runtime_client.upload_file( + workspace_id, + name=script.script_name, + content=content.decode("utf-8"), + content_type=( + mimetypes.guess_type(script.script_name)[0] + or "text/plain" + ), + ) + except RuntimeClientError as exc: raise HTTPException( - status.HTTP_409_CONFLICT, - "script has no workspace path", - ) - old_object_id = script.current_object_id - storage_data = await request.app.state.storage_client.create_server_object( - workspace_id=context.workspace.workspace_id, - user_id=script.owner_user_id, - usage_type="working_copy", - file_name=storage_object.file_name or script.script_name, - content_type=storage_object.mime_type - or mimetypes.guess_type(script.script_name)[0] - or "application/octet-stream", - content=content, - visibility=script.visibility, - is_immutable=False, - idempotency_key=( - f"script-update:{script.script_id}:" - f"{hashlib.sha256(content).hexdigest()}" - ), - relative_path=storage_object.relative_path, - ) - script.current_object_id = storage_data["storage_object_id"] + status_code=exc.status_code, + detail=exc.detail, + ) from exc + script.updated_at = datetime.now(UTC).replace(tzinfo=None) - if old_object_id != script.current_object_id: - try: - await request.app.state.storage_client.delete_object(old_object_id) - except Exception: - pass + storage_data = { + "storage_object_id": script.current_object_id, + "relative_path": script.script_name, + "object_key": f"{workspace_id}/{script.script_name}", + "content_hash": hashlib.sha256(content).hexdigest(), + "size_bytes": len(content), + } return { "request_id": context.request_id, "data": script_payload(script, storage_data), @@ -733,20 +766,40 @@ async def delete_script( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - script, storage_object = await get_script_row( - script_id, - context, - session, - for_update=True, + # Scripts created via the jupyter path do not have a corresponding + # StorageObjects row, so we look up the Scripts row on its own and + # forward the delete to the workspace's live Jupyter instance. + script = await session.scalar( + select(Scripts) + .where( + Scripts.script_id == script_id, + Scripts.workspace_id == context.workspace.workspace_id, + Scripts.status == "active", + ) + .with_for_update() ) + if script is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "script not found", + ) require_script_modify_access( script, user_id=context.user.user_id, is_admin=context.is_admin, ) - await request.app.state.storage_client.delete_object( - script.current_object_id - ) + + runtime_client = request.app.state.runtime_client + try: + await runtime_client.delete_file( + context.workspace.workspace_id, name=script.script_name + ) + except RuntimeClientError as exc: + raise HTTPException( + status_code=exc.status_code, + detail=exc.detail, + ) from exc + script.status = "deleted" script.deleted_at = datetime.now(UTC).replace(tzinfo=None) return { diff --git a/frontend/Dockerfile b/frontend/Dockerfile index f724af8..36b9919 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -7,6 +7,10 @@ COPY frontend/ ./ RUN pnpm typecheck && pnpm build FROM nginx:alpine +ENV TZ=Asia/Shanghai +RUN apk add --no-cache tzdata \ + && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ + && echo $TZ > /etc/timezone COPY ./default.conf /etc/nginx/conf.d/default.conf.template COPY ./scripts/nginx-entrypoint.sh /usr/local/bin/model-platform-entrypoint.sh RUN sed -i 's/\r$//' /usr/local/bin/model-platform-entrypoint.sh \ diff --git a/runtime/Dockerfile b/runtime/Dockerfile index b2df1d7..77b8bdf 100644 --- a/runtime/Dockerfile +++ b/runtime/Dockerfile @@ -3,7 +3,8 @@ FROM python:3.12-slim-bookworm ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PYTHONPATH=/app \ - PATH="/app/.venv/bin:${PATH}" + PATH="/app/.venv/bin:${PATH}" \ + TZ=Asia/Shanghai WORKDIR /app # 从官方 Rclone 镜像直接复制 rclone 二进制文件(高效、稳定) COPY --from=rclone/rclone:latest /usr/local/bin/rclone /usr/local/bin/rclone @@ -28,6 +29,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ procps \ build-essential \ python3-dev \ + tzdata \ + && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ + && echo $TZ > /etc/timezone \ + && dpkg-reconfigure --frontend noninteractive tzdata \ && sed -i 's/#user_allow_other/user_allow_other/g' /etc/fuse.conf \ && rm -rf /var/lib/apt/lists/* diff --git a/schedule/Dockerfile b/schedule/Dockerfile index 0e109d6..f2523f3 100644 --- a/schedule/Dockerfile +++ b/schedule/Dockerfile @@ -3,7 +3,8 @@ FROM python:3.12-slim-bookworm ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ PYTHONPATH=/app \ - PATH="/app/.venv/bin:${PATH}" + PATH="/app/.venv/bin:${PATH}" \ + TZ=Asia/Shanghai WORKDIR /app RUN ( \ @@ -24,6 +25,10 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ g++ \ python3-dev \ build-essential \ + tzdata \ + && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime \ + && echo $TZ > /etc/timezone \ + && dpkg-reconfigure --frontend noninteractive tzdata \ && rm -rf /var/lib/apt/lists/* COPY --from=ghcr.io/astral-sh/uv:latest /uv /bin/uv