refactor: directory

This commit is contained in:
tao.chen
2026-08-12 12:23:19 +08:00
parent a378ea5352
commit 3981c32781
4 changed files with 265 additions and 64 deletions
+59
View File
@@ -288,5 +288,64 @@ class RuntimeClient:
ws = await self._ensure_workspace(workspace_id) ws = await self._ensure_workspace(workspace_id)
return await self._jupyter_request(workspace_id, ws, "GET", name) return await self._jupyter_request(workspace_id, ws, "GET", name)
async def create_directory(
self,
workspace_id: str,
*,
name: str,
) -> dict[str, Any]:
"""Create a directory in the workspace Jupyter.
Uses ``PUT /api/contents/{name}`` with body ``{"type": "directory"}``.
The directory's on-disk name in Jupyter is ``name``; callers that
want the display basename preserved separately should pass a ULID
(or other stable identifier) as ``name`` and keep the human-readable
basename in the database.
"""
ws = await self._ensure_workspace(workspace_id)
return await self._jupyter_request(
workspace_id, ws, "PUT", name, body={"type": "directory"}
)
async def delete_directory(
self,
workspace_id: str,
*,
name: str,
) -> None:
"""Delete a directory 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
returns ``409 Conflict`` when the directory is not empty; the
caller is responsible for emptying the directory first.
"""
ws = await self._ensure_workspace(workspace_id)
await self._jupyter_request(workspace_id, ws, "DELETE", name)
async def ensure_directory(
self,
workspace_id: str,
name: str,
) -> None:
"""Ensure a directory exists in the workspace Jupyter.
Lazy-backfill primitive used during create flows: performs a
``GET /api/contents/{name}`` first, returning immediately if the
directory already exists. On ``404 Not Found`` it falls through
to :meth:`create_directory`. Any other error is propagated as a
:class:`RuntimeClientError`. The GET-first pattern avoids the
ambiguity of calling ``PUT /api/contents/{name}`` on a path that
may already exist.
"""
ws = await self._ensure_workspace(workspace_id)
try:
await self._jupyter_request(workspace_id, ws, "GET", name)
except RuntimeClientError as exc:
if exc.status_code == 404:
await self.create_directory(workspace_id, name=name)
return
raise
__all__ = ["RuntimeClient", "RuntimeClientError"] __all__ = ["RuntimeClient", "RuntimeClientError"]
+181 -48
View File
@@ -303,12 +303,28 @@ async def create_script_record(
context: RequestContext, context: RequestContext,
session: AsyncSession, session: AsyncSession,
) -> tuple[Scripts, dict[str, Any]]: ) -> tuple[Scripts, dict[str, Any]]:
folder = ( parent = normalize_user_path(parent_path) if parent_path else ""
("scripts" if script_type == "python" else "notebooks") scoped_prefix = user_relative_path(context)
if parent_path is None if parent:
else normalize_user_path(parent_path) parent_dir_row = await session.scalar(
select(StorageObjects).where(
StorageObjects.workspace_id == context.workspace.workspace_id,
StorageObjects.object_status == "available",
StorageObjects.object_type == "directory",
StorageObjects.relative_path == f"{scoped_prefix}/{parent}",
) )
child_path = f"{folder}/{name}" if folder else name )
if parent_dir_row is None:
raise HTTPException(
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) relative_path = user_relative_path(context, child_path)
logger.debug(relative_path) logger.debug(relative_path)
@@ -319,6 +335,7 @@ async def create_script_record(
# claim the same display name within the same workspace. # claim the same display name within the same workspace.
script_id = new_ulid() script_id = new_ulid()
jupyter_name = _jupyter_path(script_type, script_id) jupyter_name = _jupyter_path(script_type, script_id)
nested_jupyter_name = f"{parent_ulid}/{jupyter_name}" if parent_ulid else jupyter_name
name_clash = await session.scalar( name_clash = await session.scalar(
select(Scripts.script_id).where( select(Scripts.script_id).where(
Scripts.workspace_id == context.workspace.workspace_id, Scripts.workspace_id == context.workspace.workspace_id,
@@ -340,19 +357,28 @@ async def create_script_record(
workspace_id = context.workspace.workspace_id workspace_id = context.workspace.workspace_id
content_hash = hashlib.sha256(content).hexdigest() content_hash = hashlib.sha256(content).hexdigest()
size_bytes = len(content) size_bytes = len(content)
if parent_ulid:
try:
await runtime_client.ensure_directory(workspace_id, parent_ulid)
except RuntimeClientError as exc:
raise HTTPException(
status_code=exc.status_code,
detail=exc.detail,
) from exc
try: try:
if script_type == "notebook": if script_type == "notebook":
notebook = json.loads(content.decode("utf-8")) notebook = json.loads(content.decode("utf-8"))
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=jupyter_name, name=nested_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=jupyter_name, name=nested_jupyter_name,
content=content.decode("utf-8"), content=content.decode("utf-8"),
content_type=(mimetypes.guess_type(jupyter_name)[0] or "text/plain"), content_type=(mimetypes.guess_type(jupyter_name)[0] or "text/plain"),
) )
@@ -370,9 +396,11 @@ async def create_script_record(
# file is queryable as a workspace file from the user's POV; the # file is queryable as a workspace file from the user's POV; the
# storage_uri points at where the replicated bytes will land. # storage_uri points at where the replicated bytes will land.
object_id = new_ulid() object_id = new_ulid()
object_key = f"{workspace_id}/{jupyter_name}" object_key = f"{workspace_id}/{nested_jupyter_name}"
bucket_name = settings.s3_workspace_bucket bucket_name = settings.s3_workspace_bucket
relative_path = user_relative_path(context, jupyter_name) relative_path = user_relative_path(
context, f"{parent}/{jupyter_name}" if parent else jupyter_name
)
mime_type = mimetypes.guess_type(jupyter_name)[0] mime_type = mimetypes.guess_type(jupyter_name)[0]
storage_object = StorageObjects( storage_object = StorageObjects(
storage_object_id=object_id, storage_object_id=object_id,
@@ -407,11 +435,26 @@ async def create_script_record(
visibility=visibility, visibility=visibility,
status="active", status="active",
) )
try:
session.add(storage_object) session.add(storage_object)
session.add(script) session.add(script)
await session.flush() await session.flush()
await session.refresh(storage_object) await session.refresh(storage_object)
await session.refresh(script) await session.refresh(script)
except Exception:
# Best-effort compensating cleanup: remove the Jupyter file we
# just created so a failed flush does not leave an orphan on disk.
try:
await runtime_client.delete_file(workspace_id, name=nested_jupyter_name)
except RuntimeClientError as cleanup_exc:
if cleanup_exc.status_code == 404:
pass
else:
logger.warning(
f"failed to clean up Jupyter file {nested_jupyter_name} "
f"after DB flush error: {cleanup_exc.status_code} {cleanup_exc.detail}"
)
raise
return script, storage_object return script, storage_object
@@ -582,6 +625,7 @@ async def get_workspace_tree(
) )
async def create_workspace_directory( async def create_workspace_directory(
payload: CreateWorkspaceDirectoryRequest, payload: CreateWorkspaceDirectoryRequest,
request: Request,
context: RequestContext = Depends(request_context), context: RequestContext = Depends(request_context),
session: AsyncSession = Depends(database_session), session: AsyncSession = Depends(database_session),
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -591,24 +635,27 @@ async def create_workspace_directory(
relative_path = user_relative_path(context, child_path) relative_path = user_relative_path(context, child_path)
scoped_prefix = user_relative_path(context) scoped_prefix = user_relative_path(context)
path_hash = hashlib.sha256(relative_path.encode("utf-8")).digest() path_hash = hashlib.sha256(relative_path.encode("utf-8")).digest()
# Validate parent exists: there must be at least one StorageObject whose
# relative_path is exactly the parent directory (the directory row itself)
# OR lives somewhere below the parent (any file/dir nested under it).
if parent: if parent:
parent_relative = f"{scoped_prefix}/{parent}" parent_relative = f"{scoped_prefix}/{parent}"
existing_parent = await session.scalar( # Only an available directory row at the exact parent path counts as
select(StorageObjects.storage_object_id).where( # a parent. Capture its ULID so the new Jupyter directory can be nested
# under it on disk.
parent_dir_row = await session.scalar(
select(StorageObjects).where(
StorageObjects.workspace_id == context.workspace.workspace_id, StorageObjects.workspace_id == context.workspace.workspace_id,
StorageObjects.object_status == "available", StorageObjects.object_status == "available",
(StorageObjects.relative_path == parent_relative) StorageObjects.object_type == "directory",
| StorageObjects.relative_path.like(f"{parent_relative}/%"), StorageObjects.relative_path == parent_relative,
) )
) )
if existing_parent is None: if parent_dir_row is None:
raise HTTPException( raise HTTPException(
status.HTTP_404_NOT_FOUND, status.HTTP_404_NOT_FOUND,
"parent directory 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). # 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. # A soft-deleted row at the same path can be revived; an available row is a conflict.
existing = await session.scalar( existing = await session.scalar(
@@ -626,11 +673,36 @@ async def create_workspace_directory(
"a file or directory with the same path already exists", "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
# before persisting the DB row; if persistence fails we clean up.
dir_id = new_ulid()
jupyter_path = f"{parent_ulid}/{dir_id}" if parent_ulid else dir_id
runtime_client = request.app.state.runtime_client
workspace_id = context.workspace.workspace_id
if parent_ulid:
try:
await runtime_client.ensure_directory(workspace_id, parent_ulid)
except RuntimeClientError as exc:
raise HTTPException(
status_code=exc.status_code,
detail=exc.detail,
) from exc
try:
await runtime_client.create_directory(workspace_id, name=jupyter_path)
except RuntimeClientError as exc:
raise HTTPException(
status_code=exc.status_code,
detail=exc.detail,
) from exc
if existing is None: if existing is None:
directory = StorageObjects( directory = StorageObjects(
storage_object_id=new_ulid(), storage_object_id=dir_id,
workspace_id=context.workspace.workspace_id, workspace_id=context.workspace.workspace_id,
storage_backend="rustfs", storage_backend=settings.storage_backend,
object_type="directory", object_type="directory",
usage_type="working_copy", usage_type="working_copy",
storage_uri=f"inline://directory/{relative_path}", storage_uri=f"inline://directory/{relative_path}",
@@ -644,7 +716,6 @@ async def create_workspace_directory(
) )
session.add(directory) session.add(directory)
else: else:
# Revive the soft-deleted row, keeping its original storage_object_id.
directory = existing directory = existing
directory.object_status = "available" directory.object_status = "available"
directory.is_deleted = 0 directory.is_deleted = 0
@@ -657,7 +728,22 @@ async def create_workspace_directory(
directory.visibility = "private" directory.visibility = "private"
directory.created_by = context.user.user_id directory.created_by = context.user.user_id
try:
await session.flush() await session.flush()
except Exception:
# Best-effort compensating cleanup: remove the Jupyter directory we
# just created so a failed flush does not leave an orphan on disk.
try:
await runtime_client.delete_directory(workspace_id, name=jupyter_path)
except RuntimeClientError as cleanup_exc:
if cleanup_exc.status_code == 404:
pass
else:
logger.warning(
f"failed to clean up Jupyter directory {jupyter_path} "
f"after DB flush error: {cleanup_exc.status_code} {cleanup_exc.detail}"
)
raise
return { return {
"request_id": context.request_id, "request_id": context.request_id,
"data": { "data": {
@@ -679,55 +765,102 @@ async def delete_workspace_directory(
) -> dict[str, Any]: ) -> dict[str, Any]:
directory_path = normalize_user_path(path, allow_empty=False) directory_path = normalize_user_path(path, allow_empty=False)
# The original query JOINed StorageObjects to filter scripts by scoped_prefix = user_relative_path(context)
# ``relative_path`` prefix. Jupyter-only scripts have no target_relative = f"{scoped_prefix}/{directory_path}"
# StorageObject row, and the Scripts schema does not track target_dir_row = await session.scalar(
# directory membership, so we cannot honour the directory filter select(StorageObjects).where(
# for jupyter-created files. We therefore enumerate all active StorageObjects.workspace_id == context.workspace.workspace_id,
# scripts the user owns in the workspace and forward each delete StorageObjects.object_type == "directory",
# to the runtime. ``directory_path`` is still returned in the StorageObjects.relative_path == target_relative,
# response for API compatibility. StorageObjects.object_status == "available",
rows = ( )
)
if target_dir_row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "directory not found")
target_ulid = target_dir_row.storage_object_id
child_prefix = f"{target_relative}/"
descendants = (
( (
await session.execute( await session.execute(
select(Scripts).where( select(StorageObjects).where(
Scripts.workspace_id == context.workspace.workspace_id, StorageObjects.workspace_id == context.workspace.workspace_id,
Scripts.owner_user_id == context.user.user_id, StorageObjects.object_status == "available",
Scripts.status == "active", StorageObjects.relative_path.like(f"{child_prefix}%"),
) ).order_by(func.length(StorageObjects.relative_path).desc())
) )
) )
.scalars() .scalars()
.all() .all()
) )
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: deleted_scripts = 0
# Best-effort: try to delete from jupyter, but do not let one
# failure abort the rest. The jupyter call will 404 if the for descendant in descendants:
# file is not actually in the workspace (e.g. legacy scripts if descendant.object_type == "file":
# whose on-disk filename we do not know); we treat that as a jupyter_path = descendant.object_key.removeprefix(f"{workspace_id}/")
# no-op and still mark the row deleted.
try: try:
await runtime_client.delete_file( await runtime_client.delete_file(workspace_id, name=jupyter_path)
workspace_id, except RuntimeClientError as exc:
name=_jupyter_path(script.script_type, script.script_id), if exc.status_code != 404:
logger.warning(
f"delete_workspace_directory: jupyter delete failed "
f"for file {descendant.storage_object_id}: "
f"{exc.status_code} {exc.detail}"
)
script = await session.scalar(
select(Scripts).where(
Scripts.workspace_id == context.workspace.workspace_id,
Scripts.current_object_id == descendant.storage_object_id,
)
)
if script is not None:
script.status = "deleted"
script.deleted_at = datetime.now(UTC).replace(tzinfo=None)
deleted_scripts += 1
descendant.object_status = "deleted"
descendant.is_deleted = 1
descendant.deleted_at = datetime.now(UTC).replace(tzinfo=None)
else:
try:
await runtime_client.delete_directory(
workspace_id, name=descendant.storage_object_id
) )
except RuntimeClientError as exc: except RuntimeClientError as exc:
if exc.status_code != 404: if exc.status_code != 404:
logger.warning( logger.warning(
f"delete_workspace_directory: jupyter delete " f"delete_workspace_directory: jupyter delete failed "
f"failed for {script.script_id} ({script.script_name}): " f"for directory {descendant.storage_object_id}: "
f"{exc.status_code} {exc.detail}" f"{exc.status_code} {exc.detail}"
) )
script.status = "deleted" descendant.object_status = "deleted"
script.deleted_at = datetime.now(UTC).replace(tzinfo=None) descendant.is_deleted = 1
descendant.deleted_at = datetime.now(UTC).replace(tzinfo=None)
try:
await runtime_client.delete_directory(workspace_id, name=target_ulid)
except RuntimeClientError as exc:
if exc.status_code != 404:
logger.warning(
f"delete_workspace_directory: jupyter delete failed "
f"for target directory {target_ulid}: "
f"{exc.status_code} {exc.detail}"
)
target_dir_row.object_status = "deleted"
target_dir_row.is_deleted = 1
target_dir_row.deleted_at = datetime.now(UTC).replace(tzinfo=None)
await session.flush()
return { return {
"request_id": context.request_id, "request_id": context.request_id,
"data": { "data": {
"path": directory_path, "path": directory_path,
"status": "deleted", "status": "deleted",
"deleted_scripts": len(rows), "deleted_scripts": deleted_scripts,
"versions_preserved": True, "versions_preserved": True,
}, },
"meta": {}, "meta": {},
@@ -121,10 +121,14 @@ export function ScriptExplorer({
role_code: null, role_code: null,
is_system_admin: false, is_system_admin: false,
} as AuthUser); } as AuthUser);
const inferred = inferredDirectories(groupScripts);
groups.push({ groups.push({
user: groupUser, user: groupUser,
scripts: groupScripts, scripts: groupScripts,
directories: inferredDirectories(groupScripts), directories:
ownerUserId === user?.user_id
? mergeDirectories(directories, inferred)
: inferred,
}); });
} }
@@ -189,6 +189,11 @@ export default function ScriptsPage() {
void submitPublish(publish.releaseNote, publish.visibility); void submitPublish(publish.releaseNote, publish.visibility);
}; };
const triggerUpload = (parentPath?: string) => {
chooseUpload(parentPath);
uploadInputRef.current?.click();
};
return ( return (
<section className="workspace-layout"> <section className="workspace-layout">
<ScriptExplorer <ScriptExplorer
@@ -203,11 +208,11 @@ export default function ScriptsPage() {
keyword={keyword} keyword={keyword}
onKeywordChange={setKeyword} onKeywordChange={setKeyword}
onRefresh={() => void load(true)} onRefresh={() => void load(true)}
onUpload={() => chooseUpload("")} onUpload={() => triggerUpload("")}
onOpenCreateDialog={(parentPath, scriptType) => onOpenCreateDialog={(parentPath, scriptType) =>
openCreateDialog(parentPath, scriptType)} openCreateDialog(parentPath, scriptType)}
onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)} onOpenFolderDialog={(parentPath) => openFolderDialog(parentPath)}
onChooseUpload={(parentPath) => chooseUpload(parentPath)} onChooseUpload={(parentPath) => triggerUpload(parentPath)}
onContextMenu={showContextMenu} onContextMenu={showContextMenu}
onSelect={openTab} onSelect={openTab}
uploadInputRef={uploadInputRef} uploadInputRef={uploadInputRef}