update: create file

This commit is contained in:
tao.chen
2026-08-04 12:40:38 +08:00
parent a27cda5a0c
commit 4919fe0909
6 changed files with 226 additions and 89 deletions
+6 -1
View File
@@ -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
+96 -31
View File
@@ -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"]
+108 -55
View File
@@ -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 {
+4
View File
@@ -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 \
+6 -1
View File
@@ -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/*
+6 -1
View File
@@ -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