diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index fc11b6f8f3..e98a1bfe44 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -50,6 +50,7 @@ from .app import ( agent, annotation, app, + app_asset, audio, completion, conversation, @@ -145,6 +146,7 @@ __all__ = [ "api", "apikey", "app", + "app_asset", "audio", "billing", "bp", diff --git a/api/controllers/console/app/app_asset.py b/api/controllers/console/app/app_asset.py new file mode 100644 index 0000000000..046660d680 --- /dev/null +++ b/api/controllers/console/app/app_asset.py @@ -0,0 +1,259 @@ +from flask import request +from flask_restx import Resource +from pydantic import BaseModel, Field, field_validator + +from controllers.console import console_ns +from controllers.console.app.error import ( + AppAssetFileRequiredError, + AppAssetNodeNotFoundError, + AppAssetPathConflictError, +) +from controllers.console.app.wraps import get_app_model +from controllers.console.wraps import account_initialization_required, setup_required +from libs.login import current_account_with_tenant, login_required +from models import App +from models.model import AppMode +from services.app_asset_service import AppAssetService +from services.errors.app_asset import ( + AppAssetNodeNotFoundError as ServiceNodeNotFoundError, +) +from services.errors.app_asset import ( + AppAssetParentNotFoundError, +) +from services.errors.app_asset import ( + AppAssetPathConflictError as ServicePathConflictError, +) + +DEFAULT_REF_TEMPLATE_SWAGGER_2_0 = "#/definitions/{model}" + + +class CreateFolderPayload(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + parent_id: str | None = None + + +class CreateFilePayload(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + parent_id: str | None = None + + @field_validator("name", mode="before") + @classmethod + def strip_name(cls, v: str) -> str: + return v.strip() if isinstance(v, str) else v + + @field_validator("parent_id", mode="before") + @classmethod + def empty_to_none(cls, v: str | None) -> str | None: + return v or None + + +class UpdateFileContentPayload(BaseModel): + content: str + + +class RenameNodePayload(BaseModel): + name: str = Field(..., min_length=1, max_length=255) + + +class MoveNodePayload(BaseModel): + parent_id: str | None = None + + +class ReorderNodePayload(BaseModel): + after_node_id: str | None = Field(default=None, description="Place after this node, None for first position") + + +def reg(cls: type[BaseModel]) -> None: + console_ns.schema_model(cls.__name__, cls.model_json_schema(ref_template=DEFAULT_REF_TEMPLATE_SWAGGER_2_0)) + + +reg(CreateFolderPayload) +reg(CreateFilePayload) +reg(UpdateFileContentPayload) +reg(RenameNodePayload) +reg(MoveNodePayload) +reg(ReorderNodePayload) + + +@console_ns.route("/apps//assets/tree") +class AppAssetTreeResource(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + def get(self, app_model: App): + current_user, _ = current_account_with_tenant() + tree = AppAssetService.get_asset_tree(app_model, current_user.id) + return {"children": [view.model_dump() for view in tree.transform()]} + + +@console_ns.route("/apps//assets/folders") +class AppAssetFolderResource(Resource): + @console_ns.expect(console_ns.models[CreateFolderPayload.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + def post(self, app_model: App): + current_user, _ = current_account_with_tenant() + payload = CreateFolderPayload.model_validate(console_ns.payload or {}) + + try: + node = AppAssetService.create_folder(app_model, current_user.id, payload.name, payload.parent_id) + return node.model_dump(), 201 + except AppAssetParentNotFoundError: + raise AppAssetNodeNotFoundError() + except ServicePathConflictError: + raise AppAssetPathConflictError() + + +@console_ns.route("/apps//assets/files") +class AppAssetFileResource(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + def post(self, app_model: App): + current_user, _ = current_account_with_tenant() + + file = request.files.get("file") + if not file: + raise AppAssetFileRequiredError() + + payload = CreateFilePayload.model_validate(request.form.to_dict()) + content = file.read() + + try: + node = AppAssetService.create_file(app_model, current_user.id, payload.name, content, payload.parent_id) + return node.model_dump(), 201 + except AppAssetParentNotFoundError: + raise AppAssetNodeNotFoundError() + except ServicePathConflictError: + raise AppAssetPathConflictError() + + +@console_ns.route("/apps//assets/files/") +class AppAssetFileDetailResource(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + def get(self, app_model: App, node_id: str): + current_user, _ = current_account_with_tenant() + try: + content = AppAssetService.get_file_content(app_model, current_user.id, node_id) + return {"content": content.decode("utf-8", errors="replace")} + except ServiceNodeNotFoundError: + raise AppAssetNodeNotFoundError() + + @console_ns.expect(console_ns.models[UpdateFileContentPayload.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + def put(self, app_model: App, node_id: str): + current_user, _ = current_account_with_tenant() + + file = request.files.get("file") + if file: + content = file.read() + else: + payload = UpdateFileContentPayload.model_validate(console_ns.payload or {}) + content = payload.content.encode("utf-8") + + try: + node = AppAssetService.update_file_content(app_model, current_user.id, node_id, content) + return node.model_dump() + except ServiceNodeNotFoundError: + raise AppAssetNodeNotFoundError() + + +@console_ns.route("/apps//assets/nodes/") +class AppAssetNodeResource(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + def delete(self, app_model: App, node_id: str): + current_user, _ = current_account_with_tenant() + try: + AppAssetService.delete_node(app_model, current_user.id, node_id) + return {"result": "success"}, 200 + except ServiceNodeNotFoundError: + raise AppAssetNodeNotFoundError() + + +@console_ns.route("/apps//assets/nodes//rename") +class AppAssetNodeRenameResource(Resource): + @console_ns.expect(console_ns.models[RenameNodePayload.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + def post(self, app_model: App, node_id: str): + current_user, _ = current_account_with_tenant() + payload = RenameNodePayload.model_validate(console_ns.payload or {}) + + try: + node = AppAssetService.rename_node(app_model, current_user.id, node_id, payload.name) + return node.model_dump() + except ServiceNodeNotFoundError: + raise AppAssetNodeNotFoundError() + except ServicePathConflictError: + raise AppAssetPathConflictError() + + +@console_ns.route("/apps//assets/nodes//move") +class AppAssetNodeMoveResource(Resource): + @console_ns.expect(console_ns.models[MoveNodePayload.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + def post(self, app_model: App, node_id: str): + current_user, _ = current_account_with_tenant() + payload = MoveNodePayload.model_validate(console_ns.payload or {}) + + try: + node = AppAssetService.move_node(app_model, current_user.id, node_id, payload.parent_id) + return node.model_dump() + except ServiceNodeNotFoundError: + raise AppAssetNodeNotFoundError() + except AppAssetParentNotFoundError: + raise AppAssetNodeNotFoundError() + except ServicePathConflictError: + raise AppAssetPathConflictError() + + +@console_ns.route("/apps//assets/nodes//reorder") +class AppAssetNodeReorderResource(Resource): + @console_ns.expect(console_ns.models[ReorderNodePayload.__name__]) + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + def post(self, app_model: App, node_id: str): + current_user, _ = current_account_with_tenant() + payload = ReorderNodePayload.model_validate(console_ns.payload or {}) + + try: + node = AppAssetService.reorder_node(app_model, current_user.id, node_id, payload.after_node_id) + return node.model_dump() + except ServiceNodeNotFoundError: + raise AppAssetNodeNotFoundError() + + +@console_ns.route("/apps//assets/publish") +class AppAssetPublishResource(Resource): + @setup_required + @login_required + @account_initialization_required + @get_app_model(mode=[AppMode.ADVANCED_CHAT, AppMode.WORKFLOW]) + def post(self, app_model: App): + current_user, _ = current_account_with_tenant() + published = AppAssetService.publish(app_model, current_user.id) + return { + "id": published.id, + "version": published.version, + "asset_tree": published.asset_tree.model_dump(), + }, 201 diff --git a/api/controllers/console/app/error.py b/api/controllers/console/app/error.py index fbd7901646..c4cf4005f5 100644 --- a/api/controllers/console/app/error.py +++ b/api/controllers/console/app/error.py @@ -110,8 +110,24 @@ class TracingConfigCheckError(BaseHTTPException): class InvokeRateLimitError(BaseHTTPException): - """Raised when the Invoke returns rate limit error.""" - error_code = "rate_limit_error" description = "Rate Limit Error" code = 429 + + +class AppAssetNodeNotFoundError(BaseHTTPException): + error_code = "app_asset_node_not_found" + description = "App asset node not found." + code = 404 + + +class AppAssetFileRequiredError(BaseHTTPException): + error_code = "app_asset_file_required" + description = "File is required." + code = 400 + + +class AppAssetPathConflictError(BaseHTTPException): + error_code = "app_asset_path_conflict" + description = "Path already exists." + code = 409 diff --git a/api/migrations/versions/2026_01_14_1215-a1b2c3d4e5f6_add_app_asset_drafts_table.py b/api/migrations/versions/2026_01_14_1215-a1b2c3d4e5f6_add_app_asset_drafts_table.py new file mode 100644 index 0000000000..6f847683fd --- /dev/null +++ b/api/migrations/versions/2026_01_14_1215-a1b2c3d4e5f6_add_app_asset_drafts_table.py @@ -0,0 +1,39 @@ +"""add app_asset_drafts table. + +Revision ID: a1b2c3d4e5f6 +Revises: 85c8b4a64f53 +Create Date: 2026-01-14 12:15:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +revision = "a1b2c3d4e5f6" +down_revision = "85c8b4a64f53" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "app_asset_drafts", + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("version", sa.String(length=255), nullable=False), + sa.Column("asset_tree", models.types.LongText(), nullable=False), + sa.Column("created_by", models.types.StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.Column("updated_by", models.types.StringUUID(), nullable=True), + sa.Column("updated_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP"), nullable=False), + sa.PrimaryKeyConstraint("id", name="app_asset_draft_pkey"), + ) + with op.batch_alter_table("app_asset_drafts", schema=None) as batch_op: + batch_op.create_index("app_asset_draft_version_idx", ["tenant_id", "app_id", "version"], unique=False) + + +def downgrade(): + op.drop_table("app_asset_drafts") diff --git a/api/models/__init__.py b/api/models/__init__.py index 3fba153f8a..44eecac4ba 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -9,6 +9,7 @@ from .account import ( TenantStatus, ) from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint +from .app_asset import AppAssetDraft from .dataset import ( AppDatasetJoin, Dataset, @@ -123,6 +124,7 @@ __all__ = [ "App", "AppAnnotationHitHistory", "AppAnnotationSetting", + "AppAssetDraft", "AppDatasetJoin", "AppMCPServer", "AppMode", diff --git a/api/models/app_asset.py b/api/models/app_asset.py new file mode 100644 index 0000000000..aaaa03e065 --- /dev/null +++ b/api/models/app_asset.py @@ -0,0 +1,57 @@ +from datetime import datetime +from uuid import uuid4 + +import sqlalchemy as sa +from sqlalchemy import DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from .app_asset_tree import AppAssetFileTree +from .base import Base +from .types import LongText, StringUUID + + +class AppAssetDraft(Base): + __tablename__ = "app_asset_drafts" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="app_asset_draft_pkey"), + sa.Index("app_asset_draft_version_idx", "tenant_id", "app_id", "version"), + ) + + VERSION_DRAFT = "draft" + + id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4())) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + version: Mapped[str] = mapped_column(String(255), nullable=False) + _asset_tree: Mapped[str] = mapped_column("asset_tree", LongText, nullable=False, default='{"nodes":[]}') + created_by: Mapped[str] = mapped_column(StringUUID, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + updated_by: Mapped[str | None] = mapped_column(StringUUID) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + nullable=False, + default=func.current_timestamp(), + server_default=func.current_timestamp(), + onupdate=func.current_timestamp(), + ) + + @property + def asset_tree(self) -> AppAssetFileTree: + if not self._asset_tree: + return AppAssetFileTree() + return AppAssetFileTree.model_validate_json(self._asset_tree) + + @asset_tree.setter + def asset_tree(self, value: AppAssetFileTree) -> None: + self._asset_tree = value.model_dump_json() + + @staticmethod + def get_storage_key(tenant_id: str, app_id: str, node_id: str) -> str: + return f"app_assets/{tenant_id}/{app_id}/draft/{node_id}" + + @staticmethod + def get_published_storage_key(tenant_id: str, app_id: str, draft_id: str) -> str: + return f"app_assets/{tenant_id}/{app_id}/published/{draft_id}.zip" + + def __repr__(self) -> str: + return f"" diff --git a/api/models/app_asset_tree.py b/api/models/app_asset_tree.py new file mode 100644 index 0000000000..653e0e2a93 --- /dev/null +++ b/api/models/app_asset_tree.py @@ -0,0 +1,230 @@ +from __future__ import annotations + +from collections import defaultdict +from collections.abc import Generator +from enum import StrEnum + +from pydantic import BaseModel, Field + + +class AssetNodeType(StrEnum): + FILE = "file" + FOLDER = "folder" + + +class AppAssetNode(BaseModel): + id: str = Field(description="Unique identifier for the node") + node_type: AssetNodeType = Field(description="Type of node: file or folder") + name: str = Field(description="Name of the file or folder") + parent_id: str | None = Field(default=None, description="Parent folder ID, None for root level") + order: int = Field(default=0, description="Sort order within parent folder, lower values first") + extension: str = Field(default="", description="File extension without dot, empty for folders") + size: int = Field(default=0, description="File size in bytes, 0 for folders") + checksum: str = Field(default="", description="SHA-256 checksum of file content, empty for folders") + + @classmethod + def create_folder(cls, node_id: str, name: str, parent_id: str | None = None) -> AppAssetNode: + return cls(id=node_id, node_type=AssetNodeType.FOLDER, name=name, parent_id=parent_id) + + @classmethod + def create_file( + cls, node_id: str, name: str, parent_id: str | None = None, size: int = 0, checksum: str = "" + ) -> AppAssetNode: + return cls( + id=node_id, + node_type=AssetNodeType.FILE, + name=name, + parent_id=parent_id, + extension=name.rsplit(".", 1)[-1] if "." in name else "", + size=size, + checksum=checksum, + ) + + +class AppAssetTreeView(BaseModel): + id: str = Field(description="Unique identifier for the node") + node_type: str = Field(description="Type of node: 'file' or 'folder'") + name: str = Field(description="Name of the file or folder") + path: str = Field(description="Full path from root, e.g. '/folder/file.txt'") + extension: str = Field(default="", description="File extension without dot") + size: int = Field(default=0, description="File size in bytes") + checksum: str = Field(default="", description="SHA-256 checksum of file content") + children: list[AppAssetTreeView] = Field(default_factory=list, description="Child nodes for folders") + + +class AppAssetNodeNotFoundError(Exception): + pass + + +class AppAssetParentNotFoundError(Exception): + pass + + +class AppAssetPathConflictError(Exception): + pass + + +class AppAssetFileTree(BaseModel): + """ + File tree structure for app assets using adjacency list pattern. + + Design: + - Storage: Flat list with parent_id references (adjacency list) + - Path: Computed dynamically via get_path(), not stored + - Order: Integer field for user-defined sorting within each folder + - API response: transform() builds nested tree with computed paths + + Why adjacency list over nested tree or materialized path: + - Simpler CRUD: move/rename only updates one node's parent_id + - No path cascade: renaming parent doesn't require updating all descendants + - JSON-friendly: flat list serializes cleanly to database JSON column + - Trade-off: path lookup is O(depth), acceptable for typical file trees + """ + + nodes: list[AppAssetNode] = Field(default_factory=list, description="Flat list of all nodes in the tree") + + def get(self, node_id: str) -> AppAssetNode | None: + return next((n for n in self.nodes if n.id == node_id), None) + + def get_children(self, parent_id: str | None) -> list[AppAssetNode]: + return [n for n in self.nodes if n.parent_id == parent_id] + + def has_child_named(self, parent_id: str | None, name: str) -> bool: + return any(n.name == name and n.parent_id == parent_id for n in self.nodes) + + def get_path(self, node_id: str) -> str: + node = self.get(node_id) + if not node: + raise AppAssetNodeNotFoundError(node_id) + parts: list[str] = [] + current: AppAssetNode | None = node + while current: + parts.append(current.name) + current = self.get(current.parent_id) if current.parent_id else None + return "/" + "/".join(reversed(parts)) + + def get_descendant_ids(self, node_id: str) -> list[str]: + result: list[str] = [] + stack = [node_id] + while stack: + current_id = stack.pop() + for child in self.nodes: + if child.parent_id == current_id: + result.append(child.id) + stack.append(child.id) + return result + + def add(self, node: AppAssetNode) -> AppAssetNode: + if self.get(node.id): + raise AppAssetPathConflictError(node.id) + if self.has_child_named(node.parent_id, node.name): + raise AppAssetPathConflictError(node.name) + if node.parent_id: + parent = self.get(node.parent_id) + if not parent or parent.node_type != AssetNodeType.FOLDER: + raise AppAssetParentNotFoundError(node.parent_id) + siblings = self.get_children(node.parent_id) + node.order = max((s.order for s in siblings), default=-1) + 1 + self.nodes.append(node) + return node + + def update(self, node_id: str, size: int, checksum: str) -> AppAssetNode: + node = self.get(node_id) + if not node or node.node_type != AssetNodeType.FILE: + raise AppAssetNodeNotFoundError(node_id) + node.size = size + node.checksum = checksum + return node + + def rename(self, node_id: str, new_name: str) -> AppAssetNode: + node = self.get(node_id) + if not node: + raise AppAssetNodeNotFoundError(node_id) + if node.name != new_name and self.has_child_named(node.parent_id, new_name): + raise AppAssetPathConflictError(new_name) + node.name = new_name + if node.node_type == AssetNodeType.FILE: + node.extension = new_name.rsplit(".", 1)[-1] if "." in new_name else "" + return node + + def move(self, node_id: str, new_parent_id: str | None) -> AppAssetNode: + node = self.get(node_id) + if not node: + raise AppAssetNodeNotFoundError(node_id) + if new_parent_id: + parent = self.get(new_parent_id) + if not parent or parent.node_type != AssetNodeType.FOLDER: + raise AppAssetParentNotFoundError(new_parent_id) + if self.has_child_named(new_parent_id, node.name): + raise AppAssetPathConflictError(node.name) + node.parent_id = new_parent_id + siblings = self.get_children(new_parent_id) + node.order = max((s.order for s in siblings if s.id != node_id), default=-1) + 1 + return node + + def reorder(self, node_id: str, after_node_id: str | None) -> AppAssetNode: + node = self.get(node_id) + if not node: + raise AppAssetNodeNotFoundError(node_id) + + siblings = sorted(self.get_children(node.parent_id), key=lambda x: x.order) + siblings = [s for s in siblings if s.id != node_id] + + if after_node_id is None: + insert_idx = 0 + else: + after_node = self.get(after_node_id) + if not after_node or after_node.parent_id != node.parent_id: + raise AppAssetNodeNotFoundError(after_node_id) + insert_idx = next((i for i, s in enumerate(siblings) if s.id == after_node_id), -1) + 1 + + siblings.insert(insert_idx, node) + for idx, sibling in enumerate(siblings): + sibling.order = idx + + return node + + def remove(self, node_id: str) -> list[str]: + node = self.get(node_id) + if not node: + raise AppAssetNodeNotFoundError(node_id) + ids_to_remove = [node_id] + self.get_descendant_ids(node_id) + self.nodes = [n for n in self.nodes if n.id not in ids_to_remove] + return ids_to_remove + + def walk_files(self) -> Generator[AppAssetNode, None, None]: + return (n for n in self.nodes if n.node_type == AssetNodeType.FILE) + + def transform(self) -> list[AppAssetTreeView]: + by_parent: dict[str | None, list[AppAssetNode]] = defaultdict(list) + for n in self.nodes: + by_parent[n.parent_id].append(n) + + for children in by_parent.values(): + children.sort(key=lambda x: x.order) + + paths: dict[str, str] = {} + tree_views: dict[str, AppAssetTreeView] = {} + + def build_view(node: AppAssetNode, parent_path: str) -> None: + path = f"{parent_path}/{node.name}" + paths[node.id] = path + child_views: list[AppAssetTreeView] = [] + for child in by_parent.get(node.id, []): + build_view(child, path) + child_views.append(tree_views[child.id]) + tree_views[node.id] = AppAssetTreeView( + id=node.id, + node_type=node.node_type.value, + name=node.name, + path=path, + extension=node.extension, + size=node.size, + checksum=node.checksum, + children=child_views, + ) + + for root_node in by_parent.get(None, []): + build_view(root_node, "") + + return [tree_views[n.id] for n in by_parent.get(None, [])] diff --git a/api/services/app_asset_service.py b/api/services/app_asset_service.py new file mode 100644 index 0000000000..bd80d98fe2 --- /dev/null +++ b/api/services/app_asset_service.py @@ -0,0 +1,319 @@ +import hashlib +import io +import logging +import zipfile +from uuid import uuid4 + +from sqlalchemy.orm import Session + +from extensions.ext_database import db +from extensions.ext_storage import storage +from libs.datetime_utils import naive_utc_now +from models.app_asset import AppAssetDraft +from models.app_asset_tree import ( + AppAssetFileTree, + AppAssetNode, + AssetNodeType, +) +from models.app_asset_tree import ( + AppAssetNodeNotFoundError as TreeNodeNotFoundError, +) +from models.app_asset_tree import ( + AppAssetParentNotFoundError as TreeParentNotFoundError, +) +from models.app_asset_tree import ( + AppAssetPathConflictError as TreePathConflictError, +) +from models.model import App + +from .errors.app_asset import ( + AppAssetNodeNotFoundError, + AppAssetParentNotFoundError, + AppAssetPathConflictError, +) + +logger = logging.getLogger(__name__) + + +class AppAssetService: + @staticmethod + def get_or_create_draft(session: Session, app_model: App, account_id: str) -> AppAssetDraft: + draft = ( + session.query(AppAssetDraft) + .filter( + AppAssetDraft.tenant_id == app_model.tenant_id, + AppAssetDraft.app_id == app_model.id, + AppAssetDraft.version == AppAssetDraft.VERSION_DRAFT, + ) + .first() + ) + if not draft: + draft = AppAssetDraft( + id=str(uuid4()), + tenant_id=app_model.tenant_id, + app_id=app_model.id, + version=AppAssetDraft.VERSION_DRAFT, + created_by=account_id, + ) + session.add(draft) + session.commit() + return draft + + @staticmethod + def get_asset_tree(app_model: App, account_id: str) -> AppAssetFileTree: + with Session(db.engine) as session: + draft = AppAssetService.get_or_create_draft(session, app_model, account_id) + return draft.asset_tree + + @staticmethod + def create_folder( + app_model: App, + account_id: str, + name: str, + parent_id: str | None = None, + ) -> AppAssetNode: + with Session(db.engine, expire_on_commit=False) as session: + draft = AppAssetService.get_or_create_draft(session, app_model, account_id) + tree = draft.asset_tree + + node = AppAssetNode.create_folder(str(uuid4()), name, parent_id) + + try: + tree.add(node) + except TreeParentNotFoundError as e: + raise AppAssetParentNotFoundError(str(e)) from e + except TreePathConflictError as e: + raise AppAssetPathConflictError(str(e)) from e + + draft.asset_tree = tree + draft.updated_by = account_id + session.commit() + + return node + + @staticmethod + def create_file( + app_model: App, + account_id: str, + name: str, + content: bytes, + parent_id: str | None = None, + ) -> AppAssetNode: + with Session(db.engine, expire_on_commit=False) as session: + draft = AppAssetService.get_or_create_draft(session, app_model, account_id) + tree = draft.asset_tree + + node_id = str(uuid4()) + checksum = hashlib.sha256(content).hexdigest() + node = AppAssetNode.create_file(node_id, name, parent_id, len(content), checksum) + + try: + tree.add(node) + except TreeParentNotFoundError as e: + raise AppAssetParentNotFoundError(str(e)) from e + except TreePathConflictError as e: + raise AppAssetPathConflictError(str(e)) from e + + storage_key = AppAssetDraft.get_storage_key(app_model.tenant_id, app_model.id, node_id) + storage.save(storage_key, content) + + draft.asset_tree = tree + draft.updated_by = account_id + session.commit() + + return node + + @staticmethod + def get_file_content(app_model: App, account_id: str, node_id: str) -> bytes: + with Session(db.engine) as session: + draft = AppAssetService.get_or_create_draft(session, app_model, account_id) + tree = draft.asset_tree + + node = tree.get(node_id) + if not node or node.node_type != AssetNodeType.FILE: + raise AppAssetNodeNotFoundError(f"File node {node_id} not found") + + storage_key = AppAssetDraft.get_storage_key(app_model.tenant_id, app_model.id, node_id) + return storage.load_once(storage_key) + + @staticmethod + def update_file_content( + app_model: App, + account_id: str, + node_id: str, + content: bytes, + ) -> AppAssetNode: + with Session(db.engine, expire_on_commit=False) as session: + draft = AppAssetService.get_or_create_draft(session, app_model, account_id) + tree = draft.asset_tree + + checksum = hashlib.sha256(content).hexdigest() + + try: + node = tree.update(node_id, len(content), checksum) + except TreeNodeNotFoundError as e: + raise AppAssetNodeNotFoundError(str(e)) from e + + storage_key = AppAssetDraft.get_storage_key(app_model.tenant_id, app_model.id, node_id) + storage.save(storage_key, content) + + draft.asset_tree = tree + draft.updated_by = account_id + session.commit() + + return node + + @staticmethod + def rename_node( + app_model: App, + account_id: str, + node_id: str, + new_name: str, + ) -> AppAssetNode: + with Session(db.engine, expire_on_commit=False) as session: + draft = AppAssetService.get_or_create_draft(session, app_model, account_id) + tree = draft.asset_tree + + try: + node = tree.rename(node_id, new_name) + except TreeNodeNotFoundError as e: + raise AppAssetNodeNotFoundError(str(e)) from e + except TreePathConflictError as e: + raise AppAssetPathConflictError(str(e)) from e + + draft.asset_tree = tree + draft.updated_by = account_id + session.commit() + + return node + + @staticmethod + def move_node( + app_model: App, + account_id: str, + node_id: str, + new_parent_id: str | None, + ) -> AppAssetNode: + with Session(db.engine, expire_on_commit=False) as session: + draft = AppAssetService.get_or_create_draft(session, app_model, account_id) + tree = draft.asset_tree + + try: + node = tree.move(node_id, new_parent_id) + except TreeNodeNotFoundError as e: + raise AppAssetNodeNotFoundError(str(e)) from e + except TreeParentNotFoundError as e: + raise AppAssetParentNotFoundError(str(e)) from e + except TreePathConflictError as e: + raise AppAssetPathConflictError(str(e)) from e + + draft.asset_tree = tree + draft.updated_by = account_id + session.commit() + + return node + + @staticmethod + def reorder_node( + app_model: App, + account_id: str, + node_id: str, + after_node_id: str | None, + ) -> AppAssetNode: + with Session(db.engine, expire_on_commit=False) as session: + draft = AppAssetService.get_or_create_draft(session, app_model, account_id) + tree = draft.asset_tree + + try: + node = tree.reorder(node_id, after_node_id) + except TreeNodeNotFoundError as e: + raise AppAssetNodeNotFoundError(str(e)) from e + + draft.asset_tree = tree + draft.updated_by = account_id + session.commit() + + return node + + @staticmethod + def delete_node(app_model: App, account_id: str, node_id: str) -> None: + with Session(db.engine) as session: + draft = AppAssetService.get_or_create_draft(session, app_model, account_id) + tree = draft.asset_tree + + try: + removed_ids = tree.remove(node_id) + except TreeNodeNotFoundError as e: + raise AppAssetNodeNotFoundError(str(e)) from e + + for nid in removed_ids: + storage_key = AppAssetDraft.get_storage_key(app_model.tenant_id, app_model.id, nid) + try: + storage.delete(storage_key) + except Exception: + logger.warning("Failed to delete storage file %s", storage_key, exc_info=True) + + draft.asset_tree = tree + draft.updated_by = account_id + session.commit() + + @staticmethod + def publish(app_model: App, account_id: str) -> AppAssetDraft: + with Session(db.engine, expire_on_commit=False) as session: + draft = AppAssetService.get_or_create_draft(session, app_model, account_id) + tree = draft.asset_tree + + # TODO: use sandbox virtual environment to create zip file + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for file_node in tree.walk_files(): + storage_key = AppAssetDraft.get_storage_key(app_model.tenant_id, app_model.id, file_node.id) + content = storage.load_once(storage_key) + archive_path = tree.get_path(file_node.id).lstrip("/") + zf.writestr(archive_path, content) + + published = AppAssetDraft( + id=str(uuid4()), + tenant_id=app_model.tenant_id, + app_id=app_model.id, + version=str(naive_utc_now()), + created_by=account_id, + ) + published.asset_tree = tree + session.add(published) + session.flush() + + zip_key = AppAssetDraft.get_published_storage_key(app_model.tenant_id, app_model.id, published.id) + storage.save(zip_key, zip_buffer.getvalue()) + + session.commit() + + return published + + @staticmethod + def get_published_file_content( + app_model: App, + draft_id: str, + file_path: str, + ) -> bytes: + with Session(db.engine) as session: + published = ( + session.query(AppAssetDraft) + .filter( + AppAssetDraft.tenant_id == app_model.tenant_id, + AppAssetDraft.app_id == app_model.id, + AppAssetDraft.id == draft_id, + ) + .first() + ) + if not published or published.version == AppAssetDraft.VERSION_DRAFT: + raise AppAssetNodeNotFoundError(f"Published version {draft_id} not found") + + zip_key = AppAssetDraft.get_published_storage_key(app_model.tenant_id, app_model.id, draft_id) + zip_data = storage.load_once(zip_key) + + archive_path = file_path.lstrip("/") + with zipfile.ZipFile(io.BytesIO(zip_data), "r") as zf: + if archive_path not in zf.namelist(): + raise AppAssetNodeNotFoundError(f"File {file_path} not found in published version") + return zf.read(archive_path) diff --git a/api/services/errors/app_asset.py b/api/services/errors/app_asset.py new file mode 100644 index 0000000000..de114c5a84 --- /dev/null +++ b/api/services/errors/app_asset.py @@ -0,0 +1,13 @@ +from .base import BaseServiceError + + +class AppAssetNodeNotFoundError(BaseServiceError): + pass + + +class AppAssetParentNotFoundError(BaseServiceError): + pass + + +class AppAssetPathConflictError(BaseServiceError): + pass diff --git a/api/services/sandbox/sandbox_provider_service.py b/api/services/sandbox/sandbox_provider_service.py index 5c11b9390b..ac0caac8bf 100644 --- a/api/services/sandbox/sandbox_provider_service.py +++ b/api/services/sandbox/sandbox_provider_service.py @@ -76,7 +76,9 @@ PROVIDER_CONFIG_SCHEMAS: dict[str, list[BasicProviderConfig]] = { BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name="docker_sock"), BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name="docker_image"), ], - SandboxProviderType.LOCAL: [], + SandboxProviderType.LOCAL: [ + BasicProviderConfig(type=BasicProviderConfig.Type.TEXT_INPUT, name="base_working_path"), + ], }