fix: path error

This commit is contained in:
tao.chen
2026-08-04 12:57:40 +08:00
parent 4919fe0909
commit 2a23030d6f
+55 -53
View File
@@ -100,6 +100,18 @@ def safe_script_name(value: str, script_type: str) -> str:
return name return name
def _jupyter_path(script_type: str, script_id: str) -> str:
"""Return the in-Jupyter path used for a script.
The path is ``notebooks/{script_id}.{ext}`` — deterministic and
collision-free because ``script_id`` is a fresh ULID. The user's
``script_name`` is kept on the Scripts row as a display label only;
the on-disk filename is owned by the database.
"""
ext = ".ipynb" if script_type == "notebook" else ".py"
return f"notebooks/{script_id}{ext}"
def validate_script_content(content: str, script_type: str) -> bytes: def validate_script_content(content: str, script_type: str) -> bytes:
encoded = content.encode("utf-8") encoded = content.encode("utf-8")
if len(encoded) > 10 * 1024 * 1024: if len(encoded) > 10 * 1024 * 1024:
@@ -281,23 +293,25 @@ async def create_script_record(
child_path = f"{folder}/{name}" if folder else name child_path = f"{folder}/{name}" if folder else name
relative_path = user_relative_path(context, child_path) relative_path = user_relative_path(context, child_path)
logger.debug(relative_path) logger.debug(relative_path)
existing_script = await session.scalar(
select(Scripts) # The Jupyter-side filename is owned by the database: a fresh ULID
.join( # guarantees uniqueness, so the StorageObject-based 409 check from
StorageObjects, # the legacy flow no longer applies. We still do a Scripts-only
StorageObjects.storage_object_id == Scripts.current_object_id, # conflict check on the user-supplied name so two scripts cannot
) # claim the same display name within the same workspace.
.where( script_id = new_ulid()
jupyter_name = _jupyter_path(script_type, script_id)
name_clash = await session.scalar(
select(Scripts.script_id).where(
Scripts.workspace_id == context.workspace.workspace_id, Scripts.workspace_id == context.workspace.workspace_id,
StorageObjects.relative_path == relative_path, Scripts.script_name == name,
Scripts.status == "active", Scripts.status == "active",
Scripts.is_deleted == 0,
) )
) )
if existing_script is not None: if name_clash is not None:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
"a file with the same path already exists", "a script with the same name already exists in this workspace",
) )
# Push the file directly to the workspace's live Jupyter instance. # Push the file directly to the workspace's live Jupyter instance.
@@ -314,16 +328,16 @@ async def create_script_record(
logger.debug(notebook) logger.debug(notebook)
jupyter_resp = await runtime_client.create_notebook( jupyter_resp = await runtime_client.create_notebook(
workspace_id, workspace_id,
name=name, name=jupyter_name,
cells=notebook.get("cells"), cells=notebook.get("cells"),
) )
else: else:
jupyter_resp = await runtime_client.upload_file( jupyter_resp = await runtime_client.upload_file(
workspace_id, workspace_id,
name=name, name=jupyter_name,
content=content.decode("utf-8"), content=content.decode("utf-8"),
content_type=( content_type=(
mimetypes.guess_type(name)[0] or "text/plain" mimetypes.guess_type(jupyter_name)[0] or "text/plain"
), ),
) )
logger.debug(jupyter_resp) logger.debug(jupyter_resp)
@@ -338,38 +352,25 @@ async def create_script_record(
# The storage_object_id is a fresh ULID — there is no real # The storage_object_id is a fresh ULID — there is no real
# StorageObject row for this file; downstream list/get operations # StorageObject row for this file; downstream list/get operations
# that JOIN StorageObjects will skip jupyter-only scripts. # that JOIN StorageObjects will skip jupyter-only scripts.
object_id = new_ulid()
storage_data = { storage_data = {
"storage_object_id": new_ulid(), "storage_object_id": object_id,
"relative_path": relative_path, "relative_path": jupyter_name,
"object_key": f"{workspace_id}/{relative_path}", "object_key": f"{workspace_id}/{jupyter_name}",
"content_hash": content_hash, "content_hash": content_hash,
"size_bytes": size_bytes, "size_bytes": size_bytes,
} }
object_id = storage_data["storage_object_id"] script = Scripts(
script = await session.scalar( script_id=script_id,
select(Scripts).where(Scripts.current_object_id == object_id) workspace_id=context.workspace.workspace_id,
current_object_id=object_id,
owner_user_id=context.user.user_id,
script_name=name,
script_type=script_type,
visibility=visibility,
status="active",
) )
now = datetime.now(UTC).replace(tzinfo=None) session.add(script)
if script is None:
script = Scripts(
script_id=new_ulid(),
workspace_id=context.workspace.workspace_id,
current_object_id=object_id,
owner_user_id=context.user.user_id,
script_name=name,
script_type=script_type,
visibility=visibility,
status="active",
)
session.add(script)
else:
script.owner_user_id = context.user.user_id
script.script_name = name
script.script_type = script_type
script.visibility = visibility
script.status = "active"
script.deleted_at = None
script.updated_at = now
await session.flush() await session.flush()
await session.refresh(script) await session.refresh(script)
return script, storage_data return script, storage_data
@@ -608,16 +609,15 @@ async def delete_workspace_directory(
runtime_client = request.app.state.runtime_client runtime_client = request.app.state.runtime_client
workspace_id = context.workspace.workspace_id workspace_id = context.workspace.workspace_id
for script in rows: for script in rows:
if not script.script_name:
continue
# Best-effort: try to delete from jupyter, but do not let one # Best-effort: try to delete from jupyter, but do not let one
# failure abort the rest. The jupyter call will 404 if the # failure abort the rest. The jupyter call will 404 if the
# file is not actually in the workspace root (e.g. legacy # file is not actually in the workspace (e.g. legacy scripts
# scripts that were never mirrored to jupyter); we treat that # whose on-disk filename we do not know); we treat that as a
# as a no-op and still mark the row deleted. # no-op and still mark the row deleted.
try: try:
await runtime_client.delete_file( await runtime_client.delete_file(
workspace_id, name=script.script_name workspace_id,
name=_jupyter_path(script.script_type, script.script_id),
) )
except RuntimeClientError as exc: except RuntimeClientError as exc:
if exc.status_code != 404: if exc.status_code != 404:
@@ -720,21 +720,22 @@ async def update_script(
content = validate_script_content(payload.content, script.script_type) content = validate_script_content(payload.content, script.script_type)
runtime_client = request.app.state.runtime_client runtime_client = request.app.state.runtime_client
workspace_id = context.workspace.workspace_id workspace_id = context.workspace.workspace_id
jupyter_name = _jupyter_path(script.script_type, script.script_id)
try: try:
if script.script_type == "notebook": if script.script_type == "notebook":
notebook = json.loads(content.decode("utf-8")) notebook = json.loads(content.decode("utf-8"))
jupyter_resp = await runtime_client.create_notebook( jupyter_resp = await runtime_client.create_notebook(
workspace_id, workspace_id,
name=script.script_name, name=jupyter_name,
cells=notebook.get("cells"), cells=notebook.get("cells"),
) )
else: else:
jupyter_resp = await runtime_client.upload_file( jupyter_resp = await runtime_client.upload_file(
workspace_id, workspace_id,
name=script.script_name, name=jupyter_name,
content=content.decode("utf-8"), content=content.decode("utf-8"),
content_type=( content_type=(
mimetypes.guess_type(script.script_name)[0] mimetypes.guess_type(jupyter_name)[0]
or "text/plain" or "text/plain"
), ),
) )
@@ -747,8 +748,8 @@ async def update_script(
script.updated_at = datetime.now(UTC).replace(tzinfo=None) script.updated_at = datetime.now(UTC).replace(tzinfo=None)
storage_data = { storage_data = {
"storage_object_id": script.current_object_id, "storage_object_id": script.current_object_id,
"relative_path": script.script_name, "relative_path": jupyter_name,
"object_key": f"{workspace_id}/{script.script_name}", "object_key": f"{workspace_id}/{jupyter_name}",
"content_hash": hashlib.sha256(content).hexdigest(), "content_hash": hashlib.sha256(content).hexdigest(),
"size_bytes": len(content), "size_bytes": len(content),
} }
@@ -792,7 +793,8 @@ async def delete_script(
runtime_client = request.app.state.runtime_client runtime_client = request.app.state.runtime_client
try: try:
await runtime_client.delete_file( await runtime_client.delete_file(
context.workspace.workspace_id, name=script.script_name context.workspace.workspace_id,
name=_jupyter_path(script.script_type, script.script_id),
) )
except RuntimeClientError as exc: except RuntimeClientError as exc:
raise HTTPException( raise HTTPException(