Files
dify/api/services/app_asset_package_service.py
Harry 65e89520c0 refactor: unify download item types and eliminate extension-based branching
Merge AssetDownloadItem, AssetInlineItem into SandboxDownloadItem with
optional 'content' field. All consumers now follow a clean pipeline:
  get items → accessor.resolve_items() → AppAssetService.to_download_items() → download

Key changes:
- SandboxDownloadItem gains content: bytes | None (entities.py)
- ZipSandbox.download_items() handles both inline (base64 heredoc) and
  remote (curl) via a single pipeline — no structural branching
- AssetDownloadService.build_download_script() takes unified list
- CachedContentAccessor.resolve_items() batch-enriches items from DB
  (extension-agnostic, no 'if md' checks needed)
- AppAssetService.to_download_items() converts AssetItem → SandboxDownloadItem
- DraftAppAssetsInitializer, package_and_upload, export_bundle simplified
- file_upload/node.py switched to SandboxDownloadItem
- Deleted AssetDownloadItem and AssetInlineItem classes
2026-03-10 17:12:15 +08:00

196 lines
7.0 KiB
Python

"""Service for packaging and publishing app assets.
This service handles operations that require core.zip_sandbox,
separated from AppAssetService to avoid circular imports.
Dependency flow:
core/* -> AppAssetPackageService -> AppAssetService
(core modules can import this service without circular dependency)
Inline content optimisation:
``AssetItem`` objects returned by the build pipeline may carry an
in-process *content* field (e.g. resolved ``.md`` skill documents).
``AppAssetService.to_download_items()`` converts these into unified
``SandboxDownloadItem`` instances, and ``ZipSandbox.download_items()``
handles both inline and remote items natively.
"""
import logging
from uuid import uuid4
from sqlalchemy.orm import Session
from core.app.entities.app_asset_entities import AppAssetFileTree
from core.app_assets.builder import AssetBuildPipeline, BuildContext
from core.app_assets.builder.file_builder import FileBuilder
from core.app_assets.builder.skill_builder import SkillBuilder
from core.app_assets.entities.assets import AssetItem
from core.app_assets.storage import AssetPaths
from core.zip_sandbox import ZipSandbox
from models.app_asset import AppAssets
from models.model import App
logger = logging.getLogger(__name__)
class AppAssetPackageService:
"""Service for packaging and publishing app assets.
This service is designed to be imported by core/* modules without
causing circular imports. It depends on AppAssetService for basic
asset operations but provides the packaging/publishing functionality
that requires core.zip_sandbox.
"""
@staticmethod
def get_tenant_app_assets(tenant_id: str, assets_id: str) -> AppAssets:
"""Get app assets by tenant_id and assets_id.
This is a read-only operation that doesn't require AppAssetService.
"""
from extensions.ext_database import db
with Session(db.engine, expire_on_commit=False) as session:
app_assets = (
session.query(AppAssets)
.filter(
AppAssets.tenant_id == tenant_id,
AppAssets.id == assets_id,
)
.first()
)
if not app_assets:
raise ValueError(f"App assets not found for tenant_id={tenant_id}, assets_id={assets_id}")
return app_assets
@staticmethod
def get_draft_asset_items(tenant_id: str, app_id: str, file_tree: AppAssetFileTree) -> list[AssetItem]:
"""Convert file tree to asset items for packaging."""
files = file_tree.walk_files()
return [
AssetItem(
asset_id=f.id,
path=file_tree.get_path(f.id),
file_name=f.name,
extension=f.extension,
storage_key=AssetPaths.draft(tenant_id, app_id, f.id),
)
for f in files
]
@staticmethod
def package_and_upload(
*,
assets: list[AssetItem],
upload_url: str,
tenant_id: str,
app_id: str,
user_id: str,
storage_key: str = "",
) -> None:
"""Package assets into a ZIP and upload directly to the given URL.
Uses ``AppAssetService.to_download_items()`` to convert assets
into unified download items, then ``ZipSandbox.download_items()``
handles both inline content and remote presigned URLs natively.
When *assets* is empty an empty ZIP is written directly to storage
using *storage_key*, bypassing the HTTP ticket URL.
"""
from services.app_asset_service import AppAssetService
if not assets:
import io
import zipfile
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w"):
pass
buf.seek(0)
# Write directly to storage instead of going through the HTTP
# ticket URL. The ticket URL (FILES_API_URL) is designed for
# sandbox containers (agentbox) and is not routable from the api
# container in standard Docker Compose deployments.
if storage_key:
from extensions.ext_storage import storage
storage.save(storage_key, buf.getvalue())
else:
import requests
requests.put(upload_url, data=buf.getvalue(), timeout=30)
return
download_items = AppAssetService.to_download_items(assets)
with ZipSandbox(tenant_id=tenant_id, user_id=user_id, app_id="asset-packager") as zs:
zs.download_items(download_items)
archive = zs.zip()
zs.upload(archive, upload_url)
@staticmethod
def publish(session: Session, app_model: App, account_id: str, workflow_id: str) -> AppAssets:
"""Publish app assets for a workflow.
Creates a versioned copy of draft assets and packages them for
runtime use. The build ZIP contains resolved ``.md`` content
(inline from ``SkillBuilder``) and raw draft content for all
other files. A separate source ZIP snapshots the raw drafts for
later export.
"""
from services.app_asset_service import AppAssetService
tenant_id = app_model.tenant_id
app_id = app_model.id
assets = AppAssetService.get_or_create_assets(session, app_model, account_id)
tree = assets.asset_tree
publish_id = str(uuid4())
published = AppAssets(
id=publish_id,
tenant_id=tenant_id,
app_id=app_id,
version=workflow_id,
created_by=account_id,
)
published.asset_tree = tree
session.add(published)
session.flush()
asset_storage = AppAssetService.get_storage()
accessor = AppAssetService.get_accessor(tenant_id, app_id)
build_pipeline = AssetBuildPipeline([SkillBuilder(accessor=accessor), FileBuilder()])
ctx = BuildContext(tenant_id=tenant_id, app_id=app_id, build_id=publish_id)
built_assets = build_pipeline.build_all(tree, ctx)
# Runtime ZIP: resolved .md (inline) + raw draft (remote).
runtime_zip_key = AssetPaths.build_zip(tenant_id, app_id, publish_id)
runtime_upload_url = asset_storage.get_upload_url(runtime_zip_key)
AppAssetPackageService.package_and_upload(
assets=built_assets,
upload_url=runtime_upload_url,
tenant_id=tenant_id,
app_id=app_id,
user_id=account_id,
storage_key=runtime_zip_key,
)
# Source ZIP: all raw draft content (for export/restore).
source_items = AppAssetService.get_draft_assets(tenant_id, app_id)
source_key = AssetPaths.source_zip(tenant_id, app_id, workflow_id)
source_upload_url = asset_storage.get_upload_url(source_key)
AppAssetPackageService.package_and_upload(
assets=source_items,
upload_url=source_upload_url,
tenant_id=tenant_id,
app_id=app_id,
user_id=account_id,
storage_key=source_key,
)
return published