diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index 8584ebe..9d099dd 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -100,6 +100,18 @@ def safe_script_name(value: str, script_type: str) -> str: 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: encoded = content.encode("utf-8") if len(encoded) > 10 * 1024 * 1024: @@ -281,23 +293,25 @@ async def create_script_record( child_path = f"{folder}/{name}" if folder else name relative_path = user_relative_path(context, child_path) logger.debug(relative_path) - existing_script = await session.scalar( - select(Scripts) - .join( - StorageObjects, - StorageObjects.storage_object_id == Scripts.current_object_id, - ) - .where( + + # The Jupyter-side filename is owned by the database: a fresh ULID + # guarantees uniqueness, so the StorageObject-based 409 check from + # the legacy flow no longer applies. We still do a Scripts-only + # conflict check on the user-supplied name so two scripts cannot + # claim the same display name within the same workspace. + 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, - StorageObjects.relative_path == relative_path, + Scripts.script_name == name, Scripts.status == "active", - Scripts.is_deleted == 0, ) ) - if existing_script is not None: + if name_clash is not None: raise HTTPException( 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. @@ -314,16 +328,16 @@ async def create_script_record( logger.debug(notebook) jupyter_resp = await runtime_client.create_notebook( workspace_id, - name=name, + name=jupyter_name, cells=notebook.get("cells"), ) else: jupyter_resp = await runtime_client.upload_file( workspace_id, - name=name, + name=jupyter_name, content=content.decode("utf-8"), content_type=( - mimetypes.guess_type(name)[0] or "text/plain" + mimetypes.guess_type(jupyter_name)[0] or "text/plain" ), ) 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 # StorageObject row for this file; downstream list/get operations # that JOIN StorageObjects will skip jupyter-only scripts. + object_id = new_ulid() storage_data = { - "storage_object_id": new_ulid(), - "relative_path": relative_path, - "object_key": f"{workspace_id}/{relative_path}", + "storage_object_id": object_id, + "relative_path": jupyter_name, + "object_key": f"{workspace_id}/{jupyter_name}", "content_hash": content_hash, "size_bytes": size_bytes, } - object_id = storage_data["storage_object_id"] - script = await session.scalar( - select(Scripts).where(Scripts.current_object_id == object_id) + script = Scripts( + script_id=script_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) - 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 + session.add(script) await session.flush() await session.refresh(script) return script, storage_data @@ -608,16 +609,15 @@ async def delete_workspace_directory( 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. + # file is not actually in the workspace (e.g. legacy scripts + # whose on-disk filename we do not know); 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 + workspace_id, + name=_jupyter_path(script.script_type, script.script_id), ) except RuntimeClientError as exc: if exc.status_code != 404: @@ -720,21 +720,22 @@ async def update_script( content = validate_script_content(payload.content, script.script_type) runtime_client = request.app.state.runtime_client workspace_id = context.workspace.workspace_id + jupyter_name = _jupyter_path(script.script_type, script.script_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, + name=jupyter_name, cells=notebook.get("cells"), ) else: jupyter_resp = await runtime_client.upload_file( workspace_id, - name=script.script_name, + name=jupyter_name, content=content.decode("utf-8"), content_type=( - mimetypes.guess_type(script.script_name)[0] + mimetypes.guess_type(jupyter_name)[0] or "text/plain" ), ) @@ -747,8 +748,8 @@ async def update_script( script.updated_at = datetime.now(UTC).replace(tzinfo=None) storage_data = { "storage_object_id": script.current_object_id, - "relative_path": script.script_name, - "object_key": f"{workspace_id}/{script.script_name}", + "relative_path": jupyter_name, + "object_key": f"{workspace_id}/{jupyter_name}", "content_hash": hashlib.sha256(content).hexdigest(), "size_bytes": len(content), } @@ -792,7 +793,8 @@ async def delete_script( runtime_client = request.app.state.runtime_client try: 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: raise HTTPException(