diff --git a/backend/src/backend/runtime_client.py b/backend/src/backend/runtime_client.py index 34529ff..4be857e 100644 --- a/backend/src/backend/runtime_client.py +++ b/backend/src/backend/runtime_client.py @@ -270,5 +270,23 @@ class RuntimeClient: ws = await self._ensure_workspace(workspace_id) await self._jupyter_request(workspace_id, ws, "DELETE", name) + async def get_file( + self, + workspace_id: str, + *, + name: str, + ) -> dict[str, Any]: + """Read a file's contents descriptor from the workspace Jupyter. + + Returns the Jupyter contents payload (``type``, ``content``, + ``format``, ``mimetype``, ``size``, ...). For ``type="notebook"`` + the ``content`` field is the notebook dict + (``cells``/``metadata``/``nbformat``); for ``type="file"`` it is + the raw text when ``format="text"`` or base64-encoded bytes when + ``format="base64"``. + """ + ws = await self._ensure_workspace(workspace_id) + return await self._jupyter_request(workspace_id, ws, "GET", name) + __all__ = ["RuntimeClient", "RuntimeClientError"] diff --git a/backend/src/backend/scripts.py b/backend/src/backend/scripts.py index 7f03d51..b322d23 100644 --- a/backend/src/backend/scripts.py +++ b/backend/src/backend/scripts.py @@ -835,12 +835,25 @@ async def publish_version( context: RequestContext = Depends(request_context), session: AsyncSession = Depends(database_session), ) -> dict[str, Any]: - script, source_object = await get_script_row( - script_id, - context, - session, - for_update=True, + # Jupyter-only scripts do not have a StorageObjects row, so the + # legacy get_script_row helper raises 409 before we even get here. + # Look up the Scripts row on its own — version publication is a + # metadata operation, we do not need the working-copy object + # metadata. + script = await session.scalar( + select(Scripts) + .where( + Scripts.script_id == script_id, + Scripts.workspace_id == context.workspace.workspace_id, + Scripts.status == "active", + ) + .with_for_update() ) + if script is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "script not found", + ) require_script_modify_access( script, user_id=context.user.user_id, @@ -854,22 +867,29 @@ async def publish_version( status.HTTP_412_PRECONDITION_FAILED, "source_object_id is not the current working copy", ) - if not source_object.relative_path: - raise HTTPException( - status.HTTP_409_CONFLICT, - "script has no workspace path", - ) - if not source_object.bucket_name or not source_object.object_key: - raise HTTPException( - status.HTTP_409_CONFLICT, - "script working copy is not stored in object storage", - ) - content = await asyncio.to_thread( - request.app.state.object_store.get_bytes, - bucket_name=source_object.bucket_name, - object_key=source_object.object_key, - ) + # Read the working-copy content from the workspace's Jupyter + # instance. Notebooks come back as a dict (json.dumps it); text + # files come back as a UTF-8 string. + runtime_client = request.app.state.runtime_client + workspace_id = context.workspace.workspace_id + jupyter_name = _jupyter_path(script.script_type, script.script_id) + try: + contents = await runtime_client.get_file( + workspace_id, name=jupyter_name + ) + except RuntimeClientError as exc: + raise HTTPException( + status_code=exc.status_code, + detail=exc.detail, + ) from exc + if contents.get("type") == "notebook": + content = json.dumps( + contents.get("content", {}), ensure_ascii=False + ).encode("utf-8") + else: + content = (contents.get("content") or "").encode("utf-8") + content_hash = hashlib.sha256(content).hexdigest() existing = await session.scalar( select(Versions).where( @@ -912,7 +932,7 @@ async def publish_version( artifact_object_id=artifact["storage_object_id"], version_no=version_no, version_label=f"v{version_no}.0", - source_path=source_object.relative_path, + source_path=jupyter_name, artifact_path=artifact["storage_uri"], content_hash=content_hash, file_size_bytes=len(content), @@ -951,6 +971,54 @@ async def list_versions( } +@router.get("/api/v1/scripts/{script_id}/latest-version") +async def latest_version( + script_id: str, + context: RequestContext = Depends(request_context), + session: AsyncSession = Depends(database_session), +) -> dict[str, Any]: + """Return just the latest version's ``version_label`` + ``versions_id``. + + Lightweight alternative to :func:`list_versions` for the editor + header that only needs to show "vN · ". Validates the script + exists in the caller's workspace, then queries the single most + recent version row. Returns ``data: null`` when the script has no + published versions yet (so the frontend can render an empty label + without a 404 round-trip). + """ + script = await session.scalar( + select(Scripts.script_id).where( + Scripts.script_id == script_id, + Scripts.workspace_id == context.workspace.workspace_id, + Scripts.status == "active", + ) + ) + if script is None: + raise HTTPException( + status.HTTP_404_NOT_FOUND, + "script not found", + ) + latest = await session.scalar( + select(Versions).where( + Versions.script_id == script_id, + ).order_by(Versions.version_no.desc()).limit(1) + ) + if latest is None: + return { + "request_id": context.request_id, + "data": None, + "meta": {"has_versions": False}, + } + return { + "request_id": context.request_id, + "data": { + "versions_id": latest.versions_id, + "version_label": latest.version_label, + }, + "meta": {"has_versions": True}, + } + + @router.get("/api/v1/versions/{versions_id}") async def get_version( versions_id: str, diff --git a/frontend/app/context/AuthContext.tsx b/frontend/app/context/AuthContext.tsx index 643348a..aeda940 100644 --- a/frontend/app/context/AuthContext.tsx +++ b/frontend/app/context/AuthContext.tsx @@ -220,6 +220,8 @@ export function useApi(): WorkspaceBoundApi { rawApi.releaseFileLockOnUnload(workspaceId, session), createJupyterAccessTicket: (session) => rawApi.createJupyterAccessTicket(workspaceId, session), + getLatestScriptVersion: (scriptId) => + rawApi.getLatestScriptVersion(workspaceId, scriptId), listScriptVersions: (scriptId) => rawApi.listScriptVersions(workspaceId, scriptId), publishScriptVersion: (input) => rawApi.publishScriptVersion(workspaceId, input), listSchedules: () => rawApi.listSchedules(workspaceId), diff --git a/frontend/app/features/platform/ModelPlatformApp.tsx b/frontend/app/features/platform/ModelPlatformApp.tsx index 6e3cab9..947e8c2 100644 --- a/frontend/app/features/platform/ModelPlatformApp.tsx +++ b/frontend/app/features/platform/ModelPlatformApp.tsx @@ -11,6 +11,7 @@ import { useLocation, useNavigate } from "react-router"; import { type ActiveEditSession, + type LatestVersion, type ScriptItem, type ScriptType, type StableVersion, @@ -163,6 +164,10 @@ function AuthenticatedModelPlatformApp() { } | null>(null); const [versions, setVersions] = useState([]); const [versionsLoading, setVersionsLoading] = useState(false); + const [latestVersion, setLatestVersion] = useState( + null, + ); + const [latestVersionLoading, setLatestVersionLoading] = useState(false); const [publishTarget, setPublishTarget] = useState(null); const [releaseNote, setReleaseNote] = useState(""); const [publishVisibility, setPublishVisibility] = @@ -244,10 +249,30 @@ function AuthenticatedModelPlatformApp() { useEffect(() => { if (!selectedId) { setVersions([]); + setLatestVersion(null); return; } let ignore = false; setVersionsLoading(true); + setLatestVersionLoading(true); + void api.getLatestScriptVersion(selectedId) + .then((item) => { + if (!ignore) setLatestVersion(item); + }) + .catch((error) => { + if (!ignore) { + setToast({ + tone: "error", + message: + error instanceof Error + ? error.message + : "最新版本加载失败", + }); + } + }) + .finally(() => { + if (!ignore) setLatestVersionLoading(false); + }); void api.listScriptVersions(selectedId) .then((items) => { if (!ignore) setVersions(items); @@ -1007,8 +1032,8 @@ function AuthenticatedModelPlatformApp() { ? editorOpenError.message : null } - latestVersion={versions[0] ?? null} - versionsLoading={versionsLoading} + latestVersion={latestVersion} + versionsLoading={latestVersionLoading} onOpenEditor={() => void openScriptEditor(selected)} onEndEditing={() => void endEditing()} onClose={() => { diff --git a/frontend/app/features/platform/ScriptWorkspace.tsx b/frontend/app/features/platform/ScriptWorkspace.tsx index 4786539..d73bb9e 100644 --- a/frontend/app/features/platform/ScriptWorkspace.tsx +++ b/frontend/app/features/platform/ScriptWorkspace.tsx @@ -1,8 +1,8 @@ import Icon from "../../components/Icon"; import type { ActiveEditSession, + LatestVersion, ScriptItem, - StableVersion, } from "../../services/api"; import { scriptIcon } from "./WorkspaceTree"; @@ -17,7 +17,7 @@ type ScriptWorkspaceProps = { jupyterUrl: string | null; editBusy: boolean; openError: string | null; - latestVersion: StableVersion | null; + latestVersion: LatestVersion | null; versionsLoading: boolean; onOpenEditor: () => void; onEndEditing: () => void; diff --git a/frontend/app/services/api.ts b/frontend/app/services/api.ts index 90956be..bc9844b 100644 --- a/frontend/app/services/api.ts +++ b/frontend/app/services/api.ts @@ -558,6 +558,23 @@ export async function createJupyterAccessTicket( }; } +export type LatestVersion = { + versions_id: string; + version_label: string; +}; + +export async function getLatestScriptVersion( + workspaceId: string, + scriptId: string, +): Promise { + const resp = await apiRequest<{ data: LatestVersion | null }>( + `/api/v1/scripts/${scriptId}/latest-version`, + {}, + workspaceId, + ); + return resp.data; +} + export async function listScriptVersions( workspaceId: string, scriptId: string, @@ -1102,6 +1119,7 @@ export type WorkspaceBoundApi = { createJupyterAccessTicket: ( session: ActiveEditSession, ) => Promise; + getLatestScriptVersion: (scriptId: string) => Promise; listScriptVersions: (scriptId: string) => Promise; publishScriptVersion: ( input: Parameters[1],