update: remove workspace operation table and refactor

This commit is contained in:
tao.chen
2026-08-05 14:43:48 +08:00
parent c3b078a827
commit 07d2423c13
9 changed files with 87 additions and 1013 deletions
+85 -91
View File
@@ -1,8 +1,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio import hashlib
import mimetypes
import secrets
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import Any, AsyncIterator from typing import Any, AsyncIterator
@@ -18,14 +16,21 @@ from common.db.models import (
UploadSessions, UploadSessions,
Users, Users,
WorkspaceMembers, WorkspaceMembers,
Workspaces) Workspaces,
)
from common.ids import new_ulid from common.ids import new_ulid
from common.service_app import create_service_app from common.service_app import create_service_app
from common.storage import AsyncStorageBackend, PURPOSE_BUCKETS, build_storage_config, create_storage from common.storage import (
AsyncStorageBackend,
PURPOSE_BUCKETS,
build_storage_config,
create_storage,
)
from common.storage.schemas import ( from common.storage.schemas import (
CreateUploadRequest, CreateUploadRequest,
DownloadUrlRequest, DownloadUrlRequest,
ServerObjectRequest) ServerObjectRequest,
)
from backend.services.storage import ( from backend.services.storage import (
create_download_url_payload, create_download_url_payload,
create_server_object_payload, create_server_object_payload,
@@ -43,10 +48,7 @@ def hash_bytes(value: str) -> bytes:
return hashlib.sha256(value.encode("utf-8")).digest() return hashlib.sha256(value.encode("utf-8")).digest()
def normalized_idempotency_key( def normalized_idempotency_key(workspace_id: str, user_id: str, value: str) -> str:
workspace_id: str,
user_id: str,
value: str) -> str:
digest = hashlib.sha256( digest = hashlib.sha256(
f"{workspace_id}:{user_id}:{value}".encode("utf-8") f"{workspace_id}:{user_id}:{value}".encode("utf-8")
).hexdigest() ).hexdigest()
@@ -133,9 +135,7 @@ async def lifespan(app: Any) -> AsyncIterator[None]:
await engine.dispose() await engine.dispose()
app = create_service_app( app = create_service_app(settings.service_name, lifespan=lifespan)
settings.service_name,
lifespan=lifespan)
async def database_session(request: Request) -> AsyncIterator[AsyncSession]: async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
@@ -144,46 +144,41 @@ async def database_session(request: Request) -> AsyncIterator[AsyncSession]:
async def require_workspace_member( async def require_workspace_member(
session: AsyncSession, session: AsyncSession, workspace_id: str, user_id: str
workspace_id: str, ) -> Workspaces:
user_id: str) -> Workspaces:
statement = ( statement = (
select(Workspaces) select(Workspaces)
.join( .join(
WorkspaceMembers, WorkspaceMembers, WorkspaceMembers.workspace_id == Workspaces.workspace_id
WorkspaceMembers.workspace_id == Workspaces.workspace_id) )
.join(Users, Users.user_id == WorkspaceMembers.user_id) .join(Users, Users.user_id == WorkspaceMembers.user_id)
.where( .where(
Workspaces.workspace_id == workspace_id, Workspaces.workspace_id == workspace_id,
Workspaces.status == "active", Workspaces.status == "active",
WorkspaceMembers.user_id == user_id, WorkspaceMembers.user_id == user_id,
WorkspaceMembers.member_status == "active", WorkspaceMembers.member_status == "active",
Users.status == "active") Users.status == "active",
)
) )
workspace = await session.scalar(statement) workspace = await session.scalar(statement)
if workspace is None: if workspace is None:
raise HTTPException( raise HTTPException(
status.HTTP_403_FORBIDDEN, status.HTTP_403_FORBIDDEN, "user is not an active workspace member"
"user is not an active workspace member") )
return workspace return workspace
async def create_upload_record( async def create_upload_record(
payload: CreateUploadRequest, payload: CreateUploadRequest, session: AsyncSession, request: Request
session: AsyncSession, ) -> dict[str, Any]:
request: Request) -> dict[str, Any]:
workspace = await require_workspace_member( workspace = await require_workspace_member(
session, session, payload.workspace_id, payload.user_id
payload.workspace_id, )
payload.user_id)
stored_key = normalized_idempotency_key( stored_key = normalized_idempotency_key(
payload.workspace_id, payload.workspace_id, payload.user_id, payload.idempotency_key
payload.user_id, )
payload.idempotency_key)
existing = await session.scalar( existing = await session.scalar(
select(UploadSessions).where( select(UploadSessions).where(UploadSessions.idempotency_key == stored_key)
UploadSessions.idempotency_key == stored_key
)
) )
if existing is not None: if existing is not None:
if ( if (
@@ -195,7 +190,8 @@ async def create_upload_record(
): ):
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
"idempotency key was used with different upload metadata") "idempotency key was used with different upload metadata",
)
upload = existing upload = existing
else: else:
upload_id = new_ulid() upload_id = new_ulid()
@@ -203,9 +199,7 @@ async def create_upload_record(
# Keep the opaque upload id while preserving the original extension. # Keep the opaque upload id while preserving the original extension.
# Jupyter selects its editor from this suffix, so an extensionless # Jupyter selects its editor from this suffix, so an extensionless
# object would make notebooks look like generic JSON/text files. # object would make notebooks look like generic JSON/text files.
file_extension = PurePosixPath( file_extension = PurePosixPath(safe_file_name(payload.file_name)).suffix.lower()
safe_file_name(payload.file_name)
).suffix.lower()
object_key = f"{payload.workspace_id}/{upload_id}{file_extension}" object_key = f"{payload.workspace_id}/{upload_id}{file_extension}"
upload = UploadSessions( upload = UploadSessions(
upload_id=upload_id, upload_id=upload_id,
@@ -223,14 +217,13 @@ async def create_upload_record(
file_name=payload.file_name, file_name=payload.file_name,
usage_type=payload.usage_type, usage_type=payload.usage_type,
visibility=payload.visibility, visibility=payload.visibility,
is_immutable=int(payload.is_immutable)) is_immutable=int(payload.is_immutable),
)
session.add(upload) session.add(upload)
await session.flush() await session.flush()
if upload.upload_status == "completed" and upload.storage_object_id: if upload.upload_status == "completed" and upload.storage_object_id:
storage_object = await session.get( storage_object = await session.get(StorageObjects, upload.storage_object_id)
StorageObjects,
upload.storage_object_id)
if storage_object is None or storage_object.object_status != "available": if storage_object is None or storage_object.object_status != "available":
# The previously-completed object was deleted (or never # The previously-completed object was deleted (or never
# materialized). Treat the idempotency hit as a tombstone # materialized). Treat the idempotency hit as a tombstone
@@ -247,7 +240,8 @@ async def create_upload_record(
if upload.upload_status not in {"created", "uploading"}: if upload.upload_status not in {"created", "uploading"}:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
f"upload cannot continue from status {upload.upload_status}") f"upload cannot continue from status {upload.upload_status}",
)
# Two-step server-proxied upload: the caller PUTs the raw bytes to # Two-step server-proxied upload: the caller PUTs the raw bytes to
# ``upload_path`` after this response, which routes through # ``upload_path`` after this response, which routes through
@@ -283,9 +277,8 @@ def _public_base_url(request: Request) -> str:
async def upload_bytes_to_session( async def upload_bytes_to_session(
upload_id: str, upload_id: str, session: AsyncSession, request: Request
session: AsyncSession, ) -> StorageObjects:
request: Request) -> StorageObjects:
"""Server-proxied upload: read raw bytes from the request body, validate """Server-proxied upload: read raw bytes from the request body, validate
against the ``UploadSessions`` expectations, call ``backend.put``, and against the ``UploadSessions`` expectations, call ``backend.put``, and
create the ``StorageObjects`` row. create the ``StorageObjects`` row.
@@ -310,7 +303,8 @@ async def upload_bytes_to_session(
if upload.upload_status not in {"created", "uploading"}: if upload.upload_status not in {"created", "uploading"}:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
f"upload cannot continue from status {upload.upload_status}") f"upload cannot continue from status {upload.upload_status}",
)
if upload.expires_at < utcnow(): if upload.expires_at < utcnow():
upload.upload_status = "expired" upload.upload_status = "expired"
raise HTTPException(status.HTTP_409_CONFLICT, "upload expired") raise HTTPException(status.HTTP_409_CONFLICT, "upload expired")
@@ -325,14 +319,15 @@ async def upload_bytes_to_session(
upload.upload_status = "failed" upload.upload_status = "failed"
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
"uploaded bytes size does not match expected_size_bytes") "uploaded bytes size does not match expected_size_bytes",
)
actual_hash = hashlib.sha256(content).hexdigest() if content else "" actual_hash = hashlib.sha256(content).hexdigest() if content else ""
if upload.expected_hash and actual_hash != upload.expected_hash: if upload.expected_hash and actual_hash != upload.expected_hash:
upload.upload_status = "failed" upload.upload_status = "failed"
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT, "uploaded bytes hash does not match expected_hash"
"uploaded bytes hash does not match expected_hash") )
# Round-trip content_type + sha256 metadata through the storage backend # Round-trip content_type + sha256 metadata through the storage backend
# so the next head() (or our own put signature) can recover them. # so the next head() (or our own put signature) can recover them.
@@ -351,7 +346,8 @@ async def upload_bytes_to_session(
upload.upload_status = "failed" upload.upload_status = "failed"
raise HTTPException( raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE, status.HTTP_503_SERVICE_UNAVAILABLE,
f"failed to write object to storage: {exc}") from exc f"failed to write object to storage: {exc}",
) from exc
file_name = safe_file_name(upload.file_name_hint or "upload.bin") file_name = safe_file_name(upload.file_name_hint or "upload.bin")
item = StorageObjects( item = StorageObjects(
@@ -374,7 +370,8 @@ async def upload_bytes_to_session(
visibility=upload.visibility, visibility=upload.visibility,
is_immutable=int(upload.is_immutable), is_immutable=int(upload.is_immutable),
object_status="available", object_status="available",
created_by=upload.user_id) created_by=upload.user_id,
)
session.add(item) session.add(item)
await session.flush() await session.flush()
await session.refresh(item) await session.refresh(item)
@@ -388,7 +385,8 @@ async def upload_bytes_to_session(
async def create_upload( async def create_upload(
payload: CreateUploadRequest, payload: CreateUploadRequest,
request: Request, request: Request,
session: AsyncSession = Depends(database_session)) -> dict[str, Any]: session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
return { return {
"data": await create_upload_record(payload, session, request), "data": await create_upload_record(payload, session, request),
} }
@@ -396,9 +394,8 @@ async def create_upload(
@app.put("/internal/v1/uploads/{upload_id}") @app.put("/internal/v1/uploads/{upload_id}")
async def upload_bytes( async def upload_bytes(
upload_id: str, upload_id: str, request: Request, session: AsyncSession = Depends(database_session)
request: Request, ) -> dict[str, Any]:
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
"""Server-proxied upload: PUT raw bytes in the request body. Replaces the """Server-proxied upload: PUT raw bytes in the request body. Replaces the
old ``POST /uploads/{id}/complete`` flow that paired presigned-PUT with old ``POST /uploads/{id}/complete`` flow that paired presigned-PUT with
a head()-validate step. a head()-validate step.
@@ -407,12 +404,10 @@ async def upload_bytes(
return {"data": storage_payload(item)} return {"data": storage_payload(item)}
@app.post( @app.post("/internal/v1/uploads/{upload_id}/abort")
"/internal/v1/uploads/{upload_id}/abort")
async def abort_upload( async def abort_upload(
upload_id: str, upload_id: str, request: Request, session: AsyncSession = Depends(database_session)
request: Request, ) -> dict[str, Any]:
session: AsyncSession = Depends(database_session)) -> dict[str, Any]:
upload = await session.scalar( upload = await session.scalar(
select(UploadSessions) select(UploadSessions)
.where(UploadSessions.upload_id == upload_id) .where(UploadSessions.upload_id == upload_id)
@@ -422,52 +417,52 @@ async def abort_upload(
raise HTTPException(status.HTTP_404_NOT_FOUND, "upload not found") raise HTTPException(status.HTTP_404_NOT_FOUND, "upload not found")
if upload.upload_status == "completed": if upload.upload_status == "completed":
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT, "completed upload cannot be aborted"
"completed upload cannot be aborted") )
if upload.upload_status != "aborted": if upload.upload_status != "aborted":
await request.app.state.object_stores[ await request.app.state.object_stores[upload.bucket_name].delete(
upload.bucket_name upload.object_key
].delete(upload.object_key) )
upload.upload_status = "aborted" upload.upload_status = "aborted"
return {"data": {"upload_id": upload_id, "status": "aborted"}} return {"data": {"upload_id": upload_id, "status": "aborted"}}
@app.post( @app.post("/internal/v1/objects")
"/internal/v1/objects")
async def create_server_object( async def create_server_object(
payload: ServerObjectRequest, payload: ServerObjectRequest,
request: Request, request: Request,
session: AsyncSession = Depends(database_session)) -> dict[str, Any]: session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
return await create_server_object_payload(payload, request, session) return await create_server_object_payload(payload, request, session)
@app.post( @app.post("/internal/v1/objects/{storage_object_id}/download-url")
"/internal/v1/objects/{storage_object_id}/download-url")
async def create_download_url( async def create_download_url(
storage_object_id: str, storage_object_id: str,
payload: DownloadUrlRequest, payload: DownloadUrlRequest,
request: Request, request: Request,
session: AsyncSession = Depends(database_session)) -> dict[str, Any]: session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
item = await session.get(StorageObjects, storage_object_id) item = await session.get(StorageObjects, storage_object_id)
return await create_download_url_payload(item, payload, request) return await create_download_url_payload(item, payload, request)
@app.delete( @app.delete("/internal/v1/objects/{storage_object_id}")
"/internal/v1/objects/{storage_object_id}")
async def delete_object( async def delete_object(
storage_object_id: str, storage_object_id: str,
request: Request, request: Request,
session: AsyncSession = Depends(database_session)) -> dict[str, Any]: session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Soft-delete a storage object. See ``backend.services.storage.soft_delete_object``.""" """Soft-delete a storage object. See ``backend.services.storage.soft_delete_object``."""
return await soft_delete_object(storage_object_id, request, session) return await soft_delete_object(storage_object_id, request, session)
@app.post( @app.post("/internal/v1/objects/{storage_object_id}/restore")
"/internal/v1/objects/{storage_object_id}/restore")
async def restore_object( async def restore_object(
storage_object_id: str, storage_object_id: str,
request: Request, request: Request,
session: AsyncSession = Depends(database_session)) -> dict[str, Any]: session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Restore a soft-deleted object from the trash bucket. """Restore a soft-deleted object from the trash bucket.
Copies the bytes back to the source bucket + key and flips the Copies the bytes back to the source bucket + key and flips the
@@ -486,13 +481,11 @@ async def restore_object(
if item is None: if item is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found") raise HTTPException(status.HTTP_404_NOT_FOUND, "object not found")
if item.object_status != "deleted": if item.object_status != "deleted":
raise HTTPException( raise HTTPException(status.HTTP_409_CONFLICT, "object is not in trash")
status.HTTP_409_CONFLICT,
"object is not in trash")
if not item.trash_key or not item.bucket_name or not item.object_key: if not item.trash_key or not item.bucket_name or not item.object_key:
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT, "object has no trash pointer; cannot restore"
"object has no trash pointer; cannot restore") )
try: try:
# Cross-backend copy: get from trash, put back to source bucket. # Cross-backend copy: get from trash, put back to source bucket.
object_stores = request.app.state.object_stores object_stores = request.app.state.object_stores
@@ -513,12 +506,12 @@ async def restore_object(
} }
@app.post( @app.post("/internal/v1/admin/trash/purge")
"/internal/v1/admin/trash/purge")
async def purge_trash_object( async def purge_trash_object(
payload: dict[str, Any], payload: dict[str, Any],
request: Request, request: Request,
session: AsyncSession = Depends(database_session)) -> dict[str, Any]: session: AsyncSession = Depends(database_session),
) -> dict[str, Any]:
"""Physically delete a trashed object. """Physically delete a trashed object.
Admin / reaper endpoint — given a ``storage_object_id``, deletes Admin / reaper endpoint — given a ``storage_object_id``, deletes
@@ -530,8 +523,8 @@ async def purge_trash_object(
storage_object_id = (payload or {}).get("storage_object_id", "").strip() storage_object_id = (payload or {}).get("storage_object_id", "").strip()
if not storage_object_id: if not storage_object_id:
raise HTTPException( raise HTTPException(
status.HTTP_400_BAD_REQUEST, status.HTTP_400_BAD_REQUEST, "storage_object_id is required"
"storage_object_id is required") )
item = await session.scalar( item = await session.scalar(
select(StorageObjects) select(StorageObjects)
.where(StorageObjects.storage_object_id == storage_object_id) .where(StorageObjects.storage_object_id == storage_object_id)
@@ -542,12 +535,13 @@ async def purge_trash_object(
if item.object_status != "deleted": if item.object_status != "deleted":
raise HTTPException( raise HTTPException(
status.HTTP_409_CONFLICT, status.HTTP_409_CONFLICT,
"object is not in trash; refuse to hard-delete live data") "object is not in trash; refuse to hard-delete live data",
)
if item.trash_key: if item.trash_key:
try: try:
await request.app.state.object_stores[ await request.app.state.object_stores[settings.s3_trash_bucket].delete(
settings.s3_trash_bucket item.trash_key
].delete(item.trash_key) )
except Exception as exc: except Exception as exc:
raise HTTPException( raise HTTPException(
status.HTTP_502_BAD_GATEWAY, status.HTTP_502_BAD_GATEWAY,
-2
View File
@@ -1,7 +1,6 @@
from common.db.base import Base from common.db.base import Base
from common.db.models.events import ConsumerInbox, OutboxEvents from common.db.models.events import ConsumerInbox, OutboxEvents
from common.db.models.identity import Permissions, RolePermissions, Roles, Users from common.db.models.identity import Permissions, RolePermissions, Roles, Users
from common.db.models.runtime import WorkspaceOperations
from common.db.models.schedules import ( from common.db.models.schedules import (
ScheduleEdges, ScheduleEdges,
ScheduleNodeRuns, ScheduleNodeRuns,
@@ -32,6 +31,5 @@ __all__ = [
"Users", "Users",
"Versions", "Versions",
"WorkspaceMembers", "WorkspaceMembers",
"WorkspaceOperations",
"Workspaces", "Workspaces",
] ]
-55
View File
@@ -1,55 +0,0 @@
import datetime
from typing import Optional
from sqlalchemy import Index, String, Text, text
from sqlalchemy.dialects.mysql import CHAR, DATETIME, INTEGER, TINYINT
from sqlalchemy.orm import Mapped, mapped_column
from common.db.base import Base
class WorkspaceOperations(Base):
__tablename__ = "workspace_operations"
__table_args__ = (
Index("fk_workspace_operations_user", "requested_by"),
Index("idx_workspace_operations_runtime", "runtime_id", "created_at"),
Index(
"idx_workspace_operations_workspace",
"workspace_id",
"operation_status",
"created_at",
),
Index("uk_workspace_operations_request", "request_id", unique=True),
{"comment": "无状态 Backend 的 Workspace/Jupyter 异步操作记录"},
)
operation_id: Mapped[str] = mapped_column(CHAR(26), primary_key=True)
workspace_id: Mapped[str] = mapped_column(CHAR(26), nullable=False)
operation_type: Mapped[str] = mapped_column(
String(24),
nullable=False,
comment="open/close/mount/unmount/start/stop/restart/recycle",
)
operation_status: Mapped[str] = mapped_column(
String(24),
nullable=False,
server_default=text("'pending'"),
comment="pending/running/succeeded/failed/cancelled",
)
state_version: Mapped[int] = mapped_column(
INTEGER, nullable=False, server_default=text("0"), comment="乐观锁版本"
)
requested_by: Mapped[str] = mapped_column(CHAR(26), nullable=False)
created_at: Mapped[datetime.datetime] = mapped_column(
DATETIME(fsp=3), nullable=False, server_default=text("CURRENT_TIMESTAMP(3)")
)
runtime_id: Mapped[Optional[str]] = mapped_column(CHAR(26))
request_id: Mapped[Optional[str]] = mapped_column(String(128))
started_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
finished_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
error_code: Mapped[Optional[str]] = mapped_column(String(64))
error_message: Mapped[Optional[str]] = mapped_column(Text)
is_deleted: Mapped[int] = mapped_column(
TINYINT(1), nullable=False, server_default=text("0")
)
deleted_at: Mapped[Optional[datetime.datetime]] = mapped_column(DATETIME(fsp=3))
@@ -1,534 +0,0 @@
"""initial baseline (19 active tables)
Revision ID: 8d86e2f82860
Revises:
Create Date: 2026-07-31 13:17:52.047720
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import mysql
# revision identifiers, used by Alembic.
revision: str = '8d86e2f82860'
down_revision: str | Sequence[str] | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('consumer_inbox',
sa.Column('consumer_name', sa.String(length=128), nullable=False),
sa.Column('event_id', mysql.CHAR(length=26), nullable=False),
sa.Column('process_status', sa.String(length=16), server_default=sa.text("'processing'"), nullable=False, comment='processing/succeeded/failed'),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('message_id', sa.String(length=128), nullable=True, comment='Inbox message ID'),
sa.Column('processed_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('error_message', sa.String(length=2000), nullable=True),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('consumer_name', 'event_id'),
comment='消费者幂等 Inbox,防止 Stream 重投导致重复执行'
)
op.create_index('idx_consumer_inbox_status', 'consumer_inbox', ['consumer_name', 'process_status', 'created_at'], unique=False)
op.create_table('data_resources',
sa.Column('resource_id', mysql.CHAR(length=26), nullable=False),
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('storage_object_id', mysql.CHAR(length=26), nullable=False),
sa.Column('owner_user_id', mysql.CHAR(length=26), nullable=False),
sa.Column('resource_name', sa.String(length=255), nullable=False),
sa.Column('visibility', sa.String(length=16), server_default=sa.text("'private'"), nullable=False),
sa.Column('status', sa.String(length=16), server_default=sa.text("'active'"), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('updated_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('description', sa.String(length=1000), nullable=True),
sa.Column('schema_json', sa.JSON(), nullable=True, comment='字段结构、行数等可选元数据'),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('resource_id'),
comment='数据资源'
)
op.create_index('idx_data_resources_owner', 'data_resources', ['owner_user_id', 'status'], unique=False)
op.create_index('idx_data_resources_workspace', 'data_resources', ['workspace_id', 'visibility', 'status'], unique=False)
op.create_index('uk_data_resources_object', 'data_resources', ['storage_object_id'], unique=True)
op.create_table('outbox_events',
sa.Column('event_id', mysql.CHAR(length=26), nullable=False),
sa.Column('aggregate_type', sa.String(length=64), nullable=False),
sa.Column('aggregate_id', sa.String(length=128), nullable=False),
sa.Column('event_type', sa.String(length=128), nullable=False),
sa.Column('schema_version', mysql.SMALLINT(), server_default=sa.text('1'), nullable=False),
sa.Column('payload_json', sa.JSON(), nullable=False),
sa.Column('event_status', sa.String(length=16), server_default=sa.text("'pending'"), nullable=False, comment='pending/published/failed'),
sa.Column('available_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('retry_count', mysql.INTEGER(), server_default=sa.text('0'), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('trace_id', sa.String(length=64), nullable=True),
sa.Column('idempotency_key', sa.String(length=128), nullable=True),
sa.Column('published_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('last_error', sa.String(length=2000), nullable=True),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('event_id'),
comment='事务 Outbox;提交后发布到内部事件总线'
)
op.create_index('idx_outbox_aggregate', 'outbox_events', ['aggregate_type', 'aggregate_id', 'created_at'], unique=False)
op.create_index('idx_outbox_idempotency', 'outbox_events', ['idempotency_key'], unique=False)
op.create_index('idx_outbox_pending', 'outbox_events', ['event_status', 'available_at', 'created_at'], unique=False)
op.create_table('permissions',
sa.Column('permission_id', mysql.CHAR(length=26), nullable=False),
sa.Column('permission_code', sa.String(length=128), nullable=False),
sa.Column('permission_name', sa.String(length=100), nullable=False),
sa.Column('module_code', sa.String(length=64), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('description', sa.String(length=500), nullable=True),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('permission_id'),
comment='权限点'
)
op.create_index('idx_permissions_module', 'permissions', ['module_code'], unique=False)
op.create_index('uk_permissions_code', 'permissions', ['permission_code'], unique=True)
op.create_table('role_permissions',
sa.Column('role_id', mysql.CHAR(length=26), nullable=False),
sa.Column('permission_id', mysql.CHAR(length=26), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('role_id', 'permission_id'),
comment='角色权限'
)
op.create_index('fk_role_permissions_permission', 'role_permissions', ['permission_id'], unique=False)
op.create_table('roles',
sa.Column('role_id', mysql.CHAR(length=26), nullable=False),
sa.Column('role_code', sa.String(length=64), nullable=False),
sa.Column('role_name', sa.String(length=100), nullable=False),
sa.Column('role_scope', sa.String(length=16), nullable=False, comment='platform/workspace'),
sa.Column('is_builtin', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('updated_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('description', sa.String(length=500), nullable=True),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('role_id'),
comment='角色'
)
op.create_index('uk_roles_code', 'roles', ['role_code'], unique=True)
op.create_table('schedule_edges',
sa.Column('edge_id', mysql.CHAR(length=26), nullable=False),
sa.Column('schedule_id', mysql.CHAR(length=26), nullable=False),
sa.Column('source_node_id', mysql.CHAR(length=26), nullable=False),
sa.Column('target_node_id', mysql.CHAR(length=26), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('condition_expr', sa.String(length=1000), nullable=True),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('edge_id'),
comment='DAG 有向边'
)
op.create_index('fk_schedule_edges_source', 'schedule_edges', ['source_node_id'], unique=False)
op.create_index('idx_schedule_edges_target', 'schedule_edges', ['target_node_id'], unique=False)
op.create_index('uk_schedule_edges_pair', 'schedule_edges', ['schedule_id', 'source_node_id', 'target_node_id'], unique=True)
op.create_table('schedule_node_runs',
sa.Column('node_run_id', mysql.CHAR(length=26), nullable=False),
sa.Column('run_id', mysql.CHAR(length=26), nullable=False),
sa.Column('node_id', mysql.CHAR(length=26), nullable=False),
sa.Column('versions_id', mysql.CHAR(length=26), nullable=False),
sa.Column('attempt_no', mysql.INTEGER(), server_default=sa.text('1'), nullable=False),
sa.Column('node_status', sa.String(length=24), server_default=sa.text("'queued'"), nullable=False, comment='queued/running/succeeded/failed/skipped/cancelled/timed_out'),
sa.Column('state_version', mysql.INTEGER(), server_default=sa.text('0'), nullable=False, comment='乐观锁版本'),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('started_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('finished_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('duration_ms', mysql.BIGINT(), nullable=True),
sa.Column('exit_code', sa.Integer(), nullable=True),
sa.Column('message', sa.String(length=2000), nullable=True),
sa.Column('metrics_json', sa.JSON(), nullable=True),
sa.Column('logs_object_id', mysql.CHAR(length=26), nullable=True),
sa.Column('result_object_id', mysql.CHAR(length=26), nullable=True),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('node_run_id'),
comment='调度节点运行与重试'
)
op.create_index('fk_node_runs_logs', 'schedule_node_runs', ['logs_object_id'], unique=False)
op.create_index('fk_node_runs_node', 'schedule_node_runs', ['node_id'], unique=False)
op.create_index('fk_node_runs_result', 'schedule_node_runs', ['result_object_id'], unique=False)
op.create_index('idx_node_runs_status', 'schedule_node_runs', ['run_id', 'node_status'], unique=False)
op.create_index('idx_node_runs_version', 'schedule_node_runs', ['versions_id'], unique=False)
op.create_index('uk_node_runs_attempt', 'schedule_node_runs', ['run_id', 'node_id', 'attempt_no'], unique=True)
op.create_table('schedule_nodes',
sa.Column('node_id', mysql.CHAR(length=26), nullable=False),
sa.Column('schedule_id', mysql.CHAR(length=26), nullable=False),
sa.Column('node_key', sa.String(length=64), nullable=False, comment='画布内稳定标识'),
sa.Column('node_name', sa.String(length=255), nullable=False),
sa.Column('versions_id', mysql.CHAR(length=26), nullable=False),
sa.Column('timeout_seconds', mysql.INTEGER(), server_default=sa.text('600'), nullable=False),
sa.Column('retry_count', mysql.INTEGER(), server_default=sa.text('0'), nullable=False),
sa.Column('retry_interval_sec', mysql.INTEGER(), server_default=sa.text('5'), nullable=False),
sa.Column('position_x', sa.DECIMAL(precision=10, scale=2), server_default=sa.text('0.00'), nullable=False),
sa.Column('position_y', sa.DECIMAL(precision=10, scale=2), server_default=sa.text('0.00'), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('updated_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('arguments_json', sa.JSON(), nullable=True),
sa.Column('env_refs_json', sa.JSON(), nullable=True, comment='只存密钥引用,不存明文密钥'),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('node_id'),
comment='DAG 节点,必须引用稳定版本'
)
op.create_index('idx_schedule_nodes_version', 'schedule_nodes', ['versions_id'], unique=False)
op.create_index('uk_schedule_nodes_key', 'schedule_nodes', ['schedule_id', 'node_key'], unique=True)
op.create_table('schedule_runs',
sa.Column('run_id', mysql.CHAR(length=26), nullable=False),
sa.Column('schedule_id', mysql.CHAR(length=26), nullable=False),
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('workflow_version', mysql.INTEGER(), nullable=False),
sa.Column('trigger_type', sa.String(length=16), nullable=False, comment='manual/cron/api/retry'),
sa.Column('idempotency_key', sa.String(length=128), nullable=False),
sa.Column('run_status', sa.String(length=24), server_default=sa.text("'queued'"), nullable=False, comment='queued/running/succeeded/failed/cancelled/timed_out'),
sa.Column('state_version', mysql.INTEGER(), server_default=sa.text('0'), nullable=False, comment='乐观锁版本'),
sa.Column('schedule_snapshot', sa.JSON(), nullable=False, comment='执行时 DAG 快照'),
sa.Column('queued_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('triggered_by', mysql.CHAR(length=26), nullable=True),
sa.Column('started_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('finished_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('duration_ms', mysql.BIGINT(), nullable=True),
sa.Column('error_code', sa.String(length=64), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('logs_object_id', mysql.CHAR(length=26), nullable=True),
sa.Column('result_object_id', mysql.CHAR(length=26), nullable=True),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('run_id'),
comment='调度运行'
)
op.create_index('fk_schedule_runs_logs', 'schedule_runs', ['logs_object_id'], unique=False)
op.create_index('fk_schedule_runs_result', 'schedule_runs', ['result_object_id'], unique=False)
op.create_index('fk_schedule_runs_user', 'schedule_runs', ['triggered_by'], unique=False)
op.create_index('idx_schedule_runs_schedule', 'schedule_runs', ['schedule_id', 'created_at'], unique=False)
op.create_index('idx_schedule_runs_status', 'schedule_runs', ['run_status', 'queued_at'], unique=False)
op.create_index('idx_schedule_runs_workspace_status', 'schedule_runs', ['workspace_id', 'run_status', 'queued_at'], unique=False)
op.create_index('uk_schedule_runs_idempotency', 'schedule_runs', ['idempotency_key'], unique=True)
op.create_table('schedules',
sa.Column('schedule_id', mysql.CHAR(length=26), nullable=False),
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('schedule_name', sa.String(length=255), nullable=False),
sa.Column('trigger_type', sa.String(length=16), server_default=sa.text("'cron'"), nullable=False, comment='manual/cron/api'),
sa.Column('timezone', sa.String(length=64), server_default=sa.text("'Asia/Shanghai'"), nullable=False),
sa.Column('enabled', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('workflow_version', mysql.INTEGER(), server_default=sa.text('1'), nullable=False),
sa.Column('max_concurrency', mysql.INTEGER(), server_default=sa.text('1'), nullable=False),
sa.Column('failure_policy', sa.String(length=24), server_default=sa.text("'stop'"), nullable=False, comment='stop/continue'),
sa.Column('created_by', mysql.CHAR(length=26), nullable=False),
sa.Column('updated_by', mysql.CHAR(length=26), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('updated_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('description', sa.String(length=1000), nullable=True),
sa.Column('cron_expression', sa.String(length=128), nullable=True),
sa.Column('last_run_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('next_run_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('schedule_id'),
comment='调度方案'
)
op.create_index('fk_schedules_created_by', 'schedules', ['created_by'], unique=False)
op.create_index('fk_schedules_updated_by', 'schedules', ['updated_by'], unique=False)
op.create_index('idx_schedules_due', 'schedules', ['enabled', 'next_run_at'], unique=False)
op.create_index('idx_schedules_workspace', 'schedules', ['workspace_id', 'enabled', 'updated_at'], unique=False)
op.create_table('scripts',
sa.Column('script_id', mysql.CHAR(length=26), nullable=False),
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('current_object_id', mysql.CHAR(length=26), nullable=False, comment='当前工作副本'),
sa.Column('owner_user_id', mysql.CHAR(length=26), nullable=False),
sa.Column('script_name', sa.String(length=255), nullable=False),
sa.Column('script_type', sa.String(length=16), nullable=False, comment='python/notebook'),
sa.Column('visibility', sa.String(length=16), server_default=sa.text("'private'"), nullable=False),
sa.Column('status', sa.String(length=16), server_default=sa.text("'active'"), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('updated_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('is_locked', mysql.TINYINT(display_width=1), server_default=sa.text('1'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('script_id'),
comment='可执行 Python/Notebook 脚本'
)
op.create_index('idx_scripts_owner', 'scripts', ['owner_user_id', 'status'], unique=False)
op.create_index('idx_scripts_workspace', 'scripts', ['workspace_id', 'script_type', 'visibility', 'status'], unique=False)
op.create_index('uk_scripts_current_object', 'scripts', ['current_object_id'], unique=True)
op.create_index('uk_scripts_workspace_name', 'scripts', ['workspace_id', 'script_name', 'script_type'], unique=True)
op.create_table('storage_objects',
sa.Column('storage_object_id', mysql.CHAR(length=26), nullable=False),
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('object_type', sa.String(length=16), nullable=False, comment='file/directory'),
sa.Column('usage_type', sa.String(length=32), nullable=False, comment='working_copy/public_script/data_resource/version_artifact/snapshot/run_log/run_result'),
sa.Column('storage_backend', sa.String(length=16), nullable=False, comment='rustfs'),
sa.Column('storage_uri', sa.String(length=1500), nullable=False),
sa.Column('file_name', sa.String(length=255), nullable=False),
sa.Column('size_bytes', mysql.BIGINT(), server_default=sa.text('0'), nullable=False),
sa.Column('visibility', sa.String(length=16), server_default=sa.text("'private'"), nullable=False, comment='private/workspace/public'),
sa.Column('is_immutable', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('object_status', sa.String(length=24), server_default=sa.text("'available'"), nullable=False, comment='uploading/available/deleting/deleted/failed'),
sa.Column('created_by', mysql.CHAR(length=26), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('updated_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('owner_user_id', mysql.CHAR(length=26), nullable=True),
sa.Column('parent_object_id', mysql.CHAR(length=26), nullable=True),
sa.Column('relative_path', sa.String(length=1024), nullable=True, comment='Workspace 相对路径'),
sa.Column('path_hash', sa.BINARY(length=32), nullable=True, comment='SHA-256(relative_path),由应用写入'),
sa.Column('bucket_name', sa.String(length=128), nullable=True),
sa.Column('object_key', sa.String(length=1024), nullable=True),
sa.Column('object_key_hash', sa.BINARY(length=32), nullable=True, comment='SHA-256(object_key),由应用写入'),
sa.Column('file_extension', sa.String(length=32), nullable=True),
sa.Column('mime_type', sa.String(length=255), nullable=True),
sa.Column('content_hash', mysql.CHAR(length=64), nullable=True, comment='SHA-256 hex'),
sa.Column('object_etag', sa.String(length=255), nullable=True),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('storage_object_id'),
comment='Workspace 文件和 RustFS 对象的统一元数据'
)
op.create_index('fk_storage_created_by', 'storage_objects', ['created_by'], unique=False)
op.create_index('idx_storage_content_hash', 'storage_objects', ['content_hash'], unique=False)
op.create_index('idx_storage_owner', 'storage_objects', ['owner_user_id', 'object_status'], unique=False)
op.create_index('idx_storage_parent', 'storage_objects', ['parent_object_id'], unique=False)
op.create_index('idx_storage_workspace_usage', 'storage_objects', ['workspace_id', 'usage_type', 'object_status'], unique=False)
op.create_index('uk_storage_bucket_key', 'storage_objects', ['storage_backend', 'bucket_name', 'object_key_hash'], unique=True)
op.create_index('uk_storage_workspace_path', 'storage_objects', ['workspace_id', 'storage_backend', 'path_hash'], unique=True)
op.create_table('upload_sessions',
sa.Column('upload_id', mysql.CHAR(length=26), nullable=False),
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('user_id', mysql.CHAR(length=26), nullable=False),
sa.Column('idempotency_key', sa.String(length=128), nullable=False),
sa.Column('bucket_name', sa.String(length=128), nullable=False),
sa.Column('object_key', sa.String(length=1024), nullable=False),
sa.Column('object_key_hash', sa.BINARY(length=32), nullable=False),
sa.Column('upload_status', sa.String(length=24), server_default=sa.text("'created'"), nullable=False, comment='created/uploading/completed/expired/aborted/failed'),
sa.Column('expires_at', mysql.DATETIME(fsp=3), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('updated_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('multipart_upload_id', sa.String(length=255), nullable=True),
sa.Column('expected_size_bytes', mysql.BIGINT(), nullable=True),
sa.Column('expected_hash', mysql.CHAR(length=64), nullable=True),
sa.Column('content_type', sa.String(length=255), nullable=True),
sa.Column('storage_object_id', mysql.CHAR(length=26), nullable=True),
sa.Column('completed_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('upload_id'),
comment='RustFS 预签名上传会话;URL 本身不持久化'
)
op.create_index('fk_upload_sessions_storage_object', 'upload_sessions', ['storage_object_id'], unique=False)
op.create_index('fk_upload_sessions_user', 'upload_sessions', ['user_id'], unique=False)
op.create_index('idx_upload_sessions_expiry', 'upload_sessions', ['upload_status', 'expires_at'], unique=False)
op.create_index('idx_upload_sessions_object_key', 'upload_sessions', ['bucket_name', 'object_key_hash'], unique=False)
op.create_index('idx_upload_sessions_workspace', 'upload_sessions', ['workspace_id', 'user_id', 'created_at'], unique=False)
op.create_index('uk_upload_sessions_idempotency', 'upload_sessions', ['idempotency_key'], unique=True)
op.create_table('users',
sa.Column('user_id', mysql.CHAR(length=26), nullable=False),
sa.Column('username', sa.String(length=64), nullable=False),
sa.Column('display_name', sa.String(length=100), nullable=False),
sa.Column('password_hash', sa.String(length=255), nullable=False),
sa.Column('status', sa.String(length=16), server_default=sa.text("'active'"), nullable=False, comment='active/disabled/locked'),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('updated_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('email', sa.String(length=255), nullable=True),
sa.Column('platform_role_id', mysql.CHAR(length=26), nullable=True),
sa.Column('avatar_uri', sa.String(length=1000), nullable=True),
sa.Column('last_login_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('user_id'),
comment='平台用户'
)
op.create_index('fk_users_platform_role', 'users', ['platform_role_id'], unique=False)
op.create_index('idx_users_status', 'users', ['status'], unique=False)
op.create_index('uk_users_email', 'users', ['email'], unique=True)
op.create_index('uk_users_username', 'users', ['username'], unique=True)
op.create_table('versions',
sa.Column('versions_id', mysql.CHAR(length=26), nullable=False, comment='稳定版本唯一 ID'),
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('script_id', mysql.CHAR(length=26), nullable=False),
sa.Column('source_object_id', mysql.CHAR(length=26), nullable=False, comment='发布时的源对象'),
sa.Column('artifact_object_id', mysql.CHAR(length=26), nullable=False, comment='RustFS 不可变版本制品'),
sa.Column('version_no', mysql.INTEGER(), nullable=False),
sa.Column('version_label', sa.String(length=32), nullable=False, comment='例如 v1.0'),
sa.Column('source_path', sa.String(length=1024), nullable=False, comment='发布时路径快照'),
sa.Column('artifact_path', sa.String(length=1500), nullable=False),
sa.Column('content_hash', mysql.CHAR(length=64), nullable=False),
sa.Column('file_size_bytes', mysql.BIGINT(), server_default=sa.text('0'), nullable=False),
sa.Column('visibility', sa.String(length=16), server_default=sa.text("'private'"), nullable=False),
sa.Column('created_by', mysql.CHAR(length=26), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('release_note', sa.String(length=1000), nullable=True),
sa.Column('schedule_hidden_at', mysql.DATETIME(fsp=3), nullable=True, comment='从调度稳定版本列表移除的时间;不影响版本和运行历史'),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('versions_id'),
comment='不可变稳定版本;调度节点必须引用 versions_id'
)
op.create_index('fk_versions_source_object', 'versions', ['source_object_id'], unique=False)
op.create_index('idx_versions_creator', 'versions', ['created_by', 'created_at'], unique=False)
op.create_index('idx_versions_workspace_created', 'versions', ['workspace_id', 'created_at'], unique=False)
op.create_index('uk_versions_artifact', 'versions', ['artifact_object_id'], unique=True)
op.create_index('uk_versions_script_hash', 'versions', ['script_id', 'content_hash'], unique=True)
op.create_index('uk_versions_script_no', 'versions', ['script_id', 'version_no'], unique=True)
op.create_table('workspace_members',
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('user_id', mysql.CHAR(length=26), nullable=False),
sa.Column('role_id', mysql.CHAR(length=26), nullable=False),
sa.Column('member_status', sa.String(length=16), server_default=sa.text("'active'"), nullable=False),
sa.Column('joined_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('updated_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('workspace_id', 'user_id'),
comment='Workspace 成员与角色'
)
op.create_index('idx_workspace_members_role', 'workspace_members', ['role_id'], unique=False)
op.create_index('idx_workspace_members_user', 'workspace_members', ['user_id', 'member_status'], unique=False)
op.create_table('workspace_operations',
sa.Column('operation_id', mysql.CHAR(length=26), nullable=False),
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('operation_type', sa.String(length=24), nullable=False, comment='open/close/mount/unmount/start/stop/restart/recycle'),
sa.Column('operation_status', sa.String(length=24), server_default=sa.text("'pending'"), nullable=False, comment='pending/running/succeeded/failed/cancelled'),
sa.Column('state_version', mysql.INTEGER(), server_default=sa.text('0'), nullable=False, comment='乐观锁版本'),
sa.Column('requested_by', mysql.CHAR(length=26), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('runtime_id', mysql.CHAR(length=26), nullable=True),
sa.Column('request_id', sa.String(length=128), nullable=True),
sa.Column('started_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('finished_at', mysql.DATETIME(fsp=3), nullable=True),
sa.Column('error_code', sa.String(length=64), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('operation_id'),
comment='无状态 Backend 的 Workspace/Jupyter 异步操作记录'
)
op.create_index('fk_workspace_operations_user', 'workspace_operations', ['requested_by'], unique=False)
op.create_index('idx_workspace_operations_runtime', 'workspace_operations', ['runtime_id', 'created_at'], unique=False)
op.create_index('idx_workspace_operations_workspace', 'workspace_operations', ['workspace_id', 'operation_status', 'created_at'], unique=False)
op.create_index('uk_workspace_operations_request', 'workspace_operations', ['request_id'], unique=True)
op.create_table('workspaces',
sa.Column('workspace_id', mysql.CHAR(length=26), nullable=False),
sa.Column('workspace_code', sa.String(length=64), nullable=False),
sa.Column('workspace_name', sa.String(length=150), nullable=False),
sa.Column('active_root_uri', sa.String(length=1500), nullable=False, comment='活动工作区,建议 NFS/PVC/file URI'),
sa.Column('quota_bytes', mysql.BIGINT(), server_default=sa.text('0'), nullable=False, comment='0 表示不限额'),
sa.Column('used_bytes', mysql.BIGINT(), server_default=sa.text('0'), nullable=False),
sa.Column('status', sa.String(length=24), server_default=sa.text("'active'"), nullable=False, comment='creating/active/suspended/deleting/deleted'),
sa.Column('created_by', mysql.CHAR(length=26), nullable=False),
sa.Column('created_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('updated_at', mysql.DATETIME(fsp=3), server_default=sa.text('CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3)'), nullable=False),
sa.Column('description', sa.String(length=1000), nullable=True),
sa.Column('artifact_bucket', sa.String(length=128), nullable=True, comment='RustFS bucket'),
sa.Column('artifact_prefix', sa.String(length=512), nullable=True, comment='RustFS object key prefix'),
sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text('0'), nullable=False),
sa.Column('deleted_at', mysql.DATETIME(fsp=3), nullable=True),
sa.PrimaryKeyConstraint('workspace_id'),
comment='Workspace'
)
op.create_index('fk_workspaces_created_by', 'workspaces', ['created_by'], unique=False)
op.create_index('idx_workspaces_status', 'workspaces', ['status'], unique=False)
op.create_index('uk_workspaces_code', 'workspaces', ['workspace_code'], unique=True)
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_index('uk_workspaces_code', table_name='workspaces')
op.drop_index('idx_workspaces_status', table_name='workspaces')
op.drop_index('fk_workspaces_created_by', table_name='workspaces')
op.drop_table('workspaces')
op.drop_index('uk_workspace_operations_request', table_name='workspace_operations')
op.drop_index('idx_workspace_operations_workspace', table_name='workspace_operations')
op.drop_index('idx_workspace_operations_runtime', table_name='workspace_operations')
op.drop_index('fk_workspace_operations_user', table_name='workspace_operations')
op.drop_table('workspace_operations')
op.drop_index('idx_workspace_members_user', table_name='workspace_members')
op.drop_index('idx_workspace_members_role', table_name='workspace_members')
op.drop_table('workspace_members')
op.drop_index('uk_versions_script_no', table_name='versions')
op.drop_index('uk_versions_script_hash', table_name='versions')
op.drop_index('uk_versions_artifact', table_name='versions')
op.drop_index('idx_versions_workspace_created', table_name='versions')
op.drop_index('idx_versions_creator', table_name='versions')
op.drop_index('fk_versions_source_object', table_name='versions')
op.drop_table('versions')
op.drop_index('uk_users_username', table_name='users')
op.drop_index('uk_users_email', table_name='users')
op.drop_index('idx_users_status', table_name='users')
op.drop_index('fk_users_platform_role', table_name='users')
op.drop_table('users')
op.drop_index('uk_upload_sessions_idempotency', table_name='upload_sessions')
op.drop_index('idx_upload_sessions_workspace', table_name='upload_sessions')
op.drop_index('idx_upload_sessions_object_key', table_name='upload_sessions')
op.drop_index('idx_upload_sessions_expiry', table_name='upload_sessions')
op.drop_index('fk_upload_sessions_user', table_name='upload_sessions')
op.drop_index('fk_upload_sessions_storage_object', table_name='upload_sessions')
op.drop_table('upload_sessions')
op.drop_index('uk_storage_workspace_path', table_name='storage_objects')
op.drop_index('uk_storage_bucket_key', table_name='storage_objects')
op.drop_index('idx_storage_workspace_usage', table_name='storage_objects')
op.drop_index('idx_storage_parent', table_name='storage_objects')
op.drop_index('idx_storage_owner', table_name='storage_objects')
op.drop_index('idx_storage_content_hash', table_name='storage_objects')
op.drop_index('fk_storage_created_by', table_name='storage_objects')
op.drop_table('storage_objects')
op.drop_index('uk_scripts_workspace_name', table_name='scripts')
op.drop_index('uk_scripts_current_object', table_name='scripts')
op.drop_index('idx_scripts_workspace', table_name='scripts')
op.drop_index('idx_scripts_owner', table_name='scripts')
op.drop_table('scripts')
op.drop_index('idx_schedules_workspace', table_name='schedules')
op.drop_index('idx_schedules_due', table_name='schedules')
op.drop_index('fk_schedules_updated_by', table_name='schedules')
op.drop_index('fk_schedules_created_by', table_name='schedules')
op.drop_table('schedules')
op.drop_index('uk_schedule_runs_idempotency', table_name='schedule_runs')
op.drop_index('idx_schedule_runs_workspace_status', table_name='schedule_runs')
op.drop_index('idx_schedule_runs_status', table_name='schedule_runs')
op.drop_index('idx_schedule_runs_schedule', table_name='schedule_runs')
op.drop_index('fk_schedule_runs_user', table_name='schedule_runs')
op.drop_index('fk_schedule_runs_result', table_name='schedule_runs')
op.drop_index('fk_schedule_runs_logs', table_name='schedule_runs')
op.drop_table('schedule_runs')
op.drop_index('uk_schedule_nodes_key', table_name='schedule_nodes')
op.drop_index('idx_schedule_nodes_version', table_name='schedule_nodes')
op.drop_table('schedule_nodes')
op.drop_index('uk_node_runs_attempt', table_name='schedule_node_runs')
op.drop_index('idx_node_runs_version', table_name='schedule_node_runs')
op.drop_index('idx_node_runs_status', table_name='schedule_node_runs')
op.drop_index('fk_node_runs_result', table_name='schedule_node_runs')
op.drop_index('fk_node_runs_node', table_name='schedule_node_runs')
op.drop_index('fk_node_runs_logs', table_name='schedule_node_runs')
op.drop_table('schedule_node_runs')
op.drop_index('uk_schedule_edges_pair', table_name='schedule_edges')
op.drop_index('idx_schedule_edges_target', table_name='schedule_edges')
op.drop_index('fk_schedule_edges_source', table_name='schedule_edges')
op.drop_table('schedule_edges')
op.drop_index('uk_roles_code', table_name='roles')
op.drop_table('roles')
op.drop_index('fk_role_permissions_permission', table_name='role_permissions')
op.drop_table('role_permissions')
op.drop_index('uk_permissions_code', table_name='permissions')
op.drop_index('idx_permissions_module', table_name='permissions')
op.drop_table('permissions')
op.drop_index('idx_outbox_pending', table_name='outbox_events')
op.drop_index('idx_outbox_idempotency', table_name='outbox_events')
op.drop_index('idx_outbox_aggregate', table_name='outbox_events')
op.drop_table('outbox_events')
op.drop_index('uk_data_resources_object', table_name='data_resources')
op.drop_index('idx_data_resources_workspace', table_name='data_resources')
op.drop_index('idx_data_resources_owner', table_name='data_resources')
op.drop_table('data_resources')
op.drop_index('idx_consumer_inbox_status', table_name='consumer_inbox')
op.drop_table('consumer_inbox')
# ### end Alembic commands ###
@@ -1,57 +0,0 @@
"""enable password login for the seeded development users
Revision ID: 9a1b2c3d4e5f
Revises: b71c4f2a9d10
Create Date: 2026-08-03 16:00:00
"""
from collections.abc import Sequence
import os
from alembic import op
import sqlalchemy as sa
from common.auth.passwords import hash_password
revision: str = "9a1b2c3d4e5f"
down_revision: str | Sequence[str] | None = "b71c4f2a9d10"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
SEEDED_USER_IDS = (
"0000000000RF6FG1SDBXG59S13",
"0000000000H2QYCGPCWQM1JSGS",
"0000000000RWG40ESZPGJT629J",
"00000000004CQV7WASJA6N6FW4",
)
DISABLED_PASSWORD = "demo-login-disabled"
def upgrade() -> None:
password = os.environ.get("INITIAL_ADMIN_PASSWORD", "admin12345")
users = sa.table(
"users",
sa.column("user_id", sa.String),
sa.column("password_hash", sa.String),
)
op.execute(
users.update()
.where(users.c.user_id.in_(SEEDED_USER_IDS))
.where(users.c.password_hash == DISABLED_PASSWORD)
.values(password_hash=hash_password(password))
)
def downgrade() -> None:
users = sa.table(
"users",
sa.column("user_id", sa.String),
sa.column("password_hash", sa.String),
)
op.execute(
users.update()
.where(users.c.user_id.in_(SEEDED_USER_IDS))
.values(password_hash=DISABLED_PASSWORD)
)
@@ -1,33 +0,0 @@
"""add the RustFS trash object key
Revision ID: a2b3c4d5e6f7
Revises: 9a1b2c3d4e5f
Create Date: 2026-08-03 16:30:00
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "a2b3c4d5e6f7"
down_revision: str | Sequence[str] | None = "9a1b2c3d4e5f"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"storage_objects",
sa.Column(
"trash_key",
sa.String(length=1100),
nullable=True,
comment="Path inside the trash bucket where soft-deleted bytes are stored",
),
)
def downgrade() -> None:
op.drop_column("storage_objects", "trash_key")
@@ -1,168 +0,0 @@
"""seed the self-hosted demo users and workspaces
Revision ID: b71c4f2a9d10
Revises: 8d86e2f82860
Create Date: 2026-07-31 16:00:00
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "b71c4f2a9d10"
down_revision: str | Sequence[str] | None = "8d86e2f82860"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
ADMIN_ROLE_ID = "0000000000000000000000000A"
DEVELOPER_ROLE_ID = "0000000000000000000000000B"
USERS = (
("0000000000RF6FG1SDBXG59S13", "admin-zhang", "张三", ADMIN_ROLE_ID),
("0000000000H2QYCGPCWQM1JSGS", "admin-li", "李四", ADMIN_ROLE_ID),
("0000000000RWG40ESZPGJT629J", "dev-wang", "王五", DEVELOPER_ROLE_ID),
("00000000004CQV7WASJA6N6FW4", "dev-zhao", "赵六", DEVELOPER_ROLE_ID),
)
WORKSPACES = (
("00000000000BM630VT9ARVFZPC", "model-development", "模型开发 Workspace"),
("0000000000AE0NC0V5T424KK86", "risk-validation", "风险验证 Workspace"),
)
def upgrade() -> None:
roles = sa.table(
"roles",
sa.column("role_id", sa.String),
sa.column("role_code", sa.String),
sa.column("role_name", sa.String),
sa.column("role_scope", sa.String),
sa.column("is_builtin", sa.Integer),
sa.column("description", sa.String),
)
users = sa.table(
"users",
sa.column("user_id", sa.String),
sa.column("username", sa.String),
sa.column("display_name", sa.String),
sa.column("password_hash", sa.String),
sa.column("status", sa.String),
sa.column("email", sa.String),
sa.column("platform_role_id", sa.String),
)
workspaces = sa.table(
"workspaces",
sa.column("workspace_id", sa.String),
sa.column("workspace_code", sa.String),
sa.column("workspace_name", sa.String),
sa.column("active_root_uri", sa.String),
sa.column("status", sa.String),
sa.column("created_by", sa.String),
sa.column("description", sa.String),
)
members = sa.table(
"workspace_members",
sa.column("workspace_id", sa.String),
sa.column("user_id", sa.String),
sa.column("role_id", sa.String),
sa.column("member_status", sa.String),
)
op.bulk_insert(
roles,
[
{
"role_id": ADMIN_ROLE_ID,
"role_code": "admin",
"role_name": "管理员",
"role_scope": "workspace",
"is_builtin": 1,
"description": "Self-hosted workspace administrator",
},
{
"role_id": DEVELOPER_ROLE_ID,
"role_code": "developer",
"role_name": "开发人员",
"role_scope": "workspace",
"is_builtin": 1,
"description": "Self-hosted workspace developer",
},
],
)
op.bulk_insert(
users,
[
{
"user_id": user_id,
"username": username,
"display_name": display_name,
"password_hash": "demo-login-disabled",
"status": "active",
"email": f"{username}@model-platform.local",
"platform_role_id": role_id,
}
for user_id, username, display_name, role_id in USERS
],
)
op.bulk_insert(
workspaces,
[
{
"workspace_id": workspace_id,
"workspace_code": workspace_code,
"workspace_name": workspace_name,
"active_root_uri": f"s3://workspaces/{workspace_id}/",
"status": "active",
"created_by": USERS[0][0],
"description": "Self-hosted demo workspace",
}
for workspace_id, workspace_code, workspace_name in WORKSPACES
],
)
op.bulk_insert(
members,
[
{
"workspace_id": workspace_id,
"user_id": user_id,
"role_id": role_id,
"member_status": "active",
}
for workspace_id, _, _ in WORKSPACES
for user_id, _, _, role_id in USERS
],
)
def downgrade() -> None:
connection = op.get_bind()
workspace_ids = [workspace_id for workspace_id, _, _ in WORKSPACES]
user_ids = [user_id for user_id, _, _, _ in USERS]
connection.execute(
sa.text(
"DELETE FROM workspace_members "
"WHERE workspace_id IN :workspace_ids AND user_id IN :user_ids"
).bindparams(
sa.bindparam("workspace_ids", expanding=True),
sa.bindparam("user_ids", expanding=True),
),
{"workspace_ids": workspace_ids, "user_ids": user_ids},
)
connection.execute(
sa.text("DELETE FROM workspaces WHERE workspace_id IN :workspace_ids")
.bindparams(sa.bindparam("workspace_ids", expanding=True)),
{"workspace_ids": workspace_ids},
)
connection.execute(
sa.text("DELETE FROM users WHERE user_id IN :user_ids")
.bindparams(sa.bindparam("user_ids", expanding=True)),
{"user_ids": user_ids},
)
connection.execute(
sa.text("DELETE FROM roles WHERE role_id IN :role_ids")
.bindparams(sa.bindparam("role_ids", expanding=True)),
{"role_ids": [ADMIN_ROLE_ID, DEVELOPER_ROLE_ID]},
)
@@ -1,73 +0,0 @@
"""add object metadata columns to upload_sessions
Stores file_name / usage_type / visibility / is_immutable at session creation
so the server-proxied PUT step can build the StorageObjects row without
re-sending them. Replaces the old CompleteUploadRequest payload that bridged
the presign-PUT and head()-validate steps.
Revision ID: c3d4e5f6a7b8
Revises: a2b3c4d5e6f7
Create Date: 2026-08-05 12:00:00
"""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "c3d4e5f6a7b8"
down_revision: str | Sequence[str] | None = "a2b3c4d5e6f7"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"upload_sessions",
sa.Column(
"file_name",
sa.String(length=255),
nullable=False,
server_default="",
),
)
op.add_column(
"upload_sessions",
sa.Column(
"usage_type",
sa.String(length=32),
nullable=False,
server_default="working_copy",
comment=(
"data_resource/version_artifact/snapshot/run_log/run_result/"
"working_copy/public_script"
),
),
)
op.add_column(
"upload_sessions",
sa.Column(
"visibility",
sa.String(length=16),
nullable=False,
server_default="private",
comment="private/workspace/public",
),
)
op.add_column(
"upload_sessions",
sa.Column(
"is_immutable",
sa.TINYINT(1),
nullable=False,
server_default="0",
),
)
def downgrade() -> None:
op.drop_column("upload_sessions", "is_immutable")
op.drop_column("upload_sessions", "visibility")
op.drop_column("upload_sessions", "usage_type")
op.drop_column("upload_sessions", "file_name")
+2
View File
@@ -192,9 +192,11 @@ async def start_workspace(ws_id: str) -> dict:
port = get_free_port() port = get_free_port()
token = secrets.token_urlsafe(16) token = secrets.token_urlsafe(16)
logger.debug(f"starting workspace {ws_id} with token {token}")
base_path = f"/jupyter/{ws_id}/" base_path = f"/jupyter/{ws_id}/"
cmd = [ cmd = [
"uvx",
"jupyter", "jupyter",
"notebook", "notebook",
f"--port={port}", f"--port={port}",