update: save version
This commit is contained in:
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user