update: save version

This commit is contained in:
tao.chen
2026-08-04 13:35:16 +08:00
parent 14e84b6ce2
commit e18a7a34a3
6 changed files with 156 additions and 25 deletions
+18
View File
@@ -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"]
+88 -20
View File
@@ -835,11 +835,24 @@ 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,
@@ -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 · <id>". 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,
+2
View File
@@ -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),
@@ -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<StableVersion[]>([]);
const [versionsLoading, setVersionsLoading] = useState(false);
const [latestVersion, setLatestVersion] = useState<LatestVersion | null>(
null,
);
const [latestVersionLoading, setLatestVersionLoading] = useState(false);
const [publishTarget, setPublishTarget] = useState<ScriptItem | null>(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={() => {
@@ -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;
+18
View File
@@ -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<LatestVersion | null> {
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<JupyterAccessTicket>;
getLatestScriptVersion: (scriptId: string) => Promise<LatestVersion | null>;
listScriptVersions: (scriptId: string) => Promise<StableVersion[]>;
publishScriptVersion: (
input: Parameters<typeof publishScriptVersion>[1],