[fix]:[FL-215][ATP模型管理去掉版本概念:上传文件直接用于展示]

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
chengkai3
2026-06-28 18:47:18 +08:00
parent 54384cb829
commit 08ba3e19d3
6 changed files with 373 additions and 15 deletions
+36
View File
@@ -9,6 +9,7 @@ from ...schemas.atp_asset import (
AtpAssetCreateRequest, AtpAssetCreateRequest,
AtpAssetDetail, AtpAssetDetail,
AtpAssetFileListResponse, AtpAssetFileListResponse,
AtpAssetFileUploadResponse,
AtpAssetListResponse, AtpAssetListResponse,
AtpAssetReleaseCreateRequest, AtpAssetReleaseCreateRequest,
AtpAssetReleaseDetail, AtpAssetReleaseDetail,
@@ -32,6 +33,7 @@ from ...services.atp_asset_service import (
get_release_by_id, get_release_by_id,
get_run_detail, get_run_detail,
list_assets, list_assets,
list_asset_files,
list_release_files, list_release_files,
list_releases, list_releases,
list_runs, list_runs,
@@ -40,6 +42,7 @@ from ...services.atp_asset_service import (
serialize_release_detail, serialize_release_detail,
update_asset, update_asset,
update_release, update_release,
upload_asset_archive,
) )
router = APIRouter(prefix="/atp", tags=["atp-assets"], dependencies=[Depends(require_enabled_menu_route)]) router = APIRouter(prefix="/atp", tags=["atp-assets"], dependencies=[Depends(require_enabled_menu_route)])
@@ -88,6 +91,30 @@ def create_atp_asset_endpoint(
return AtpAssetDetail(**created.model_dump()) return AtpAssetDetail(**created.model_dump())
@router.post("/assets/{asset_id}/files/upload", response_model=AtpAssetFileUploadResponse)
def upload_atp_asset_files_endpoint(
asset_id: str,
archive: UploadFile = File(...),
current_user: CurrentUser = Depends(require_permission("atp.manage")),
db: Session = Depends(get_db),
) -> AtpAssetFileUploadResponse:
try:
archive_content = archive.file.read()
finally:
try:
archive.file.close()
except Exception:
pass
return upload_asset_archive(
db,
asset_id=asset_id,
archive_filename=archive.filename or "model.zip",
archive_content=archive_content,
actor_user_id=current_user.user.id,
)
@router.get("/assets/{asset_id}", response_model=AtpAssetDetail) @router.get("/assets/{asset_id}", response_model=AtpAssetDetail)
def get_atp_asset_detail( def get_atp_asset_detail(
asset_id: str, asset_id: str,
@@ -245,6 +272,15 @@ def get_atp_release_files(
return list_release_files(db, release_id=release_id) return list_release_files(db, release_id=release_id)
@router.get("/assets/{asset_id}/files", response_model=AtpAssetFileListResponse)
def get_atp_asset_files(
asset_id: str,
_: CurrentUser = Depends(require_any_permission("atp.read", "atp.run", "atp.manage")),
db: Session = Depends(get_db),
) -> AtpAssetFileListResponse:
return list_asset_files(db, asset_id=asset_id)
@router.get("/releases/{release_id}/runs", response_model=AtpAssetRunListResponse) @router.get("/releases/{release_id}/runs", response_model=AtpAssetRunListResponse)
def get_atp_release_runs( def get_atp_release_runs(
release_id: str, release_id: str,
+12 -1
View File
@@ -26,6 +26,8 @@ class AtpAssetSummary(BaseModel):
active_release_no: int | None = None active_release_no: int | None = None
active_release_id: str | None = None active_release_id: str | None = None
active_release_tag: str | None = None active_release_tag: str | None = None
storage_mount_code: str | None = None
storage_root_path: str | None = None
release_count: int = 0 release_count: int = 0
run_count: int = 0 run_count: int = 0
last_run_status: AtpAssetRunStatus | None = None last_run_status: AtpAssetRunStatus | None = None
@@ -151,13 +153,22 @@ class AtpAssetFileEntry(BaseModel):
class AtpAssetFileListResponse(BaseModel): class AtpAssetFileListResponse(BaseModel):
release_id: str asset_id: str
release_id: str | None = None
storage_mount_code: str storage_mount_code: str
storage_root_path: str storage_root_path: str
items: list[AtpAssetFileEntry] items: list[AtpAssetFileEntry]
total: int total: int
class AtpAssetFileUploadResponse(BaseModel):
asset_id: str
storage_mount_code: str
storage_root_path: str
uploaded_count: int
success: bool = True
class AtpAssetRunSummary(BaseModel): class AtpAssetRunSummary(BaseModel):
id: str id: str
asset_id: str asset_id: str
+233 -4
View File
@@ -15,6 +15,7 @@ import time
import zipfile import zipfile
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from uuid import uuid4
from fastapi import HTTPException, status from fastapi import HTTPException, status
from sqlalchemy import func, or_, select from sqlalchemy import func, or_, select
@@ -29,6 +30,7 @@ from ..schemas.atp_asset import (
AtpAssetDetail, AtpAssetDetail,
AtpAssetFileEntry, AtpAssetFileEntry,
AtpAssetFileListResponse, AtpAssetFileListResponse,
AtpAssetFileUploadResponse,
AtpAssetListResponse, AtpAssetListResponse,
AtpAssetReleaseCreateRequest, AtpAssetReleaseCreateRequest,
AtpAssetReleaseDetail, AtpAssetReleaseDetail,
@@ -65,6 +67,7 @@ VALID_RUNNER_KIND = {"atp", "egm", "hybrid"}
VALID_RUN_STATUS = {"pending", "running", "success", "failed"} VALID_RUN_STATUS = {"pending", "running", "success", "failed"}
LOG_MAX_CHARS = 200_000 LOG_MAX_CHARS = 200_000
ATP_ASSET_RELEASES_ROOT = "/atp-library" ATP_ASSET_RELEASES_ROOT = "/atp-library"
ATP_ASSET_FILES_ROOT = "/atp-library/assets"
@dataclass(slots=True) @dataclass(slots=True)
@@ -355,6 +358,14 @@ def _build_release_storage_root(asset_code: str, release_no: int, voltage_level:
return normalize_virtual_path(f"{ATP_ASSET_RELEASES_ROOT}/{voltage_segment}/{tower_segment}/r{release_no}") return normalize_virtual_path(f"{ATP_ASSET_RELEASES_ROOT}/{voltage_segment}/{tower_segment}/r{release_no}")
def _build_asset_storage_root(asset: AtpAsset) -> str:
return normalize_virtual_path(f"{ATP_ASSET_FILES_ROOT}/{asset.id}")
def _asset_storage_mount() -> str:
return "main"
def _write_archive_to_storage( def _write_archive_to_storage(
driver: StorageDriver, driver: StorageDriver,
*, *,
@@ -364,9 +375,9 @@ def _write_archive_to_storage(
) -> int: ) -> int:
filename = (archive_filename or "").strip().lower() filename = (archive_filename or "").strip().lower()
if filename and not filename.endswith(".zip"): if filename and not filename.endswith(".zip"):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包必须是 zip 格式") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="ZIP 包必须是 zip 格式")
if not archive_content: if not archive_content:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包不能为空") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="ZIP 包不能为空")
driver.ensure_directory(storage_root_path) driver.ensure_directory(storage_root_path)
ensured_directories = {normalize_virtual_path(storage_root_path)} ensured_directories = {normalize_virtual_path(storage_root_path)}
@@ -398,13 +409,41 @@ def _write_archive_to_storage(
) )
extracted_count += 1 extracted_count += 1
except zipfile.BadZipFile as exc: except zipfile.BadZipFile as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"Release ZIP 文件损坏: {exc}") from exc raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"ZIP 文件损坏: {exc}") from exc
if extracted_count <= 0: if extracted_count <= 0:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Release ZIP 包中没有可导入文件") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="ZIP 包中没有可导入文件")
return extracted_count return extracted_count
def _read_upload_file_bytes(file: Any) -> tuple[str, bytes, str | None]:
filename = (getattr(file, "filename", None) or "").strip() or "upload.zip"
content_type = getattr(file, "content_type", None)
try:
content = file.file.read()
finally:
try:
file.file.close()
except Exception:
pass
return filename, content, content_type
def _write_archive_to_asset_storage(
driver: StorageDriver,
*,
asset_storage_root: str,
archive_filename: str,
archive_content: bytes,
) -> int:
return _write_archive_to_storage(
driver,
storage_root_path=asset_storage_root,
archive_filename=archive_filename,
archive_content=archive_content,
)
def _resolve_runner_kind_from_tree(tree: StorageTree) -> str: def _resolve_runner_kind_from_tree(tree: StorageTree) -> str:
detected_entry = _auto_detect_entry_file(tree) detected_entry = _auto_detect_entry_file(tree)
detected_egm_subdir = _auto_detect_egm_subdir(tree) detected_egm_subdir = _auto_detect_egm_subdir(tree)
@@ -584,6 +623,8 @@ def serialize_asset(
active_release_no=item.active_release_no, active_release_no=item.active_release_no,
active_release_id=active_release.id if active_release else None, active_release_id=active_release.id if active_release else None,
active_release_tag=active_release.release_tag if active_release else None, active_release_tag=active_release.release_tag if active_release else None,
storage_mount_code=_asset_storage_mount(),
storage_root_path=_build_asset_storage_root(item),
release_count=release_count, release_count=release_count,
run_count=run_count, run_count=run_count,
last_run_status=last_run_status, # type: ignore[arg-type] last_run_status=last_run_status, # type: ignore[arg-type]
@@ -838,6 +879,108 @@ def create_asset(db: Session, payload: AtpAssetCreateRequest, *, actor_user_id:
return serialize_asset(saved, release_count=0, run_count=0, last_run_status=None, last_run_date=None, active_release=None) return serialize_asset(saved, release_count=0, run_count=0, last_run_status=None, last_run_date=None, active_release=None)
def upload_asset_archive(
db: Session,
*,
asset_id: str,
archive_filename: str,
archive_content: bytes,
actor_user_id: str,
) -> AtpAssetFileUploadResponse:
asset = get_asset_by_id(db, asset_id)
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
storage_mount_code = _asset_storage_mount()
storage_root_path = _build_asset_storage_root(asset)
mount = _resolve_mount(db, storage_mount_code)
driver = _build_driver_or_400(mount)
root_parent = _parent_virtual_path(storage_root_path)
root_name = PurePosixPath(storage_root_path).name or asset.id
staging_root_path = normalize_virtual_path(f"{root_parent}/{root_name}.__upload__{uuid4().hex}")
backup_root_path = normalize_virtual_path(f"{root_parent}/{root_name}.__backup__{uuid4().hex}")
backup_exists = False
try:
uploaded_count = _write_archive_to_asset_storage(
driver,
asset_storage_root=staging_root_path,
archive_filename=archive_filename,
archive_content=archive_content,
)
try:
driver.list_dir(storage_root_path)
backup_exists = True
except StoragePathNotFoundError:
backup_exists = False
if backup_exists:
driver.move_path(
storage_root_path,
is_dir=True,
target_parent_path=root_parent,
new_name=PurePosixPath(backup_root_path).name,
)
try:
driver.move_path(
staging_root_path,
is_dir=True,
target_parent_path=root_parent,
new_name=root_name,
)
except Exception:
if backup_exists:
try:
driver.move_path(
backup_root_path,
is_dir=True,
target_parent_path=root_parent,
new_name=root_name,
)
except Exception:
pass
raise
finally:
try:
driver.delete_path(staging_root_path, is_dir=True, recursive=True)
except Exception:
pass
if backup_exists:
try:
driver.delete_path(backup_root_path, is_dir=True, recursive=True)
except Exception:
pass
try:
tree = _walk_storage_tree(driver, storage_root_path)
except HTTPException:
tree = StorageTree(files=[], directories=[], file_paths=set(), dir_paths=set(), max_depth=0)
asset.active_release_no = None
asset.update_user = actor_user_id
asset.update_date = utcnow()
db.commit()
_publish_change(
"asset.files_uploaded",
{
"action": "files_uploaded",
"asset_id": asset.id,
"storage_root_path": storage_root_path,
"file_count": len(tree.files),
},
)
return AtpAssetFileUploadResponse(
asset_id=asset.id,
storage_mount_code=storage_mount_code,
storage_root_path=storage_root_path,
uploaded_count=uploaded_count,
success=True,
)
def update_asset( def update_asset(
db: Session, db: Session,
asset_id: str, asset_id: str,
@@ -890,6 +1033,12 @@ def delete_asset(db: Session, asset_id: str) -> bool:
return False return False
# Delete physical files for all releases before deleting database records # Delete physical files for all releases before deleting database records
try:
asset_mount = _resolve_mount(db, _asset_storage_mount())
asset_driver = _build_driver_or_400(asset_mount)
asset_driver.delete_path(_build_asset_storage_root(item), is_dir=True, recursive=True)
except Exception:
pass
for release in item.releases: for release in item.releases:
try: try:
mount = _resolve_mount(db, release.storage_mount_code) mount = _resolve_mount(db, release.storage_mount_code)
@@ -1296,6 +1445,7 @@ def list_release_files(db: Session, *, release_id: str) -> AtpAssetFileListRespo
] ]
merged_items = sorted([*dir_items, *items], key=lambda entry: (not entry.is_dir, entry.relative_path)) merged_items = sorted([*dir_items, *items], key=lambda entry: (not entry.is_dir, entry.relative_path))
return AtpAssetFileListResponse( return AtpAssetFileListResponse(
asset_id=release.asset_id,
release_id=release.id, release_id=release.id,
storage_mount_code=release.storage_mount_code, storage_mount_code=release.storage_mount_code,
storage_root_path=release.storage_root_path, storage_root_path=release.storage_root_path,
@@ -1304,6 +1454,70 @@ def list_release_files(db: Session, *, release_id: str) -> AtpAssetFileListRespo
) )
def list_asset_files(db: Session, *, asset_id: str) -> AtpAssetFileListResponse:
asset = get_asset_by_id(db, asset_id)
if not asset:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Asset not found")
storage_mount_code = _asset_storage_mount()
storage_root_path = _build_asset_storage_root(asset)
mount = _resolve_mount(db, storage_mount_code)
driver = _build_driver_or_400(mount)
try:
tree = _walk_storage_tree(driver, storage_root_path)
except HTTPException as exc:
if exc.status_code == status.HTTP_404_NOT_FOUND:
fallback_release = db.execute(
select(AtpAssetRelease)
.options(joinedload(AtpAssetRelease.asset))
.where(AtpAssetRelease.asset_id == asset.id)
.order_by(AtpAssetRelease.is_active.desc(), AtpAssetRelease.release_no.desc(), AtpAssetRelease.id.desc())
).scalars().first()
if fallback_release:
return list_release_files(db, release_id=fallback_release.id)
return AtpAssetFileListResponse(
asset_id=asset.id,
release_id=None,
storage_mount_code=storage_mount_code,
storage_root_path=storage_root_path,
items=[],
total=0,
)
raise
items = [
AtpAssetFileEntry(
relative_path=_relative_from_root(storage_root_path, item.path),
name=item.name,
is_dir=False,
size=item.size,
mime_type=item.mime_type,
file_role=_infer_asset_file_role(asset, _relative_from_root(storage_root_path, item.path), False),
)
for item in tree.files
]
dir_items = [
AtpAssetFileEntry(
relative_path=path,
name=path.split("/")[-1],
is_dir=True,
size=0,
mime_type=None,
file_role=_infer_asset_file_role(asset, path, True),
)
for path in tree.directories
]
merged_items = sorted([*dir_items, *items], key=lambda entry: (not entry.is_dir, entry.relative_path))
return AtpAssetFileListResponse(
asset_id=asset.id,
release_id=None,
storage_mount_code=storage_mount_code,
storage_root_path=storage_root_path,
items=merged_items,
total=len(merged_items),
)
def _infer_file_role(release: AtpAssetRelease, relative_path: str, is_dir: bool) -> str | None: def _infer_file_role(release: AtpAssetRelease, relative_path: str, is_dir: bool) -> str | None:
if is_dir: if is_dir:
if release.egm_subdir and relative_path == release.egm_subdir: if release.egm_subdir and relative_path == release.egm_subdir:
@@ -1333,6 +1547,21 @@ def _infer_file_role(release: AtpAssetRelease, relative_path: str, is_dir: bool)
return "other" return "other"
def _infer_asset_file_role(asset: AtpAsset, relative_path: str, is_dir: bool) -> str | None:
if is_dir:
return None
lower = relative_path.lower()
if lower.endswith(".atp"):
return "atp"
if lower.endswith(".py"):
return "script"
if lower.endswith(("tpbig.exe", "rjtzl.exe")):
return "executable"
if lower.endswith((".doc", ".docx", ".txt", ".md", ".pdf")):
return "doc"
return "other"
def list_runs( def list_runs(
db: Session, db: Session,
*, *,
+74
View File
@@ -235,6 +235,80 @@ def test_create_release_from_archive_detects_storage_path_conflict(tmp_path) ->
session.close() session.close()
def test_upload_asset_archive_and_list_asset_files(tmp_path) -> None:
testing_session = _build_sessionmaker()
session: Session = testing_session()
try:
_seed_vfs_mount(session, root_dir=tmp_path / "vfs")
asset = atp_asset_service.create_asset(
session,
AtpAssetCreateRequest(
code="ATP-ASSET-DIRECT-UPLOAD",
name="直接上传模型",
voltage_level="220",
tower_type="sihuita",
scene_type="raoji3",
),
actor_user_id="tester",
)
assert asset is not None
uploaded = atp_asset_service.upload_asset_archive(
session,
asset_id=asset.id,
archive_filename="model.zip",
archive_content=_build_zip({"work.atp": b"ATP INPUT", "docs/readme.txt": b"docs"}),
actor_user_id="tester",
)
assert uploaded.success is True
assert uploaded.storage_root_path == f"/atp-library/assets/{asset.id}"
files = atp_asset_service.list_asset_files(session, asset_id=asset.id)
assert files.asset_id == asset.id
assert files.release_id is None
assert files.total == 3
assert [item.relative_path for item in files.items] == ["docs", "docs/readme.txt", "work.atp"]
finally:
session.close()
def test_list_asset_files_falls_back_to_latest_release(tmp_path) -> None:
testing_session = _build_sessionmaker()
session: Session = testing_session()
try:
_seed_vfs_mount(session, root_dir=tmp_path / "vfs")
asset = atp_asset_service.create_asset(
session,
AtpAssetCreateRequest(
code="ATP-ASSET-LEGACY-FALLBACK",
name="旧版本模型",
voltage_level="220",
tower_type="sihuita",
scene_type="raoji3",
),
actor_user_id="tester",
)
assert asset is not None
release = atp_asset_service.create_release_from_archive(
session,
asset_id=asset.id,
release_tag="v1",
archive_filename="release.zip",
archive_content=_build_zip({"work.atp": b"ATP INPUT", "docs/readme.txt": b"docs"}),
actor_user_id="tester",
)
files = atp_asset_service.list_asset_files(session, asset_id=asset.id)
assert files.asset_id == asset.id
assert files.release_id == release.id
assert files.total == 3
assert [item.relative_path for item in files.items] == ["docs", "docs/readme.txt", "work.atp"]
finally:
session.close()
def test_list_assets_paginates_after_filtering(tmp_path) -> None: def test_list_assets_paginates_after_filtering(tmp_path) -> None:
testing_session = _build_sessionmaker() testing_session = _build_sessionmaker()
session: Session = testing_session() session: Session = testing_session()
+6 -9
View File
@@ -19,7 +19,6 @@ import {
Breadcrumb, Breadcrumb,
Typography, Typography,
Upload, Upload,
message,
type CardProps, type CardProps,
} from "antd"; } from "antd";
import { UploadOutlined, FolderOutlined, FileOutlined, TableOutlined, FolderOpenOutlined, HomeOutlined } from "@ant-design/icons"; import { UploadOutlined, FolderOutlined, FileOutlined, TableOutlined, FolderOpenOutlined, HomeOutlined } from "@ant-design/icons";
@@ -31,7 +30,7 @@ import { useAuth } from "@/components/auth-provider";
import { useMobileDetection } from "@/hooks/use-mobile-detection"; import { useMobileDetection } from "@/hooks/use-mobile-detection";
import { useToastFeedback } from "@/hooks/use-toast-feedback"; import { useToastFeedback } from "@/hooks/use-toast-feedback";
import { readApiError } from "@/lib/api"; import { readApiError } from "@/lib/api";
import type { AtpAssetListResponse, AtpAssetSummary, AtpAssetFileEntry } from "@/types/auth"; import type { AtpAssetListResponse, AtpAssetSummary } from "@/types/auth";
const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>; const AntCard = Card as unknown as ComponentType<CardProps & RefAttributes<HTMLDivElement>>;
@@ -229,20 +228,18 @@ export default function AtpModelsPage() {
const createdAsset = await response.json(); const createdAsset = await response.json();
if (values.files.length > 0) { if (values.files.length > 0) {
const formData = new FormData();
const JSZip = (await import("jszip")).default; const JSZip = (await import("jszip")).default;
const zip = new JSZip(); const zip = new JSZip();
for (const file of values.files) { for (const file of values.files) {
const path = (file as any).webkitRelativePath || file.name; const path = (file as any).webkitRelativePath || file.name;
zip.file(path, file); zip.file(path, file);
} }
const zipBlob = await zip.generateAsync({ type: "blob" }); const zipBlob = await zip.generateAsync({ type: "blob" });
const formData = new FormData();
formData.append("archive", zipBlob, "model.zip"); formData.append("archive", zipBlob, "model.zip");
const uploadResponse = await fetchWithAuth( const uploadResponse = await fetchWithAuth(
`/api/v1/atp/assets/${createdAsset.id}/releases/upload`, `/api/v1/atp/assets/${createdAsset.id}/files/upload`,
{ {
method: "POST", method: "POST",
body: formData, body: formData,
@@ -536,10 +533,10 @@ export default function AtpModelsPage() {
const filesQueries = useQueries({ const filesQueries = useQueries({
queries: assetsInCurrentPath.map((asset) => ({ queries: assetsInCurrentPath.map((asset) => ({
queryKey: ["atp-asset-files", asset.id, asset.active_release_id], queryKey: ["atp-asset-files", asset.id],
enabled: Boolean(user && canRead && asset.active_release_id && fileViewPath.length >= 4), enabled: Boolean(user && canRead && fileViewPath.length >= 4),
queryFn: async () => { queryFn: async () => {
const response = await fetchWithAuth(`/api/v1/atp/assets/${asset.id}/releases/${asset.active_release_id}/files`); const response = await fetchWithAuth(`/api/v1/atp/assets/${asset.id}/files`);
if (!response.ok) { if (!response.ok) {
return { assetId: asset.id, items: [] }; return { assetId: asset.id, items: [] };
} }
+12 -1
View File
@@ -923,6 +923,8 @@ export type AtpAssetSummary = {
active_release_no: number | null; active_release_no: number | null;
active_release_id: string | null; active_release_id: string | null;
active_release_tag: string | null; active_release_tag: string | null;
storage_mount_code: string | null;
storage_root_path: string | null;
release_count: number; release_count: number;
run_count: number; run_count: number;
last_run_status: AtpAssetRunStatus | null; last_run_status: AtpAssetRunStatus | null;
@@ -987,13 +989,22 @@ export type AtpAssetFileEntry = {
}; };
export type AtpAssetFileListResponse = { export type AtpAssetFileListResponse = {
release_id: string; asset_id: string;
release_id: string | null;
storage_mount_code: string; storage_mount_code: string;
storage_root_path: string; storage_root_path: string;
items: AtpAssetFileEntry[]; items: AtpAssetFileEntry[];
total: number; total: number;
}; };
export type AtpAssetFileUploadResponse = {
asset_id: string;
storage_mount_code: string;
storage_root_path: string;
uploaded_count: number;
success: boolean;
};
export type AtpAssetRunSummary = { export type AtpAssetRunSummary = {
id: string; id: string;
asset_id: string; asset_id: string;