This commit is contained in:
tao.chen
2026-08-13 18:08:12 +08:00
parent 532f0d6c91
commit 19157ad20b
+67 -53
View File
@@ -118,31 +118,17 @@ 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.
def _jupyter_path(script_type: str, script_name: str) -> str:
"""Return the Jupyter basename for a newly-created script.
The path is ``{script_id}.{ext}`` — deterministic and
collision-free because ``script_id`` is a fresh ULID. Jupyter's
contents API treats the suffix after ``/api/contents/`` as a path
relative to its ``--notebook-dir`` (the workspace root), so a
``notebooks/`` prefix here would push the file into a non-existent
``notebooks/`` subdir on disk. 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.
The ``/notebooks/`` segment in the user's browser URL
(``/jupyter/<ws>/notebooks/<file>.ipynb``) is jupyter's URL route
for the editor view, not a filesystem path — jupyter routes that
URL to the file at the workspace root.
NOTE: This function returns the flat basename only (``{script_id}.{ext}``).
It must NOT be used directly as a Jupyter path for scripts that live
inside sub-directories. Callers must combine it with the parent
directory ULID (for newly-created files) or derive the real path from
``StorageObjects.object_key`` (for existing files).
Callers MUST pass the value returned by ``safe_script_name()`` so the
extension is correct and the name is POSIX-safe. The on-disk filename
is the user's name — there is no separate ULID segment.
"""
ext = ".ipynb" if script_type == "notebook" else ".py"
return f"{script_id}{ext}"
expected_suffix = ".py" if script_type == "python" else ".ipynb"
if not script_name.lower().endswith(expected_suffix):
script_name += expected_suffix
return script_name
def _derive_jupyter_path(
@@ -367,6 +353,7 @@ async def create_script_record(
) -> tuple[Scripts, dict[str, Any]]:
parent = normalize_user_path(parent_path) if parent_path else ""
scoped_prefix = user_relative_path(context)
parent_dir_row = None
if parent:
parent_dir_row = await session.scalar(
select(StorageObjects).where(
@@ -381,26 +368,32 @@ async def create_script_record(
status.HTTP_404_NOT_FOUND,
"parent directory not found",
)
parent_ulid = parent_dir_row.storage_object_id
else:
parent_ulid = None
display_parent = parent
child_path = f"{display_parent}/{name}" if display_parent else name
relative_path = user_relative_path(context, child_path)
logger.debug(relative_path)
# 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.
# The Jupyter-side filename is the sanitized script name; extension and
# path separators are enforced by safe_script_name(). The script_id ULID
# remains the Scripts PK only. 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.
user_id = context.user.user_id
script_id = new_ulid()
jupyter_basename = _jupyter_path(script_type, script_id)
# Jupyter path inside workspace root: {user_id}/{parent_or_self}/{basename}
if parent_ulid:
jupyter_path = f"{user_id}/{parent_ulid}/{jupyter_basename}"
script_id = new_ulid() # PK only — kept as ULID
jupyter_basename = _jupyter_path(script_type, name) # name is already passed through safe_script_name
# The Jupyter path uses the parent directory's user-relative path
# (parent_dir_row.relative_path looks like "workspace/{user_id}/foo/bar"),
# NOT the parent's ULID. Strip the "workspace/{user_id}/" prefix to get
# the Jupyter-relative parent segment.
parent_relative = parent_dir_row.relative_path if parent_dir_row is not None else None
workspace_user_prefix = f"workspace/{user_id}"
if parent_relative and parent_relative.startswith(workspace_user_prefix + "/"):
parent_segment = parent_relative[len(workspace_user_prefix) + 1:]
else:
parent_segment = ""
if parent_segment:
jupyter_path = f"{user_id}/{parent_segment}/{jupyter_basename}"
else:
jupyter_path = f"{user_id}/{jupyter_basename}"
name_clash = await session.scalar(
@@ -430,9 +423,9 @@ async def create_script_record(
# directory under it. ensure_directory is idempotent (GET-first pattern).
try:
await runtime_client.ensure_directory(workspace_id, user_id)
if parent_ulid:
if parent_segment:
await runtime_client.ensure_directory(
workspace_id, f"{user_id}/{parent_ulid}"
workspace_id, f"{user_id}/{parent_segment}"
)
except RuntimeClientError as exc:
raise HTTPException(
@@ -783,11 +776,12 @@ async def create_workspace_directory(
relative_path = user_relative_path(context, child_path)
scoped_prefix = user_relative_path(context)
path_hash = hashlib.sha256(relative_path.encode("utf-8")).digest()
parent_dir_row = None
if parent:
parent_relative = f"{scoped_prefix}/{parent}"
# Only an available directory row at the exact parent path counts as
# a parent. Capture its ULID so the new Jupyter directory can be nested
# under it on disk.
# a parent. Capture it so we can derive the new Jupyter path from its
# relative_path (which uses the user-supplied directory names).
parent_dir_row = await session.scalar(
select(StorageObjects).where(
StorageObjects.workspace_id == context.workspace.workspace_id,
@@ -801,9 +795,6 @@ async def create_workspace_directory(
status.HTTP_404_NOT_FOUND,
"parent directory not found",
)
parent_ulid = parent_dir_row.storage_object_id
else:
parent_ulid = None
# Conflict check uses the unique index on (workspace_id, storage_backend, path_hash).
# A soft-deleted row at the same path can be revived; an available row is a conflict.
existing = await session.scalar(
@@ -821,15 +812,22 @@ async def create_workspace_directory(
"a file or directory with the same path already exists",
)
# Generate the Jupyter-facing directory ULID up front so the DB id and
# the on-disk directory name match. Create the directory in Jupyter
# The Jupyter-facing directory name is the sanitized user-supplied name.
# dir_id is still generated up front because the DB PK and path_hash
# collision-revival reuse depend on it. Create the directory in Jupyter
# before persisting the DB row; if persistence fails we clean up.
dir_id = new_ulid()
dir_id = new_ulid() # PK only — not in the path anymore
user_id = context.user.user_id
if parent_ulid:
jupyter_path = f"{user_id}/{parent_ulid}/{dir_id}"
parent_relative = parent_dir_row.relative_path if parent_dir_row is not None else None
workspace_user_prefix = f"workspace/{user_id}"
if parent_relative and parent_relative.startswith(workspace_user_prefix + "/"):
parent_segment = parent_relative[len(workspace_user_prefix) + 1:]
else:
jupyter_path = f"{user_id}/{dir_id}"
parent_segment = ""
if parent_segment:
jupyter_path = f"{user_id}/{parent_segment}/{name}"
else:
jupyter_path = f"{user_id}/{name}"
runtime_client = request.app.state.runtime_client
workspace_id = context.workspace.workspace_id
@@ -839,9 +837,9 @@ async def create_workspace_directory(
# directory under it. ensure_directory is idempotent (GET-first pattern).
try:
await runtime_client.ensure_directory(workspace_id, user_id)
if parent_ulid:
if parent_segment:
await runtime_client.ensure_directory(
workspace_id, f"{user_id}/{parent_ulid}"
workspace_id, f"{user_id}/{parent_segment}"
)
except RuntimeClientError as exc:
raise HTTPException(
@@ -983,9 +981,18 @@ async def delete_workspace_directory(
descendant.is_deleted = 1
descendant.deleted_at = datetime.now(UTC).replace(tzinfo=None)
else:
# Sub-directory jupyter path: derive from relative_path (new rows carry
# the user-supplied name; legacy ULID-pathed rows fall back to the
# storage_object_id for backward compatibility).
descendant_relative = descendant.relative_path or ""
descendant_user_prefix = f"workspace/{descendant.owner_user_id}"
if descendant_relative.startswith(descendant_user_prefix + "/"):
desc_segment = descendant_relative[len(descendant_user_prefix) + 1:]
else:
desc_segment = descendant.storage_object_id
try:
await runtime_client.delete_directory(
workspace_id, name=f"{descendant.owner_user_id}/{descendant.storage_object_id}"
workspace_id, name=f"{descendant.owner_user_id}/{desc_segment}"
)
except RuntimeClientError as exc:
if exc.status_code != 404:
@@ -998,8 +1005,15 @@ async def delete_workspace_directory(
descendant.is_deleted = 1
descendant.deleted_at = datetime.now(UTC).replace(tzinfo=None)
# Target directory jupyter path: derive from the user-relative target
# path; fall back to the storage_object_id for legacy ULID-pathed rows.
target_user_prefix = f"workspace/{context.user.user_id}"
if target_relative.startswith(target_user_prefix + "/"):
target_segment = target_relative[len(target_user_prefix) + 1:]
else:
target_segment = target_ulid
try:
await runtime_client.delete_directory(workspace_id, name=f"{context.user.user_id}/{target_ulid}")
await runtime_client.delete_directory(workspace_id, name=f"{context.user.user_id}/{target_segment}")
except RuntimeClientError as exc:
if exc.status_code != 404:
logger.warning(