diff --git a/api/controllers/console/__init__.py b/api/controllers/console/__init__.py index 5e88fc8496..3af02f37dc 100644 --- a/api/controllers/console/__init__.py +++ b/api/controllers/console/__init__.py @@ -122,6 +122,7 @@ from .explore import ( saved_message, trial, ) +from .snippets import snippet_workflow, snippet_workflow_draft_variable from .socketio import workflow as socketio_workflow # Import tag controllers @@ -137,6 +138,7 @@ from .workspace import ( model_providers, models, plugin, + snippets, tool_providers, trigger_providers, workspace, @@ -212,6 +214,9 @@ __all__ = [ "saved_message", "setup", "site", + "snippet_workflow", + "snippet_workflow_draft_variable", + "snippets", "socketio_workflow", "spec", "statistic", diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py index d51dc68391..66bf77402f 100644 --- a/api/controllers/console/app/app.py +++ b/api/controllers/console/app/app.py @@ -64,6 +64,7 @@ register_enum_models(console_ns, IconType) _logger = logging.getLogger(__name__) _TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$") +_CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$") class AppListQuery(BaseModel): @@ -74,6 +75,7 @@ class AppListQuery(BaseModel): ) name: str | None = Field(default=None, description="Filter by app name") tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs") + creator_ids: list[str] | None = Field(default=None, description="Filter by creator account IDs") is_created_by_me: bool | None = Field(default=None, description="Filter by creator") @field_validator("tag_ids", mode="before") @@ -94,10 +96,29 @@ class AppListQuery(BaseModel): except ValueError as exc: raise ValueError("Invalid UUID format in tag_ids.") from exc + @field_validator("creator_ids", mode="before") + @classmethod + def validate_creator_ids(cls, value: list[str] | None) -> list[str] | None: + if not value: + return None + + if not isinstance(value, list): + raise ValueError("Unsupported creator_ids type.") + + items = [str(item).strip() for item in value if item and str(item).strip()] + if not items: + return None + + try: + return [str(uuid.UUID(item)) for item in items] + except ValueError as exc: + raise ValueError("Invalid UUID format in creator_ids.") from exc + def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]: normalized: dict[str, str | list[str]] = {} indexed_tag_ids: list[tuple[int, str]] = [] + indexed_creator_ids: list[tuple[int, str]] = [] for key in query_args: match = _TAG_IDS_BRACKET_PATTERN.fullmatch(key) @@ -105,12 +126,19 @@ def _normalize_app_list_query_args(query_args: MultiDict[str, str]) -> dict[str, indexed_tag_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key)) continue + match = _CREATOR_IDS_BRACKET_PATTERN.fullmatch(key) + if match: + indexed_creator_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key)) + continue + value = query_args.get(key) if value is not None: normalized[key] = value if indexed_tag_ids: normalized["tag_ids"] = [value for _, value in sorted(indexed_tag_ids)] + if indexed_creator_ids: + normalized["creator_ids"] = [value for _, value in sorted(indexed_creator_ids)] return normalized @@ -486,6 +514,7 @@ class AppListApi(Resource): mode=args.mode, name=args.name, tag_ids=args.tag_ids, + creator_ids=args.creator_ids, is_created_by_me=args.is_created_by_me, ) diff --git a/api/controllers/console/snippets/payloads.py b/api/controllers/console/snippets/payloads.py new file mode 100644 index 0000000000..24cc990fa7 --- /dev/null +++ b/api/controllers/console/snippets/payloads.py @@ -0,0 +1,164 @@ +import uuid +from typing import Any, Literal + +from pydantic import AliasChoices, BaseModel, Field, field_validator + + +class SnippetListQuery(BaseModel): + """Query parameters for listing snippets.""" + + page: int = Field(default=1, ge=1, le=99999) + limit: int = Field(default=20, ge=1, le=100) + keyword: str | None = None + is_published: bool | None = Field(default=None, description="Filter by published status") + creators: list[str] | None = Field( + default=None, + description="Filter by creator account IDs", + validation_alias=AliasChoices("creators", "creator_id"), + ) + tag_ids: list[str] | None = Field(default=None, description="Filter by tag IDs") + + @field_validator("creators", mode="before") + @classmethod + def parse_creators(cls, value: object) -> list[str] | None: + """Normalize creators filter from query string or list input.""" + return cls._normalize_string_list(value) + + @field_validator("tag_ids", mode="before") + @classmethod + def parse_tag_ids(cls, value: object) -> list[str] | None: + """Normalize and validate tag IDs from query string or list input.""" + items = cls._normalize_string_list(value) + if not items: + return None + try: + return [str(uuid.UUID(item)) for item in items] + except ValueError as exc: + raise ValueError("Invalid UUID format in tag_ids.") from exc + + @staticmethod + def _normalize_string_list(value: object) -> list[str] | None: + if value is None: + return None + if isinstance(value, str): + return [item.strip() for item in value.split(",") if item.strip()] or None + if isinstance(value, list): + return [str(item).strip() for item in value if str(item).strip()] or None + return None + + +class IconInfo(BaseModel): + """Icon information model.""" + + icon: str | None = None + icon_type: Literal["emoji", "image"] | None = None + icon_background: str | None = None + icon_url: str | None = None + + +class InputFieldDefinition(BaseModel): + """Input field definition for snippet parameters.""" + + default: str | None = None + hint: bool | None = None + label: str | None = None + max_length: int | None = None + options: list[str] | None = None + placeholder: str | None = None + required: bool | None = None + type: str | None = None # e.g., "text-input" + + +class CreateSnippetPayload(BaseModel): + """Payload for creating a new snippet.""" + + name: str = Field(..., min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=2000) + type: Literal["node", "group"] = "node" + icon_info: IconInfo | None = None + graph: dict[str, Any] | None = None + input_fields: list[InputFieldDefinition] | None = Field(default_factory=list) + + +class UpdateSnippetPayload(BaseModel): + """Payload for updating a snippet.""" + + name: str | None = Field(default=None, min_length=1, max_length=255) + description: str | None = Field(default=None, max_length=2000) + icon_info: IconInfo | None = None + + +class SnippetDraftSyncPayload(BaseModel): + """Payload for syncing snippet draft workflow.""" + + graph: dict[str, Any] + hash: str | None = None + conversation_variables: list[dict[str, Any]] | None = Field( + default=None, + description="Ignored. Snippet workflows do not persist conversation variables.", + ) + input_fields: list[dict[str, Any]] | None = None + + +class SnippetWorkflowListQuery(BaseModel): + """Query parameters for listing snippet published workflows.""" + + page: int = Field(default=1, ge=1, le=99999) + limit: int = Field(default=10, ge=1, le=100) + + +class WorkflowRunQuery(BaseModel): + """Query parameters for workflow runs.""" + + last_id: str | None = None + limit: int = Field(default=20, ge=1, le=100) + + +class SnippetDraftRunPayload(BaseModel): + """Payload for running snippet draft workflow.""" + + inputs: dict[str, Any] + files: list[dict[str, Any]] | None = None + + +class SnippetDraftNodeRunPayload(BaseModel): + """Payload for running a single node in snippet draft workflow.""" + + inputs: dict[str, Any] + query: str = "" + files: list[dict[str, Any]] | None = None + + +class SnippetIterationNodeRunPayload(BaseModel): + """Payload for running an iteration node in snippet draft workflow.""" + + inputs: dict[str, Any] | None = None + + +class SnippetLoopNodeRunPayload(BaseModel): + """Payload for running a loop node in snippet draft workflow.""" + + inputs: dict[str, Any] | None = None + + +class PublishWorkflowPayload(BaseModel): + """Payload for publishing snippet workflow.""" + + knowledge_base_setting: dict[str, Any] | None = None + + +class SnippetImportPayload(BaseModel): + """Payload for importing snippet from DSL.""" + + mode: str = Field(..., description="Import mode: yaml-content or yaml-url") + yaml_content: str | None = Field(default=None, description="YAML content (required for yaml-content mode)") + yaml_url: str | None = Field(default=None, description="YAML URL (required for yaml-url mode)") + name: str | None = Field(default=None, description="Override snippet name") + description: str | None = Field(default=None, description="Override snippet description") + snippet_id: str | None = Field(default=None, description="Snippet ID to update (optional)") + + +class IncludeSecretQuery(BaseModel): + """Query parameter for including secret variables in export.""" + + include_secret: str = Field(default="false", description="Whether to include secret variables") diff --git a/api/controllers/console/snippets/snippet_workflow.py b/api/controllers/console/snippets/snippet_workflow.py new file mode 100644 index 0000000000..5e2421275b --- /dev/null +++ b/api/controllers/console/snippets/snippet_workflow.py @@ -0,0 +1,678 @@ +import logging +from collections.abc import Callable +from functools import wraps + +from flask import request +from flask_restx import Resource +from pydantic import Field +from sqlalchemy.orm import Session, sessionmaker +from werkzeug.exceptions import BadRequest, InternalServerError, NotFound + +from controllers.common.schema import register_response_schema_models, register_schema_models +from controllers.console import console_ns +from controllers.console.app.error import DraftWorkflowNotExist, DraftWorkflowNotSync +from controllers.console.app.workflow import ( + RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE, + WorkflowPaginationResponse, + WorkflowResponse, +) +from controllers.console.snippets.payloads import ( + PublishWorkflowPayload, + SnippetDraftNodeRunPayload, + SnippetDraftRunPayload, + SnippetDraftSyncPayload, + SnippetIterationNodeRunPayload, + SnippetLoopNodeRunPayload, + SnippetWorkflowListQuery, + WorkflowRunQuery, +) +from controllers.console.wraps import ( + account_initialization_required, + edit_permission_required, + setup_required, +) +from core.app.apps.base_app_queue_manager import AppQueueManager +from core.app.entities.app_invoke_entities import InvokeFrom +from extensions.ext_database import db +from extensions.ext_redis import redis_client +from fields.workflow_run_fields import ( + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionListResponse, + WorkflowRunNodeExecutionResponse, + WorkflowRunPaginationResponse, +) +from graphon.graph_engine.manager import GraphEngineManager +from libs import helper +from libs.helper import TimestampField +from libs.login import current_account_with_tenant, login_required +from models.snippet import CustomizedSnippet +from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError +from services.snippet_generate_service import SnippetGenerateService +from services.snippet_service import SnippetService + +logger = logging.getLogger(__name__) + +# Register Pydantic models with Swagger + + +def _snippet_session_maker() -> sessionmaker[Session]: + return sessionmaker(bind=db.engine, expire_on_commit=False) + + +def _snippet_service() -> SnippetService: + return SnippetService(_snippet_session_maker()) + + +class SnippetWorkflowResponse(WorkflowResponse): + input_fields: list[dict] = Field(default_factory=list) + + +register_schema_models( + console_ns, + SnippetDraftSyncPayload, + SnippetDraftNodeRunPayload, + SnippetDraftRunPayload, + SnippetIterationNodeRunPayload, + SnippetLoopNodeRunPayload, + SnippetWorkflowListQuery, + WorkflowRunQuery, + PublishWorkflowPayload, +) +register_response_schema_models( + console_ns, + SnippetWorkflowResponse, + WorkflowPaginationResponse, + WorkflowRunPaginationResponse, + WorkflowRunDetailResponse, + WorkflowRunNodeExecutionListResponse, + WorkflowRunNodeExecutionResponse, +) + + +class SnippetNotFoundError(Exception): + """Snippet not found error.""" + + pass + + +def get_snippet[**P, R](view_func: Callable[P, R]) -> Callable[P, R]: + """Decorator to fetch and validate snippet access.""" + + @wraps(view_func) + def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R: + if not kwargs.get("snippet_id"): + raise ValueError("missing snippet_id in path parameters") + + _, current_tenant_id = current_account_with_tenant() + + snippet_id = str(kwargs.get("snippet_id")) + del kwargs["snippet_id"] + + snippet_service = _snippet_service() + snippet = snippet_service.get_snippet_by_id( + snippet_id=snippet_id, + tenant_id=current_tenant_id, + ) + + if not snippet: + raise NotFound("Snippet not found") + + kwargs["snippet"] = snippet + + return view_func(*args, **kwargs) + + return decorated_view + + +@console_ns.route("/snippets//workflows/draft") +class SnippetDraftWorkflowApi(Resource): + @console_ns.doc("get_snippet_draft_workflow") + @console_ns.response( + 200, + "Draft workflow retrieved successfully", + console_ns.models[SnippetWorkflowResponse.__name__], + ) + @console_ns.response(404, "Snippet or draft workflow not found") + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + def get(self, snippet: CustomizedSnippet): + """Get draft workflow for snippet.""" + snippet_service = _snippet_service() + workflow = snippet_service.get_draft_workflow(snippet=snippet) + + if not workflow: + raise DraftWorkflowNotExist() + + workflow.conversation_variables = [] + response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + response["input_fields"] = snippet.input_fields_list + return response + + @console_ns.doc("sync_snippet_draft_workflow") + @console_ns.expect(console_ns.models.get(SnippetDraftSyncPayload.__name__)) + @console_ns.response(200, "Draft workflow synced successfully") + @console_ns.response(400, "Hash mismatch") + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + def post(self, snippet: CustomizedSnippet): + """Sync draft workflow for snippet.""" + current_user, _ = current_account_with_tenant() + + payload = SnippetDraftSyncPayload.model_validate(console_ns.payload or {}) + + try: + snippet_service = _snippet_service() + workflow = snippet_service.sync_draft_workflow( + snippet=snippet, + graph=payload.graph, + unique_hash=payload.hash, + account=current_user, + input_fields=payload.input_fields, + ) + except WorkflowHashNotEqualError: + raise DraftWorkflowNotSync() + except ValueError as e: + return {"message": str(e)}, 400 + + return { + "result": "success", + "hash": workflow.unique_hash, + "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), + } + + +@console_ns.route("/snippets//workflows/draft/config") +class SnippetDraftConfigApi(Resource): + @console_ns.doc("get_snippet_draft_config") + @console_ns.response(200, "Draft config retrieved successfully") + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + def get(self, snippet: CustomizedSnippet): + """Get snippet draft workflow configuration limits.""" + return { + "parallel_depth_limit": 3, + } + + +@console_ns.route("/snippets//workflows/publish") +class SnippetPublishedWorkflowApi(Resource): + @console_ns.doc("get_snippet_published_workflow") + @console_ns.response( + 200, + "Published workflow retrieved successfully", + console_ns.models[SnippetWorkflowResponse.__name__], + ) + @console_ns.response(404, "Snippet not found") + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + def get(self, snippet: CustomizedSnippet): + """Get published workflow for snippet.""" + if not snippet.is_published: + return None + + snippet_service = _snippet_service() + workflow = snippet_service.get_published_workflow(snippet=snippet) + + if not workflow: + return None + + response = SnippetWorkflowResponse.model_validate(workflow, from_attributes=True).model_dump(mode="json") + response["input_fields"] = snippet.input_fields_list + return response + + @console_ns.doc("publish_snippet_workflow") + @console_ns.expect(console_ns.models.get(PublishWorkflowPayload.__name__)) + @console_ns.response(200, "Workflow published successfully") + @console_ns.response(400, "No draft workflow found") + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + def post(self, snippet: CustomizedSnippet): + """Publish snippet workflow.""" + current_user, _ = current_account_with_tenant() + snippet_service = _snippet_service() + + with Session(db.engine) as session: + snippet = session.merge(snippet) + try: + workflow = snippet_service.publish_workflow( + session=session, + snippet=snippet, + account=current_user, + ) + workflow_created_at = TimestampField().format(workflow.created_at) + session.commit() + except ValueError as e: + return {"message": str(e)}, 400 + + return { + "result": "success", + "created_at": workflow_created_at, + } + + +@console_ns.route("/snippets//workflows/default-workflow-block-configs") +class SnippetDefaultBlockConfigsApi(Resource): + @console_ns.doc("get_snippet_default_block_configs") + @console_ns.response(200, "Default block configs retrieved successfully") + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + def get(self, snippet: CustomizedSnippet): + """Get default block configurations for snippet workflow.""" + snippet_service = _snippet_service() + return snippet_service.get_default_block_configs() + + +@console_ns.route("/snippets//workflows") +class SnippetPublishedAllWorkflowApi(Resource): + @console_ns.expect(console_ns.models[SnippetWorkflowListQuery.__name__]) + @console_ns.doc("get_all_snippet_published_workflows") + @console_ns.doc(description="Get all published workflows for a snippet") + @console_ns.doc(params={"snippet_id": "Snippet ID"}) + @console_ns.response( + 200, + "Published workflows retrieved successfully", + console_ns.models[WorkflowPaginationResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + def get(self, snippet: CustomizedSnippet): + """Get all published workflow versions for snippet.""" + args = SnippetWorkflowListQuery.model_validate(request.args.to_dict(flat=True)) + + snippet_service = _snippet_service() + with Session(db.engine) as session: + workflows, has_more = snippet_service.get_all_published_workflows( + session=session, + snippet=snippet, + page=args.page, + limit=args.limit, + ) + + return WorkflowPaginationResponse.model_validate( + { + "items": workflows, + "page": args.page, + "limit": args.limit, + "has_more": has_more, + }, + from_attributes=True, + ).model_dump(mode="json") + + +@console_ns.route("/snippets//workflows//restore") +class SnippetDraftWorkflowRestoreApi(Resource): + @console_ns.doc("restore_snippet_workflow_to_draft") + @console_ns.doc(description="Restore a published snippet workflow version into the draft workflow") + @console_ns.doc(params={"snippet_id": "Snippet ID", "workflow_id": "Published workflow ID"}) + @console_ns.response(200, "Workflow restored successfully") + @console_ns.response(400, "Source workflow must be published") + @console_ns.response(404, "Workflow not found") + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + def post(self, snippet: CustomizedSnippet, workflow_id: str): + """Restore a published snippet workflow version into the draft workflow.""" + current_user, _ = current_account_with_tenant() + snippet_service = _snippet_service() + + try: + workflow = snippet_service.restore_published_workflow_to_draft( + snippet=snippet, + workflow_id=workflow_id, + account=current_user, + ) + except IsDraftWorkflowError as exc: + raise BadRequest(RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE) from exc + except WorkflowNotFoundError as exc: + raise NotFound(str(exc)) from exc + except ValueError as exc: + raise BadRequest(str(exc)) from exc + + return { + "result": "success", + "hash": workflow.unique_hash, + "updated_at": TimestampField().format(workflow.updated_at or workflow.created_at), + } + + +@console_ns.route("/snippets//workflow-runs") +class SnippetWorkflowRunsApi(Resource): + @console_ns.doc("list_snippet_workflow_runs") + @console_ns.response( + 200, + "Workflow runs retrieved successfully", + console_ns.models[WorkflowRunPaginationResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @get_snippet + def get(self, snippet: CustomizedSnippet): + """List workflow runs for snippet.""" + query = WorkflowRunQuery.model_validate( + { + "last_id": request.args.get("last_id"), + "limit": request.args.get("limit", type=int, default=20), + } + ) + args = { + "last_id": query.last_id, + "limit": query.limit, + } + + snippet_service = _snippet_service() + result = snippet_service.get_snippet_workflow_runs(snippet=snippet, args=args) + + return WorkflowRunPaginationResponse.model_validate(result, from_attributes=True).model_dump(mode="json") + + +@console_ns.route("/snippets//workflow-runs/") +class SnippetWorkflowRunDetailApi(Resource): + @console_ns.doc("get_snippet_workflow_run_detail") + @console_ns.response( + 200, + "Workflow run detail retrieved successfully", + console_ns.models[WorkflowRunDetailResponse.__name__], + ) + @console_ns.response(404, "Workflow run not found") + @setup_required + @login_required + @account_initialization_required + @get_snippet + def get(self, snippet: CustomizedSnippet, run_id): + """Get workflow run detail for snippet.""" + run_id = str(run_id) + + snippet_service = _snippet_service() + workflow_run = snippet_service.get_snippet_workflow_run(snippet=snippet, run_id=run_id) + + if not workflow_run: + raise NotFound("Workflow run not found") + + return WorkflowRunDetailResponse.model_validate(workflow_run, from_attributes=True).model_dump(mode="json") + + +@console_ns.route("/snippets//workflow-runs//node-executions") +class SnippetWorkflowRunNodeExecutionsApi(Resource): + @console_ns.doc("list_snippet_workflow_run_node_executions") + @console_ns.response( + 200, + "Node executions retrieved successfully", + console_ns.models[WorkflowRunNodeExecutionListResponse.__name__], + ) + @setup_required + @login_required + @account_initialization_required + @get_snippet + def get(self, snippet: CustomizedSnippet, run_id): + """List node executions for a workflow run.""" + run_id = str(run_id) + + snippet_service = _snippet_service() + node_executions = snippet_service.get_snippet_workflow_run_node_executions( + snippet=snippet, + run_id=run_id, + ) + + return WorkflowRunNodeExecutionListResponse.model_validate( + {"data": node_executions}, from_attributes=True + ).model_dump(mode="json") + + +@console_ns.route("/snippets//workflows/draft/nodes//run") +class SnippetDraftNodeRunApi(Resource): + @console_ns.doc("run_snippet_draft_node") + @console_ns.doc(description="Run a single node in snippet draft workflow (single-step debugging)") + @console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"}) + @console_ns.expect(console_ns.models.get(SnippetDraftNodeRunPayload.__name__)) + @console_ns.response( + 200, "Node run completed successfully", console_ns.models[WorkflowRunNodeExecutionResponse.__name__] + ) + @console_ns.response(404, "Snippet or draft workflow not found") + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + def post(self, snippet: CustomizedSnippet, node_id: str): + """ + Run a single node in snippet draft workflow. + + Executes a specific node with provided inputs for single-step debugging. + Returns the node execution result including status, outputs, and timing. + """ + current_user, _ = current_account_with_tenant() + payload = SnippetDraftNodeRunPayload.model_validate(console_ns.payload or {}) + + user_inputs = payload.inputs + + # Get draft workflow for file parsing + snippet_service = _snippet_service() + draft_workflow = snippet_service.get_draft_workflow(snippet=snippet) + if not draft_workflow: + raise NotFound("Draft workflow not found") + + files = SnippetGenerateService.parse_files(draft_workflow, payload.files) + + workflow_node_execution = SnippetGenerateService.run_draft_node( + snippet=snippet, + node_id=node_id, + user_inputs=user_inputs, + account=current_user, + query=payload.query, + files=files, + session_maker=_snippet_session_maker(), + ) + + return WorkflowRunNodeExecutionResponse.model_validate( + workflow_node_execution, from_attributes=True + ).model_dump(mode="json") + + +@console_ns.route("/snippets//workflows/draft/nodes//last-run") +class SnippetDraftNodeLastRunApi(Resource): + @console_ns.doc("get_snippet_draft_node_last_run") + @console_ns.doc(description="Get last run result for a node in snippet draft workflow") + @console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"}) + @console_ns.response( + 200, "Node last run retrieved successfully", console_ns.models[WorkflowRunNodeExecutionResponse.__name__] + ) + @console_ns.response(404, "Snippet, draft workflow, or node last run not found") + @setup_required + @login_required + @account_initialization_required + @get_snippet + def get(self, snippet: CustomizedSnippet, node_id: str): + """ + Get the last run result for a specific node in snippet draft workflow. + + Returns the most recent execution record for the given node, + including status, inputs, outputs, and timing information. + """ + snippet_service = _snippet_service() + draft_workflow = snippet_service.get_draft_workflow(snippet=snippet) + if not draft_workflow: + raise NotFound("Draft workflow not found") + + node_exec = snippet_service.get_snippet_node_last_run( + snippet=snippet, + workflow=draft_workflow, + node_id=node_id, + ) + if node_exec is None: + raise NotFound("Node last run not found") + + return WorkflowRunNodeExecutionResponse.model_validate(node_exec, from_attributes=True).model_dump(mode="json") + + +@console_ns.route("/snippets//workflows/draft/iteration/nodes//run") +class SnippetDraftRunIterationNodeApi(Resource): + @console_ns.doc("run_snippet_draft_iteration_node") + @console_ns.doc(description="Run draft workflow iteration node for snippet") + @console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"}) + @console_ns.expect(console_ns.models.get(SnippetIterationNodeRunPayload.__name__)) + @console_ns.response(200, "Iteration node run started successfully (SSE stream)") + @console_ns.response(404, "Snippet or draft workflow not found") + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + def post(self, snippet: CustomizedSnippet, node_id: str): + """ + Run a draft workflow iteration node for snippet. + + Iteration nodes execute their internal sub-graph multiple times over an input list. + Returns an SSE event stream with iteration progress and results. + """ + current_user, _ = current_account_with_tenant() + args = SnippetIterationNodeRunPayload.model_validate(console_ns.payload or {}).model_dump(exclude_none=True) + + try: + response = SnippetGenerateService.generate_single_iteration( + snippet=snippet, + user=current_user, + node_id=node_id, + args=args, + streaming=True, + session_maker=_snippet_session_maker(), + ) + + return helper.compact_generate_response(response) + except ValueError as e: + raise e + except Exception: + logger.exception("internal server error.") + raise InternalServerError() + + +@console_ns.route("/snippets//workflows/draft/loop/nodes//run") +class SnippetDraftRunLoopNodeApi(Resource): + @console_ns.doc("run_snippet_draft_loop_node") + @console_ns.doc(description="Run draft workflow loop node for snippet") + @console_ns.doc(params={"snippet_id": "Snippet ID", "node_id": "Node ID"}) + @console_ns.expect(console_ns.models.get(SnippetLoopNodeRunPayload.__name__)) + @console_ns.response(200, "Loop node run started successfully (SSE stream)") + @console_ns.response(404, "Snippet or draft workflow not found") + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + def post(self, snippet: CustomizedSnippet, node_id: str): + """ + Run a draft workflow loop node for snippet. + + Loop nodes execute their internal sub-graph repeatedly until a condition is met. + Returns an SSE event stream with loop progress and results. + """ + current_user, _ = current_account_with_tenant() + args = SnippetLoopNodeRunPayload.model_validate(console_ns.payload or {}) + + try: + response = SnippetGenerateService.generate_single_loop( + snippet=snippet, + user=current_user, + node_id=node_id, + args=args, + streaming=True, + session_maker=_snippet_session_maker(), + ) + + return helper.compact_generate_response(response) + except ValueError as e: + raise e + except Exception: + logger.exception("internal server error.") + raise InternalServerError() + + +@console_ns.route("/snippets//workflows/draft/run") +class SnippetDraftWorkflowRunApi(Resource): + @console_ns.doc("run_snippet_draft_workflow") + @console_ns.expect(console_ns.models.get(SnippetDraftRunPayload.__name__)) + @console_ns.response(200, "Draft workflow run started successfully (SSE stream)") + @console_ns.response(404, "Snippet or draft workflow not found") + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + def post(self, snippet: CustomizedSnippet): + """ + Run draft workflow for snippet. + + Executes the snippet's draft workflow with the provided inputs + and returns an SSE event stream with execution progress and results. + """ + current_user, _ = current_account_with_tenant() + + payload = SnippetDraftRunPayload.model_validate(console_ns.payload or {}) + args = payload.model_dump(exclude_none=True) + + try: + response = SnippetGenerateService.generate( + snippet=snippet, + user=current_user, + args=args, + invoke_from=InvokeFrom.DEBUGGER, + streaming=True, + session_maker=_snippet_session_maker(), + ) + + return helper.compact_generate_response(response) + except ValueError as e: + raise e + except Exception: + logger.exception("internal server error.") + raise InternalServerError() + + +@console_ns.route("/snippets//workflow-runs/tasks//stop") +class SnippetWorkflowTaskStopApi(Resource): + @console_ns.doc("stop_snippet_workflow_task") + @console_ns.response(200, "Task stopped successfully") + @console_ns.response(404, "Snippet not found") + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + def post(self, snippet: CustomizedSnippet, task_id: str): + """ + Stop a running snippet workflow task. + + Uses both the legacy stop flag mechanism and the graph engine + command channel for backward compatibility. + """ + # Stop using both mechanisms for backward compatibility + # Legacy stop flag mechanism (without user check) + AppQueueManager.set_stop_flag_no_user_check(task_id) + + # New graph engine command channel mechanism + GraphEngineManager(redis_client).send_stop_command(task_id) + + return {"result": "success"} diff --git a/api/controllers/console/snippets/snippet_workflow_draft_variable.py b/api/controllers/console/snippets/snippet_workflow_draft_variable.py new file mode 100644 index 0000000000..2ee69eeac7 --- /dev/null +++ b/api/controllers/console/snippets/snippet_workflow_draft_variable.py @@ -0,0 +1,320 @@ +""" +Snippet draft workflow variable APIs. + +Mirrors console app routes under /apps/.../workflows/draft/variables for snippet scope, +using CustomizedSnippet.id as WorkflowDraftVariable.app_id (same invariant as snippet execution). + +Snippet workflows do not expose system variables (`node_id == sys`) or conversation variables +(`node_id == conversation`): paginated list queries exclude those rows; single-variable GET/PATCH/DELETE/reset +reject them; `GET .../system-variables` and `GET .../conversation-variables` return empty lists for API parity. +Other routes mirror `workflow_draft_variable` app APIs under `/snippets/...`. +""" + +from collections.abc import Callable +from functools import wraps +from typing import Any + +from flask import Response, request +from flask_restx import Resource, marshal, marshal_with +from sqlalchemy.orm import Session, sessionmaker + +from controllers.console import console_ns +from controllers.console.app.error import DraftWorkflowNotExist +from controllers.console.app.workflow_draft_variable import ( + WorkflowDraftVariableListQuery, + WorkflowDraftVariableUpdatePayload, + _ensure_variable_access, + _file_access_controller, + validate_node_id, + workflow_draft_variable_list_model, + workflow_draft_variable_list_without_value_model, + workflow_draft_variable_model, +) +from controllers.console.snippets.snippet_workflow import get_snippet +from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required +from controllers.web.error import InvalidArgumentError, NotFoundError +from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID +from extensions.ext_database import db +from factories.file_factory import build_from_mapping, build_from_mappings +from factories.variable_factory import build_segment_with_type +from graphon.variables.types import SegmentType +from libs.login import current_user, login_required +from models.snippet import CustomizedSnippet +from models.workflow import WorkflowDraftVariable +from services.snippet_service import SnippetService +from services.workflow_draft_variable_service import WorkflowDraftVariableList, WorkflowDraftVariableService + +_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS: frozenset[str] = frozenset( + {SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID} +) + + +def _snippet_service() -> SnippetService: + return SnippetService(sessionmaker(bind=db.engine, expire_on_commit=False)) + + +def _ensure_snippet_draft_variable_row_allowed( + *, + variable: WorkflowDraftVariable, + variable_id: str, +) -> None: + """Snippet scope only supports canvas-node draft variables; treat sys/conversation rows as not found.""" + if variable.node_id in _SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS: + raise NotFoundError(description=f"variable not found, id={variable_id}") + + +def _snippet_draft_var_prerequisite[**P, R](f: Callable[P, R]) -> Callable[P, R | Response]: + """Setup, auth, snippet resolution, and tenant edit permission (same stack as snippet workflow APIs).""" + + @setup_required + @login_required + @account_initialization_required + @get_snippet + @edit_permission_required + @wraps(f) + def wrapper(*args: P.args, **kwargs: P.kwargs) -> R | Response: + return f(*args, **kwargs) + + return wrapper + + +@console_ns.route("/snippets//workflows/draft/variables") +class SnippetWorkflowVariableCollectionApi(Resource): + @console_ns.expect(console_ns.models[WorkflowDraftVariableListQuery.__name__]) + @console_ns.doc("get_snippet_workflow_variables") + @console_ns.doc(description="List draft workflow variables without values (paginated, snippet scope)") + @console_ns.response( + 200, + "Workflow variables retrieved successfully", + workflow_draft_variable_list_without_value_model, + ) + @_snippet_draft_var_prerequisite + @marshal_with(workflow_draft_variable_list_without_value_model) + def get(self, snippet: CustomizedSnippet) -> WorkflowDraftVariableList: + args = WorkflowDraftVariableListQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore + + snippet_service = _snippet_service() + if snippet_service.get_draft_workflow(snippet=snippet) is None: + raise DraftWorkflowNotExist() + + with Session(bind=db.engine, expire_on_commit=False) as session: + draft_var_srv = WorkflowDraftVariableService(session=session) + workflow_vars = draft_var_srv.list_variables_without_values( + app_id=snippet.id, + page=args.page, + limit=args.limit, + user_id=current_user.id, + exclude_node_ids=_SNIPPET_EXCLUDED_DRAFT_VARIABLE_NODE_IDS, + ) + + return workflow_vars + + @console_ns.doc("delete_snippet_workflow_variables") + @console_ns.doc(description="Delete all draft workflow variables for the current user (snippet scope)") + @console_ns.response(204, "Workflow variables deleted successfully") + @_snippet_draft_var_prerequisite + def delete(self, snippet: CustomizedSnippet) -> Response: + draft_var_srv = WorkflowDraftVariableService(session=db.session()) + draft_var_srv.delete_user_workflow_variables(snippet.id, user_id=current_user.id) + db.session.commit() + return Response("", 204) + + +@console_ns.route("/snippets//workflows/draft/nodes//variables") +class SnippetNodeVariableCollectionApi(Resource): + @console_ns.doc("get_snippet_node_variables") + @console_ns.doc(description="Get variables for a specific node (snippet draft workflow)") + @console_ns.response(200, "Node variables retrieved successfully", workflow_draft_variable_list_model) + @_snippet_draft_var_prerequisite + @marshal_with(workflow_draft_variable_list_model) + def get(self, snippet: CustomizedSnippet, node_id: str) -> WorkflowDraftVariableList: + validate_node_id(node_id) + with Session(bind=db.engine, expire_on_commit=False) as session: + draft_var_srv = WorkflowDraftVariableService(session=session) + node_vars = draft_var_srv.list_node_variables(snippet.id, node_id, user_id=current_user.id) + + return node_vars + + @console_ns.doc("delete_snippet_node_variables") + @console_ns.doc(description="Delete all variables for a specific node (snippet draft workflow)") + @console_ns.response(204, "Node variables deleted successfully") + @_snippet_draft_var_prerequisite + def delete(self, snippet: CustomizedSnippet, node_id: str) -> Response: + validate_node_id(node_id) + srv = WorkflowDraftVariableService(db.session()) + srv.delete_node_variables(snippet.id, node_id, user_id=current_user.id) + db.session.commit() + return Response("", 204) + + +@console_ns.route("/snippets//workflows/draft/variables/") +class SnippetVariableApi(Resource): + @console_ns.doc("get_snippet_workflow_variable") + @console_ns.doc(description="Get a specific draft workflow variable (snippet scope)") + @console_ns.response(200, "Variable retrieved successfully", workflow_draft_variable_model) + @console_ns.response(404, "Variable not found") + @_snippet_draft_var_prerequisite + @marshal_with(workflow_draft_variable_model) + def get(self, snippet: CustomizedSnippet, variable_id: str) -> WorkflowDraftVariable: + draft_var_srv = WorkflowDraftVariableService(session=db.session()) + variable = _ensure_variable_access( + variable=draft_var_srv.get_variable(variable_id=variable_id), + app_id=snippet.id, + variable_id=variable_id, + ) + _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) + return variable + + @console_ns.doc("update_snippet_workflow_variable") + @console_ns.doc(description="Update a draft workflow variable (snippet scope)") + @console_ns.expect(console_ns.models[WorkflowDraftVariableUpdatePayload.__name__]) + @console_ns.response(200, "Variable updated successfully", workflow_draft_variable_model) + @console_ns.response(404, "Variable not found") + @_snippet_draft_var_prerequisite + @marshal_with(workflow_draft_variable_model) + def patch(self, snippet: CustomizedSnippet, variable_id: str) -> WorkflowDraftVariable: + draft_var_srv = WorkflowDraftVariableService(session=db.session()) + args_model = WorkflowDraftVariableUpdatePayload.model_validate(console_ns.payload or {}) + + variable = _ensure_variable_access( + variable=draft_var_srv.get_variable(variable_id=variable_id), + app_id=snippet.id, + variable_id=variable_id, + ) + _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) + + new_name = args_model.name + raw_value = args_model.value + if new_name is None and raw_value is None: + return variable + + new_value = None + if raw_value is not None: + if variable.value_type == SegmentType.FILE: + if not isinstance(raw_value, dict): + raise InvalidArgumentError(description=f"expected dict for file, got {type(raw_value)}") + raw_value = build_from_mapping( + mapping=raw_value, + tenant_id=snippet.tenant_id, + access_controller=_file_access_controller, + ) + elif variable.value_type == SegmentType.ARRAY_FILE: + if not isinstance(raw_value, list): + raise InvalidArgumentError(description=f"expected list for files, got {type(raw_value)}") + if len(raw_value) > 0 and not isinstance(raw_value[0], dict): + raise InvalidArgumentError(description=f"expected dict for files[0], got {type(raw_value)}") + raw_value = build_from_mappings( + mappings=raw_value, + tenant_id=snippet.tenant_id, + access_controller=_file_access_controller, + ) + new_value = build_segment_with_type(variable.value_type, raw_value) + draft_var_srv.update_variable(variable, name=new_name, value=new_value) + db.session.commit() + return variable + + @console_ns.doc("delete_snippet_workflow_variable") + @console_ns.doc(description="Delete a draft workflow variable (snippet scope)") + @console_ns.response(204, "Variable deleted successfully") + @console_ns.response(404, "Variable not found") + @_snippet_draft_var_prerequisite + def delete(self, snippet: CustomizedSnippet, variable_id: str) -> Response: + draft_var_srv = WorkflowDraftVariableService(session=db.session()) + variable = _ensure_variable_access( + variable=draft_var_srv.get_variable(variable_id=variable_id), + app_id=snippet.id, + variable_id=variable_id, + ) + _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) + draft_var_srv.delete_variable(variable) + db.session.commit() + return Response("", 204) + + +@console_ns.route("/snippets//workflows/draft/variables//reset") +class SnippetVariableResetApi(Resource): + @console_ns.doc("reset_snippet_workflow_variable") + @console_ns.doc(description="Reset a draft workflow variable to its default value (snippet scope)") + @console_ns.response(200, "Variable reset successfully", workflow_draft_variable_model) + @console_ns.response(204, "Variable reset (no content)") + @console_ns.response(404, "Variable not found") + @_snippet_draft_var_prerequisite + def put(self, snippet: CustomizedSnippet, variable_id: str) -> Response | Any: + draft_var_srv = WorkflowDraftVariableService(session=db.session()) + snippet_service = _snippet_service() + draft_workflow = snippet_service.get_draft_workflow(snippet=snippet) + if draft_workflow is None: + raise NotFoundError( + f"Draft workflow not found, snippet_id={snippet.id}", + ) + variable = _ensure_variable_access( + variable=draft_var_srv.get_variable(variable_id=variable_id), + app_id=snippet.id, + variable_id=variable_id, + ) + _ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id=variable_id) + + resetted = draft_var_srv.reset_variable(draft_workflow, variable) + db.session.commit() + if resetted is None: + return Response("", 204) + return marshal(resetted, workflow_draft_variable_model) + + +@console_ns.route("/snippets//workflows/draft/conversation-variables") +class SnippetConversationVariableCollectionApi(Resource): + @console_ns.doc("get_snippet_conversation_variables") + @console_ns.doc( + description="Conversation variables are not used in snippet workflows; returns an empty list for API parity" + ) + @console_ns.response(200, "Conversation variables retrieved successfully", workflow_draft_variable_list_model) + @_snippet_draft_var_prerequisite + @marshal_with(workflow_draft_variable_list_model) + def get(self, snippet: CustomizedSnippet) -> WorkflowDraftVariableList: + return WorkflowDraftVariableList(variables=[]) + + +@console_ns.route("/snippets//workflows/draft/system-variables") +class SnippetSystemVariableCollectionApi(Resource): + @console_ns.doc("get_snippet_system_variables") + @console_ns.doc( + description="System variables are not used in snippet workflows; returns an empty list for API parity" + ) + @console_ns.response(200, "System variables retrieved successfully", workflow_draft_variable_list_model) + @_snippet_draft_var_prerequisite + @marshal_with(workflow_draft_variable_list_model) + def get(self, snippet: CustomizedSnippet) -> WorkflowDraftVariableList: + return WorkflowDraftVariableList(variables=[]) + + +@console_ns.route("/snippets//workflows/draft/environment-variables") +class SnippetEnvironmentVariableCollectionApi(Resource): + @console_ns.doc("get_snippet_environment_variables") + @console_ns.doc(description="Get environment variables from snippet draft workflow graph") + @console_ns.response(200, "Environment variables retrieved successfully") + @console_ns.response(404, "Draft workflow not found") + @_snippet_draft_var_prerequisite + def get(self, snippet: CustomizedSnippet) -> dict[str, list[dict[str, Any]]]: + snippet_service = _snippet_service() + workflow = snippet_service.get_draft_workflow(snippet=snippet) + if workflow is None: + raise DraftWorkflowNotExist() + + env_vars_list: list[dict[str, Any]] = [] + for v in workflow.environment_variables: + env_vars_list.append( + { + "id": v.id, + "type": "env", + "name": v.name, + "description": v.description, + "selector": v.selector, + "value_type": v.value_type.exposed_type().value, + "value": v.value, + "edited": False, + "visible": True, + "editable": True, + } + ) + + return {"items": env_vars_list} diff --git a/api/controllers/console/tag/tags.py b/api/controllers/console/tag/tags.py index 4e2ea2060d..d2ec06c062 100644 --- a/api/controllers/console/tag/tags.py +++ b/api/controllers/console/tag/tags.py @@ -51,7 +51,7 @@ class TagBindingRemovePayload(BaseModel): class TagListQueryParam(BaseModel): - type: Literal["knowledge", "app", ""] = Field("", description="Tag type filter") + type: Literal["knowledge", "app", "snippet", ""] = Field("", description="Tag type filter") keyword: str | None = Field(None, description="Search keyword") @@ -96,7 +96,10 @@ class TagListApi(Resource): @login_required @account_initialization_required @console_ns.doc( - params={"type": 'Tag type filter. Can be "knowledge" or "app".', "keyword": "Search keyword for tag name."} + params={ + "type": 'Tag type filter. Can be "knowledge", "app", or "snippet".', + "keyword": "Search keyword for tag name.", + } ) @console_ns.doc(responses={200: ("Success", [console_ns.models[TagResponse.__name__]])}) @with_current_tenant_id diff --git a/api/controllers/console/workspace/snippets.py b/api/controllers/console/workspace/snippets.py new file mode 100644 index 0000000000..509dcd4584 --- /dev/null +++ b/api/controllers/console/workspace/snippets.py @@ -0,0 +1,428 @@ +import logging +import re +from urllib.parse import quote + +from flask import Response, request +from flask_restx import Resource, marshal +from sqlalchemy.orm import Session, sessionmaker +from werkzeug.datastructures import MultiDict +from werkzeug.exceptions import NotFound + +from controllers.common.schema import register_schema_models +from controllers.console import console_ns +from controllers.console.snippets.payloads import ( + CreateSnippetPayload, + IncludeSecretQuery, + SnippetImportPayload, + SnippetListQuery, + UpdateSnippetPayload, +) +from controllers.console.wraps import ( + account_initialization_required, + edit_permission_required, + setup_required, +) +from extensions.ext_database import db +from fields.snippet_fields import snippet_fields, snippet_list_fields, snippet_pagination_fields +from libs.login import current_account_with_tenant, login_required +from models.snippet import SnippetType +from services.app_dsl_service import ImportStatus +from services.snippet_dsl_service import SnippetDslService +from services.snippet_service import SnippetService + +logger = logging.getLogger(__name__) +_TAG_IDS_BRACKET_PATTERN = re.compile(r"^tag_ids\[(\d+)\]$") +_CREATOR_IDS_BRACKET_PATTERN = re.compile(r"^creator_ids\[(\d+)\]$") + + +def _snippet_service() -> SnippetService: + return SnippetService(sessionmaker(bind=db.engine, expire_on_commit=False)) + + +def _normalize_snippet_list_query_args(query_args: MultiDict[str, str]) -> dict[str, str | list[str]]: + normalized: dict[str, str | list[str]] = {} + indexed_tag_ids: list[tuple[int, str]] = [] + indexed_creator_ids: list[tuple[int, str]] = [] + + for key in query_args: + match = _TAG_IDS_BRACKET_PATTERN.fullmatch(key) + if match: + indexed_tag_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key)) + continue + + match = _CREATOR_IDS_BRACKET_PATTERN.fullmatch(key) + if match: + indexed_creator_ids.extend((int(match.group(1)), value) for value in query_args.getlist(key)) + continue + + value = query_args.get(key) + if value is not None: + normalized[key] = value + + if indexed_tag_ids: + normalized["tag_ids"] = [value for _, value in sorted(indexed_tag_ids)] + if indexed_creator_ids: + normalized["creators"] = [value for _, value in sorted(indexed_creator_ids)] + + return normalized + + +# Register Pydantic models with Swagger +register_schema_models( + console_ns, + SnippetListQuery, + CreateSnippetPayload, + UpdateSnippetPayload, + SnippetImportPayload, + IncludeSecretQuery, +) + +# Create namespace models for marshaling +snippet_model = console_ns.model("Snippet", snippet_fields) +snippet_list_model = console_ns.model("SnippetList", snippet_list_fields) +snippet_pagination_model = console_ns.model("SnippetPagination", snippet_pagination_fields) + + +@console_ns.route("/workspaces/current/customized-snippets") +class CustomizedSnippetsApi(Resource): + @console_ns.doc("list_customized_snippets") + @console_ns.expect(console_ns.models.get(SnippetListQuery.__name__)) + @console_ns.response(200, "Snippets retrieved successfully", snippet_pagination_model) + @setup_required + @login_required + @account_initialization_required + def get(self): + """List customized snippets with pagination and search.""" + _, current_tenant_id = current_account_with_tenant() + + query = SnippetListQuery.model_validate(_normalize_snippet_list_query_args(request.args)) + + snippet_service = _snippet_service() + snippets, total, has_more = snippet_service.get_snippets( + tenant_id=current_tenant_id, + page=query.page, + limit=query.limit, + keyword=query.keyword, + is_published=query.is_published, + creators=query.creators, + tag_ids=query.tag_ids, + ) + + return { + "data": marshal(snippets, snippet_list_fields), + "page": query.page, + "limit": query.limit, + "total": total, + "has_more": has_more, + }, 200 + + @console_ns.doc("create_customized_snippet") + @console_ns.expect(console_ns.models.get(CreateSnippetPayload.__name__)) + @console_ns.response(201, "Snippet created successfully", snippet_model) + @console_ns.response(400, "Invalid request") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + def post(self): + """Create a new customized snippet.""" + current_user, current_tenant_id = current_account_with_tenant() + + payload = CreateSnippetPayload.model_validate(console_ns.payload or {}) + + try: + snippet_type = SnippetType(payload.type) + except ValueError: + snippet_type = SnippetType.NODE + + try: + if payload.graph is not None: + SnippetService.validate_snippet_graph_forbidden_nodes(payload.graph) + + snippet_service = _snippet_service() + snippet = snippet_service.create_snippet( + tenant_id=current_tenant_id, + name=payload.name, + description=payload.description, + snippet_type=snippet_type, + icon_info=payload.icon_info.model_dump() if payload.icon_info else None, + input_fields=[f.model_dump() for f in payload.input_fields] if payload.input_fields else None, + account=current_user, + ) + except ValueError as e: + return {"message": str(e)}, 400 + + return marshal(snippet, snippet_fields), 201 + + +@console_ns.route("/workspaces/current/customized-snippets/") +class CustomizedSnippetDetailApi(Resource): + @console_ns.doc("get_customized_snippet") + @console_ns.response(200, "Snippet retrieved successfully", snippet_model) + @console_ns.response(404, "Snippet not found") + @setup_required + @login_required + @account_initialization_required + def get(self, snippet_id: str): + """Get customized snippet details.""" + _, current_tenant_id = current_account_with_tenant() + + snippet_service = _snippet_service() + snippet = snippet_service.get_snippet_by_id( + snippet_id=str(snippet_id), + tenant_id=current_tenant_id, + ) + + if not snippet: + raise NotFound("Snippet not found") + + return marshal(snippet, snippet_fields), 200 + + @console_ns.doc("update_customized_snippet") + @console_ns.expect(console_ns.models.get(UpdateSnippetPayload.__name__)) + @console_ns.response(200, "Snippet updated successfully", snippet_model) + @console_ns.response(400, "Invalid request") + @console_ns.response(404, "Snippet not found") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + def patch(self, snippet_id: str): + """Update customized snippet.""" + current_user, current_tenant_id = current_account_with_tenant() + + snippet_service = _snippet_service() + snippet = snippet_service.get_snippet_by_id( + snippet_id=str(snippet_id), + tenant_id=current_tenant_id, + ) + + if not snippet: + raise NotFound("Snippet not found") + + payload = UpdateSnippetPayload.model_validate(console_ns.payload or {}) + update_data = payload.model_dump(exclude_unset=True) + + if "icon_info" in update_data and update_data["icon_info"] is not None: + update_data["icon_info"] = payload.icon_info.model_dump() if payload.icon_info else None + + if not update_data: + return {"message": "No valid fields to update"}, 400 + + try: + with Session(db.engine, expire_on_commit=False) as session: + snippet = session.merge(snippet) + snippet = SnippetService.update_snippet( + session=session, + snippet=snippet, + account_id=current_user.id, + data=update_data, + ) + session.commit() + except ValueError as e: + return {"message": str(e)}, 400 + + return marshal(snippet, snippet_fields), 200 + + @console_ns.doc("delete_customized_snippet") + @console_ns.response(204, "Snippet deleted successfully") + @console_ns.response(404, "Snippet not found") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + def delete(self, snippet_id: str): + """Delete customized snippet.""" + _, current_tenant_id = current_account_with_tenant() + + snippet_service = _snippet_service() + snippet = snippet_service.get_snippet_by_id( + snippet_id=str(snippet_id), + tenant_id=current_tenant_id, + ) + + if not snippet: + raise NotFound("Snippet not found") + + with Session(db.engine) as session: + snippet = session.merge(snippet) + SnippetService.delete_snippet( + session=session, + snippet=snippet, + ) + session.commit() + + return "", 204 + + +@console_ns.route("/workspaces/current/customized-snippets//export") +class CustomizedSnippetExportApi(Resource): + @console_ns.doc("export_customized_snippet") + @console_ns.doc(description="Export snippet configuration as DSL") + @console_ns.doc(params={"snippet_id": "Snippet ID to export"}) + @console_ns.response(200, "Snippet exported successfully") + @console_ns.response(404, "Snippet not found") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + def get(self, snippet_id: str): + """Export snippet as DSL.""" + _, current_tenant_id = current_account_with_tenant() + + snippet_service = _snippet_service() + snippet = snippet_service.get_snippet_by_id( + snippet_id=str(snippet_id), + tenant_id=current_tenant_id, + ) + + if not snippet: + raise NotFound("Snippet not found") + + # Get include_secret parameter + query = IncludeSecretQuery.model_validate(request.args.to_dict()) + + with Session(db.engine) as session: + export_service = SnippetDslService(session) + result = export_service.export_snippet_dsl(snippet=snippet, include_secret=query.include_secret == "true") + + # Set filename with .snippet extension + filename = f"{snippet.name}.snippet" + encoded_filename = quote(filename) + + response = Response( + result, + mimetype="application/x-yaml", + ) + response.headers["Content-Disposition"] = f"attachment; filename*=UTF-8''{encoded_filename}" + response.headers["Content-Type"] = "application/x-yaml" + + return response + + +@console_ns.route("/workspaces/current/customized-snippets/imports") +class CustomizedSnippetImportApi(Resource): + @console_ns.doc("import_customized_snippet") + @console_ns.doc(description="Import snippet from DSL") + @console_ns.expect(console_ns.models.get(SnippetImportPayload.__name__)) + @console_ns.response(200, "Snippet imported successfully") + @console_ns.response(202, "Import pending confirmation") + @console_ns.response(400, "Import failed") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + def post(self): + """Import snippet from DSL.""" + current_user, _ = current_account_with_tenant() + payload = SnippetImportPayload.model_validate(console_ns.payload or {}) + + with Session(db.engine) as session: + import_service = SnippetDslService(session) + result = import_service.import_snippet( + account=current_user, + import_mode=payload.mode, + yaml_content=payload.yaml_content, + yaml_url=payload.yaml_url, + snippet_id=payload.snippet_id, + name=payload.name, + description=payload.description, + ) + session.commit() + + # Return appropriate status code based on result + status = result.status + if status == ImportStatus.FAILED: + return result.model_dump(mode="json"), 400 + elif status == ImportStatus.PENDING: + return result.model_dump(mode="json"), 202 + return result.model_dump(mode="json"), 200 + + +@console_ns.route("/workspaces/current/customized-snippets/imports//confirm") +class CustomizedSnippetImportConfirmApi(Resource): + @console_ns.doc("confirm_snippet_import") + @console_ns.doc(description="Confirm a pending snippet import") + @console_ns.doc(params={"import_id": "Import ID to confirm"}) + @console_ns.response(200, "Import confirmed successfully") + @console_ns.response(400, "Import failed") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + def post(self, import_id: str): + """Confirm a pending snippet import.""" + current_user, _ = current_account_with_tenant() + + with Session(db.engine) as session: + import_service = SnippetDslService(session) + result = import_service.confirm_import(import_id=import_id, account=current_user) + session.commit() + + if result.status == ImportStatus.FAILED: + return result.model_dump(mode="json"), 400 + return result.model_dump(mode="json"), 200 + + +@console_ns.route("/workspaces/current/customized-snippets//check-dependencies") +class CustomizedSnippetCheckDependenciesApi(Resource): + @console_ns.doc("check_snippet_dependencies") + @console_ns.doc(description="Check dependencies for a snippet") + @console_ns.doc(params={"snippet_id": "Snippet ID"}) + @console_ns.response(200, "Dependencies checked successfully") + @console_ns.response(404, "Snippet not found") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + def get(self, snippet_id: str): + """Check dependencies for a snippet.""" + _, current_tenant_id = current_account_with_tenant() + + snippet_service = _snippet_service() + snippet = snippet_service.get_snippet_by_id( + snippet_id=str(snippet_id), + tenant_id=current_tenant_id, + ) + + if not snippet: + raise NotFound("Snippet not found") + + with Session(db.engine) as session: + import_service = SnippetDslService(session) + result = import_service.check_dependencies(snippet=snippet) + + return result.model_dump(mode="json"), 200 + + +@console_ns.route("/workspaces/current/customized-snippets//use-count/increment") +class CustomizedSnippetUseCountIncrementApi(Resource): + @console_ns.doc("increment_snippet_use_count") + @console_ns.doc(description="Increment snippet use count by 1") + @console_ns.doc(params={"snippet_id": "Snippet ID"}) + @console_ns.response(200, "Use count incremented successfully") + @console_ns.response(404, "Snippet not found") + @setup_required + @login_required + @account_initialization_required + @edit_permission_required + def post(self, snippet_id: str): + """Increment snippet use count when it is inserted into a workflow.""" + _, current_tenant_id = current_account_with_tenant() + + snippet_service = _snippet_service() + snippet = snippet_service.get_snippet_by_id( + snippet_id=str(snippet_id), + tenant_id=current_tenant_id, + ) + + if not snippet: + raise NotFound("Snippet not found") + + with Session(db.engine) as session: + snippet = session.merge(snippet) + SnippetService.increment_use_count(session=session, snippet=snippet) + session.commit() + session.refresh(snippet) + + return {"result": "success", "use_count": snippet.use_count}, 200 diff --git a/api/core/app/apps/workflow/app_generator.py b/api/core/app/apps/workflow/app_generator.py index fb4f94bc87..cdc65c6e41 100644 --- a/api/core/app/apps/workflow/app_generator.py +++ b/api/core/app/apps/workflow/app_generator.py @@ -10,7 +10,7 @@ from typing import TYPE_CHECKING, Any, Literal, overload from flask import Flask, current_app from pydantic import ValidationError from sqlalchemy import select -from sqlalchemy.orm import sessionmaker +from sqlalchemy.orm import Session, sessionmaker import contexts from configs import dify_config @@ -68,6 +68,25 @@ def _extract_trace_session_id_from_debug_args(args: Mapping[str, Any] | Any) -> class WorkflowAppGenerator(BaseAppGenerator): + @staticmethod + def _ensure_snippet_start_node_in_worker(*, session: Session, workflow: Workflow) -> Workflow: + """Re-apply snippet virtual Start injection after worker reloads workflow from DB.""" + if workflow.kind_or_standard != "snippet": + return workflow + + from models.snippet import CustomizedSnippet + from services.snippet_generate_service import SnippetGenerateService + + snippet = session.scalar( + select(CustomizedSnippet).where( + CustomizedSnippet.id == workflow.app_id, + CustomizedSnippet.tenant_id == workflow.tenant_id, + ) + ) + if snippet is None: + return workflow + return SnippetGenerateService.ensure_start_node_for_worker(workflow, snippet) + @staticmethod def _should_prepare_user_inputs(args: Mapping[str, Any]) -> bool: return not bool(args.get(SKIP_PREPARE_USER_INPUTS_KEY)) @@ -592,6 +611,8 @@ class WorkflowAppGenerator(BaseAppGenerator): if workflow is None: raise ValueError("Workflow not found") + workflow = self._ensure_snippet_start_node_in_worker(session=session, workflow=workflow) + # Determine system_user_id based on invocation source is_external_api_call = application_generate_entity.invoke_from in { InvokeFrom.WEB_APP, diff --git a/api/core/app/apps/workflow/app_runner.py b/api/core/app/apps/workflow/app_runner.py index ecb485885f..e35735038c 100644 --- a/api/core/app/apps/workflow/app_runner.py +++ b/api/core/app/apps/workflow/app_runner.py @@ -11,6 +11,7 @@ from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, Workfl from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository from core.workflow.node_factory import get_default_root_node_id from core.workflow.nodes.agent_v2.session_cleanup_layer import build_workflow_agent_session_cleanup_layer +from core.workflow.snippet_start import get_compatible_start_aliases from core.workflow.system_variables import build_bootstrap_variables, build_system_variables from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool from core.workflow.workflow_entry import WorkflowEntry @@ -118,7 +119,15 @@ class WorkflowAppRunner(WorkflowBasedAppRunner): ), ) root_node_id = self._root_node_id or get_default_root_node_id(self._workflow.graph_dict) - add_node_inputs_to_pool(variable_pool, node_id=root_node_id, inputs=inputs) + add_node_inputs_to_pool( + variable_pool, + node_id=root_node_id, + inputs=inputs, + aliases=get_compatible_start_aliases( + workflow_kind=getattr(self._workflow, "kind_or_standard", None), + root_node_id=root_node_id, + ), + ) graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) graph = self._init_graph( diff --git a/api/core/workflow/snippet_start.py b/api/core/workflow/snippet_start.py new file mode 100644 index 0000000000..bba065aa0f --- /dev/null +++ b/api/core/workflow/snippet_start.py @@ -0,0 +1,20 @@ +"""Shared snippet virtual Start-node identifiers and compatibility helpers. + +Snippet workflows do not persist a real canvas Start node, so the backend +injects one at runtime. Existing workflow references commonly use the public +selector shape ``#start.#``; keep that contract stable by treating the +runtime-only snippet Start node as compatible with the legacy ``start`` id. +""" + +from __future__ import annotations + +LEGACY_START_NODE_ID = "start" +SNIPPET_VIRTUAL_START_NODE_ID = "__snippet_virtual_start__" + + +def get_compatible_start_aliases(*, workflow_kind: str | None, root_node_id: str | None) -> tuple[str, ...]: + """Return additional selector ids that should mirror snippet Start inputs.""" + if workflow_kind == "snippet" and root_node_id == SNIPPET_VIRTUAL_START_NODE_ID: + return (LEGACY_START_NODE_ID,) + + return () diff --git a/api/core/workflow/variable_pool_initializer.py b/api/core/workflow/variable_pool_initializer.py index 43523e01b2..5ec31176d2 100644 --- a/api/core/workflow/variable_pool_initializer.py +++ b/api/core/workflow/variable_pool_initializer.py @@ -10,6 +10,19 @@ def add_variables_to_pool(variable_pool: VariablePool, variables: Sequence[Varia variable_pool.add(variable.selector, variable) -def add_node_inputs_to_pool(variable_pool: VariablePool, *, node_id: str, inputs: Mapping[str, Any]) -> None: - for key, value in inputs.items(): - variable_pool.add((node_id, key), value) +def add_node_inputs_to_pool( + variable_pool: VariablePool, + *, + node_id: str, + inputs: Mapping[str, Any], + aliases: Sequence[str] = (), +) -> None: + """Store node inputs under the primary node id and any compatible aliases.""" + node_ids: list[str] = [node_id] + for alias in aliases: + if alias not in node_ids: + node_ids.append(alias) + + for current_node_id in node_ids: + for key, value in inputs.items(): + variable_pool.add((current_node_id, key), value) diff --git a/api/fields/snippet_fields.py b/api/fields/snippet_fields.py new file mode 100644 index 0000000000..ec0821fc85 --- /dev/null +++ b/api/fields/snippet_fields.py @@ -0,0 +1,52 @@ +from flask_restx import fields + +from fields.member_fields import simple_account_fields +from libs.helper import TimestampField + +tag_fields = {"id": fields.String, "name": fields.String, "type": fields.String} + +# Snippet list item fields (lightweight for list display) +snippet_list_fields = { + "id": fields.String, + "name": fields.String, + "description": fields.String, + "type": fields.String, + "version": fields.Integer, + "use_count": fields.Integer, + "is_published": fields.Boolean, + "icon_info": fields.Raw, + "tags": fields.List(fields.Nested(tag_fields)), + "created_by": fields.String, + "author_name": fields.String, + "created_at": TimestampField, + "updated_by": fields.String, + "updated_at": TimestampField, +} + +# Full snippet fields (includes creator info and graph data) +snippet_fields = { + "id": fields.String, + "name": fields.String, + "description": fields.String, + "type": fields.String, + "version": fields.Integer, + "use_count": fields.Integer, + "is_published": fields.Boolean, + "icon_info": fields.Raw, + "graph": fields.Raw(attribute="graph_dict"), + "input_fields": fields.Raw(attribute="input_fields_list"), + "tags": fields.List(fields.Nested(tag_fields)), + "created_by": fields.Nested(simple_account_fields, attribute="created_by_account", allow_null=True), + "created_at": TimestampField, + "updated_by": fields.Nested(simple_account_fields, attribute="updated_by_account", allow_null=True), + "updated_at": TimestampField, +} + +# Pagination response fields +snippet_pagination_fields = { + "data": fields.List(fields.Nested(snippet_list_fields)), + "page": fields.Integer, + "limit": fields.Integer, + "total": fields.Integer, + "has_more": fields.Boolean, +} diff --git a/api/migrations/versions/2026_06_03_1100-2b3c4d5e6f70_add_customized_snippets.py b/api/migrations/versions/2026_06_03_1100-2b3c4d5e6f70_add_customized_snippets.py new file mode 100644 index 0000000000..8a81db0688 --- /dev/null +++ b/api/migrations/versions/2026_06_03_1100-2b3c4d5e6f70_add_customized_snippets.py @@ -0,0 +1,64 @@ +"""add customized snippets + +Revision ID: 2b3c4d5e6f70 +Revises: 8d4c2a1b9f03 +Create Date: 2026-06-03 11:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models.types + +# revision identifiers, used by Alembic. +revision = "2b3c4d5e6f70" +down_revision = "8d4c2a1b9f03" +branch_labels = None +depends_on = None + + +def _is_pg() -> bool: + return op.get_bind().dialect.name == "postgresql" + + +def _current_timestamp_default(): + return sa.text("CURRENT_TIMESTAMP(0)") if _is_pg() else sa.func.current_timestamp() + + +def upgrade() -> None: + op.create_table( + "customized_snippets", + sa.Column("id", models.types.StringUUID(), nullable=False), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("description", models.types.LongText(), nullable=True), + sa.Column("type", sa.String(length=50), server_default=sa.text("'node'"), nullable=False), + sa.Column("workflow_id", models.types.StringUUID(), nullable=True), + sa.Column("is_published", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("version", sa.Integer(), server_default=sa.text("1"), nullable=False), + sa.Column("use_count", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("icon_info", models.types.AdjustedJSON(), nullable=True), + sa.Column("input_fields", models.types.LongText(), nullable=True), + sa.Column("created_by", models.types.StringUUID(), nullable=True), + sa.Column("created_at", sa.DateTime(), server_default=_current_timestamp_default(), nullable=False), + sa.Column("updated_by", models.types.StringUUID(), nullable=True), + sa.Column("updated_at", sa.DateTime(), server_default=_current_timestamp_default(), nullable=False), + sa.PrimaryKeyConstraint("id", name="customized_snippet_pkey"), + ) + + with op.batch_alter_table("customized_snippets", schema=None) as batch_op: + batch_op.create_index("customized_snippet_tenant_idx", ["tenant_id"], unique=False) + + with op.batch_alter_table("workflows", schema=None) as batch_op: + batch_op.add_column(sa.Column("kind", sa.String(length=255), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table("workflows", schema=None) as batch_op: + batch_op.drop_column("kind") + + with op.batch_alter_table("customized_snippets", schema=None) as batch_op: + batch_op.drop_index("customized_snippet_tenant_idx") + + op.drop_table("customized_snippets") diff --git a/api/models/__init__.py b/api/models/__init__.py index 44f3a6231b..4fdcda34ff 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -106,6 +106,7 @@ from .provider import ( TenantDefaultModel, TenantPreferredModelProvider, ) +from .snippet import CustomizedSnippet, SnippetType from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding from .task import CeleryTask, CeleryTaskSet from .tools import ( @@ -131,12 +132,14 @@ from .workflow import ( WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowArchiveLog, + WorkflowKind, WorkflowNodeExecutionModel, WorkflowNodeExecutionOffload, WorkflowNodeExecutionTriggeredFrom, WorkflowPause, WorkflowRun, WorkflowType, + resolve_workflow_kind, ) __all__ = [ @@ -179,6 +182,7 @@ __all__ = [ "CreatorUserRole", "CredentialPermission", "CredentialPermissionType", + "CustomizedSnippet", "DataSourceApiKeyAuthBinding", "DataSourceOauthBinding", "Dataset", @@ -227,6 +231,7 @@ __all__ = [ "RecommendedApp", "SavedMessage", "Site", + "SnippetType", "Tag", "TagBinding", "Tenant", @@ -259,6 +264,7 @@ __all__ = [ "WorkflowComment", "WorkflowCommentMention", "WorkflowCommentReply", + "WorkflowKind", "WorkflowNodeExecutionModel", "WorkflowNodeExecutionOffload", "WorkflowNodeExecutionTriggeredFrom", @@ -269,4 +275,5 @@ __all__ = [ "WorkflowToolProvider", "WorkflowTriggerStatus", "WorkflowType", + "resolve_workflow_kind", ] diff --git a/api/models/enums.py b/api/models/enums.py index 3b22d5a550..d30d2447db 100644 --- a/api/models/enums.py +++ b/api/models/enums.py @@ -224,6 +224,7 @@ class TagType(StrEnum): KNOWLEDGE = "knowledge" APP = "app" + SNIPPET = "snippet" class DatasetMetadataType(StrEnum): diff --git a/api/models/model.py b/api/models/model.py index 6381d28b28..b84a06bf69 100644 --- a/api/models/model.py +++ b/api/models/model.py @@ -2499,7 +2499,7 @@ class Tag(TypeBase): sa.Index("tag_name_idx", "name"), ) - TAG_TYPE_LIST = ["knowledge", "app"] + TAG_TYPE_LIST = ["knowledge", "app", "snippet"] id: Mapped[str] = mapped_column( StringUUID, insert_default=lambda: str(uuid4()), default_factory=lambda: str(uuid4()), init=False diff --git a/api/models/snippet.py b/api/models/snippet.py new file mode 100644 index 0000000000..b6fbc4ed82 --- /dev/null +++ b/api/models/snippet.py @@ -0,0 +1,123 @@ +import json +from datetime import datetime +from enum import StrEnum +from typing import Any + +import sqlalchemy as sa +from sqlalchemy import DateTime, String, func +from sqlalchemy.orm import Mapped, mapped_column + +from libs.uuid_utils import uuidv7 + +from .account import Account +from .base import Base +from .engine import db +from .model import Tag, TagBinding +from .types import AdjustedJSON, LongText, StringUUID + + +class SnippetType(StrEnum): + """Snippet Type Enum""" + + NODE = "node" + GROUP = "group" + + +class CustomizedSnippet(Base): + """ + Customized Snippet Model + + Stores reusable workflow components (nodes or node groups) that can be + shared across applications within a workspace. + """ + + __tablename__ = "customized_snippets" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="customized_snippet_pkey"), + sa.Index("customized_snippet_tenant_idx", "tenant_id"), + ) + + id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuidv7())) + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + description: Mapped[str | None] = mapped_column(LongText, nullable=True) + type: Mapped[str] = mapped_column(String(50), nullable=False, server_default=sa.text("'node'")) + + # Workflow reference for published version + workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + + # State flags + is_published: Mapped[bool] = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("false")) + version: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("1")) + use_count: Mapped[int] = mapped_column(sa.Integer, nullable=False, server_default=sa.text("0")) + + # Visual customization + icon_info: Mapped[dict | None] = mapped_column(AdjustedJSON, nullable=True) + + # Snippet configuration (stored as JSON text) + input_fields: Mapped[str | None] = mapped_column(LongText, nullable=True) + + # Audit fields + created_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.current_timestamp()) + updated_by: Mapped[str | None] = mapped_column(StringUUID, nullable=True) + updated_at: Mapped[datetime] = mapped_column( + DateTime, nullable=False, server_default=func.current_timestamp(), onupdate=func.current_timestamp() + ) + + @property + def graph_dict(self) -> dict[str, Any]: + """Get graph from associated workflow.""" + if self.workflow_id: + from .workflow import Workflow + + workflow = db.session.get(Workflow, self.workflow_id) + if workflow: + return json.loads(workflow.graph) if workflow.graph else {} + return {} + + @property + def input_fields_list(self) -> list[dict[str, Any]]: + """Parse input_fields JSON to list.""" + return json.loads(self.input_fields) if self.input_fields else [] + + @property + def tags(self): + """Get snippet tags.""" + tags = db.session.scalars( + sa.select(Tag) + .join(TagBinding, Tag.id == TagBinding.tag_id) + .where( + TagBinding.target_id == self.id, + TagBinding.tenant_id == self.tenant_id, + Tag.tenant_id == self.tenant_id, + Tag.type == "snippet", + ) + ).all() + + return tags or [] + + @property + def created_by_account(self) -> Account | None: + """Get the account that created this snippet.""" + if self.created_by: + return db.session.get(Account, self.created_by) + return None + + @property + def author_name(self) -> str | None: + """Get the creator account name.""" + account = self.created_by_account + return account.name if account else None + + @property + def updated_by_account(self) -> Account | None: + """Get the account that last updated this snippet.""" + if self.updated_by: + return db.session.get(Account, self.updated_by) + return None + + @property + def version_str(self) -> str: + """Get version as string for API response.""" + return str(self.version) diff --git a/api/models/workflow.py b/api/models/workflow.py index b3cb921b07..f1a4e13414 100644 --- a/api/models/workflow.py +++ b/api/models/workflow.py @@ -112,6 +112,7 @@ class WorkflowType(StrEnum): WORKFLOW = "workflow" CHAT = "chat" RAG_PIPELINE = "rag-pipeline" + SNIPPET = "snippet" @classmethod def value_of(cls, value: str) -> "WorkflowType": @@ -140,6 +141,26 @@ class WorkflowType(StrEnum): return cls.WORKFLOW if app_mode == AppMode.WORKFLOW else cls.CHAT +class WorkflowKind(StrEnum): + STANDARD = "standard" + SNIPPET = "snippet" + + @classmethod + def value_of(cls, value: str) -> "WorkflowKind": + for kind in cls: + if kind.value == value: + return kind + raise ValueError(f"invalid workflow kind value {value}") + + +def resolve_workflow_kind(kind: str | WorkflowKind | None) -> WorkflowKind: + if kind is None: + return WorkflowKind.STANDARD + if isinstance(kind, WorkflowKind): + return kind + return WorkflowKind.value_of(kind) + + class _InvalidGraphDefinitionError(Exception): pass @@ -187,6 +208,12 @@ class Workflow(Base): # bug tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) type: Mapped[WorkflowType] = mapped_column(EnumText(WorkflowType, length=255), nullable=False) + kind: Mapped[WorkflowKind | None] = mapped_column( + EnumText(WorkflowKind, length=255), + nullable=True, + default=WorkflowKind.STANDARD, + server_default=sa.text("'standard'"), + ) version: Mapped[str] = mapped_column(String(255), nullable=False) marked_name: Mapped[str] = mapped_column(String(255), default="", server_default="") marked_comment: Mapped[str] = mapped_column(String(255), default="", server_default="") @@ -228,12 +255,14 @@ class Workflow(Base): # bug rag_pipeline_variables: list[dict], marked_name: str = "", marked_comment: str = "", + kind: str | None = WorkflowKind.STANDARD.value, ) -> "Workflow": workflow = Workflow() workflow.id = str(uuid4()) workflow.tenant_id = tenant_id workflow.app_id = app_id workflow.type = WorkflowType(type) + workflow.kind = resolve_workflow_kind(kind) workflow.version = version workflow.graph = graph workflow.features = features @@ -255,6 +284,14 @@ class Workflow(Base): # bug def updated_by_account(self): return db.session.get(Account, self.updated_by) if self.updated_by else None + @property + def kind_or_standard(self) -> str: + return self.resolved_kind.value + + @property + def resolved_kind(self) -> WorkflowKind: + return resolve_workflow_kind(self.kind) + @property def graph_dict(self) -> Mapping[str, Any]: # TODO(QuantumGhost): Consider caching `graph_dict` to avoid repeated JSON decoding. diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md index 46d3d8c87a..7ea7d92f49 100644 --- a/api/openapi/markdown/console-swagger.md +++ b/api/openapi/markdown/console-swagger.md @@ -8068,6 +8068,610 @@ Generate structured output rules using LLM | 400 | Invalid request parameters | | 402 | Provider quota exceeded | +### /snippets/{snippet_id}/workflow-runs + +#### GET +##### Summary + +List workflow runs for snippet + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow runs retrieved successfully | [WorkflowRunPaginationResponse](#workflowrunpaginationresponse) | + +### /snippets/{snippet_id}/workflow-runs/tasks/{task_id}/stop + +#### POST +##### Summary + +Stop a running snippet workflow task + +##### Description + +Uses both the legacy stop flag mechanism and the graph engine +command channel for backward compatibility. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | +| task_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Task stopped successfully | +| 404 | Snippet not found | + +### /snippets/{snippet_id}/workflow-runs/{run_id} + +#### GET +##### Summary + +Get workflow run detail for snippet + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| run_id | path | | Yes | string | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow run detail retrieved successfully | [WorkflowRunDetailResponse](#workflowrundetailresponse) | +| 404 | Workflow run not found | | + +### /snippets/{snippet_id}/workflow-runs/{run_id}/node-executions + +#### GET +##### Summary + +List node executions for a workflow run + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| run_id | path | | Yes | string | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node executions retrieved successfully | [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse) | + +### /snippets/{snippet_id}/workflows + +#### GET +##### Summary + +Get all published workflow versions for snippet + +##### Description + +Get all published workflows for a snippet + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [SnippetWorkflowListQuery](#snippetworkflowlistquery) | +| snippet_id | path | Snippet ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Published workflows retrieved successfully | [WorkflowPaginationResponse](#workflowpaginationresponse) | + +### /snippets/{snippet_id}/workflows/default-workflow-block-configs + +#### GET +##### Summary + +Get default block configurations for snippet workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Default block configs retrieved successfully | + +### /snippets/{snippet_id}/workflows/draft + +#### GET +##### Summary + +Get draft workflow for snippet + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Draft workflow retrieved successfully | [SnippetWorkflowResponse](#snippetworkflowresponse) | +| 404 | Snippet or draft workflow not found | | + +#### POST +##### Summary + +Sync draft workflow for snippet + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | +| payload | body | | Yes | [SnippetDraftSyncPayload](#snippetdraftsyncpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Draft workflow synced successfully | +| 400 | Hash mismatch | + +### /snippets/{snippet_id}/workflows/draft/config + +#### GET +##### Summary + +Get snippet draft workflow configuration limits + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Draft config retrieved successfully | + +### /snippets/{snippet_id}/workflows/draft/conversation-variables + +#### GET +##### Description + +Conversation variables are not used in snippet workflows; returns an empty list for API parity + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Conversation variables retrieved successfully | [WorkflowDraftVariableList](#workflowdraftvariablelist) | + +### /snippets/{snippet_id}/workflows/draft/environment-variables + +#### GET +##### Description + +Get environment variables from snippet draft workflow graph + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Environment variables retrieved successfully | +| 404 | Draft workflow not found | + +### /snippets/{snippet_id}/workflows/draft/iteration/nodes/{node_id}/run + +#### POST +##### Summary + +Run a draft workflow iteration node for snippet + +##### Description + +Run draft workflow iteration node for snippet +Iteration nodes execute their internal sub-graph multiple times over an input list. +Returns an SSE event stream with iteration progress and results. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [SnippetIterationNodeRunPayload](#snippetiterationnoderunpayload) | +| node_id | path | Node ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Iteration node run started successfully (SSE stream) | +| 404 | Snippet or draft workflow not found | + +### /snippets/{snippet_id}/workflows/draft/loop/nodes/{node_id}/run + +#### POST +##### Summary + +Run a draft workflow loop node for snippet + +##### Description + +Run draft workflow loop node for snippet +Loop nodes execute their internal sub-graph repeatedly until a condition is met. +Returns an SSE event stream with loop progress and results. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [SnippetLoopNodeRunPayload](#snippetloopnoderunpayload) | +| node_id | path | Node ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Loop node run started successfully (SSE stream) | +| 404 | Snippet or draft workflow not found | + +### /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/last-run + +#### GET +##### Summary + +Get the last run result for a specific node in snippet draft workflow + +##### Description + +Get last run result for a node in snippet draft workflow +Returns the most recent execution record for the given node, +including status, inputs, outputs, and timing information. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | Node ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node last run retrieved successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | +| 404 | Snippet, draft workflow, or node last run not found | | + +### /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/run + +#### POST +##### Summary + +Run a single node in snippet draft workflow + +##### Description + +Run a single node in snippet draft workflow (single-step debugging) +Executes a specific node with provided inputs for single-step debugging. +Returns the node execution result including status, outputs, and timing. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [SnippetDraftNodeRunPayload](#snippetdraftnoderunpayload) | +| node_id | path | Node ID | Yes | string | +| snippet_id | path | Snippet ID | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node run completed successfully | [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) | +| 404 | Snippet or draft workflow not found | | + +### /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/variables + +#### DELETE +##### Description + +Delete all variables for a specific node (snippet draft workflow) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Node variables deleted successfully | + +#### GET +##### Description + +Get variables for a specific node (snippet draft workflow) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| node_id | path | | Yes | string | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Node variables retrieved successfully | [WorkflowDraftVariableList](#workflowdraftvariablelist) | + +### /snippets/{snippet_id}/workflows/draft/run + +#### POST +##### Summary + +Run draft workflow for snippet + +##### Description + +Executes the snippet's draft workflow with the provided inputs +and returns an SSE event stream with execution progress and results. + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | +| payload | body | | Yes | [SnippetDraftRunPayload](#snippetdraftrunpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Draft workflow run started successfully (SSE stream) | +| 404 | Snippet or draft workflow not found | + +### /snippets/{snippet_id}/workflows/draft/system-variables + +#### GET +##### Description + +System variables are not used in snippet workflows; returns an empty list for API parity + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | System variables retrieved successfully | [WorkflowDraftVariableList](#workflowdraftvariablelist) | + +### /snippets/{snippet_id}/workflows/draft/variables + +#### DELETE +##### Description + +Delete all draft workflow variables for the current user (snippet scope) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Workflow variables deleted successfully | + +#### GET +##### Description + +List draft workflow variables without values (paginated, snippet scope) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | +| payload | body | | Yes | [WorkflowDraftVariableListQuery](#workflowdraftvariablelistquery) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Workflow variables retrieved successfully | [WorkflowDraftVariableListWithoutValue](#workflowdraftvariablelistwithoutvalue) | + +### /snippets/{snippet_id}/workflows/draft/variables/{variable_id} + +#### DELETE +##### Description + +Delete a draft workflow variable (snippet scope) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | +| variable_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Variable deleted successfully | +| 404 | Variable not found | + +#### GET +##### Description + +Get a specific draft workflow variable (snippet scope) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | +| variable_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variable retrieved successfully | [WorkflowDraftVariable](#workflowdraftvariable) | +| 404 | Variable not found | | + +#### PATCH +##### Description + +Update a draft workflow variable (snippet scope) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | +| variable_id | path | | Yes | string | +| payload | body | | Yes | [WorkflowDraftVariableUpdatePayload](#workflowdraftvariableupdatepayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variable updated successfully | [WorkflowDraftVariable](#workflowdraftvariable) | +| 404 | Variable not found | | + +### /snippets/{snippet_id}/workflows/draft/variables/{variable_id}/reset + +#### PUT +##### Description + +Reset a draft workflow variable to its default value (snippet scope) + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | +| variable_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Variable reset successfully | [WorkflowDraftVariable](#workflowdraftvariable) | +| 204 | Variable reset (no content) | | +| 404 | Variable not found | | + +### /snippets/{snippet_id}/workflows/publish + +#### GET +##### Summary + +Get published workflow for snippet + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Published workflow retrieved successfully | [SnippetWorkflowResponse](#snippetworkflowresponse) | +| 404 | Snippet not found | | + +#### POST +##### Summary + +Publish snippet workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | +| payload | body | | Yes | [PublishWorkflowPayload](#publishworkflowpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Workflow published successfully | +| 400 | No draft workflow found | + +### /snippets/{snippet_id}/workflows/{workflow_id}/restore + +#### POST +##### Summary + +Restore a published snippet workflow version into the draft workflow + +##### Description + +Restore a published snippet workflow version into the draft workflow + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | Snippet ID | Yes | string | +| workflow_id | path | Published workflow ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Workflow restored successfully | +| 400 | Source workflow must be published | +| 404 | Workflow not found | + ### /spec/schema-definitions #### GET @@ -8150,7 +8754,7 @@ Remove one or more tag bindings from a target. | Name | Located in | Description | Required | Schema | | ---- | ---------- | ----------- | -------- | ------ | | keyword | query | Search keyword for tag name. | No | string | -| type | query | Tag type filter. Can be "knowledge" or "app". | No | string | +| type | query | Tag type filter. Can be "knowledge", "app", or "snippet". | No | string | ##### Responses @@ -8616,6 +9220,222 @@ Get list of available agent providers | ---- | ----------- | ------ | | 200 | Success | [ object ] | +### /workspaces/current/customized-snippets + +#### GET +##### Summary + +List customized snippets with pagination and search + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [SnippetListQuery](#snippetlistquery) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Snippets retrieved successfully | [SnippetPagination](#snippetpagination) | + +#### POST +##### Summary + +Create a new customized snippet + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [CreateSnippetPayload](#createsnippetpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 201 | Snippet created successfully | [Snippet](#snippet) | +| 400 | Invalid request | | + +### /workspaces/current/customized-snippets/imports + +#### POST +##### Summary + +Import snippet from DSL + +##### Description + +Import snippet from DSL + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [SnippetImportPayload](#snippetimportpayload) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Snippet imported successfully | +| 202 | Import pending confirmation | +| 400 | Import failed | + +### /workspaces/current/customized-snippets/imports/{import_id}/confirm + +#### POST +##### Summary + +Confirm a pending snippet import + +##### Description + +Confirm a pending snippet import + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| import_id | path | Import ID to confirm | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Import confirmed successfully | +| 400 | Import failed | + +### /workspaces/current/customized-snippets/{snippet_id} + +#### DELETE +##### Summary + +Delete customized snippet + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 204 | Snippet deleted successfully | +| 404 | Snippet not found | + +#### GET +##### Summary + +Get customized snippet details + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Snippet retrieved successfully | [Snippet](#snippet) | +| 404 | Snippet not found | | + +#### PATCH +##### Summary + +Update customized snippet + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | | Yes | string | +| payload | body | | Yes | [UpdateSnippetPayload](#updatesnippetpayload) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Snippet updated successfully | [Snippet](#snippet) | +| 400 | Invalid request | | +| 404 | Snippet not found | | + +### /workspaces/current/customized-snippets/{snippet_id}/check-dependencies + +#### GET +##### Summary + +Check dependencies for a snippet + +##### Description + +Check dependencies for a snippet + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | Snippet ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Dependencies checked successfully | +| 404 | Snippet not found | + +### /workspaces/current/customized-snippets/{snippet_id}/export + +#### GET +##### Summary + +Export snippet as DSL + +##### Description + +Export snippet configuration as DSL + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | Snippet ID to export | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Snippet exported successfully | +| 404 | Snippet not found | + +### /workspaces/current/customized-snippets/{snippet_id}/use-count/increment + +#### POST +##### Summary + +Increment snippet use count when it is inserted into a workflow + +##### Description + +Increment snippet use count by 1 + +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| snippet_id | path | Snippet ID | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Use count incremented successfully | +| 404 | Snippet not found | + ### /workspaces/current/dataset-operators #### GET @@ -11905,6 +12725,7 @@ Enum class for api provider schema type. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| creator_ids | [ string ] | Filter by creator account IDs | No | | is_created_by_me | boolean | Filter by creator | No | | limit | integer | Page size (1-100) | No | | mode | string | App mode filter
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"all"`, `"channel"`, `"chat"`, `"completion"`, `"workflow"` | No | @@ -12606,6 +13427,19 @@ Condition detail | mode | string | App mode
*Enum:* `"advanced-chat"`, `"agent"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes | | name | string | App name | Yes | +#### CreateSnippetPayload + +Payload for creating a new snippet. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| graph | object | | No | +| icon_info | [IconInfo](#iconinfo) | | No | +| input_fields | [ [InputFieldDefinition](#inputfielddefinition) ] | | No | +| name | string | | Yes | +| type | string | *Enum:* `"group"`, `"node"` | No | + #### CredentialType | Name | Type | Description | Required | @@ -14047,6 +14881,17 @@ Request payload for bulk downloading documents as a zip archive. | form_id | string | | Yes | | type | string | | Yes | +#### IconInfo + +Icon information model. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| icon | string | | No | +| icon_background | string | | No | +| icon_type | string | *Enum:* `"emoji"`, `"image"` | No | +| icon_url | string | | No | + #### IconType | Name | Type | Description | Required | @@ -14073,9 +14918,11 @@ Request payload for bulk downloading documents as a zip archive. #### IncludeSecretQuery +Query parameter for including secret variables in export. + | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| include_secret | string | Whether to include secret values in the exported DSL | No | +| include_secret | string | Whether to include secret variables | No | #### IndexingEstimate @@ -14136,6 +14983,21 @@ Request payload for bulk downloading documents as a zip archive. | model_type | [ModelType](#modeltype) | | Yes | | provider | string | | No | +#### InputFieldDefinition + +Input field definition for snippet parameters. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| default | string | | No | +| hint | boolean | | No | +| label | string | | No | +| max_length | integer | | No | +| options | [ string ] | | No | +| placeholder | string | | No | +| required | boolean | | No | +| type | string | | No | + #### InstallPermission | Name | Type | Description | Required | @@ -15214,10 +16076,11 @@ Shared permission levels for resources (datasets, credentials, etc.) #### PublishWorkflowPayload +Payload for publishing snippet workflow. + | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | -| marked_comment | string | | No | -| marked_name | string | | No | +| knowledge_base_setting | object | | No | #### PublishedWorkflowRunPayload @@ -15707,6 +16570,157 @@ Shared permission levels for resources (datasets, credentials, etc.) | updated_by | string | | No | | use_icon_as_answer_icon | boolean | | No | +#### Snippet + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| created_at | object | | No | +| created_by | [_AnonymousInlineModel_b0fd3f86d9d5](#_anonymousinlinemodel_b0fd3f86d9d5) | | No | +| description | string | | No | +| graph | object | | No | +| icon_info | object | | No | +| id | string | | No | +| input_fields | object | | No | +| is_published | boolean | | No | +| name | string | | No | +| tags | [ [_AnonymousInlineModel_7b8b49ca164e](#_anonymousinlinemodel_7b8b49ca164e) ] | | No | +| type | string | | No | +| updated_at | object | | No | +| updated_by | [_AnonymousInlineModel_b0fd3f86d9d5](#_anonymousinlinemodel_b0fd3f86d9d5) | | No | +| use_count | integer | | No | +| version | integer | | No | + +#### SnippetDraftNodeRunPayload + +Payload for running a single node in snippet draft workflow. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | | No | +| inputs | object | | Yes | +| query | string | | No | + +#### SnippetDraftRunPayload + +Payload for running snippet draft workflow. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| files | [ object ] | | No | +| inputs | object | | Yes | + +#### SnippetDraftSyncPayload + +Payload for syncing snippet draft workflow. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_variables | [ object ] | Ignored. Snippet workflows do not persist conversation variables. | No | +| graph | object | | Yes | +| hash | string | | No | +| input_fields | [ object ] | | No | + +#### SnippetImportPayload + +Payload for importing snippet from DSL. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | Override snippet description | No | +| mode | string | Import mode: yaml-content or yaml-url | Yes | +| name | string | Override snippet name | No | +| snippet_id | string | Snippet ID to update (optional) | No | +| yaml_content | string | YAML content (required for yaml-content mode) | No | +| yaml_url | string | YAML URL (required for yaml-url mode) | No | + +#### SnippetIterationNodeRunPayload + +Payload for running an iteration node in snippet draft workflow. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| inputs | object | | No | + +#### SnippetList + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author_name | string | | No | +| created_at | object | | No | +| created_by | string | | No | +| description | string | | No | +| icon_info | object | | No | +| id | string | | No | +| is_published | boolean | | No | +| name | string | | No | +| tags | [ [_AnonymousInlineModel_7b8b49ca164e](#_anonymousinlinemodel_7b8b49ca164e) ] | | No | +| type | string | | No | +| updated_at | object | | No | +| updated_by | string | | No | +| use_count | integer | | No | +| version | integer | | No | + +#### SnippetListQuery + +Query parameters for listing snippets. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| creators | [ string ] | Filter by creator account IDs | No | +| is_published | boolean | Filter by published status | No | +| keyword | string | | No | +| limit | integer | | No | +| page | integer | | No | +| tag_ids | [ string ] | Filter by tag IDs | No | + +#### SnippetLoopNodeRunPayload + +Payload for running a loop node in snippet draft workflow. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| inputs | object | | No | + +#### SnippetPagination + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| data | [ [_AnonymousInlineModel_7b67ac8a4db8](#_anonymousinlinemodel_7b67ac8a4db8) ] | | No | +| has_more | boolean | | No | +| limit | integer | | No | +| page | integer | | No | +| total | integer | | No | + +#### SnippetWorkflowListQuery + +Query parameters for listing snippet published workflows. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| limit | integer | | No | +| page | integer | | No | + +#### SnippetWorkflowResponse + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| conversation_variables | [ [WorkflowConversationVariableResponse](#workflowconversationvariableresponse) ] | | Yes | +| created_at | integer | | Yes | +| created_by | [SimpleAccount](#simpleaccount) | | No | +| environment_variables | [ [WorkflowEnvironmentVariableResponse](#workflowenvironmentvariableresponse) ] | | Yes | +| features | object | | Yes | +| graph | object | | Yes | +| hash | string | | Yes | +| id | string | | Yes | +| input_fields | [ object ] | | No | +| marked_comment | string | | Yes | +| marked_name | string | | Yes | +| rag_pipeline_variables | [ [PipelineVariableResponse](#pipelinevariableresponse) ] | | Yes | +| tool_published | boolean | | Yes | +| updated_at | integer | | Yes | +| updated_by | [SimpleAccount](#simpleaccount) | | No | +| version | string | | Yes | + #### StatisticTimeRangeQuery | Name | Type | Description | Required | @@ -15858,7 +16872,7 @@ Default configuration for form inputs. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | keyword | string | Search keyword | No | -| type | string | Tag type filter
*Enum:* `""`, `"app"`, `"knowledge"` | No | +| type | string | Tag type filter
*Enum:* `""`, `"app"`, `"knowledge"`, `"snippet"` | No | #### TagResponse @@ -16201,6 +17215,16 @@ Tag type | name | string | App name | Yes | | use_icon_as_answer_icon | boolean | Use icon as answer icon | No | +#### UpdateSnippetPayload + +Payload for updating a snippet. + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| description | string | | No | +| icon_info | [IconInfo](#iconinfo) | | No | +| name | string | | No | + #### UpgradeMode | Name | Type | Description | Required | @@ -16963,6 +17987,8 @@ can reuse its existing handler. #### WorkflowRunQuery +Query parameters for workflow runs. + | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | last_id | string | | No | @@ -17113,6 +18139,41 @@ Workflow tool configuration | text | string | | No | | truncated | boolean | | Yes | +#### _AnonymousInlineModel_7b67ac8a4db8 + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| author_name | string | | No | +| created_at | object | | No | +| created_by | string | | No | +| description | string | | No | +| icon_info | object | | No | +| id | string | | No | +| is_published | boolean | | No | +| name | string | | No | +| tags | [ [_AnonymousInlineModel_7b8b49ca164e](#_anonymousinlinemodel_7b8b49ca164e) ] | | No | +| type | string | | No | +| updated_at | object | | No | +| updated_by | string | | No | +| use_count | integer | | No | +| version | integer | | No | + +#### _AnonymousInlineModel_7b8b49ca164e + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | string | | No | +| name | string | | No | +| type | string | | No | + +#### _AnonymousInlineModel_b0fd3f86d9d5 + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| email | string | | No | +| id | string | | No | +| name | string | | No | + #### _AnonymousInlineModel_b1954337d565 | Name | Type | Description | Required | diff --git a/api/services/app_service.py b/api/services/app_service.py index 8fd84bbdd4..22fbf41f84 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -42,6 +42,7 @@ class AppListParams(BaseModel): mode: Literal["completion", "chat", "advanced-chat", "workflow", "agent-chat", "agent", "channel", "all"] = "all" name: str | None = None tag_ids: list[str] | None = None + creator_ids: list[str] | None = None is_created_by_me: bool | None = None status: str | None = None openapi_visible: bool = False @@ -138,6 +139,8 @@ class AppService: filters.append(App.enable_api.is_(True)) if params.is_created_by_me: filters.append(App.created_by == user_id) + if params.creator_ids: + filters.append(App.created_by.in_(params.creator_ids)) if params.name: from libs.helper import escape_like_pattern diff --git a/api/services/snippet_dsl_service.py b/api/services/snippet_dsl_service.py new file mode 100644 index 0000000000..ad8d76a387 --- /dev/null +++ b/api/services/snippet_dsl_service.py @@ -0,0 +1,587 @@ +import json +import logging +import uuid +from collections.abc import Mapping +from datetime import UTC, datetime +from enum import StrEnum +from urllib.parse import urlparse + +import yaml # type: ignore +from packaging import version +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.orm import Session + +from core.helper import ssrf_proxy +from core.plugin.entities.plugin import PluginDependency +from extensions.ext_redis import redis_client +from graphon.enums import BuiltinNodeTypes +from graphon.model_runtime.utils.encoders import jsonable_encoder +from models import Account +from models.snippet import CustomizedSnippet, SnippetType +from models.workflow import Workflow +from services.plugin.dependencies_analysis import DependenciesAnalysisService +from services.snippet_service import SNIPPET_FORBIDDEN_NODE_TYPES, SnippetService + +logger = logging.getLogger(__name__) + +IMPORT_INFO_REDIS_KEY_PREFIX = "snippet_import_info:" +CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "snippet_check_dependencies:" +IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes +DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB +CURRENT_DSL_VERSION = "0.1.0" + + +class ImportMode(StrEnum): + YAML_CONTENT = "yaml-content" + YAML_URL = "yaml-url" + + +class ImportStatus(StrEnum): + COMPLETED = "completed" + COMPLETED_WITH_WARNINGS = "completed-with-warnings" + PENDING = "pending" + FAILED = "failed" + + +class SnippetImportInfo(BaseModel): + id: str + status: ImportStatus + snippet_id: str | None = None + current_dsl_version: str = CURRENT_DSL_VERSION + imported_dsl_version: str = "" + error: str = "" + + +class CheckDependenciesResult(BaseModel): + leaked_dependencies: list[PluginDependency] = Field(default_factory=list) + + +def _check_version_compatibility(imported_version: str) -> ImportStatus: + """Determine import status based on version comparison""" + try: + current_ver = version.parse(CURRENT_DSL_VERSION) + imported_ver = version.parse(imported_version) + except version.InvalidVersion: + return ImportStatus.FAILED + + # If imported version is newer than current, always return PENDING + if imported_ver > current_ver: + return ImportStatus.PENDING + + # If imported version is older than current's major, return PENDING + if imported_ver.major < current_ver.major: + return ImportStatus.PENDING + + # If imported version is older than current's minor, return COMPLETED_WITH_WARNINGS + if imported_ver.minor < current_ver.minor: + return ImportStatus.COMPLETED_WITH_WARNINGS + + # If imported version equals or is older than current's micro, return COMPLETED + return ImportStatus.COMPLETED + + +class SnippetPendingData(BaseModel): + import_mode: str + yaml_content: str + name: str | None = None + description: str | None = None + snippet_id: str | None + + +class CheckDependenciesPendingData(BaseModel): + dependencies: list[PluginDependency] + snippet_id: str | None + + +class SnippetDslService: + def __init__(self, session: Session): + self._session = session + + def _snippet_service(self) -> SnippetService: + return SnippetService(session=self._session) + + def import_snippet( + self, + *, + account: Account, + import_mode: str, + yaml_content: str | None = None, + yaml_url: str | None = None, + snippet_id: str | None = None, + name: str | None = None, + description: str | None = None, + ) -> SnippetImportInfo: + """Import a snippet from YAML content or URL.""" + import_id = str(uuid.uuid4()) + + # Validate import mode + try: + mode = ImportMode(import_mode) + except ValueError: + raise ValueError(f"Invalid import_mode: {import_mode}") + + # Get YAML content + content: str = "" + if mode == ImportMode.YAML_URL: + if not yaml_url: + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error="yaml_url is required when import_mode is yaml-url", + ) + try: + parsed_url = urlparse(yaml_url) + if parsed_url.scheme not in ["http", "https"]: + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error="Invalid URL scheme, only http and https are allowed", + ) + response = ssrf_proxy.get(yaml_url, timeout=(10, 30)) + if response.status_code != 200: + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error=f"Failed to fetch YAML from URL: {response.status_code}", + ) + content = response.text + if len(content) > DSL_MAX_SIZE: + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error=f"YAML content size exceeds maximum limit of {DSL_MAX_SIZE} bytes", + ) + except Exception as e: + logger.exception("Failed to fetch YAML from URL") + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error=f"Failed to fetch YAML from URL: {str(e)}", + ) + elif mode == ImportMode.YAML_CONTENT: + if not yaml_content: + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error="yaml_content is required when import_mode is yaml-content", + ) + content = yaml_content + if len(content) > DSL_MAX_SIZE: + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error=f"YAML content size exceeds maximum limit of {DSL_MAX_SIZE} bytes", + ) + + try: + # Parse YAML + data = yaml.safe_load(content) + if not isinstance(data, dict): + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error="Invalid YAML format: expected a dictionary", + ) + + # Validate and fix DSL version + if not data.get("version"): + data["version"] = "0.1.0" + + # Strictly validate kind field + kind = data.get("kind") + if not kind: + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error="Missing 'kind' field in DSL. Expected 'kind: snippet'.", + ) + if kind != "snippet": + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error=f"Invalid DSL kind: expected 'snippet', got '{kind}'. This DSL is for {kind}, not snippet.", + ) + + imported_version = data.get("version", "0.1.0") + if not isinstance(imported_version, str): + raise ValueError(f"Invalid version type, expected str, got {type(imported_version)}") + status = _check_version_compatibility(imported_version) + + # Extract snippet data + snippet_data = data.get("snippet") + if not snippet_data: + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error="Missing snippet data in YAML content", + ) + + # Validate workflow nodes - check for forbidden node types + workflow_data = data.get("workflow", {}) + if workflow_data: + graph = workflow_data.get("graph", {}) + nodes = graph.get("nodes", []) + forbidden_nodes_found = [] + for node in nodes: + node_data = node.get("data", {}) + if not node_data: + continue + node_type = node_data.get("type", "") + if node_type in SNIPPET_FORBIDDEN_NODE_TYPES: + forbidden_nodes_found.append(node_type) + + if forbidden_nodes_found: + forbidden_types_str = ", ".join(set(forbidden_nodes_found)) + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error=f"Snippet cannot contain the following node types: {forbidden_types_str}", + ) + + # If snippet_id is provided, check if it exists + snippet = None + if snippet_id: + stmt = select(CustomizedSnippet).where( + CustomizedSnippet.id == snippet_id, + CustomizedSnippet.tenant_id == account.current_tenant_id, + ) + snippet = self._session.scalar(stmt) + + if not snippet: + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error="Snippet not found", + ) + + # If major version mismatch, store import info in Redis + if status == ImportStatus.PENDING: + pending_data = SnippetPendingData( + import_mode=import_mode, + yaml_content=content, + name=name, + description=description, + snippet_id=snippet_id, + ) + redis_client.setex( + f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}", + IMPORT_INFO_REDIS_EXPIRY, + pending_data.model_dump_json(), + ) + + return SnippetImportInfo( + id=import_id, + status=status, + snippet_id=snippet_id, + imported_dsl_version=imported_version, + ) + + # Extract dependencies + dependencies = data.get("dependencies", []) + check_dependencies_pending_data = None + if dependencies: + check_dependencies_pending_data = [PluginDependency.model_validate(d) for d in dependencies] + + # Create or update snippet + snippet = self._create_or_update_snippet( + snippet=snippet, + data=data, + account=account, + name=name, + description=description, + dependencies=check_dependencies_pending_data, + ) + + return SnippetImportInfo( + id=import_id, + status=status, + snippet_id=snippet.id, + imported_dsl_version=imported_version, + ) + + except yaml.YAMLError as e: + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error=f"Invalid YAML format: {str(e)}", + ) + + except Exception as e: + logger.exception("Failed to import snippet") + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error=str(e), + ) + + def confirm_import(self, *, import_id: str, account: Account) -> SnippetImportInfo: + """ + Confirm an import that requires confirmation + """ + redis_key = f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}" + pending_data = redis_client.get(redis_key) + + if not pending_data: + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error="Import information expired or does not exist", + ) + + try: + if not isinstance(pending_data, str | bytes): + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error="Invalid import information", + ) + + pending_data_str = pending_data.decode("utf-8") if isinstance(pending_data, bytes) else pending_data + pending = SnippetPendingData.model_validate_json(pending_data_str) + + data = yaml.safe_load(pending.yaml_content) + if not isinstance(data, dict): + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error="Invalid YAML format: expected a dictionary", + ) + + snippet = None + if pending.snippet_id: + stmt = select(CustomizedSnippet).where( + CustomizedSnippet.id == pending.snippet_id, + CustomizedSnippet.tenant_id == account.current_tenant_id, + ) + snippet = self._session.scalar(stmt) + + snippet = self._create_or_update_snippet( + snippet=snippet, + data=data, + account=account, + name=pending.name, + description=pending.description, + ) + + redis_client.delete(redis_key) + + return SnippetImportInfo( + id=import_id, + status=ImportStatus.COMPLETED, + snippet_id=snippet.id, + imported_dsl_version=data.get("version", "0.1.0"), + ) + + except Exception as e: + logger.exception("Failed to confirm import") + return SnippetImportInfo( + id=import_id, + status=ImportStatus.FAILED, + error=str(e), + ) + + def check_dependencies(self, snippet: CustomizedSnippet) -> CheckDependenciesResult: + """ + Check dependencies for a snippet + """ + snippet_service = self._snippet_service() + workflow = snippet_service.get_draft_workflow(snippet=snippet) + if not workflow: + return CheckDependenciesResult(leaked_dependencies=[]) + + dependencies = self._extract_dependencies_from_workflow(workflow) + leaked_dependencies = DependenciesAnalysisService.generate_dependencies( + tenant_id=snippet.tenant_id, dependencies=dependencies + ) + + return CheckDependenciesResult(leaked_dependencies=leaked_dependencies) + + def _create_or_update_snippet( + self, + *, + snippet: CustomizedSnippet | None, + data: dict, + account: Account, + name: str | None = None, + description: str | None = None, + dependencies: list[PluginDependency] | None = None, + ) -> CustomizedSnippet: + """ + Create or update snippet from DSL data + """ + snippet_data = data.get("snippet", {}) + workflow_data = data.get("workflow", {}) + + # Extract snippet info + snippet_name = name or snippet_data.get("name") or "Untitled Snippet" + snippet_description = description or snippet_data.get("description") or "" + snippet_type_str = snippet_data.get("type", "node") + try: + snippet_type = SnippetType(snippet_type_str) + except ValueError: + snippet_type = SnippetType.NODE + + icon_info = snippet_data.get("icon_info", {}) + input_fields = snippet_data.get("input_fields", []) + + # Create or update snippet + if snippet: + # Update existing snippet + snippet.name = snippet_name + snippet.description = snippet_description + snippet.type = snippet_type.value + snippet.icon_info = icon_info or None + snippet.input_fields = json.dumps(input_fields) if input_fields else None + snippet.updated_by = account.id + snippet.updated_at = datetime.now(UTC).replace(tzinfo=None) + else: + # Create new snippet + snippet = CustomizedSnippet( + tenant_id=account.current_tenant_id, + name=snippet_name, + description=snippet_description, + type=snippet_type.value, + icon_info=icon_info or None, + input_fields=json.dumps(input_fields) if input_fields else None, + created_by=account.id, + ) + self._session.add(snippet) + self._session.flush() + + # Create or update draft workflow + if workflow_data: + graph = workflow_data.get("graph", {}) + + snippet_service = self._snippet_service() + # Get existing workflow hash if exists + existing_workflow = snippet_service.get_draft_workflow(snippet=snippet) + unique_hash = existing_workflow.unique_hash if existing_workflow else None + + snippet_service.sync_draft_workflow( + snippet=snippet, + graph=graph, + unique_hash=unique_hash, + account=account, + input_fields=input_fields, + ) + + self._session.commit() + return snippet + + def export_snippet_dsl(self, snippet: CustomizedSnippet, include_secret: bool = False) -> str: + """ + Export snippet as DSL + :param snippet: CustomizedSnippet instance + :param include_secret: Whether include secret variable + :return: YAML string + """ + snippet_service = self._snippet_service() + workflow = snippet_service.get_draft_workflow(snippet=snippet) + if not workflow: + raise ValueError("Missing draft workflow configuration, please check.") + + icon_info = snippet.icon_info or {} + export_data = { + "version": CURRENT_DSL_VERSION, + "kind": "snippet", + "snippet": { + "name": snippet.name, + "description": snippet.description or "", + "type": snippet.type, + "icon_info": icon_info, + "input_fields": snippet.input_fields_list, + }, + } + + self._append_workflow_export_data( + export_data=export_data, snippet=snippet, workflow=workflow, include_secret=include_secret + ) + + return yaml.dump(export_data, allow_unicode=True) # type: ignore + + def _append_workflow_export_data( + self, *, export_data: dict, snippet: CustomizedSnippet, workflow: Workflow, include_secret: bool + ) -> None: + """ + Append workflow export data + """ + workflow_dict = workflow.to_dict(include_secret=include_secret) + # Filter workspace related data from nodes + workflow_dict["environment_variables"] = [] + workflow_dict["conversation_variables"] = [] + + for node in workflow_dict.get("graph", {}).get("nodes", []): + node_data = node.get("data", {}) + if not node_data: + continue + data_type = node_data.get("type", "") + if data_type == BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: + dataset_ids = node_data.get("dataset_ids", []) + node["data"]["dataset_ids"] = [ + self._encrypt_dataset_id(dataset_id=dataset_id, tenant_id=snippet.tenant_id) + for dataset_id in dataset_ids + ] + # filter credential id from tool node + if not include_secret and data_type == BuiltinNodeTypes.TOOL: + node_data.pop("credential_id", None) + # filter credential id from agent node + if not include_secret and data_type == BuiltinNodeTypes.AGENT: + for tool in node_data.get("agent_parameters", {}).get("tools", {}).get("value", []): + tool.pop("credential_id", None) + + export_data["workflow"] = workflow_dict + dependencies = self._extract_dependencies_from_workflow(workflow) + export_data["dependencies"] = [ + jsonable_encoder(d.model_dump()) + for d in DependenciesAnalysisService.generate_dependencies( + tenant_id=snippet.tenant_id, dependencies=dependencies + ) + ] + + def _encrypt_dataset_id(self, *, dataset_id: str, tenant_id: str) -> str: + """ + Encrypt dataset ID for export + """ + # For now, just return the dataset_id as-is + # In the future, we might want to encrypt it + return dataset_id + + def _extract_dependencies_from_workflow(self, workflow: Workflow) -> list[str]: + """ + Extract dependencies from workflow + :param workflow: Workflow instance + :return: dependencies list format like ["langgenius/google"] + """ + graph = workflow.graph_dict + dependencies = self._extract_dependencies_from_workflow_graph(graph) + return dependencies + + def _extract_dependencies_from_workflow_graph(self, graph: Mapping) -> list[str]: + """ + Extract dependencies from workflow graph + :param graph: Workflow graph + :return: dependencies list format like ["langgenius/google"] + """ + dependencies = [] + for node in graph.get("nodes", []): + node_data = node.get("data", {}) + if not node_data: + continue + data_type = node_data.get("type", "") + if data_type == BuiltinNodeTypes.TOOL: + tool_config = node_data.get("tool_configurations", {}) + provider_type = tool_config.get("provider_type") + provider_name = tool_config.get("provider") + if provider_type and provider_name: + dependencies.append(f"{provider_name}/{provider_name}") + elif data_type == BuiltinNodeTypes.AGENT: + agent_parameters = node_data.get("agent_parameters", {}) + tools = agent_parameters.get("tools", {}).get("value", []) + for tool in tools: + provider_type = tool.get("provider_type") + provider_name = tool.get("provider") + if provider_type and provider_name: + dependencies.append(f"{provider_name}/{provider_name}") + + return dependencies diff --git a/api/services/snippet_generate_service.py b/api/services/snippet_generate_service.py new file mode 100644 index 0000000000..f0cd4d901c --- /dev/null +++ b/api/services/snippet_generate_service.py @@ -0,0 +1,475 @@ +""" +Service for generating snippet workflow executions. + +Uses an adapter pattern to bridge CustomizedSnippet with the App-based +WorkflowAppGenerator. The adapter (_SnippetAsApp) provides the minimal App-like +interface needed by the generator, avoiding modifications to core workflow +infrastructure. + +Key invariants: +- Snippets always run as WORKFLOW mode (not CHAT or ADVANCED_CHAT). +- The adapter maps snippet.id to app_id in workflow execution records. +- Snippet debugging has no rate limiting (max_active_requests = 0). + +Supported execution modes: +- Full workflow run (generate): Runs the entire draft workflow as SSE stream. +- Single node run (run_draft_node): Synchronous single-step debugging for regular nodes. +- Single iteration run (generate_single_iteration): SSE stream for iteration container nodes. +- Single loop run (generate_single_loop): SSE stream for loop container nodes. +""" + +import json +import logging +from collections.abc import Generator, Mapping, Sequence +from typing import Any, Union, cast + +from sqlalchemy.orm import Session, make_transient, sessionmaker + +from core.app.app_config.features.file_upload.manager import FileUploadConfigManager +from core.app.apps.workflow.app_generator import WorkflowAppGenerator +from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.file_access import DatabaseFileAccessController +from core.workflow.snippet_start import SNIPPET_VIRTUAL_START_NODE_ID +from factories import file_factory +from graphon.file.models import File +from models import Account +from models.model import App, AppMode, EndUser +from models.snippet import CustomizedSnippet +from models.workflow import Workflow, WorkflowNodeExecutionModel +from services.snippet_service import SnippetService +from services.workflow_service import WorkflowService + +logger = logging.getLogger(__name__) +_file_access_controller = DatabaseFileAccessController() + + +class _SnippetAsApp: + """ + Minimal adapter that wraps a CustomizedSnippet to satisfy the App-like + interface required by WorkflowAppGenerator, WorkflowAppConfigManager, + and WorkflowService.run_draft_workflow_node. + + Used properties: + - id: maps to snippet.id (stored as app_id in workflows table) + - tenant_id: maps to snippet.tenant_id + - mode: hardcoded to AppMode.WORKFLOW since snippets always run as workflows + - max_active_requests: defaults to 0 (no limit) for snippet debugging + - app_model_config_id: None (snippets don't have app model configs) + """ + + id: str + tenant_id: str + mode: str + max_active_requests: int + app_model_config_id: str | None + + def __init__(self, snippet: CustomizedSnippet) -> None: + self.id = snippet.id + self.tenant_id = snippet.tenant_id + self.mode = AppMode.WORKFLOW.value + self.max_active_requests = 0 + self.app_model_config_id = None + + +class SnippetGenerateService: + """ + Service for running snippet workflow executions. + + Adapts CustomizedSnippet to work with the existing App-based + WorkflowAppGenerator infrastructure, avoiding duplication of the + complex workflow execution pipeline. + """ + + # Specific ID for the injected virtual Start node so it can be recognised + _VIRTUAL_START_NODE_ID = SNIPPET_VIRTUAL_START_NODE_ID + + @classmethod + def _is_virtual_start_event(cls, message: Mapping[str, Any] | str) -> bool: + """ + Return True when *message* is a snippet-only virtual Start node event. + + The virtual Start node is injected purely for snippet execution and is + not part of the persisted draft graph. Filter its node lifecycle events + out of the SSE stream so the frontend only receives nodes that exist on + the canvas. + """ + if not isinstance(message, Mapping): + return False + + if message.get("event") not in {"node_started", "node_finished"}: + return False + + data = message.get("data") + if not isinstance(data, Mapping): + return False + + return data.get("node_id") == cls._VIRTUAL_START_NODE_ID + + @classmethod + def _filter_virtual_start_events( + cls, + response: Mapping[str, Any] | Generator[Mapping[str, Any] | str, None, None], + ) -> Mapping[str, Any] | Generator[Mapping[str, Any] | str, None, None]: + """ + Drop snippet virtual Start node lifecycle events from stream responses. + + Blocking responses are returned unchanged because they never expose the + injected node as a standalone event payload. + """ + if isinstance(response, Mapping): + return response + + def _stream() -> Generator[Mapping[str, Any] | str, None, None]: + for message in response: + if cls._is_virtual_start_event(message): + continue + yield message + + return _stream() + + @classmethod + def generate( + cls, + snippet: CustomizedSnippet, + user: Union[Account, EndUser], + args: Mapping[str, Any], + invoke_from: InvokeFrom, + streaming: bool = True, + session_maker: sessionmaker[Session] | None = None, + ) -> Mapping[str, Any] | Generator[str, None, None]: + """ + Run a snippet's draft workflow. + + Retrieves the draft workflow, adapts the snippet to an App-like proxy, + then delegates execution to WorkflowAppGenerator. + + If the workflow graph has no Start node, a virtual Start node is injected + in-memory so that: + 1. Graph validation passes (root node must have execution_type=ROOT). + 2. User inputs are processed into the variable pool by the StartNode logic. + + :param snippet: CustomizedSnippet instance + :param user: Account or EndUser initiating the run + :param args: Workflow inputs (must include "inputs" key) + :param invoke_from: Source of invocation (typically DEBUGGER) + :param streaming: Whether to stream the response + :return: Blocking response mapping or SSE streaming generator + :raises ValueError: If the snippet has no draft workflow + """ + snippet_service = SnippetService(session_maker) + workflow = snippet_service.get_draft_workflow(snippet=snippet) + if not workflow: + raise ValueError("Workflow not initialized") + + # Inject a virtual Start node when the graph doesn't have one. + workflow = cls._ensure_start_node(workflow, snippet) + + # Adapt snippet to App-like interface for WorkflowAppGenerator + app_proxy = cast(App, _SnippetAsApp(snippet)) + + response = WorkflowAppGenerator().generate( + app_model=app_proxy, + workflow=workflow, + user=user, + args=args, + invoke_from=invoke_from, + streaming=streaming, + call_depth=0, + ) + + return WorkflowAppGenerator.convert_to_event_stream(cls._filter_virtual_start_events(response)) + + @classmethod + def run_published( + cls, + snippet: CustomizedSnippet, + user: Union[Account, EndUser], + args: Mapping[str, Any], + invoke_from: InvokeFrom, + session_maker: sessionmaker[Session] | None = None, + ) -> Mapping[str, Any]: + """ + Run a snippet's published workflow in non-streaming (blocking) mode. + + Similar to :meth:`generate` but targets the published workflow instead + of the draft, and returns the raw blocking response without SSE + wrapping. Designed for programmatic callers that need direct workflow outputs. + + :param snippet: CustomizedSnippet instance (must be published) + :param user: Account or EndUser initiating the run + :param args: Workflow inputs (must include "inputs" key) + :param invoke_from: Source of invocation + :return: Blocking response mapping with workflow outputs + :raises ValueError: If the snippet has no published workflow + """ + snippet_service = SnippetService(session_maker) + workflow = snippet_service.get_published_workflow(snippet) + if not workflow: + raise ValueError("No published workflow found for snippet") + + # Inject a virtual Start node when the graph doesn't have one. + workflow = cls._ensure_start_node(workflow, snippet) + + app_proxy = cast(App, _SnippetAsApp(snippet)) + + response = WorkflowAppGenerator().generate( + app_model=app_proxy, + workflow=workflow, + user=user, + args=args, + invoke_from=invoke_from, + streaming=False, + call_depth=0, + ) + return response + + @classmethod + def ensure_start_node_for_worker(cls, workflow: Workflow, snippet: CustomizedSnippet) -> Workflow: + """Public wrapper for worker-thread start-node injection.""" + return cls._ensure_start_node(workflow, snippet) + + @classmethod + def _ensure_start_node(cls, workflow: Workflow, snippet: CustomizedSnippet) -> Workflow: + """ + Return *workflow* with a Start node. + + If the graph already contains a Start node, the original workflow is + returned unchanged. Otherwise a virtual Start node is injected and the + workflow object is detached from the SQLAlchemy session so the in-memory + change is never flushed to the database. + """ + graph_dict = workflow.graph_dict + nodes: list[dict[str, Any]] = graph_dict.get("nodes", []) + + has_start = any(node.get("data", {}).get("type") == "start" for node in nodes) + if has_start: + return workflow + + modified_graph = cls._inject_virtual_start_node( + graph_dict=graph_dict, + input_fields=snippet.input_fields_list, + ) + + # Detach from session to prevent accidental DB persistence of the + # modified graph. All attributes remain accessible for read. + make_transient(workflow) + workflow.graph = json.dumps(modified_graph) + return workflow + + @classmethod + def _inject_virtual_start_node( + cls, + graph_dict: Mapping[str, Any], + input_fields: list[dict[str, Any]], + ) -> dict[str, Any]: + """ + Build a new graph dict with a virtual Start node prepended. + + The virtual Start node is wired to every existing node that has no + incoming edges (i.e. the current root candidates). This guarantees: + + :param graph_dict: Original graph configuration. + :param input_fields: Snippet input field definitions from + ``CustomizedSnippet.input_fields_list``. + :return: New graph dict containing the virtual Start node and edges. + """ + nodes: list[dict[str, Any]] = list(graph_dict.get("nodes", [])) + edges: list[dict[str, Any]] = list(graph_dict.get("edges", [])) + + # Identify nodes with no incoming edges. + nodes_with_incoming: set[str] = set() + for edge in edges: + target = edge.get("target") + if isinstance(target, str): + nodes_with_incoming.add(target) + root_candidate_ids = [n["id"] for n in nodes if n["id"] not in nodes_with_incoming] + + # Build Start node ``variables`` from snippet input fields. + start_variables: list[dict[str, Any]] = [] + for field in input_fields: + var: dict[str, Any] = { + "variable": field.get("variable", ""), + "label": field.get("label", field.get("variable", "")), + "type": field.get("type", "text-input"), + "required": field.get("required", False), + "options": field.get("options", []), + } + if field.get("max_length") is not None: + var["max_length"] = field["max_length"] + start_variables.append(var) + + virtual_start_node: dict[str, Any] = { + "id": cls._VIRTUAL_START_NODE_ID, + "data": { + "type": "start", + "title": "Start", + "variables": start_variables, + }, + } + + # Create edges from virtual Start to each root candidate. + new_edges: list[dict[str, Any]] = [ + { + "source": cls._VIRTUAL_START_NODE_ID, + "sourceHandle": "source", + "target": root_id, + "targetHandle": "target", + } + for root_id in root_candidate_ids + ] + + return { + **graph_dict, + "nodes": [virtual_start_node, *nodes], + "edges": [*edges, *new_edges], + } + + @classmethod + def run_draft_node( + cls, + snippet: CustomizedSnippet, + node_id: str, + user_inputs: Mapping[str, Any], + account: Account, + query: str = "", + files: Sequence[File] | None = None, + session_maker: sessionmaker[Session] | None = None, + ) -> WorkflowNodeExecutionModel: + """ + Run a single node in a snippet's draft workflow (single-step debugging). + + Retrieves the draft workflow, adapts the snippet to an App-like proxy, + parses file inputs, then delegates to WorkflowService.run_draft_workflow_node. + + :param snippet: CustomizedSnippet instance + :param node_id: ID of the node to run + :param user_inputs: User input values for the node + :param account: Account initiating the run + :param query: Optional query string + :param files: Optional parsed file objects + :return: WorkflowNodeExecutionModel with execution results + :raises ValueError: If the snippet has no draft workflow + """ + snippet_service = SnippetService(session_maker) + draft_workflow = snippet_service.get_draft_workflow(snippet=snippet) + if not draft_workflow: + raise ValueError("Workflow not initialized") + + app_proxy = cast(App, _SnippetAsApp(snippet)) + + workflow_service = WorkflowService() + return workflow_service.run_draft_workflow_node( + app_model=app_proxy, + draft_workflow=draft_workflow, + node_id=node_id, + user_inputs=user_inputs, + account=account, + query=query, + files=files, + ) + + @classmethod + def generate_single_iteration( + cls, + snippet: CustomizedSnippet, + user: Union[Account, EndUser], + node_id: str, + args: Mapping[str, Any], + streaming: bool = True, + session_maker: sessionmaker[Session] | None = None, + ) -> Mapping[str, Any] | Generator[str, None, None]: + """ + Run a single iteration node in a snippet's draft workflow. + + Iteration nodes are container nodes that execute their sub-graph multiple + times, producing many events. Therefore, this uses the full WorkflowAppGenerator + pipeline with SSE streaming (unlike regular single-step node run). + + :param snippet: CustomizedSnippet instance + :param user: Account or EndUser initiating the run + :param node_id: ID of the iteration node to run + :param args: Dict containing 'inputs' key with iteration input data + :param streaming: Whether to stream the response (should be True) + :return: SSE streaming generator + :raises ValueError: If the snippet has no draft workflow + """ + snippet_service = SnippetService(session_maker) + workflow = snippet_service.get_draft_workflow(snippet=snippet) + if not workflow: + raise ValueError("Workflow not initialized") + + app_proxy = cast(App, _SnippetAsApp(snippet)) + + return WorkflowAppGenerator.convert_to_event_stream( + WorkflowAppGenerator().single_iteration_generate( + app_model=app_proxy, + workflow=workflow, + node_id=node_id, + user=user, + args=args, + streaming=streaming, + ) + ) + + @classmethod + def generate_single_loop( + cls, + snippet: CustomizedSnippet, + user: Union[Account, EndUser], + node_id: str, + args: Any, + streaming: bool = True, + session_maker: sessionmaker[Session] | None = None, + ) -> Mapping[str, Any] | Generator[str, None, None]: + """ + Run a single loop node in a snippet's draft workflow. + + Loop nodes are container nodes that execute their sub-graph repeatedly, + producing many events. Therefore, this uses the full WorkflowAppGenerator + pipeline with SSE streaming (unlike regular single-step node run). + + :param snippet: CustomizedSnippet instance + :param user: Account or EndUser initiating the run + :param node_id: ID of the loop node to run + :param args: Pydantic model with 'inputs' attribute containing loop input data + :param streaming: Whether to stream the response (should be True) + :return: SSE streaming generator + :raises ValueError: If the snippet has no draft workflow + """ + snippet_service = SnippetService(session_maker) + workflow = snippet_service.get_draft_workflow(snippet=snippet) + if not workflow: + raise ValueError("Workflow not initialized") + + app_proxy = cast(App, _SnippetAsApp(snippet)) + + return WorkflowAppGenerator.convert_to_event_stream( + WorkflowAppGenerator().single_loop_generate( + app_model=app_proxy, + workflow=workflow, + node_id=node_id, + user=user, + args=args, # type: ignore[arg-type] + streaming=streaming, + ) + ) + + @staticmethod + def parse_files(workflow: Workflow, files: list[dict] | None = None) -> Sequence[File]: + """ + Parse file mappings into File objects based on workflow configuration. + + :param workflow: Workflow instance for file upload config + :param files: Raw file mapping dicts + :return: Parsed File objects + """ + files = files or [] + file_extra_config = FileUploadConfigManager.convert(workflow.features_dict, is_vision=False) + if file_extra_config is None: + return [] + return file_factory.build_from_mappings( + mappings=files, + tenant_id=workflow.tenant_id, + config=file_extra_config, + access_controller=_file_access_controller, + ) diff --git a/api/services/snippet_service.py b/api/services/snippet_service.py new file mode 100644 index 0000000000..aa46626e56 --- /dev/null +++ b/api/services/snippet_service.py @@ -0,0 +1,833 @@ +import json +import logging +from collections.abc import Iterator, Mapping, Sequence +from contextlib import contextmanager +from datetime import UTC, datetime +from typing import Any + +from sqlalchemy import delete, func, select +from sqlalchemy.orm import Session, sessionmaker + +from core.db import session_factory +from core.workflow.node_factory import LATEST_VERSION, NODE_TYPE_CLASSES_MAPPING +from graphon.enums import BuiltinNodeTypes, NodeType +from libs.infinite_scroll_pagination import InfiniteScrollPagination +from models import Account, TagBinding +from models.enums import WorkflowRunTriggeredFrom +from models.model import UploadFile +from models.snippet import CustomizedSnippet, SnippetType +from models.tools import WorkflowToolProvider +from models.workflow import ( + Workflow, + WorkflowAppLog, + WorkflowArchiveLog, + WorkflowDraftVariable, + WorkflowDraftVariableFile, + WorkflowKind, + WorkflowNodeExecutionModel, + WorkflowRun, + WorkflowType, +) +from repositories.factory import DifyAPIRepositoryFactory +from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError +from services.tag_service import TagService +from services.workflow_restore import apply_published_workflow_snapshot_to_draft + +logger = logging.getLogger(__name__) + +# Node types not allowed in snippet workflows (sync, publish, DSL import). +SNIPPET_FORBIDDEN_NODE_TYPES: frozenset[str] = frozenset( + { + BuiltinNodeTypes.START, + BuiltinNodeTypes.HUMAN_INPUT, + BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL, + } +) + + +class SnippetService: + """Service for managing customized snippets.""" + + def __init__( + self, + session_maker: sessionmaker[Session] | Session | None = None, + session: Session | None = None, + ): + """Initialize SnippetService with repository dependencies.""" + if isinstance(session_maker, Session): + session = session_maker + session_maker = None + if session is not None: + session_maker = sessionmaker(bind=session.get_bind(), expire_on_commit=False) + elif session_maker is None: + session_maker = session_factory.get_session_maker() + assert session_maker is not None + self._session = session + self._session_maker = session_maker + self._node_execution_service_repo = DifyAPIRepositoryFactory.create_api_workflow_node_execution_repository( + session_maker + ) + self._workflow_run_repo = DifyAPIRepositoryFactory.create_api_workflow_run_repository(session_maker) + + @contextmanager + def _session_scope(self) -> Iterator[Session]: + current_session = getattr(self, "_session", None) + if current_session is not None: + yield current_session + return + + with self._session_maker() as session: + yield session + + def _commit_if_owned(self, session: Session) -> None: + if getattr(self, "_session", None) is None: + session.commit() + + @staticmethod + def _snippet_kind_filter(): + """Match snippet workflows by business kind.""" + return Workflow.kind == WorkflowKind.SNIPPET.value + + @staticmethod + def _delete_draft_variable_files(*, session: Session, snippet: CustomizedSnippet) -> None: + file_ids = list( + session.scalars( + select(WorkflowDraftVariable.file_id).where( + WorkflowDraftVariable.app_id == snippet.id, + WorkflowDraftVariable.file_id.is_not(None), + ) + ).all() + ) + if not file_ids: + return + + file_records = session.execute( + select(WorkflowDraftVariableFile.id, WorkflowDraftVariableFile.upload_file_id, UploadFile.key) + .join(UploadFile, UploadFile.id == WorkflowDraftVariableFile.upload_file_id) + .where( + WorkflowDraftVariableFile.tenant_id == snippet.tenant_id, + WorkflowDraftVariableFile.app_id == snippet.id, + WorkflowDraftVariableFile.id.in_(file_ids), + ) + ).all() + upload_file_ids: list[str] = [] + + from extensions.ext_storage import storage + + for _, upload_file_id, storage_key in file_records: + try: + storage.delete(storage_key) + except Exception: + logger.exception("Failed to delete snippet draft variable storage object %s", storage_key) + upload_file_ids.append(upload_file_id) + + if upload_file_ids: + session.execute( + delete(UploadFile) + .where(UploadFile.id.in_(upload_file_ids)) + .execution_options(synchronize_session=False) + ) + session.execute( + delete(WorkflowDraftVariableFile) + .where( + WorkflowDraftVariableFile.tenant_id == snippet.tenant_id, + WorkflowDraftVariableFile.app_id == snippet.id, + WorkflowDraftVariableFile.id.in_(file_ids), + ) + .execution_options(synchronize_session=False) + ) + + @staticmethod + def _delete_archived_workflow_run_files(*, snippet: CustomizedSnippet) -> None: + from configs import dify_config + from libs.archive_storage import ArchiveStorageNotConfiguredError, get_archive_storage + + if not (dify_config.BILLING_ENABLED and dify_config.ARCHIVE_STORAGE_ENABLED): + return + + prefix = f"{snippet.tenant_id}/app_id={snippet.id}/" + try: + archive_storage = get_archive_storage() + except ArchiveStorageNotConfiguredError as e: + logger.info("Archive storage not configured, skipping snippet archive file cleanup: %s", e) + return + + try: + keys = archive_storage.list_objects(prefix) + except Exception: + logger.exception("Failed to list snippet archive files for prefix %s", prefix) + return + + for key in keys: + try: + archive_storage.delete_object(key) + except Exception: + logger.exception("Failed to delete snippet archive file %s", key) + + @staticmethod + def validate_snippet_graph_forbidden_nodes(graph: Mapping[str, Any]) -> None: + """Reject graphs that contain node types not allowed in snippets.""" + nodes = graph.get("nodes") or [] + disallowed: list[tuple[str, str]] = [] + for node in nodes: + if not isinstance(node, dict): + continue + node_data = node.get("data") or {} + node_type = node_data.get("type") + if not isinstance(node_type, str): + continue + if node_type in SNIPPET_FORBIDDEN_NODE_TYPES: + node_id = node.get("id") + disallowed.append((str(node_id) if node_id is not None else "?", node_type)) + if not disallowed: + return + detail = ", ".join(f"{nid}:{t}" for nid, t in disallowed) + raise ValueError( + f"Snippet workflow cannot contain start, human-input, or knowledge-retrieval nodes. Found: {detail}" + ) + + # --- CRUD Operations --- + + def get_snippets( + self, + *, + tenant_id: str, + page: int = 1, + limit: int = 20, + keyword: str | None = None, + is_published: bool | None = None, + creators: list[str] | None = None, + tag_ids: list[str] | None = None, + ) -> tuple[Sequence[CustomizedSnippet], int, bool]: + """ + Get paginated list of snippets with optional search. + + :param tenant_id: Tenant ID + :param page: Page number (1-indexed) + :param limit: Number of items per page + :param keyword: Optional search keyword for name/description + :param is_published: Optional filter by published status (True/False/None for all) + :param creators: Optional filter by creator account IDs + :param tag_ids: Optional filter by tag IDs + :return: Tuple of (snippets list, total count, has_more flag) + """ + stmt = ( + select(CustomizedSnippet) + .where(CustomizedSnippet.tenant_id == tenant_id) + .order_by(CustomizedSnippet.created_at.desc()) + ) + + if keyword: + stmt = stmt.where( + CustomizedSnippet.name.ilike(f"%{keyword}%") | CustomizedSnippet.description.ilike(f"%{keyword}%") + ) + + if is_published is not None: + stmt = stmt.where(CustomizedSnippet.is_published == is_published) + + if creators: + stmt = stmt.where(CustomizedSnippet.created_by.in_(creators)) + + if tag_ids: + target_ids = TagService.get_target_ids_by_tag_ids("snippet", tenant_id, tag_ids) + if target_ids: + stmt = stmt.where(CustomizedSnippet.id.in_(target_ids)) + else: + return [], 0, False + + with self._session_scope() as session: + # Get total count + count_stmt = select(func.count()).select_from(stmt.subquery()) + total = session.scalar(count_stmt) or 0 + + # Apply pagination + stmt = stmt.limit(limit + 1).offset((page - 1) * limit) + snippets = list(session.scalars(stmt).all()) + + has_more = len(snippets) > limit + if has_more: + snippets = snippets[:-1] + + return snippets, total, has_more + + def get_snippet_by_id( + self, + *, + snippet_id: str, + tenant_id: str, + ) -> CustomizedSnippet | None: + """ + Get snippet by ID with tenant isolation. + + :param snippet_id: Snippet ID + :param tenant_id: Tenant ID + :return: CustomizedSnippet or None + """ + with self._session_scope() as session: + stmt = select(CustomizedSnippet).where( + CustomizedSnippet.id == snippet_id, + CustomizedSnippet.tenant_id == tenant_id, + ) + return session.scalar(stmt) + + def create_snippet( + self, + *, + tenant_id: str, + name: str, + description: str | None, + snippet_type: SnippetType, + icon_info: dict | None, + input_fields: list[dict] | None, + account: Account, + ) -> CustomizedSnippet: + """ + Create a new snippet. + + :param tenant_id: Tenant ID + :param name: Snippet name + :param description: Snippet description + :param snippet_type: Type of snippet (node or group) + :param icon_info: Icon information + :param input_fields: Input field definitions + :param account: Creator account + :return: Created CustomizedSnippet + """ + snippet = CustomizedSnippet( + tenant_id=tenant_id, + name=name, + description=description or "", + type=snippet_type.value, + icon_info=icon_info, + input_fields=json.dumps(input_fields) if input_fields else None, + created_by=account.id, + ) + + with self._session_scope() as session: + session.add(snippet) + self._commit_if_owned(session) + + return snippet + + @staticmethod + def update_snippet( + *, + session: Session, + snippet: CustomizedSnippet, + account_id: str, + data: dict, + ) -> CustomizedSnippet: + """ + Update snippet attributes. + + :param session: Database session + :param snippet: Snippet to update + :param account_id: ID of account making the update + :param data: Dictionary of fields to update + :return: Updated CustomizedSnippet + """ + if "name" in data: + snippet.name = data["name"] + + if "description" in data: + snippet.description = data["description"] + + if "icon_info" in data: + snippet.icon_info = data["icon_info"] + + snippet.updated_by = account_id + snippet.updated_at = datetime.now(UTC).replace(tzinfo=None) + + session.add(snippet) + return snippet + + @staticmethod + def delete_snippet( + *, + session: Session, + snippet: CustomizedSnippet, + ) -> bool: + """ + Delete a snippet. + + :param session: Database session + :param snippet: Snippet to delete + :return: True if deleted successfully + """ + SnippetService._delete_draft_variable_files(session=session, snippet=snippet) + session.execute( + delete(WorkflowDraftVariable) + .where(WorkflowDraftVariable.app_id == snippet.id) + .execution_options(synchronize_session=False) + ) + session.execute( + delete(WorkflowToolProvider) + .where( + WorkflowToolProvider.tenant_id == snippet.tenant_id, + WorkflowToolProvider.app_id == snippet.id, + ) + .execution_options(synchronize_session=False) + ) + session.execute( + delete(WorkflowAppLog) + .where( + WorkflowAppLog.tenant_id == snippet.tenant_id, + WorkflowAppLog.app_id == snippet.id, + ) + .execution_options(synchronize_session=False) + ) + session.execute( + delete(WorkflowArchiveLog) + .where( + WorkflowArchiveLog.tenant_id == snippet.tenant_id, + WorkflowArchiveLog.app_id == snippet.id, + ) + .execution_options(synchronize_session=False) + ) + SnippetService._delete_archived_workflow_run_files(snippet=snippet) + session.execute( + delete(WorkflowNodeExecutionModel) + .where( + WorkflowNodeExecutionModel.tenant_id == snippet.tenant_id, + WorkflowNodeExecutionModel.app_id == snippet.id, + ) + .execution_options(synchronize_session=False) + ) + session.execute( + delete(WorkflowRun) + .where( + WorkflowRun.tenant_id == snippet.tenant_id, + WorkflowRun.app_id == snippet.id, + ) + .execution_options(synchronize_session=False) + ) + session.execute( + delete(Workflow) + .where( + Workflow.tenant_id == snippet.tenant_id, + Workflow.app_id == snippet.id, + SnippetService._snippet_kind_filter(), + ) + .execution_options(synchronize_session=False) + ) + session.execute( + delete(TagBinding) + .where( + TagBinding.tenant_id == snippet.tenant_id, + TagBinding.target_id == snippet.id, + ) + .execution_options(synchronize_session=False) + ) + session.delete(snippet) + return True + + # --- Workflow Operations --- + + def get_draft_workflow(self, snippet: CustomizedSnippet) -> Workflow | None: + """ + Get draft workflow for snippet. + + :param snippet: CustomizedSnippet instance + :return: Draft Workflow or None + """ + with self._session_scope() as session: + stmt = select(Workflow).where( + Workflow.tenant_id == snippet.tenant_id, + Workflow.app_id == snippet.id, + self._snippet_kind_filter(), + Workflow.version == "draft", + ) + return session.scalar(stmt) + + def get_published_workflow(self, snippet: CustomizedSnippet) -> Workflow | None: + """ + Get published workflow for snippet. + + :param snippet: CustomizedSnippet instance + :return: Published Workflow or None + """ + if not snippet.workflow_id: + return None + + with self._session_scope() as session: + stmt = select(Workflow).where( + Workflow.tenant_id == snippet.tenant_id, + Workflow.app_id == snippet.id, + self._snippet_kind_filter(), + Workflow.id == snippet.workflow_id, + ) + return session.scalar(stmt) + + def get_published_workflow_by_id(self, snippet: CustomizedSnippet, workflow_id: str) -> Workflow | None: + """ + Get a published workflow snapshot by ID for snippet history restore. + + :param snippet: CustomizedSnippet instance + :param workflow_id: Workflow ID + :return: Published Workflow or None + :raises IsDraftWorkflowError: If the workflow ID points to a draft workflow + """ + with self._session_scope() as session: + stmt = select(Workflow).where( + Workflow.tenant_id == snippet.tenant_id, + Workflow.app_id == snippet.id, + self._snippet_kind_filter(), + Workflow.id == workflow_id, + ) + workflow = session.scalar(stmt) + if not workflow: + return None + if workflow.version == Workflow.VERSION_DRAFT: + raise IsDraftWorkflowError("source workflow must be published") + return workflow + + def sync_draft_workflow( + self, + *, + snippet: CustomizedSnippet, + graph: dict, + unique_hash: str | None, + account: Account, + input_fields: list[dict] | None = None, + ) -> Workflow: + """ + Sync draft workflow for snippet. + + Snippet workflows do not persist environment variables (always empty) or + conversation variables (always empty). + + :param snippet: CustomizedSnippet instance + :param graph: Workflow graph configuration + :param unique_hash: Hash for conflict detection + :param account: Account making the change + :param input_fields: Input fields for snippet + :return: Synced Workflow + :raises WorkflowHashNotEqualError: If hash mismatch + """ + SnippetService.validate_snippet_graph_forbidden_nodes(graph) + + workflow = self.get_draft_workflow(snippet=snippet) + + if workflow and workflow.unique_hash != unique_hash: + raise WorkflowHashNotEqualError() + + # Create draft workflow if not found + if not workflow: + workflow = Workflow( + tenant_id=snippet.tenant_id, + app_id=snippet.id, + features="{}", + type=WorkflowType.WORKFLOW, + kind=WorkflowKind.SNIPPET, + version="draft", + graph=json.dumps(graph), + created_by=account.id, + environment_variables=[], + conversation_variables=[], + ) + else: + # Update existing draft workflow + workflow.graph = json.dumps(graph) + workflow.type = WorkflowType.WORKFLOW + workflow.kind = WorkflowKind.SNIPPET + workflow.updated_by = account.id + workflow.updated_at = datetime.now(UTC).replace(tzinfo=None) + workflow.environment_variables = [] + workflow.conversation_variables = [] + + # Update snippet's input_fields if provided + if input_fields is not None: + snippet.input_fields = json.dumps(input_fields) + snippet.updated_by = account.id + snippet.updated_at = datetime.now(UTC).replace(tzinfo=None) + + with self._session_scope() as session: + session.add(workflow) + session.add(snippet) + self._commit_if_owned(session) + return workflow + + def restore_published_workflow_to_draft( + self, + *, + snippet: CustomizedSnippet, + workflow_id: str, + account: Account, + ) -> Workflow: + """ + Restore a published snippet workflow snapshot into the draft workflow. + + :param snippet: CustomizedSnippet instance + :param workflow_id: Published workflow ID + :param account: Account making the change + :return: Restored draft Workflow + :raises WorkflowNotFoundError: If the source workflow does not exist + :raises IsDraftWorkflowError: If the source workflow is a draft + :raises ValueError: If the restored graph is invalid for snippets + """ + source_workflow = self.get_published_workflow_by_id(snippet=snippet, workflow_id=workflow_id) + if not source_workflow: + raise WorkflowNotFoundError("Workflow not found.") + + SnippetService.validate_snippet_graph_forbidden_nodes(source_workflow.graph_dict) + + draft_workflow = self.get_draft_workflow(snippet=snippet) + draft_workflow, _is_new_draft = apply_published_workflow_snapshot_to_draft( + tenant_id=snippet.tenant_id, + app_id=snippet.id, + source_workflow=source_workflow, + draft_workflow=draft_workflow, + account=account, + updated_at_factory=lambda: datetime.now(UTC).replace(tzinfo=None), + ) + + with self._session_scope() as session: + session.add(draft_workflow) + self._commit_if_owned(session) + return draft_workflow + + def publish_workflow( + self, + *, + session: Session, + snippet: CustomizedSnippet, + account: Account, + ) -> Workflow: + """ + Publish the draft workflow as a new version. + + :param session: Database session + :param snippet: CustomizedSnippet instance + :param account: Account making the change + :return: Published Workflow + :raises ValueError: If no draft workflow exists + """ + draft_workflow_stmt = select(Workflow).where( + Workflow.tenant_id == snippet.tenant_id, + Workflow.app_id == snippet.id, + self._snippet_kind_filter(), + Workflow.version == "draft", + ) + draft_workflow = session.scalar(draft_workflow_stmt) + if not draft_workflow: + raise ValueError("No valid workflow found.") + + SnippetService.validate_snippet_graph_forbidden_nodes(draft_workflow.graph_dict) + + # Create new published workflow + workflow = Workflow.new( + tenant_id=snippet.tenant_id, + app_id=snippet.id, + type=WorkflowType.WORKFLOW.value, + version=str(datetime.now(UTC).replace(tzinfo=None)), + graph=draft_workflow.graph, + features=draft_workflow.features, + created_by=account.id, + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=draft_workflow.rag_pipeline_variables, + kind=WorkflowKind.SNIPPET.value, + marked_name="", + marked_comment="", + ) + session.add(workflow) + + # Update snippet version + snippet.version += 1 + snippet.is_published = True + snippet.workflow_id = workflow.id + snippet.updated_by = account.id + session.add(snippet) + + return workflow + + def get_all_published_workflows( + self, + *, + session: Session, + snippet: CustomizedSnippet, + page: int, + limit: int, + ) -> tuple[Sequence[Workflow], bool]: + """ + Get all published workflow versions for snippet. + + :param session: Database session + :param snippet: CustomizedSnippet instance + :param page: Page number + :param limit: Items per page + :return: Tuple of (workflows list, has_more flag) + """ + if not snippet.workflow_id: + return [], False + + stmt = ( + select(Workflow) + .where( + Workflow.app_id == snippet.id, + self._snippet_kind_filter(), + Workflow.version != "draft", + ) + .order_by(Workflow.version.desc()) + .limit(limit + 1) + .offset((page - 1) * limit) + ) + + workflows = list(session.scalars(stmt).all()) + has_more = len(workflows) > limit + if has_more: + workflows = workflows[:-1] + + return workflows, has_more + + # --- Default Block Configs --- + + def get_default_block_configs(self) -> list[dict]: + """ + Get default block configurations for all node types. + + :return: List of default configurations + """ + default_block_configs: list[dict[str, Any]] = [] + for node_class_mapping in NODE_TYPE_CLASSES_MAPPING.values(): + node_class = node_class_mapping[LATEST_VERSION] + default_config = node_class.get_default_config() + if default_config: + default_block_configs.append(dict(default_config)) + + return default_block_configs + + def get_default_block_config(self, node_type: str, filters: dict | None = None) -> Mapping[str, object] | None: + """ + Get default config for specific node type. + + :param node_type: Node type string + :param filters: Optional filters + :return: Default configuration or None + """ + node_type_enum: NodeType = node_type + + if node_type_enum not in NODE_TYPE_CLASSES_MAPPING: + return None + + node_class = NODE_TYPE_CLASSES_MAPPING[node_type_enum][LATEST_VERSION] + default_config = node_class.get_default_config(filters=filters) + if not default_config: + return None + + return default_config + + # --- Workflow Run Operations --- + + def get_snippet_workflow_runs( + self, + *, + snippet: CustomizedSnippet, + args: dict, + ) -> InfiniteScrollPagination: + """ + Get paginated workflow runs for snippet. + + :param snippet: CustomizedSnippet instance + :param args: Request arguments (last_id, limit) + :return: InfiniteScrollPagination result + """ + limit = int(args.get("limit", 20)) + last_id = args.get("last_id") + + triggered_from_values = [ + WorkflowRunTriggeredFrom.DEBUGGING, + ] + + return self._workflow_run_repo.get_paginated_workflow_runs( + tenant_id=snippet.tenant_id, + app_id=snippet.id, + triggered_from=triggered_from_values, + limit=limit, + last_id=last_id, + ) + + def get_snippet_workflow_run( + self, + *, + snippet: CustomizedSnippet, + run_id: str, + ) -> WorkflowRun | None: + """ + Get workflow run details. + + :param snippet: CustomizedSnippet instance + :param run_id: Workflow run ID + :return: WorkflowRun or None + """ + return self._workflow_run_repo.get_workflow_run_by_id( + tenant_id=snippet.tenant_id, + app_id=snippet.id, + run_id=run_id, + ) + + def get_snippet_workflow_run_node_executions( + self, + *, + snippet: CustomizedSnippet, + run_id: str, + ) -> Sequence[WorkflowNodeExecutionModel]: + """ + Get workflow run node execution list. + + :param snippet: CustomizedSnippet instance + :param run_id: Workflow run ID + :return: List of WorkflowNodeExecutionModel + """ + workflow_run = self.get_snippet_workflow_run(snippet=snippet, run_id=run_id) + if not workflow_run: + return [] + + node_executions = self._node_execution_service_repo.get_executions_by_workflow_run( + tenant_id=snippet.tenant_id, + app_id=snippet.id, + workflow_run_id=workflow_run.id, + ) + + return node_executions + + # --- Node Execution Operations --- + + def get_snippet_node_last_run( + self, + *, + snippet: CustomizedSnippet, + workflow: Workflow, + node_id: str, + ) -> WorkflowNodeExecutionModel | None: + """ + Get the most recent execution for a specific node in a snippet workflow. + + :param snippet: CustomizedSnippet instance + :param workflow: Workflow instance + :param node_id: Node identifier + :return: WorkflowNodeExecutionModel or None + """ + return self._node_execution_service_repo.get_node_last_execution( + tenant_id=snippet.tenant_id, + app_id=snippet.id, + workflow_id=workflow.id, + node_id=node_id, + ) + + # --- Use Count --- + + @staticmethod + def increment_use_count( + *, + session: Session, + snippet: CustomizedSnippet, + ) -> None: + """ + Increment the use_count when snippet is used. + + :param session: Database session + :param snippet: CustomizedSnippet instance + """ + snippet.use_count += 1 + session.add(snippet) diff --git a/api/services/tag_service.py b/api/services/tag_service.py index 09d49d8b3e..f1e5c3fc56 100644 --- a/api/services/tag_service.py +++ b/api/services/tag_service.py @@ -12,6 +12,7 @@ from extensions.ext_database import db from models.dataset import Dataset from models.enums import TagType from models.model import App, Tag, TagBinding +from models.snippet import CustomizedSnippet class SaveTagPayload(BaseModel): @@ -159,7 +160,14 @@ class TagService: @staticmethod def save_tag_binding(payload: TagBindingCreatePayload): TagService.check_target_exists(payload.type, payload.target_id) - for tag_id in payload.tag_ids: + valid_tag_ids = db.session.scalars( + select(Tag.id).where( + Tag.id.in_(payload.tag_ids), + Tag.tenant_id == current_user.current_tenant_id, + Tag.type == payload.type, + ) + ).all() + for tag_id in valid_tag_ids: tag_binding = db.session.scalar( select(TagBinding) .where(TagBinding.tag_id == tag_id, TagBinding.target_id == payload.target_id) @@ -186,6 +194,12 @@ class TagService: TagBinding.target_id == payload.target_id, TagBinding.tag_id.in_(payload.tag_ids), TagBinding.tenant_id == current_user.current_tenant_id, + TagBinding.tag_id.in_( + select(Tag.id).where( + Tag.tenant_id == current_user.current_tenant_id, + Tag.type == payload.type, + ) + ), ) ), ) @@ -209,5 +223,13 @@ class TagService: ) if not app: raise NotFound("App not found") + elif type == "snippet": + snippet = db.session.scalar( + select(CustomizedSnippet) + .where(CustomizedSnippet.tenant_id == current_user.current_tenant_id, CustomizedSnippet.id == target_id) + .limit(1) + ) + if not snippet: + raise NotFound("Snippet not found") else: raise NotFound("Invalid binding type") diff --git a/api/services/workflow_draft_variable_service.py b/api/services/workflow_draft_variable_service.py index 59db147576..af25edd2bd 100644 --- a/api/services/workflow_draft_variable_service.py +++ b/api/services/workflow_draft_variable_service.py @@ -1,7 +1,7 @@ import dataclasses import json import logging -from collections.abc import Mapping, Sequence +from collections.abc import Mapping, Sequence, Set from concurrent.futures import ThreadPoolExecutor from datetime import datetime from enum import StrEnum @@ -271,12 +271,20 @@ class WorkflowDraftVariableService: ) def list_variables_without_values( - self, app_id: str, page: int, limit: int, user_id: str + self, + app_id: str, + page: int, + limit: int, + user_id: str, + *, + exclude_node_ids: Set[str] | None = None, ) -> WorkflowDraftVariableList: criteria = [ WorkflowDraftVariable.app_id == app_id, WorkflowDraftVariable.user_id == user_id, ] + if exclude_node_ids: + criteria.append(WorkflowDraftVariable.node_id.notin_(list(exclude_node_ids))) total = None base_stmt = select(WorkflowDraftVariable).where(*criteria) if page == 1: diff --git a/api/tests/test_containers_integration_tests/services/test_app_service.py b/api/tests/test_containers_integration_tests/services/test_app_service.py index c37fce296f..03ea1d1fa0 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_service.py @@ -307,6 +307,61 @@ class TestAppService: ) assert len(my_apps.items) == 1 + def test_get_paginate_apps_filters_by_creator_ids( + self, db_session_with_containers: Session, mock_external_service_dependencies + ): + """ + Test paginated app list with creator ID filters. + """ + fake = Faker() + + first_account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=generate_valid_password(fake), + ) + TenantService.create_owner_tenant_if_not_exist(first_account, name=fake.company()) + tenant = first_account.current_tenant + second_account = AccountService.create_account( + email=fake.email(), + name=fake.name(), + interface_language="en-US", + password=generate_valid_password(fake), + ) + + from services.app_service import AppListParams, AppService, CreateAppParams + + app_service = AppService() + app_params = CreateAppParams( + name="First Creator App", + description="Created by the first account", + mode="chat", + icon_type="emoji", + icon="💬", + icon_background="#FF6B6B", + ) + app_service.create_app(tenant.id, app_params, first_account) + other_app_params = CreateAppParams( + name="Second Creator App", + description="Created by the second account", + mode="chat", + icon_type="emoji", + icon="✍️", + icon_background="#4ECDC4", + ) + app_service.create_app(tenant.id, other_app_params, second_account) + + filtered_apps = app_service.get_paginate_apps( + first_account.id, + tenant.id, + AppListParams(page=1, limit=10, mode="chat", creator_ids=[second_account.id]), + ) + + assert filtered_apps is not None + assert len(filtered_apps.items) == 1 + assert filtered_apps.items[0].created_by == second_account.id + def test_get_paginate_apps_with_tag_filters( self, db_session_with_containers: Session, mock_external_service_dependencies ): diff --git a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py index d698b03893..35eac429c0 100644 --- a/api/tests/unit_tests/controllers/console/app/test_app_response_models.py +++ b/api/tests/unit_tests/controllers/console/app/test_app_response_models.py @@ -212,6 +212,24 @@ def test_app_list_query_normalizes_orpc_bracket_tag_ids(app_module): assert query.tag_ids == [first_tag_id, second_tag_id] +def test_app_list_query_normalizes_orpc_bracket_creator_ids(app_module): + first_creator_id = "9e8959cf-a67b-4d34-9906-1d687517b248" + second_creator_id = "1886f96a-5bf0-42bf-961d-8d2129049076" + query_args = MultiDict( + [ + ("page", "1"), + ("limit", "30"), + ("creator_ids[1]", second_creator_id), + ("creator_ids[0]", first_creator_id), + ] + ) + + normalized = app_module._normalize_app_list_query_args(query_args) + query = app_module.AppListQuery.model_validate(normalized) + + assert query.creator_ids == [first_creator_id, second_creator_id] + + def test_app_list_query_preserves_regular_query_params(app_module): query_args = MultiDict( [ @@ -263,6 +281,13 @@ def test_app_list_query_rejects_invalid_bracket_tag_id(app_module): app_module.AppListQuery.model_validate(normalized) +def test_app_list_query_rejects_invalid_bracket_creator_id(app_module): + normalized = app_module._normalize_app_list_query_args(MultiDict([("creator_ids[0]", "not-a-uuid")])) + + with pytest.raises(ValidationError): + app_module.AppListQuery.model_validate(normalized) + + def test_app_list_query_sorts_bracket_tag_ids_by_index(app_module): first_tag_id = "8c4ef3d1-58a1-4d94-8a1c-1c171d889e08" second_tag_id = "3c39395b-6d1f-4030-8b17-eaa7cc85221c" diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_list_query.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_list_query.py new file mode 100644 index 0000000000..6ef3474461 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_list_query.py @@ -0,0 +1,80 @@ +import pytest +from pydantic import ValidationError +from werkzeug.datastructures import MultiDict + +from controllers.console.snippets.payloads import SnippetListQuery +from controllers.console.workspace.snippets import _normalize_snippet_list_query_args + + +def test_snippet_list_query_accepts_comma_separated_tag_ids() -> None: + first = "11111111-1111-1111-1111-111111111111" + second = "22222222-2222-2222-2222-222222222222" + + query = SnippetListQuery.model_validate({"tag_ids": f"{first},{second}"}) + + assert query.tag_ids == [first, second] + + +def test_snippet_list_query_returns_none_for_blank_tag_ids() -> None: + query = SnippetListQuery.model_validate({"tag_ids": " , "}) + + assert query.tag_ids is None + + +def test_snippet_list_query_rejects_invalid_tag_id() -> None: + with pytest.raises(ValidationError, match="Invalid UUID format in tag_ids"): + SnippetListQuery.model_validate({"tag_ids": "not-a-uuid"}) + + +def test_snippet_list_query_accepts_creator_id_alias() -> None: + creator_id = "1886f96a-5bf0-42bf-961d-8d2129049076" + + query = SnippetListQuery.model_validate({"creator_id": creator_id}) + + assert query.creators == [creator_id] + + +def test_snippet_list_query_normalizes_creator_lists() -> None: + query = SnippetListQuery.model_validate({"creators": ["account-1", "", " account-2 "]}) + + assert query.creators == ["account-1", "account-2"] + + +def test_snippet_list_query_ignores_unsupported_list_value_type() -> None: + query = SnippetListQuery.model_validate({"creators": {"bad": "value"}}) + + assert query.creators is None + + +def test_normalize_snippet_list_query_accepts_indexed_creator_ids() -> None: + first = "9e8959cf-a67b-4d34-9906-1d687517b248" + second = "1886f96a-5bf0-42bf-961d-8d2129049076" + + normalized = _normalize_snippet_list_query_args( + MultiDict( + [ + ("creator_ids[1]", second), + ("creator_ids[0]", first), + ("keyword", "search"), + ] + ) + ) + + assert normalized == {"keyword": "search", "creators": [first, second]} + + +def test_normalize_snippet_list_query_accepts_indexed_tag_ids() -> None: + first = "11111111-1111-1111-1111-111111111111" + second = "22222222-2222-2222-2222-222222222222" + + normalized = _normalize_snippet_list_query_args( + MultiDict( + [ + ("tag_ids[1]", second), + ("tag_ids[0]", first), + ("keyword", "search"), + ] + ) + ) + + assert normalized == {"keyword": "search", "tag_ids": [first, second]} diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py new file mode 100644 index 0000000000..e43ee84bb0 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py @@ -0,0 +1,364 @@ +from __future__ import annotations + +from datetime import datetime +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from werkzeug.exceptions import HTTPException, NotFound + +from controllers.console.snippets import snippet_workflow as snippet_workflow_module + + +def _unwrap(func): + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + return func + + +@pytest.fixture(autouse=True) +def _patch_snippet_service_factory(monkeypatch: pytest.MonkeyPatch) -> None: + def factory(): + service_factory = snippet_workflow_module.SnippetService + if isinstance(service_factory, type): + return service_factory.__new__(service_factory) + return service_factory() + + monkeypatch.setattr(snippet_workflow_module, "_snippet_service", factory) + monkeypatch.setattr(snippet_workflow_module, "_snippet_session_maker", Mock(return_value=Mock())) + + +def test_get_snippet_requires_snippet_id(app): + @snippet_workflow_module.get_snippet + def view(**kwargs): + return kwargs + + with app.test_request_context("/snippets"): + with pytest.raises(ValueError, match="missing snippet_id"): + view() + + +def test_get_snippet_injects_resolved_snippet(app, monkeypatch: pytest.MonkeyPatch) -> None: + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + + @snippet_workflow_module.get_snippet + def view(**kwargs): + return kwargs["snippet"] + + monkeypatch.setattr( + snippet_workflow_module, + "current_account_with_tenant", + lambda: (SimpleNamespace(id="account-1"), "tenant-1"), + ) + monkeypatch.setattr(snippet_workflow_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet)) + + with app.test_request_context("/snippets/snippet-1"): + result = view(snippet_id="snippet-1") + + assert result is snippet + + +def test_get_snippet_raises_not_found_when_snippet_missing(app, monkeypatch: pytest.MonkeyPatch) -> None: + @snippet_workflow_module.get_snippet + def view(**kwargs): + return kwargs + + monkeypatch.setattr( + snippet_workflow_module, + "current_account_with_tenant", + lambda: (SimpleNamespace(id="account-1"), "tenant-1"), + ) + monkeypatch.setattr(snippet_workflow_module.SnippetService, "get_snippet_by_id", Mock(return_value=None)) + + with app.test_request_context("/snippets/snippet-1"): + with pytest.raises(NotFound, match="Snippet not found"): + view(snippet_id="snippet-1") + + +def test_draft_workflow_get_raises_when_missing(app, monkeypatch: pytest.MonkeyPatch) -> None: + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + monkeypatch.setattr( + snippet_workflow_module, + "SnippetService", + lambda: SimpleNamespace(get_draft_workflow=Mock(return_value=None)), + ) + + api = snippet_workflow_module.SnippetDraftWorkflowApi() + handler = _unwrap(api.get) + + with app.test_request_context("/snippets/snippet-1/workflows/draft"): + with pytest.raises(snippet_workflow_module.DraftWorkflowNotExist): + handler(api, snippet=snippet) + + +def test_draft_workflow_post_returns_400_for_invalid_graph(app, monkeypatch: pytest.MonkeyPatch) -> None: + user = SimpleNamespace(id="account-1") + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + sync_draft_workflow = Mock(side_effect=ValueError("invalid graph")) + monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1")) + monkeypatch.setattr( + snippet_workflow_module, + "SnippetService", + lambda: SimpleNamespace(sync_draft_workflow=sync_draft_workflow), + ) + + api = snippet_workflow_module.SnippetDraftWorkflowApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/snippets/snippet-1/workflows/draft", + method="POST", + json={"graph": {"nodes": [], "edges": []}, "hash": "hash-1"}, + ): + response, status_code = handler(api, snippet=snippet) + + assert status_code == 400 + assert response == {"message": "invalid graph"} + + +def test_draft_config_returns_parallel_depth_limit(app) -> None: + api = snippet_workflow_module.SnippetDraftConfigApi() + handler = _unwrap(api.get) + + with app.test_request_context("/snippets/snippet-1/workflows/draft/config"): + assert handler(api, snippet=SimpleNamespace(id="snippet-1")) == {"parallel_depth_limit": 3} + + +def test_published_workflow_get_returns_none_when_not_published(app) -> None: + api = snippet_workflow_module.SnippetPublishedWorkflowApi() + handler = _unwrap(api.get) + + with app.test_request_context("/snippets/snippet-1/workflows/publish"): + assert handler(api, snippet=SimpleNamespace(id="snippet-1", is_published=False)) is None + + +def test_published_workflow_post_returns_400_when_publish_fails(app, monkeypatch: pytest.MonkeyPatch) -> None: + user = SimpleNamespace(id="account-1") + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + merged_snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + session = SimpleNamespace(merge=Mock(return_value=merged_snippet), commit=Mock()) + + class SessionContext: + def __init__(self, engine): + self.engine = engine + + def __enter__(self): + return session + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1")) + monkeypatch.setattr(snippet_workflow_module, "Session", SessionContext) + monkeypatch.setattr(snippet_workflow_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + snippet_workflow_module, + "SnippetService", + lambda: SimpleNamespace(publish_workflow=Mock(side_effect=ValueError("No valid workflow found."))), + ) + + api = snippet_workflow_module.SnippetPublishedWorkflowApi() + handler = _unwrap(api.post) + + with app.test_request_context("/snippets/snippet-1/workflows/publish", method="POST", json={}): + response, status_code = handler(api, snippet=snippet) + + assert status_code == 400 + assert response == {"message": "No valid workflow found."} + session.commit.assert_not_called() + + +def test_default_block_configs_delegates_to_service(app, monkeypatch: pytest.MonkeyPatch) -> None: + get_default_block_configs = Mock(return_value=[{"type": "llm"}]) + monkeypatch.setattr( + snippet_workflow_module, + "SnippetService", + lambda: SimpleNamespace(get_default_block_configs=get_default_block_configs), + ) + + api = snippet_workflow_module.SnippetDefaultBlockConfigsApi() + handler = _unwrap(api.get) + + with app.test_request_context("/snippets/snippet-1/workflows/default-workflow-block-configs"): + result = handler(api, snippet=SimpleNamespace(id="snippet-1")) + + assert result == [{"type": "llm"}] + get_default_block_configs.assert_called_once() + + +def test_restore_published_snippet_workflow_to_draft_success(app, monkeypatch: pytest.MonkeyPatch) -> None: + workflow = SimpleNamespace( + unique_hash="restored-hash", + updated_at=None, + created_at=datetime(2024, 1, 1), + ) + user = SimpleNamespace(id="account-1") + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + + monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1")) + monkeypatch.setattr( + snippet_workflow_module, + "SnippetService", + lambda: SimpleNamespace(restore_published_workflow_to_draft=lambda **_kwargs: workflow), + ) + + api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/snippets/snippet-1/workflows/published-workflow/restore", + method="POST", + ): + response = handler(api, snippet=snippet, workflow_id="published-workflow") + + assert response["result"] == "success" + assert response["hash"] == "restored-hash" + + +def test_restore_published_snippet_workflow_to_draft_not_found(app, monkeypatch: pytest.MonkeyPatch) -> None: + user = SimpleNamespace(id="account-1") + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + + monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1")) + monkeypatch.setattr( + snippet_workflow_module, + "SnippetService", + lambda: SimpleNamespace( + restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw( + snippet_workflow_module.WorkflowNotFoundError("Workflow not found") + ) + ), + ) + + api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/snippets/snippet-1/workflows/published-workflow/restore", + method="POST", + ): + with pytest.raises(NotFound): + handler(api, snippet=snippet, workflow_id="published-workflow") + + +def test_restore_published_snippet_workflow_to_draft_returns_400_for_draft_source( + app, monkeypatch: pytest.MonkeyPatch +) -> None: + user = SimpleNamespace(id="account-1") + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + + monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1")) + monkeypatch.setattr( + snippet_workflow_module, + "SnippetService", + lambda: SimpleNamespace( + restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw( + snippet_workflow_module.IsDraftWorkflowError("source workflow must be published") + ) + ), + ) + + api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/snippets/snippet-1/workflows/draft-workflow/restore", + method="POST", + ): + with pytest.raises(HTTPException) as exc: + handler(api, snippet=snippet, workflow_id="draft-workflow") + + assert exc.value.code == 400 + assert exc.value.description == snippet_workflow_module.RESTORE_SOURCE_WORKFLOW_MUST_BE_PUBLISHED_MESSAGE + + +def test_restore_published_snippet_workflow_to_draft_returns_400_for_invalid_graph( + app, monkeypatch: pytest.MonkeyPatch +) -> None: + user = SimpleNamespace(id="account-1") + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + + monkeypatch.setattr(snippet_workflow_module, "current_account_with_tenant", lambda: (user, "tenant-1")) + monkeypatch.setattr( + snippet_workflow_module, + "SnippetService", + lambda: SimpleNamespace( + restore_published_workflow_to_draft=lambda **_kwargs: (_ for _ in ()).throw( + ValueError("invalid snippet workflow graph") + ) + ), + ) + + api = snippet_workflow_module.SnippetDraftWorkflowRestoreApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/snippets/snippet-1/workflows/published-workflow/restore", + method="POST", + ): + with pytest.raises(HTTPException) as exc: + handler(api, snippet=snippet, workflow_id="published-workflow") + + assert exc.value.code == 400 + assert exc.value.description == "invalid snippet workflow graph" + + +def test_workflow_run_detail_raises_not_found_when_run_missing(app, monkeypatch: pytest.MonkeyPatch) -> None: + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + monkeypatch.setattr( + snippet_workflow_module, + "SnippetService", + lambda: SimpleNamespace(get_snippet_workflow_run=Mock(return_value=None)), + ) + + api = snippet_workflow_module.SnippetWorkflowRunDetailApi() + handler = _unwrap(api.get) + + with app.test_request_context("/snippets/snippet-1/workflow-runs/run-1"): + with pytest.raises(NotFound, match="Workflow run not found"): + handler(api, snippet=snippet, run_id="run-1") + + +def test_draft_node_last_run_raises_not_found_when_execution_missing(app, monkeypatch: pytest.MonkeyPatch) -> None: + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + draft_workflow = SimpleNamespace(id="workflow-1") + monkeypatch.setattr( + snippet_workflow_module, + "SnippetService", + lambda: SimpleNamespace( + get_draft_workflow=Mock(return_value=draft_workflow), + get_snippet_node_last_run=Mock(return_value=None), + ), + ) + + api = snippet_workflow_module.SnippetDraftNodeLastRunApi() + handler = _unwrap(api.get) + + with app.test_request_context("/snippets/snippet-1/workflows/draft/nodes/llm-1/last-run"): + with pytest.raises(NotFound, match="Node last run not found"): + handler(api, snippet=snippet, node_id="llm-1") + + +def test_workflow_task_stop_uses_queue_flag_and_graph_command(app, monkeypatch: pytest.MonkeyPatch) -> None: + set_stop_flag = Mock() + send_stop_command = Mock() + monkeypatch.setattr( + snippet_workflow_module.AppQueueManager, + "set_stop_flag_no_user_check", + set_stop_flag, + ) + monkeypatch.setattr( + snippet_workflow_module, + "GraphEngineManager", + Mock(return_value=SimpleNamespace(send_stop_command=send_stop_command)), + ) + + api = snippet_workflow_module.SnippetWorkflowTaskStopApi() + handler = _unwrap(api.post) + + with app.test_request_context("/snippets/snippet-1/workflow-runs/tasks/task-1/stop", method="POST"): + result = handler(api, snippet=SimpleNamespace(id="snippet-1"), task_id="task-1") + + assert result == {"result": "success"} + set_stop_flag.assert_called_once_with("task-1") + send_stop_command.assert_called_once_with("task-1") diff --git a/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py new file mode 100644 index 0000000000..fbbeab0eb8 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow_draft_variable.py @@ -0,0 +1,279 @@ +import importlib +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from flask import Flask + +from controllers.console.snippets import snippet_workflow_draft_variable as module +from core.workflow.variable_prefixes import CONVERSATION_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID +from services.workflow_draft_variable_service import WorkflowDraftVariableList + +app_workflow_draft_variable_module = importlib.import_module("controllers.console.app.workflow_draft_variable") + + +def _unwrap(func): + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + return func + + +@pytest.fixture(autouse=True) +def _patch_snippet_service_factory(monkeypatch): + def factory(): + service_factory = module.SnippetService + if isinstance(service_factory, type): + return service_factory.__new__(service_factory) + return service_factory() + + monkeypatch.setattr(module, "_snippet_service", factory) + + +@pytest.fixture +def app(): + app = Flask("test_snippet_workflow_draft_variable") + app.config["TESTING"] = True + return app + + +def test_ensure_snippet_draft_variable_row_allowed_rejects_system_variable(): + variable = SimpleNamespace(node_id=SYSTEM_VARIABLE_NODE_ID) + + with pytest.raises(module.NotFoundError, match="variable not found"): + module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") + + +def test_ensure_snippet_draft_variable_row_allowed_rejects_conversation_variable(): + variable = SimpleNamespace(node_id=CONVERSATION_VARIABLE_NODE_ID) + + with pytest.raises(module.NotFoundError, match="variable not found"): + module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") + + +def test_ensure_snippet_draft_variable_row_allowed_accepts_canvas_node_variable(): + variable = SimpleNamespace(node_id="llm-1") + + module._ensure_snippet_draft_variable_row_allowed(variable=variable, variable_id="var-1") + + +def test_conversation_variables_returns_empty_list(app): + api = module.SnippetConversationVariableCollectionApi() + handler = _unwrap(api.get) + + with app.test_request_context("/"): + result = handler(api, snippet=SimpleNamespace(id="snippet-1")) + + assert result == WorkflowDraftVariableList(variables=[]) + + +def test_system_variables_returns_empty_list(app): + api = module.SnippetSystemVariableCollectionApi() + handler = _unwrap(api.get) + + with app.test_request_context("/"): + result = handler(api, snippet=SimpleNamespace(id="snippet-1")) + + assert result == WorkflowDraftVariableList(variables=[]) + + +def test_delete_variable_collection_deletes_current_user_variables(app, monkeypatch): + draft_var_service = SimpleNamespace(delete_user_workflow_variables=Mock()) + monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) + monkeypatch.setattr(module, "current_user", SimpleNamespace(id="user-1")) + db_session = Mock() + db_session.return_value = SimpleNamespace() + monkeypatch.setattr(module.db, "session", db_session) + api = module.SnippetWorkflowVariableCollectionApi() + handler = _unwrap(api.delete) + + with app.test_request_context("/", method="DELETE"): + response = handler(api, snippet=SimpleNamespace(id="snippet-1")) + + assert response.status_code == 204 + draft_var_service.delete_user_workflow_variables.assert_called_once_with("snippet-1", user_id="user-1") + db_session.commit.assert_called_once() + + +def test_variable_collection_get_raises_when_draft_workflow_missing(app, monkeypatch): + monkeypatch.setattr( + module, + "SnippetService", + Mock(return_value=SimpleNamespace(get_draft_workflow=Mock(return_value=None))), + ) + + api = module.SnippetWorkflowVariableCollectionApi() + handler = _unwrap(api.get) + + with app.test_request_context("/?page=1&limit=20"): + with pytest.raises(module.DraftWorkflowNotExist): + handler(api, snippet=SimpleNamespace(id="snippet-1")) + + +def test_node_variable_collection_get_lists_node_variables(app, monkeypatch): + variables = WorkflowDraftVariableList(variables=[SimpleNamespace(id="var-1")]) + list_node_variables = Mock(return_value=variables) + + class SessionContext: + def __init__(self, bind, expire_on_commit=False): + self.bind = bind + self.expire_on_commit = expire_on_commit + + def __enter__(self): + return SimpleNamespace() + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(module, "Session", SessionContext) + monkeypatch.setattr(module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(module, "current_user", SimpleNamespace(id="user-1")) + monkeypatch.setattr( + module, + "WorkflowDraftVariableService", + Mock(return_value=SimpleNamespace(list_node_variables=list_node_variables)), + ) + + api = module.SnippetNodeVariableCollectionApi() + handler = _unwrap(api.get) + + with app.test_request_context("/"): + result = handler(api, snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1") + + assert result is variables + list_node_variables.assert_called_once_with("snippet-1", "llm-1", user_id="user-1") + + +def test_node_variable_collection_delete_deletes_node_variables(app, monkeypatch): + delete_node_variables = Mock() + draft_var_service = SimpleNamespace(delete_node_variables=delete_node_variables) + monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) + monkeypatch.setattr(module, "current_user", SimpleNamespace(id="user-1")) + db_session = Mock() + db_session.return_value = SimpleNamespace() + monkeypatch.setattr(module.db, "session", db_session) + + api = module.SnippetNodeVariableCollectionApi() + handler = _unwrap(api.delete) + + with app.test_request_context("/", method="DELETE"): + response = handler(api, snippet=SimpleNamespace(id="snippet-1"), node_id="llm-1") + + assert response.status_code == 204 + delete_node_variables.assert_called_once_with("snippet-1", "llm-1", user_id="user-1") + db_session.commit.assert_called_once() + + +def test_variable_patch_returns_variable_when_no_changes(app, monkeypatch): + variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") + draft_var_service = SimpleNamespace(get_variable=Mock(return_value=variable), update_variable=Mock()) + db_session = Mock() + db_session.return_value = SimpleNamespace() + monkeypatch.setattr(module.db, "session", db_session) + monkeypatch.setattr(module, "current_user", SimpleNamespace(id="user-1")) + monkeypatch.setattr(app_workflow_draft_variable_module, "current_user", SimpleNamespace(id="user-1")) + monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) + + api = module.SnippetVariableApi() + handler = _unwrap(api.patch) + + with app.test_request_context("/", method="PATCH", json={}): + result = handler(api, snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), variable_id="var-1") + + assert result is variable + draft_var_service.update_variable.assert_not_called() + db_session.commit.assert_not_called() + + +def test_variable_delete_deletes_variable(app, monkeypatch): + variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") + delete_variable = Mock() + draft_var_service = SimpleNamespace(get_variable=Mock(return_value=variable), delete_variable=delete_variable) + db_session = Mock() + db_session.return_value = SimpleNamespace() + monkeypatch.setattr(module.db, "session", db_session) + monkeypatch.setattr(module, "current_user", SimpleNamespace(id="user-1")) + monkeypatch.setattr(app_workflow_draft_variable_module, "current_user", SimpleNamespace(id="user-1")) + monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) + + api = module.SnippetVariableApi() + handler = _unwrap(api.delete) + + with app.test_request_context("/", method="DELETE"): + response = handler(api, snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1") + + assert response.status_code == 204 + delete_variable.assert_called_once_with(variable) + db_session.commit.assert_called_once() + + +def test_variable_reset_returns_no_content_when_reset_result_is_none(app, monkeypatch): + variable = SimpleNamespace(id="var-1", app_id="snippet-1", user_id="user-1", node_id="llm-1") + draft_workflow = SimpleNamespace(id="workflow-1") + draft_var_service = SimpleNamespace( + get_variable=Mock(return_value=variable), + reset_variable=Mock(return_value=None), + ) + db_session = Mock() + db_session.return_value = SimpleNamespace() + monkeypatch.setattr(module.db, "session", db_session) + monkeypatch.setattr(module, "current_user", SimpleNamespace(id="user-1")) + monkeypatch.setattr(app_workflow_draft_variable_module, "current_user", SimpleNamespace(id="user-1")) + monkeypatch.setattr(module, "WorkflowDraftVariableService", Mock(return_value=draft_var_service)) + monkeypatch.setattr( + module, + "SnippetService", + Mock(return_value=SimpleNamespace(get_draft_workflow=Mock(return_value=draft_workflow))), + ) + + api = module.SnippetVariableResetApi() + handler = _unwrap(api.put) + + with app.test_request_context("/", method="PUT"): + response = handler(api, snippet=SimpleNamespace(id="snippet-1"), variable_id="var-1") + + assert response.status_code == 204 + draft_var_service.reset_variable.assert_called_once_with(draft_workflow, variable) + db_session.commit.assert_called_once() + + +def test_environment_variables_returns_workflow_environment_variables(app, monkeypatch): + env_var = SimpleNamespace( + id="env-1", + name="API_KEY", + description="secret", + selector=["env", "API_KEY"], + value_type=SimpleNamespace(exposed_type=Mock(return_value=SimpleNamespace(value="secret"))), + value="sk-test", + ) + monkeypatch.setattr( + module, + "SnippetService", + Mock( + return_value=SimpleNamespace( + get_draft_workflow=Mock(return_value=SimpleNamespace(environment_variables=[env_var])) + ) + ), + ) + + api = module.SnippetEnvironmentVariableCollectionApi() + handler = _unwrap(api.get) + + with app.test_request_context("/"): + result = handler(api, snippet=SimpleNamespace(id="snippet-1")) + + assert result == { + "items": [ + { + "id": "env-1", + "type": "env", + "name": "API_KEY", + "description": "secret", + "selector": ["env", "API_KEY"], + "value_type": "secret", + "value": "sk-test", + "edited": False, + "visible": True, + "editable": True, + } + ] + } diff --git a/api/tests/unit_tests/controllers/console/tag/test_tags.py b/api/tests/unit_tests/controllers/console/tag/test_tags.py index 32b39de515..3630f1bfec 100644 --- a/api/tests/unit_tests/controllers/console/tag/test_tags.py +++ b/api/tests/unit_tests/controllers/console/tag/test_tags.py @@ -105,6 +105,30 @@ class TestTagListApi: assert status == 200 assert result == [{"id": "1", "name": "tag", "type": "knowledge", "binding_count": "1"}] + def test_get_snippet_tags(self, app: Flask): + api = TagListApi() + method = unwrap(api.get) + + with app.test_request_context("/?type=snippet"): + with ( + patch( + "controllers.console.tag.tags.TagService.get_tags", + return_value=[ + SimpleNamespace( + id="1", + name="snippet-tag", + type=TagType.SNIPPET, + binding_count=1, + ) + ], + ) as get_tags_mock, + ): + result, status = method(api, "tenant-1") + + get_tags_mock.assert_called_once_with("snippet", "tenant-1", None) + assert status == 200 + assert result == [{"id": "1", "name": "snippet-tag", "type": "snippet", "binding_count": "1"}] + def test_post_success(self, app: Flask, admin_user, tag, payload_patch): api = TagListApi() method = unwrap(api.post) @@ -215,6 +239,30 @@ class TestTagBindingCollectionApi: assert status == 200 assert result["result"] == "success" + def test_create_snippet_binding_success(self, app: Flask, admin_user, payload_patch): + api = TagBindingCollectionApi() + method = unwrap(api.post) + + payload = { + "tag_ids": ["tag-1"], + "target_id": "snippet-1", + "type": "snippet", + } + + with app.test_request_context("/", json=payload): + with ( + payload_patch(payload), + patch("controllers.console.tag.tags.TagService.save_tag_binding") as save_mock, + ): + result, status = method(api, admin_user) + + save_mock.assert_called_once() + binding_payload = save_mock.call_args.args[0] + assert binding_payload.type == TagType.SNIPPET + assert binding_payload.target_id == "snippet-1" + assert status == 200 + assert result["result"] == "success" + def test_create_forbidden(self, app: Flask, readonly_user, payload_patch): api = TagBindingCollectionApi() method = unwrap(api.post) diff --git a/api/tests/unit_tests/controllers/console/workspace/test_snippets.py b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py new file mode 100644 index 0000000000..a712a46404 --- /dev/null +++ b/api/tests/unit_tests/controllers/console/workspace/test_snippets.py @@ -0,0 +1,509 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest +from werkzeug.exceptions import NotFound + +from controllers.console.workspace import snippets as snippets_module +from services.snippet_dsl_service import ImportStatus, SnippetImportInfo + + +def _unwrap(func): + while hasattr(func, "__wrapped__"): + func = func.__wrapped__ + return func + + +@pytest.fixture(autouse=True) +def _patch_snippet_service_factory(monkeypatch): + def factory(): + return snippets_module.SnippetService.__new__(snippets_module.SnippetService) + + monkeypatch.setattr(snippets_module, "_snippet_service", factory) + + +class _SessionContext: + def __init__(self, engine, *args, **kwargs): + self.engine = engine + self.session = kwargs.pop("session", None) + + def __enter__(self): + return self.session + + def __exit__(self, exc_type, exc, tb): + return False + + +def test_normalize_snippet_list_query_args_sorts_indexed_values(): + query_args = snippets_module.MultiDict( + [ + ("tag_ids[1]", "tag-b"), + ("tag_ids[0]", "tag-a"), + ("creator_ids[1]", "account-b"), + ("creator_ids[0]", "account-a"), + ("keyword", "search"), + ] + ) + + assert snippets_module._normalize_snippet_list_query_args(query_args) == { + "tag_ids": ["tag-a", "tag-b"], + "creators": ["account-a", "account-b"], + "keyword": "search", + } + + +def test_list_snippets_returns_pagination(app, monkeypatch): + user = SimpleNamespace(id="account-1") + snippets = [SimpleNamespace(id="snippet-1")] + tag_id = "11111111-1111-1111-1111-111111111111" + get_snippets = Mock(return_value=(snippets, 1, False)) + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1")) + monkeypatch.setattr(snippets_module.SnippetService, "get_snippets", get_snippets) + monkeypatch.setattr(snippets_module, "marshal", Mock(return_value=[{"id": "snippet-1"}])) + + api = snippets_module.CustomizedSnippetsApi() + handler = _unwrap(api.get) + + with app.test_request_context( + f"/workspaces/current/customized-snippets?page=2&limit=10&tag_ids[0]={tag_id}&creator_ids[0]=account-2" + ): + response, status_code = handler(api) + + assert status_code == 200 + assert response == { + "data": [{"id": "snippet-1"}], + "page": 2, + "limit": 10, + "total": 1, + "has_more": False, + } + get_snippets.assert_called_once_with( + tenant_id="tenant-1", + page=2, + limit=10, + keyword=None, + is_published=None, + creators=["account-2"], + tag_ids=[tag_id], + ) + + +def test_create_snippet_defaults_unknown_type_and_returns_created(app, monkeypatch): + user = SimpleNamespace(id="account-1") + snippet = SimpleNamespace(id="snippet-1") + create_snippet = Mock(return_value=snippet) + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1")) + monkeypatch.setattr(snippets_module.SnippetService, "create_snippet", create_snippet) + monkeypatch.setattr( + snippets_module.CreateSnippetPayload, + "model_validate", + Mock( + return_value=SimpleNamespace( + name="Snippet", + type="unknown", + description="Description", + graph=None, + icon_info=None, + input_fields=[], + ) + ), + ) + monkeypatch.setattr(snippets_module, "marshal", Mock(return_value={"id": "snippet-1"})) + + api = snippets_module.CustomizedSnippetsApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/workspaces/current/customized-snippets", + method="POST", + json={"name": "Snippet", "type": "node", "description": "Description"}, + ): + response, status_code = handler(api) + + assert status_code == 201 + assert response == {"id": "snippet-1"} + assert create_snippet.call_args.kwargs["snippet_type"] == snippets_module.SnippetType.NODE + + +def test_create_snippet_rejects_forbidden_nodes(app, monkeypatch): + user = SimpleNamespace(id="account-1") + create_snippet = Mock() + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1")) + monkeypatch.setattr(snippets_module.SnippetService, "create_snippet", create_snippet) + + api = snippets_module.CustomizedSnippetsApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/workspaces/current/customized-snippets", + method="POST", + json={ + "name": "snippet with invalid node", + "type": "node", + "graph": { + "nodes": [ + {"id": "knowledge-1", "data": {"type": "knowledge-retrieval"}}, + ], + "edges": [], + }, + }, + ): + response, status_code = handler(api) + + assert status_code == 400 + assert "knowledge-retrieval" in response["message"] + create_snippet.assert_not_called() + + +def test_get_snippet_detail_raises_when_missing(app, monkeypatch): + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1")) + monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=None)) + + api = snippets_module.CustomizedSnippetDetailApi() + handler = _unwrap(api.get) + + with app.test_request_context("/workspaces/current/customized-snippets/snippet-1"): + with pytest.raises(NotFound, match="Snippet not found"): + handler(api, snippet_id="snippet-1") + + +def test_get_snippet_detail_returns_snippet(app, monkeypatch): + snippet = SimpleNamespace(id="snippet-1") + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1")) + monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet)) + monkeypatch.setattr(snippets_module, "marshal", Mock(return_value={"id": "snippet-1"})) + + api = snippets_module.CustomizedSnippetDetailApi() + handler = _unwrap(api.get) + + with app.test_request_context("/workspaces/current/customized-snippets/snippet-1"): + response, status_code = handler(api, snippet_id="snippet-1") + + assert status_code == 200 + assert response == {"id": "snippet-1"} + + +def test_patch_snippet_returns_400_for_empty_payload(app, monkeypatch): + snippet = SimpleNamespace(id="snippet-1") + monkeypatch.setattr( + snippets_module, + "current_account_with_tenant", + lambda: (SimpleNamespace(id="user-1"), "tenant-1"), + ) + monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet)) + + api = snippets_module.CustomizedSnippetDetailApi() + handler = _unwrap(api.patch) + + with app.test_request_context( + "/workspaces/current/customized-snippets/snippet-1", + method="PATCH", + json={}, + ): + response, status_code = handler(api, snippet_id="snippet-1") + + assert status_code == 400 + assert response == {"message": "No valid fields to update"} + + +def test_patch_snippet_updates_and_commits(app, monkeypatch): + user = SimpleNamespace(id="account-1") + snippet = SimpleNamespace(id="snippet-1") + updated_snippet = SimpleNamespace(id="snippet-1", name="New") + session = SimpleNamespace(merge=Mock(return_value=snippet), commit=Mock()) + update_snippet = Mock(return_value=updated_snippet) + + class SessionContext(_SessionContext): + def __init__(self, engine, *args, **kwargs): + super().__init__(engine, *args, session=session, **kwargs) + + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1")) + monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet)) + monkeypatch.setattr(snippets_module.SnippetService, "update_snippet", update_snippet) + monkeypatch.setattr(snippets_module, "Session", SessionContext) + monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr(snippets_module, "marshal", Mock(return_value={"id": "snippet-1", "name": "New"})) + + api = snippets_module.CustomizedSnippetDetailApi() + handler = _unwrap(api.patch) + + with app.test_request_context( + "/workspaces/current/customized-snippets/snippet-1", + method="PATCH", + json={"name": "New", "icon_info": {"icon": "star"}}, + ): + response, status_code = handler(api, snippet_id="snippet-1") + + assert status_code == 200 + assert response == {"id": "snippet-1", "name": "New"} + update_snippet.assert_called_once() + assert update_snippet.call_args.kwargs["data"] == { + "name": "New", + "icon_info": {"icon": "star", "icon_background": None, "icon_type": None, "icon_url": None}, + } + session.commit.assert_called_once() + + +def test_delete_snippet_deletes_and_commits(app, monkeypatch): + snippet = SimpleNamespace(id="snippet-1") + session = SimpleNamespace(merge=Mock(return_value=snippet), commit=Mock()) + delete_snippet = Mock() + + class SessionContext(_SessionContext): + def __init__(self, engine, *args, **kwargs): + super().__init__(engine, *args, session=session, **kwargs) + + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1")) + monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet)) + monkeypatch.setattr(snippets_module.SnippetService, "delete_snippet", delete_snippet) + monkeypatch.setattr(snippets_module, "Session", SessionContext) + monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object())) + + api = snippets_module.CustomizedSnippetDetailApi() + handler = _unwrap(api.delete) + + with app.test_request_context("/workspaces/current/customized-snippets/snippet-1", method="DELETE"): + response, status_code = handler(api, snippet_id="snippet-1") + + assert status_code == 204 + assert response == "" + delete_snippet.assert_called_once_with(session=session, snippet=snippet) + session.commit.assert_called_once() + + +def test_export_snippet_returns_yaml_attachment(app, monkeypatch): + snippet = SimpleNamespace(id="snippet-1", name="Snippet One") + export_snippet_dsl = Mock(return_value="version: 0.1.0\nkind: snippet\n") + session = SimpleNamespace() + + class SessionContext(_SessionContext): + def __init__(self, engine, *args, **kwargs): + super().__init__(engine, *args, session=session, **kwargs) + + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1")) + monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet)) + monkeypatch.setattr( + snippets_module, + "SnippetDslService", + Mock(return_value=SimpleNamespace(export_snippet_dsl=export_snippet_dsl)), + ) + monkeypatch.setattr(snippets_module, "Session", SessionContext) + monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object())) + + api = snippets_module.CustomizedSnippetExportApi() + handler = _unwrap(api.get) + + with app.test_request_context("/workspaces/current/customized-snippets/snippet-1/export?include_secret=true"): + response = handler(api, snippet_id="snippet-1") + + assert response.status_code == 200 + assert response.get_data(as_text=True) == "version: 0.1.0\nkind: snippet\n" + assert response.headers["Content-Type"] == "application/x-yaml" + assert "Snippet%20One.snippet" in response.headers["Content-Disposition"] + export_snippet_dsl.assert_called_once_with(snippet=snippet, include_secret=True) + + +def test_import_snippet_returns_202_for_pending_confirmation(app, monkeypatch): + user = SimpleNamespace(id="account-1") + result = SnippetImportInfo(id="import-1", status=ImportStatus.PENDING, imported_dsl_version="999.0.0") + import_snippet = Mock(return_value=result) + session = SimpleNamespace(commit=Mock()) + + class _SessionContext: + def __init__(self, engine): + self.engine = engine + + def __enter__(self): + return session + + def __exit__(self, exc_type, exc, tb): + return False + + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1")) + monkeypatch.setattr(snippets_module, "Session", _SessionContext) + monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + snippets_module, + "SnippetDslService", + Mock(return_value=SimpleNamespace(import_snippet=import_snippet)), + ) + + api = snippets_module.CustomizedSnippetImportApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/workspaces/current/customized-snippets/imports", + method="POST", + json={"mode": "yaml-content", "yaml_content": "kind: snippet"}, + ): + response, status_code = handler(api) + + assert status_code == 202 + assert response["status"] == ImportStatus.PENDING.value + import_snippet.assert_called_once() + session.commit.assert_called_once() + + +def test_import_snippet_returns_400_for_failed_import(app, monkeypatch): + user = SimpleNamespace(id="account-1") + result = SnippetImportInfo(id="import-1", status=ImportStatus.FAILED, error="Invalid DSL") + import_snippet = Mock(return_value=result) + session = SimpleNamespace(commit=Mock()) + + class SessionContext(_SessionContext): + def __init__(self, engine, *args, **kwargs): + super().__init__(engine, *args, session=session, **kwargs) + + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1")) + monkeypatch.setattr(snippets_module, "Session", SessionContext) + monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + snippets_module, + "SnippetDslService", + Mock(return_value=SimpleNamespace(import_snippet=import_snippet)), + ) + + api = snippets_module.CustomizedSnippetImportApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/workspaces/current/customized-snippets/imports", + method="POST", + json={"mode": "yaml-content", "yaml_content": "kind: snippet"}, + ): + response, status_code = handler(api) + + assert status_code == 400 + assert response["error"] == "Invalid DSL" + session.commit.assert_called_once() + + +def test_import_confirm_returns_200_for_completed_import(app, monkeypatch): + user = SimpleNamespace(id="account-1") + result = SnippetImportInfo(id="import-1", status=ImportStatus.COMPLETED, snippet_id="snippet-1") + confirm_import = Mock(return_value=result) + session = SimpleNamespace(commit=Mock()) + + class SessionContext(_SessionContext): + def __init__(self, engine, *args, **kwargs): + super().__init__(engine, *args, session=session, **kwargs) + + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (user, "tenant-1")) + monkeypatch.setattr(snippets_module, "Session", SessionContext) + monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + snippets_module, + "SnippetDslService", + Mock(return_value=SimpleNamespace(confirm_import=confirm_import)), + ) + + api = snippets_module.CustomizedSnippetImportConfirmApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/workspaces/current/customized-snippets/imports/import-1/confirm", + method="POST", + ): + response, status_code = handler(api, import_id="import-1") + + assert status_code == 200 + assert response["snippet_id"] == "snippet-1" + confirm_import.assert_called_once_with(import_id="import-1", account=user) + session.commit.assert_called_once() + + +def test_check_dependencies_raises_when_snippet_missing(app, monkeypatch): + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1")) + monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=None)) + + api = snippets_module.CustomizedSnippetCheckDependenciesApi() + handler = _unwrap(api.get) + + with app.test_request_context("/workspaces/current/customized-snippets/snippet-1/check-dependencies"): + with pytest.raises(NotFound, match="Snippet not found"): + handler(api, snippet_id="snippet-1") + + +def test_check_dependencies_returns_dependency_result(app, monkeypatch): + snippet = SimpleNamespace(id="snippet-1") + check_dependencies = Mock( + return_value=SimpleNamespace(model_dump=Mock(return_value={"dependencies": [], "missing_dependencies": []})) + ) + session = SimpleNamespace() + + class SessionContext(_SessionContext): + def __init__(self, engine, *args, **kwargs): + super().__init__(engine, *args, session=session, **kwargs) + + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1")) + monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet)) + monkeypatch.setattr(snippets_module, "Session", SessionContext) + monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object())) + monkeypatch.setattr( + snippets_module, + "SnippetDslService", + Mock(return_value=SimpleNamespace(check_dependencies=check_dependencies)), + ) + + api = snippets_module.CustomizedSnippetCheckDependenciesApi() + handler = _unwrap(api.get) + + with app.test_request_context("/workspaces/current/customized-snippets/snippet-1/check-dependencies"): + response, status_code = handler(api, snippet_id="snippet-1") + + assert status_code == 200 + assert response == {"dependencies": [], "missing_dependencies": []} + check_dependencies.assert_called_once_with(snippet=snippet) + + +def test_increment_use_count_raises_when_snippet_missing(app, monkeypatch): + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1")) + monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=None)) + + api = snippets_module.CustomizedSnippetUseCountIncrementApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/workspaces/current/customized-snippets/snippet-1/use-count/increment", + method="POST", + ): + with pytest.raises(NotFound, match="Snippet not found"): + handler(api, snippet_id="snippet-1") + + +def test_increment_use_count_returns_refreshed_count(app, monkeypatch): + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1", use_count=2) + merged_snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1", use_count=3) + session = SimpleNamespace(merge=Mock(return_value=merged_snippet), commit=Mock(), refresh=Mock()) + + class _SessionContext: + def __init__(self, engine): + self.engine = engine + + def __enter__(self): + return session + + def __exit__(self, exc_type, exc, tb): + return False + + increment_use_count = Mock() + monkeypatch.setattr(snippets_module, "current_account_with_tenant", lambda: (SimpleNamespace(), "tenant-1")) + monkeypatch.setattr(snippets_module.SnippetService, "get_snippet_by_id", Mock(return_value=snippet)) + monkeypatch.setattr(snippets_module.SnippetService, "increment_use_count", increment_use_count) + monkeypatch.setattr(snippets_module, "Session", _SessionContext) + monkeypatch.setattr(snippets_module, "db", SimpleNamespace(engine=object())) + + api = snippets_module.CustomizedSnippetUseCountIncrementApi() + handler = _unwrap(api.post) + + with app.test_request_context( + "/workspaces/current/customized-snippets/snippet-1/use-count/increment", + method="POST", + ): + response, status_code = handler(api, snippet_id="snippet-1") + + assert status_code == 200 + assert response == {"result": "success", "use_count": 3} + increment_use_count.assert_called_once_with(session=session, snippet=merged_snippet) + session.commit.assert_called_once() + session.refresh.assert_called_once_with(merged_snippet) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py index 154693810b..a857dfdca0 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py @@ -25,6 +25,45 @@ def test_should_prepare_user_inputs_keeps_validation_when_flag_false(): assert WorkflowAppGenerator()._should_prepare_user_inputs(args) +def test_ensure_snippet_start_node_in_worker_returns_standard_workflow_without_lookup(): + session = MagicMock() + workflow = SimpleNamespace(kind_or_standard="standard") + + result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=session, workflow=workflow) + + assert result is workflow + session.scalar.assert_not_called() + + +def test_ensure_snippet_start_node_in_worker_returns_snippet_workflow_when_snippet_missing(): + session = MagicMock() + session.scalar.return_value = None + workflow = SimpleNamespace(kind_or_standard="snippet", app_id="snippet-1", tenant_id="tenant-1") + + result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=session, workflow=workflow) + + assert result is workflow + session.scalar.assert_called_once() + + +def test_ensure_snippet_start_node_in_worker_applies_snippet_start_injection(mocker: MockerFixture): + session = MagicMock() + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + session.scalar.return_value = snippet + workflow = SimpleNamespace(kind_or_standard="snippet", app_id="snippet-1", tenant_id="tenant-1") + updated_workflow = SimpleNamespace(name="updated-workflow") + ensure_start_node = mocker.patch( + "services.snippet_generate_service.SnippetGenerateService.ensure_start_node_for_worker", + return_value=updated_workflow, + ) + + result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=session, workflow=workflow) + + assert result is updated_workflow + session.scalar.assert_called_once() + ensure_start_node.assert_called_once_with(workflow, snippet) + + def test_generate_includes_parent_trace_context_in_extras(monkeypatch): generator = WorkflowAppGenerator() diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py index 4daff81732..c02683e13f 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py @@ -165,3 +165,71 @@ def test_single_node_run_validates_target_node_config(monkeypatch) -> None: ) assert seen_configs == [workflow.graph_dict["nodes"][0]] + + +def test_run_adds_inputs_with_snippet_compatible_start_aliases() -> None: + app_config = MagicMock() + app_config.app_id = "app" + app_config.tenant_id = "tenant" + app_config.workflow_id = "workflow" + + app_generate_entity = MagicMock(spec=WorkflowAppGenerateEntity) + app_generate_entity.app_config = app_config + app_generate_entity.inputs = {"question": "hello"} + app_generate_entity.files = [] + app_generate_entity.user_id = "user" + app_generate_entity.invoke_from = InvokeFrom.SERVICE_API + app_generate_entity.workflow_execution_id = "execution-id" + app_generate_entity.task_id = "task-id" + app_generate_entity.call_depth = 0 + app_generate_entity.trace_manager = None + app_generate_entity.extras = {} + app_generate_entity.single_iteration_run = None + app_generate_entity.single_loop_run = None + + workflow = MagicMock(spec=Workflow) + workflow.tenant_id = "tenant" + workflow.app_id = "app" + workflow.id = "workflow" + workflow.type = "workflow" + workflow.version = "v1" + workflow.graph_dict = {"nodes": [], "edges": []} + workflow.environment_variables = [] + workflow.kind_or_standard = "snippet" + + runner = WorkflowAppRunner( + application_generate_entity=app_generate_entity, + queue_manager=MagicMock(spec=AppQueueManager), + variable_loader=MagicMock(), + workflow=workflow, + system_user_id="system-user", + workflow_execution_repository=MagicMock(), + workflow_node_execution_repository=MagicMock(), + ) + + mock_workflow_entry = MagicMock() + mock_workflow_entry.graph_engine = MagicMock() + mock_workflow_entry.graph_engine.layer = MagicMock() + mock_workflow_entry.run.return_value = iter([]) + + with ( + patch("core.app.apps.workflow.app_runner.RedisChannel"), + patch("core.app.apps.workflow.app_runner.redis_client"), + patch("core.app.apps.workflow.app_runner.WorkflowEntry", return_value=mock_workflow_entry), + patch("core.app.apps.workflow.app_runner.build_system_variables", return_value={}), + patch("core.app.apps.workflow.app_runner.build_bootstrap_variables", return_value=[]), + patch("core.app.apps.workflow.app_runner.add_variables_to_pool"), + patch("core.app.apps.workflow.app_runner.get_default_root_node_id", return_value="root-node"), + patch( + "core.app.apps.workflow.app_runner.get_compatible_start_aliases", return_value=("legacy-start",) + ) as aliases, + patch("core.app.apps.workflow.app_runner.add_node_inputs_to_pool") as add_inputs, + patch.object(runner, "_init_graph", return_value=MagicMock()), + ): + runner.run() + + aliases.assert_called_once_with(workflow_kind="snippet", root_node_id="root-node") + add_inputs.assert_called_once() + assert add_inputs.call_args.kwargs["node_id"] == "root-node" + assert add_inputs.call_args.kwargs["inputs"] == {"question": "hello"} + assert add_inputs.call_args.kwargs["aliases"] == ("legacy-start",) diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py index 0ad941ce3b..228ca2024e 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py @@ -1,6 +1,8 @@ from __future__ import annotations +import contextlib from types import SimpleNamespace +from unittest.mock import Mock import pytest @@ -13,6 +15,40 @@ from models.model import AppMode class TestWorkflowAppGeneratorValidation: + def test_ensure_snippet_start_node_returns_original_for_non_snippet_workflow(self): + workflow = SimpleNamespace(kind_or_standard="workflow") + session = SimpleNamespace(scalar=Mock()) + + result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=session, workflow=workflow) + + assert result is workflow + session.scalar.assert_not_called() + + def test_ensure_snippet_start_node_returns_original_when_snippet_missing(self): + workflow = SimpleNamespace(kind_or_standard="snippet", app_id="snippet-1", tenant_id="tenant-1") + session = SimpleNamespace(scalar=Mock(return_value=None)) + + result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=session, workflow=workflow) + + assert result is workflow + session.scalar.assert_called_once() + + def test_ensure_snippet_start_node_delegates_when_snippet_exists(self, monkeypatch: pytest.MonkeyPatch): + workflow = SimpleNamespace(kind_or_standard="snippet", app_id="snippet-1", tenant_id="tenant-1") + snippet = SimpleNamespace(id="snippet-1") + injected_workflow = SimpleNamespace(id="workflow-injected") + session = SimpleNamespace(scalar=Mock(return_value=snippet)) + ensure_start_node = Mock(return_value=injected_workflow) + monkeypatch.setattr( + "services.snippet_generate_service.SnippetGenerateService.ensure_start_node_for_worker", + ensure_start_node, + ) + + result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=session, workflow=workflow) + + assert result is injected_workflow + ensure_start_node.assert_called_once_with(workflow, snippet) + def test_should_prepare_user_inputs(self): generator = WorkflowAppGenerator() @@ -391,3 +427,83 @@ class TestWorkflowAppGeneratorResume: assert result.ok is True assert captured_entity is not None assert captured_entity.trace_manager is existing_trace_manager + + +class TestWorkflowAppGeneratorWorker: + def test_generate_worker_uses_end_user_session_for_external_invocation(self, monkeypatch: pytest.MonkeyPatch): + generator = WorkflowAppGenerator() + + workflow = SimpleNamespace( + id="workflow-id", + tenant_id="tenant", + app_id="app", + graph_dict={}, + type="workflow", + version="1", + ) + end_user = SimpleNamespace(id="end-user-id", session_id="session-id") + session = SimpleNamespace(scalar=Mock(side_effect=[workflow, end_user])) + + class _SessionContext: + def __enter__(self): + return session + + def __exit__(self, exc_type, exc, tb): + return False + + runner_kwargs = {} + + class _Runner: + def __init__(self, **kwargs): + runner_kwargs.update(kwargs) + + def run(self): + return None + + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.preserve_flask_contexts", + lambda flask_app, context_vars: contextlib.nullcontext(), + ) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.session_factory.create_session", + lambda: _SessionContext(), + ) + monkeypatch.setattr( + "core.app.apps.workflow.app_generator.WorkflowAppGenerator._ensure_snippet_start_node_in_worker", + lambda self, *, session, workflow: workflow, + ) + monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppRunner", _Runner) + + app_config = WorkflowUIBasedAppConfig( + tenant_id="tenant", + app_id="app", + app_mode=AppMode.WORKFLOW, + additional_features=AppAdditionalFeatures(), + variables=[], + workflow_id="workflow-id", + ) + application_generate_entity = WorkflowAppGenerateEntity.model_construct( + task_id="task", + app_config=app_config, + inputs={}, + files=[], + user_id="end-user-id", + stream=False, + invoke_from=InvokeFrom.WEB_APP, + extras={}, + trace_manager=None, + workflow_execution_id="run-id", + call_depth=0, + ) + + generator._generate_worker( + flask_app=SimpleNamespace(), + application_generate_entity=application_generate_entity, + queue_manager=SimpleNamespace(), + context=SimpleNamespace(), + variable_loader=SimpleNamespace(), + workflow_execution_repository=SimpleNamespace(), + workflow_node_execution_repository=SimpleNamespace(), + ) + + assert runner_kwargs["system_user_id"] == "session-id" diff --git a/api/tests/unit_tests/core/workflow/test_snippet_start.py b/api/tests/unit_tests/core/workflow/test_snippet_start.py new file mode 100644 index 0000000000..2b54328dec --- /dev/null +++ b/api/tests/unit_tests/core/workflow/test_snippet_start.py @@ -0,0 +1,23 @@ +from core.workflow.snippet_start import ( + LEGACY_START_NODE_ID, + SNIPPET_VIRTUAL_START_NODE_ID, + get_compatible_start_aliases, +) + + +def test_get_compatible_start_aliases_returns_legacy_start_for_snippet_virtual_start() -> None: + aliases = get_compatible_start_aliases( + workflow_kind="snippet", + root_node_id=SNIPPET_VIRTUAL_START_NODE_ID, + ) + + assert aliases == (LEGACY_START_NODE_ID,) + + +def test_get_compatible_start_aliases_returns_empty_for_non_snippet_roots() -> None: + aliases = get_compatible_start_aliases( + workflow_kind="workflow", + root_node_id=SNIPPET_VIRTUAL_START_NODE_ID, + ) + + assert aliases == () diff --git a/api/tests/unit_tests/core/workflow/test_variable_pool.py b/api/tests/unit_tests/core/workflow/test_variable_pool.py index 0017cd8d3f..541dc00a68 100644 --- a/api/tests/unit_tests/core/workflow/test_variable_pool.py +++ b/api/tests/unit_tests/core/workflow/test_variable_pool.py @@ -5,7 +5,7 @@ from collections import defaultdict import pytest from core.workflow.system_variables import build_system_variables, system_variables_to_mapping -from core.workflow.variable_pool_initializer import add_variables_to_pool +from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool from core.workflow.variable_prefixes import ( CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID, @@ -80,6 +80,25 @@ def test_get_file_attribute(pool, file): assert result is None +def test_add_node_inputs_to_pool_stores_inputs_under_aliases(): + pool = VariablePool() + + add_node_inputs_to_pool( + pool, + node_id="__snippet_virtual_start__", + inputs={"query": "hello"}, + aliases=("start", "__snippet_virtual_start__"), + ) + + primary_value = pool.get(["__snippet_virtual_start__", "query"]) + alias_value = pool.get(["start", "query"]) + + assert primary_value is not None + assert primary_value.value == "hello" + assert alias_value is not None + assert alias_value.value == "hello" + + class TestVariablePool: def test_constructor(self): pool = VariablePool() diff --git a/api/tests/unit_tests/fields/test_snippet_fields.py b/api/tests/unit_tests/fields/test_snippet_fields.py new file mode 100644 index 0000000000..ad8ee6e8f0 --- /dev/null +++ b/api/tests/unit_tests/fields/test_snippet_fields.py @@ -0,0 +1,28 @@ +from types import SimpleNamespace + +from flask_restx import marshal + +from fields.snippet_fields import snippet_list_fields + + +def test_snippet_list_fields_include_author_name() -> None: + snippet = SimpleNamespace( + id="snippet-1", + name="Snippet", + description="Reusable node", + type="node", + version=1, + use_count=0, + is_published=False, + icon_info=None, + tags=[], + created_by="account-1", + author_name="Alice", + created_at=None, + updated_by="account-1", + updated_at=None, + ) + + result = marshal(snippet, snippet_list_fields) + + assert result["author_name"] == "Alice" diff --git a/api/tests/unit_tests/models/test_snippet.py b/api/tests/unit_tests/models/test_snippet.py new file mode 100644 index 0000000000..f7a22f48f8 --- /dev/null +++ b/api/tests/unit_tests/models/test_snippet.py @@ -0,0 +1,76 @@ +import json +from types import SimpleNamespace +from unittest.mock import Mock + +from models.snippet import CustomizedSnippet + + +def test_graph_dict_returns_empty_without_workflow_id() -> None: + snippet = CustomizedSnippet(workflow_id=None) + + assert snippet.graph_dict == {} + + +def test_graph_dict_loads_published_workflow_graph(monkeypatch) -> None: + workflow = SimpleNamespace(graph=json.dumps({"nodes": [{"id": "llm-1"}], "edges": []})) + session = SimpleNamespace(get=Mock(return_value=workflow)) + monkeypatch.setattr("models.snippet.db.session", session) + snippet = CustomizedSnippet(workflow_id="workflow-1") + + assert snippet.graph_dict == {"nodes": [{"id": "llm-1"}], "edges": []} + session.get.assert_called_once() + + +def test_graph_dict_returns_empty_when_workflow_missing(monkeypatch) -> None: + session = SimpleNamespace(get=Mock(return_value=None)) + monkeypatch.setattr("models.snippet.db.session", session) + snippet = CustomizedSnippet(workflow_id="missing-workflow") + + assert snippet.graph_dict == {} + + +def test_input_fields_list_parses_json_or_returns_empty() -> None: + assert CustomizedSnippet(input_fields=None).input_fields_list == [] + assert CustomizedSnippet(input_fields=json.dumps([{"variable": "query"}])).input_fields_list == [ + {"variable": "query"} + ] + + +def test_tags_returns_query_results_or_empty(monkeypatch) -> None: + tags = [SimpleNamespace(id="tag-1")] + session = SimpleNamespace(scalars=Mock(return_value=SimpleNamespace(all=Mock(return_value=tags)))) + monkeypatch.setattr("models.snippet.db.session", session) + snippet = CustomizedSnippet(id="snippet-1", tenant_id="tenant-1") + + assert snippet.tags == tags + + session.scalars.return_value.all.return_value = None + assert snippet.tags == [] + + +def test_account_properties_and_author_name(monkeypatch) -> None: + account = SimpleNamespace(id="account-1", name="Ada") + updated_account = SimpleNamespace(id="account-2", name="Grace") + session = SimpleNamespace( + get=Mock(side_effect=lambda _model, account_id: account if account_id == "account-1" else updated_account) + ) + monkeypatch.setattr("models.snippet.db.session", session) + snippet = CustomizedSnippet(created_by="account-1", updated_by="account-2") + + assert snippet.created_by_account is account + assert snippet.author_name == "Ada" + assert snippet.updated_by_account is updated_account + + +def test_account_properties_return_none_without_account_ids() -> None: + snippet = CustomizedSnippet(created_by=None, updated_by=None) + + assert snippet.created_by_account is None + assert snippet.author_name is None + assert snippet.updated_by_account is None + + +def test_version_str_returns_string_value() -> None: + snippet = CustomizedSnippet(version=7) + + assert snippet.version_str == "7" diff --git a/api/tests/unit_tests/services/test_snippet_dsl_service.py b/api/tests/unit_tests/services/test_snippet_dsl_service.py new file mode 100644 index 0000000000..4866c570fd --- /dev/null +++ b/api/tests/unit_tests/services/test_snippet_dsl_service.py @@ -0,0 +1,643 @@ +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from graphon.nodes import BuiltinNodeTypes +from services.snippet_dsl_service import ( + ImportMode, + ImportStatus, + SnippetDslService, + SnippetPendingData, + _check_version_compatibility, +) + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("not-a-version", ImportStatus.FAILED), + ("999.0.0", ImportStatus.PENDING), + ("0.1.0", ImportStatus.COMPLETED), + ], +) +def test_check_version_compatibility_special_cases(version, expected): + assert _check_version_compatibility(version) == expected + + +def test_check_version_compatibility_returns_pending_for_older_major() -> None: + assert _check_version_compatibility("0.0.9") == ImportStatus.COMPLETED_WITH_WARNINGS + + +def test_import_snippet_rejects_invalid_mode(): + service = SnippetDslService(session=SimpleNamespace()) + + with pytest.raises(ValueError, match="Invalid import_mode"): + service.import_snippet(account=SimpleNamespace(current_tenant_id="tenant-1"), import_mode="bad-mode") + + +def test_import_snippet_requires_yaml_content(): + service = SnippetDslService(session=SimpleNamespace()) + + result = service.import_snippet( + account=SimpleNamespace(current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_CONTENT.value, + ) + + assert result.status == ImportStatus.FAILED + assert result.error == "yaml_content is required when import_mode is yaml-content" + + +def test_import_snippet_requires_yaml_url() -> None: + service = SnippetDslService(session=SimpleNamespace()) + + result = service.import_snippet( + account=SimpleNamespace(current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_URL.value, + ) + + assert result.status == ImportStatus.FAILED + assert result.error == "yaml_url is required when import_mode is yaml-url" + + +def test_import_snippet_rejects_invalid_yaml_url_scheme() -> None: + service = SnippetDslService(session=SimpleNamespace()) + + result = service.import_snippet( + account=SimpleNamespace(current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_URL.value, + yaml_url="file:///tmp/snippet.yaml", + ) + + assert result.status == ImportStatus.FAILED + assert result.error == "Invalid URL scheme, only http and https are allowed" + + +def test_import_snippet_returns_failed_when_yaml_url_fetch_fails(monkeypatch) -> None: + service = SnippetDslService(session=SimpleNamespace()) + monkeypatch.setattr( + "services.snippet_dsl_service.ssrf_proxy.get", + Mock(return_value=SimpleNamespace(status_code=404, text="not found")), + ) + + result = service.import_snippet( + account=SimpleNamespace(current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_URL.value, + yaml_url="https://example.com/snippet.yaml", + ) + + assert result.status == ImportStatus.FAILED + assert result.error == "Failed to fetch YAML from URL: 404" + + +def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch) -> None: + service = SnippetDslService(session=SimpleNamespace()) + monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 3) + monkeypatch.setattr( + "services.snippet_dsl_service.ssrf_proxy.get", + Mock(return_value=SimpleNamespace(status_code=200, text="too large")), + ) + + result = service.import_snippet( + account=SimpleNamespace(current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_URL.value, + yaml_url="https://example.com/snippet.yaml", + ) + + assert result.status == ImportStatus.FAILED + assert "YAML content size exceeds maximum limit" in result.error + + +def test_import_snippet_returns_failed_when_yaml_url_fetch_raises(monkeypatch) -> None: + service = SnippetDslService(session=SimpleNamespace()) + monkeypatch.setattr( + "services.snippet_dsl_service.ssrf_proxy.get", + Mock(side_effect=RuntimeError("network down")), + ) + + result = service.import_snippet( + account=SimpleNamespace(current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_URL.value, + yaml_url="https://example.com/snippet.yaml", + ) + + assert result.status == ImportStatus.FAILED + assert result.error == "Failed to fetch YAML from URL: network down" + + +def test_import_snippet_rejects_oversized_yaml_content(monkeypatch) -> None: + service = SnippetDslService(session=SimpleNamespace()) + monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 3) + + result = service.import_snippet( + account=SimpleNamespace(current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_CONTENT.value, + yaml_content="too large", + ) + + assert result.status == ImportStatus.FAILED + assert "YAML content size exceeds maximum limit" in result.error + + +@pytest.mark.parametrize( + ("yaml_content", "expected_error"), + [ + ("- item", "Invalid YAML format: expected a dictionary"), + ("version: 0.1.0\nsnippet:\n name: Missing Kind\n", "Missing 'kind' field in DSL"), + ( + "version: 0.1.0\nkind: app\nsnippet:\n name: Wrong Kind\n", + "Invalid DSL kind: expected 'snippet', got 'app'", + ), + ("version: 0.1.0\nkind: snippet\n", "Missing snippet data in YAML content"), + ], +) +def test_import_snippet_rejects_invalid_yaml_shapes(yaml_content, expected_error) -> None: + service = SnippetDslService(session=SimpleNamespace()) + + result = service.import_snippet( + account=SimpleNamespace(current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_CONTENT.value, + yaml_content=yaml_content, + ) + + assert result.status == ImportStatus.FAILED + assert expected_error in result.error + + +def test_import_snippet_returns_failed_for_invalid_version_type() -> None: + service = SnippetDslService(session=SimpleNamespace()) + + result = service.import_snippet( + account=SimpleNamespace(current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_CONTENT.value, + yaml_content="version: 1\nkind: snippet\nsnippet:\n name: Bad Version\n", + ) + + assert result.status == ImportStatus.FAILED + assert "Invalid version type" in result.error + + +def test_import_snippet_returns_failed_for_invalid_yaml_syntax() -> None: + service = SnippetDslService(session=SimpleNamespace()) + + result = service.import_snippet( + account=SimpleNamespace(current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_CONTENT.value, + yaml_content="kind: snippet\nsnippet: [", + ) + + assert result.status == ImportStatus.FAILED + assert result.error.startswith("Invalid YAML format:") + + +def test_import_snippet_rejects_forbidden_nodes(): + service = SnippetDslService(session=SimpleNamespace()) + yaml_content = """ +version: 0.3.0 +kind: snippet +snippet: + name: Bad Snippet +workflow: + graph: + nodes: + - id: start-1 + data: + type: start + edges: [] +""" + + result = service.import_snippet( + account=SimpleNamespace(current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_CONTENT.value, + yaml_content=yaml_content, + ) + + assert result.status == ImportStatus.FAILED + assert result.error == "Snippet cannot contain the following node types: start" + + +def test_import_snippet_stores_pending_data_for_newer_dsl(monkeypatch): + service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) + setex = Mock() + monkeypatch.setattr("services.snippet_dsl_service.redis_client.setex", setex) + yaml_content = """ +version: 999.0.0 +kind: snippet +snippet: + name: Future Snippet +workflow: + graph: + nodes: [] + edges: [] +""" + + result = service.import_snippet( + account=SimpleNamespace(current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_CONTENT.value, + yaml_content=yaml_content, + name="Override", + description="Override description", + ) + + assert result.status == ImportStatus.PENDING + setex.assert_called_once() + pending = SnippetPendingData.model_validate_json(setex.call_args.args[2]) + assert pending.name == "Override" + assert pending.description == "Override description" + + +def test_import_snippet_returns_failed_when_update_target_missing(): + service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) + yaml_content = """ +version: 0.1.0 +kind: snippet +snippet: + name: Existing Snippet +workflow: + graph: + nodes: [] + edges: [] +""" + + result = service.import_snippet( + account=SimpleNamespace(current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_CONTENT.value, + yaml_content=yaml_content, + snippet_id="missing-snippet", + ) + + assert result.status == ImportStatus.FAILED + assert result.error == "Snippet not found" + + +def test_import_snippet_passes_dependencies_to_create_or_update(monkeypatch): + service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) + snippet = SimpleNamespace(id="snippet-1") + create_or_update = Mock(return_value=snippet) + monkeypatch.setattr(service, "_create_or_update_snippet", create_or_update) + yaml_content = """ +version: 0.1.0 +kind: snippet +snippet: + name: Dependency Snippet +dependencies: + - type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openai:0.0.1 +workflow: + graph: + nodes: [] + edges: [] +""" + + result = service.import_snippet( + account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + import_mode=ImportMode.YAML_CONTENT.value, + yaml_content=yaml_content, + ) + + assert result.status == ImportStatus.COMPLETED + assert result.snippet_id == "snippet-1" + dependencies = create_or_update.call_args.kwargs["dependencies"] + assert dependencies[0].value.plugin_unique_identifier == "langgenius/openai:0.0.1" + + +def test_confirm_import_returns_failed_when_pending_data_missing(monkeypatch): + service = SnippetDslService(session=SimpleNamespace()) + monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=None)) + + result = service.confirm_import(import_id="missing", account=SimpleNamespace(current_tenant_id="tenant-1")) + + assert result.status == ImportStatus.FAILED + assert result.error == "Import information expired or does not exist" + + +def test_confirm_import_returns_failed_for_invalid_pending_payload(monkeypatch): + service = SnippetDslService(session=SimpleNamespace()) + monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=object())) + + result = service.confirm_import(import_id="bad", account=SimpleNamespace(current_tenant_id="tenant-1")) + + assert result.status == ImportStatus.FAILED + assert result.error == "Invalid import information" + + +def test_confirm_import_creates_snippet_from_pending_data(monkeypatch): + service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) + account = SimpleNamespace(id="account-1", current_tenant_id="tenant-1") + snippet = SimpleNamespace(id="snippet-new") + yaml_content = """ +version: 9.0.0 +kind: snippet +snippet: + name: From DSL + type: node +workflow: + graph: + nodes: [] + edges: [] +""" + pending = SnippetPendingData( + import_mode="yaml-content", + yaml_content=yaml_content, + name="Override name", + description="Override description", + snippet_id=None, + ) + create_or_update = Mock(return_value=snippet) + monkeypatch.setattr(service, "_create_or_update_snippet", create_or_update) + monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=pending.model_dump_json())) + redis_delete = Mock() + monkeypatch.setattr("services.snippet_dsl_service.redis_client.delete", redis_delete) + + result = service.confirm_import(import_id="import-1", account=account) + + assert result.status == ImportStatus.COMPLETED + assert result.snippet_id == "snippet-new" + assert result.imported_dsl_version == "9.0.0" + create_or_update.assert_called_once() + _, kwargs = create_or_update.call_args + assert kwargs["snippet"] is None + assert kwargs["account"] is account + assert kwargs["name"] == "Override name" + assert kwargs["description"] == "Override description" + redis_delete.assert_called_once_with("snippet_import_info:import-1") + + +def test_confirm_import_returns_failed_for_non_mapping_yaml(monkeypatch): + service = SnippetDslService(session=SimpleNamespace()) + pending = SnippetPendingData( + import_mode="yaml-content", + yaml_content="- item", + snippet_id=None, + ) + monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=pending.model_dump_json())) + + result = service.confirm_import(import_id="import-1", account=SimpleNamespace(current_tenant_id="tenant-1")) + + assert result.status == ImportStatus.FAILED + assert result.error == "Invalid YAML format: expected a dictionary" + + +def test_confirm_import_returns_failed_when_create_or_update_raises(monkeypatch): + service = SnippetDslService(session=SimpleNamespace(scalar=Mock(return_value=None))) + pending = SnippetPendingData( + import_mode="yaml-content", + yaml_content="version: 0.1.0\nkind: snippet\nsnippet:\n name: Bad\n", + snippet_id="snippet-1", + ) + monkeypatch.setattr("services.snippet_dsl_service.redis_client.get", Mock(return_value=pending.model_dump_json())) + monkeypatch.setattr(service, "_create_or_update_snippet", Mock(side_effect=RuntimeError("boom"))) + + result = service.confirm_import( + import_id="import-1", + account=SimpleNamespace(current_tenant_id="tenant-1"), + ) + + assert result.status == ImportStatus.FAILED + assert result.error == "boom" + + +def test_check_dependencies_returns_empty_without_draft_workflow(monkeypatch): + service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) + monkeypatch.setattr( + "services.snippet_dsl_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)), + ) + + result = service.check_dependencies(SimpleNamespace(id="snippet-1", tenant_id="tenant-1")) + + assert result.leaked_dependencies == [] + + +def test_check_dependencies_returns_generated_dependencies(monkeypatch): + service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) + workflow = SimpleNamespace(graph_dict={"nodes": []}) + leaked_dependencies = [ + { + "type": "marketplace", + "value": {"marketplace_plugin_unique_identifier": "langgenius/openai:0.0.1"}, + } + ] + monkeypatch.setattr( + "services.snippet_dsl_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)), + ) + monkeypatch.setattr(service, "_extract_dependencies_from_workflow", Mock(return_value=["langgenius/openai"])) + monkeypatch.setattr( + "services.snippet_dsl_service.DependenciesAnalysisService.generate_dependencies", + Mock(return_value=leaked_dependencies), + ) + + result = service.check_dependencies(SimpleNamespace(id="snippet-1", tenant_id="tenant-1")) + + assert result.leaked_dependencies[0].value.plugin_unique_identifier == "langgenius/openai:0.0.1" + + +def test_create_or_update_snippet_updates_existing_snippet_and_syncs_workflow(monkeypatch): + snippet = SimpleNamespace( + id="snippet-1", + name="Old", + description="Old", + type="node", + icon_info=None, + input_fields=None, + updated_by=None, + updated_at=None, + ) + session = SimpleNamespace(add=Mock(), flush=Mock(), commit=Mock(), get_bind=Mock()) + service = SnippetDslService(session=session) + draft_workflow = SimpleNamespace(unique_hash="hash-1") + snippet_service = SimpleNamespace( + get_draft_workflow=Mock(return_value=draft_workflow), + sync_draft_workflow=Mock(), + ) + monkeypatch.setattr("services.snippet_dsl_service.SnippetService", lambda *_args, **_kwargs: snippet_service) + + result = service._create_or_update_snippet( + snippet=snippet, + data={ + "snippet": { + "name": "New", + "description": "New description", + "type": "unknown-type", + "icon_info": {"icon": "x"}, + "input_fields": [{"variable": "query"}], + }, + "workflow": {"graph": {"nodes": [], "edges": []}}, + }, + account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + ) + + assert result is snippet + assert snippet.name == "New" + assert snippet.type == "node" + assert snippet.icon_info == {"icon": "x"} + snippet_service.sync_draft_workflow.assert_called_once() + session.commit.assert_called_once() + + +def test_create_or_update_snippet_creates_new_snippet_and_flushes(monkeypatch): + session = SimpleNamespace(add=Mock(), flush=Mock(), commit=Mock(), get_bind=Mock()) + service = SnippetDslService(session=session) + snippet_service = SimpleNamespace(get_draft_workflow=Mock(return_value=None), sync_draft_workflow=Mock()) + monkeypatch.setattr("services.snippet_dsl_service.SnippetService", lambda *_args, **_kwargs: snippet_service) + + result = service._create_or_update_snippet( + snippet=None, + data={ + "snippet": { + "name": "New Snippet", + "description": "Description", + "type": "group", + "input_fields": [{"variable": "query"}], + }, + "workflow": {"graph": {"nodes": [], "edges": []}}, + }, + account=SimpleNamespace(id="account-1", current_tenant_id="tenant-1"), + ) + + assert result.name == "New Snippet" + assert result.type == "group" + session.add.assert_called_once_with(result) + session.flush.assert_called_once() + snippet_service.sync_draft_workflow.assert_called_once() + session.commit.assert_called_once() + + +def test_export_snippet_dsl_raises_without_draft_workflow(monkeypatch): + service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) + monkeypatch.setattr( + "services.snippet_dsl_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)), + ) + + with pytest.raises(ValueError, match="Missing draft workflow"): + service.export_snippet_dsl(SimpleNamespace()) + + +def test_export_snippet_dsl_returns_yaml(monkeypatch): + service = SnippetDslService(session=SimpleNamespace(get_bind=Mock())) + workflow = SimpleNamespace( + to_dict=Mock(return_value={"graph": {"nodes": []}}), + graph_dict={"nodes": []}, + ) + snippet = SimpleNamespace( + tenant_id="tenant-1", + name="Exported", + description=None, + type="node", + icon_info=None, + input_fields_list=[{"variable": "query"}], + ) + monkeypatch.setattr( + "services.snippet_dsl_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)), + ) + monkeypatch.setattr( + "services.snippet_dsl_service.DependenciesAnalysisService.generate_dependencies", + Mock(return_value=[]), + ) + + result = service.export_snippet_dsl(snippet) + + assert "kind: snippet" in result + assert "name: Exported" in result + assert "input_fields:" in result + + +def test_append_workflow_export_data_filters_credentials_and_extracts_dependencies(monkeypatch): + service = SnippetDslService(session=SimpleNamespace()) + workflow_dict = { + "graph": { + "nodes": [ + {"data": {}}, + { + "data": { + "type": BuiltinNodeTypes.TOOL, + "credential_id": "secret", + "tool_configurations": {"provider_type": "builtin", "provider": "langgenius/google"}, + } + }, + { + "data": { + "type": BuiltinNodeTypes.AGENT, + "agent_parameters": { + "tools": { + "value": [ + { + "provider_type": "builtin", + "provider": "langgenius/openai", + "credential_id": "agent-secret", + } + ] + } + }, + } + }, + ] + }, + "environment_variables": [{"name": "SECRET"}], + "conversation_variables": [{"name": "memory"}], + } + workflow = SimpleNamespace( + to_dict=Mock(return_value=workflow_dict), + graph_dict=workflow_dict["graph"], + ) + monkeypatch.setattr( + "services.snippet_dsl_service.DependenciesAnalysisService.generate_dependencies", + Mock(return_value=[]), + ) + export_data = {} + + service._append_workflow_export_data( + export_data=export_data, + snippet=SimpleNamespace(tenant_id="tenant-1"), + workflow=workflow, + include_secret=False, + ) + + nodes = export_data["workflow"]["graph"]["nodes"] + assert export_data["workflow"]["environment_variables"] == [] + assert export_data["workflow"]["conversation_variables"] == [] + assert "credential_id" not in nodes[1]["data"] + assert "credential_id" not in nodes[2]["data"]["agent_parameters"]["tools"]["value"][0] + + +def test_append_workflow_export_data_rewrites_knowledge_dataset_ids(monkeypatch): + service = SnippetDslService(session=SimpleNamespace()) + workflow_dict = { + "graph": { + "nodes": [ + { + "data": { + "type": BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL, + "dataset_ids": ["dataset-1", "dataset-2"], + } + } + ] + }, + } + workflow = SimpleNamespace(to_dict=Mock(return_value=workflow_dict), graph_dict=workflow_dict["graph"]) + monkeypatch.setattr( + service, + "_encrypt_dataset_id", + Mock(side_effect=lambda dataset_id, tenant_id: f"{tenant_id}:{dataset_id}"), + ) + monkeypatch.setattr( + "services.snippet_dsl_service.DependenciesAnalysisService.generate_dependencies", + Mock(return_value=[]), + ) + export_data = {} + + service._append_workflow_export_data( + export_data=export_data, + snippet=SimpleNamespace(tenant_id="tenant-1"), + workflow=workflow, + include_secret=True, + ) + + assert export_data["workflow"]["graph"]["nodes"][0]["data"]["dataset_ids"] == [ + "tenant-1:dataset-1", + "tenant-1:dataset-2", + ] diff --git a/api/tests/unit_tests/services/test_snippet_generate_service.py b/api/tests/unit_tests/services/test_snippet_generate_service.py new file mode 100644 index 0000000000..098787a6d6 --- /dev/null +++ b/api/tests/unit_tests/services/test_snippet_generate_service.py @@ -0,0 +1,383 @@ +import json +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from core.workflow.snippet_start import SNIPPET_VIRTUAL_START_NODE_ID +from models.workflow import Workflow, WorkflowKind, WorkflowType +from services.snippet_generate_service import SnippetGenerateService + + +def _workflow(graph: dict) -> Workflow: + return Workflow( + id="workflow-1", + tenant_id="tenant-1", + app_id="snippet-1", + type=WorkflowType.WORKFLOW, + kind=WorkflowKind.SNIPPET, + version=Workflow.VERSION_DRAFT, + graph=json.dumps(graph), + features="{}", + created_by="account-1", + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + + +def test_filter_virtual_start_events_keeps_blocking_response_unchanged(): + response = {"data": {"outputs": {"text": "ok"}}} + + assert SnippetGenerateService._filter_virtual_start_events(response) is response + + +def test_filter_virtual_start_events_removes_virtual_start_node_events(): + stream = iter( + [ + {"event": "node_started", "data": {"node_id": SNIPPET_VIRTUAL_START_NODE_ID}}, + {"event": "node_finished", "data": {"node_id": "llm-1"}}, + "raw-event", + ] + ) + + filtered = SnippetGenerateService._filter_virtual_start_events(stream) + + assert list(filtered) == [{"event": "node_finished", "data": {"node_id": "llm-1"}}, "raw-event"] + + +@pytest.mark.parametrize( + ("message", "expected"), + [ + ("raw-event", False), + ({"event": "message", "data": {"node_id": SNIPPET_VIRTUAL_START_NODE_ID}}, False), + ({"event": "node_started", "data": "not-a-dict"}, False), + ({"event": "node_started", "data": {"node_id": SNIPPET_VIRTUAL_START_NODE_ID}}, True), + ], +) +def test_is_virtual_start_event(message, expected): + assert SnippetGenerateService._is_virtual_start_event(message) is expected + + +def test_ensure_start_node_returns_workflow_when_start_already_exists(): + workflow = _workflow({"nodes": [{"id": "start", "data": {"type": "start"}}], "edges": []}) + snippet = SimpleNamespace(input_fields_list=[]) + + result = SnippetGenerateService._ensure_start_node(workflow, snippet) + + assert result is workflow + + +def test_ensure_start_node_injects_virtual_start_for_root_candidates(monkeypatch): + graph = { + "nodes": [ + {"id": "llm-1", "data": {"type": "llm"}}, + {"id": "answer-1", "data": {"type": "answer"}}, + ], + "edges": [{"source": "llm-1", "target": "answer-1"}], + } + workflow = _workflow(graph) + snippet = SimpleNamespace( + input_fields_list=[ + { + "variable": "query", + "label": "Query", + "type": "text-input", + "required": True, + "max_length": 128, + } + ] + ) + make_transient = Mock() + monkeypatch.setattr("services.snippet_generate_service.make_transient", make_transient) + + result = SnippetGenerateService._ensure_start_node(workflow, snippet) + + assert result is workflow + updated_graph = workflow.graph_dict + assert updated_graph["nodes"][0]["id"] == SNIPPET_VIRTUAL_START_NODE_ID + assert updated_graph["nodes"][0]["data"]["variables"][0]["max_length"] == 128 + assert updated_graph["edges"][-1]["source"] == SNIPPET_VIRTUAL_START_NODE_ID + assert updated_graph["edges"][-1]["target"] == "llm-1" + make_transient.assert_called_once_with(workflow) + + +def test_parse_files_returns_empty_when_upload_config_disabled(monkeypatch): + workflow = _workflow({"nodes": [], "edges": []}) + monkeypatch.setattr("services.snippet_generate_service.FileUploadConfigManager.convert", Mock(return_value=None)) + + assert SnippetGenerateService.parse_files(workflow, files=[{"id": "file-1"}]) == [] + + +def test_parse_files_delegates_to_file_factory(monkeypatch): + workflow = _workflow({"nodes": [], "edges": []}) + upload_config = SimpleNamespace(enabled=True) + files = [SimpleNamespace(id="file-1")] + monkeypatch.setattr( + "services.snippet_generate_service.FileUploadConfigManager.convert", Mock(return_value=upload_config) + ) + build_from_mappings = Mock(return_value=files) + monkeypatch.setattr("services.snippet_generate_service.file_factory.build_from_mappings", build_from_mappings) + + result = SnippetGenerateService.parse_files(workflow, files=[{"id": "file-1"}]) + + assert result == files + build_from_mappings.assert_called_once() + + +def test_generate_raises_when_draft_workflow_missing(monkeypatch): + monkeypatch.setattr( + "services.snippet_generate_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)), + ) + + with pytest.raises(ValueError, match="Workflow not initialized"): + SnippetGenerateService.generate( + snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), + user=SimpleNamespace(id="user-1"), + args={"inputs": {}}, + invoke_from="debugger", + ) + + +def test_generate_delegates_to_workflow_generator_and_filters_stream(monkeypatch): + workflow = _workflow({"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []}) + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1", input_fields_list=[]) + user = SimpleNamespace(id="user-1") + raw_stream = iter( + [ + {"event": "node_started", "data": {"node_id": SNIPPET_VIRTUAL_START_NODE_ID}}, + {"event": "node_finished", "data": {"node_id": "llm-1"}}, + ] + ) + generator = SimpleNamespace(generate=Mock(return_value=raw_stream)) + workflow_generator_class = Mock(return_value=generator) + workflow_generator_class.convert_to_event_stream = Mock(side_effect=lambda response: response) + + monkeypatch.setattr( + "services.snippet_generate_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)), + ) + ensure_start_node = Mock(return_value=workflow) + monkeypatch.setattr(SnippetGenerateService, "_ensure_start_node", ensure_start_node) + monkeypatch.setattr("services.snippet_generate_service.WorkflowAppGenerator", workflow_generator_class) + + result = SnippetGenerateService.generate( + snippet=snippet, + user=user, + args={"inputs": {"query": "hello"}}, + invoke_from="debugger", + ) + + assert list(result) == [{"event": "node_finished", "data": {"node_id": "llm-1"}}] + ensure_start_node.assert_called_once_with(workflow, snippet) + generator.generate.assert_called_once() + kwargs = generator.generate.call_args.kwargs + assert kwargs["app_model"].id == "snippet-1" + assert kwargs["workflow"] is workflow + assert kwargs["user"] is user + assert kwargs["streaming"] is True + assert kwargs["call_depth"] == 0 + workflow_generator_class.convert_to_event_stream.assert_called_once() + + +def test_run_published_delegates_to_workflow_generator_non_streaming(monkeypatch): + workflow = _workflow({"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []}) + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1", input_fields_list=[]) + user = SimpleNamespace(id="user-1") + generator = SimpleNamespace(generate=Mock(return_value={"data": {"outputs": {"answer": "ok"}}})) + + monkeypatch.setattr( + "services.snippet_generate_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_published_workflow=Mock(return_value=workflow)), + ) + ensure_start_node = Mock(return_value=workflow) + monkeypatch.setattr(SnippetGenerateService, "_ensure_start_node", ensure_start_node) + monkeypatch.setattr("services.snippet_generate_service.WorkflowAppGenerator", Mock(return_value=generator)) + + result = SnippetGenerateService.run_published( + snippet=snippet, + user=user, + args={"inputs": {"query": "hello"}}, + invoke_from="service-api", + ) + + assert result == {"data": {"outputs": {"answer": "ok"}}} + ensure_start_node.assert_called_once_with(workflow, snippet) + generator.generate.assert_called_once() + kwargs = generator.generate.call_args.kwargs + assert kwargs["app_model"].id == "snippet-1" + assert kwargs["streaming"] is False + assert kwargs["call_depth"] == 0 + + +def test_ensure_start_node_for_worker_delegates(monkeypatch): + workflow = _workflow({"nodes": [], "edges": []}) + snippet = SimpleNamespace(input_fields_list=[]) + ensure_start_node = Mock(return_value=workflow) + monkeypatch.setattr(SnippetGenerateService, "_ensure_start_node", ensure_start_node) + + result = SnippetGenerateService.ensure_start_node_for_worker(workflow, snippet) + + assert result is workflow + ensure_start_node.assert_called_once_with(workflow, snippet) + + +def test_run_draft_node_delegates_to_workflow_service(monkeypatch): + workflow = _workflow({"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []}) + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + account = SimpleNamespace(id="account-1") + execution = SimpleNamespace(id="execution-1") + workflow_service = SimpleNamespace(run_draft_workflow_node=Mock(return_value=execution)) + + monkeypatch.setattr( + "services.snippet_generate_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)), + ) + monkeypatch.setattr("services.snippet_generate_service.WorkflowService", Mock(return_value=workflow_service)) + + result = SnippetGenerateService.run_draft_node( + snippet=snippet, + node_id="llm-1", + user_inputs={"query": "hello"}, + account=account, + query="question", + files=[], + ) + + assert result is execution + workflow_service.run_draft_workflow_node.assert_called_once() + kwargs = workflow_service.run_draft_workflow_node.call_args.kwargs + assert kwargs["app_model"].id == "snippet-1" + assert kwargs["draft_workflow"] is workflow + assert kwargs["node_id"] == "llm-1" + assert kwargs["user_inputs"] == {"query": "hello"} + assert kwargs["account"] is account + assert kwargs["query"] == "question" + assert kwargs["files"] == [] + + +def test_run_draft_node_raises_when_draft_workflow_missing(monkeypatch): + monkeypatch.setattr( + "services.snippet_generate_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)), + ) + + with pytest.raises(ValueError, match="Workflow not initialized"): + SnippetGenerateService.run_draft_node( + snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), + node_id="llm-1", + user_inputs={}, + account=SimpleNamespace(id="account-1"), + ) + + +def test_generate_single_iteration_delegates_to_workflow_generator(monkeypatch): + workflow = _workflow({"nodes": [{"id": "iteration-1", "data": {"type": "iteration"}}], "edges": []}) + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + user = SimpleNamespace(id="user-1") + response = iter(["event"]) + generator = SimpleNamespace(single_iteration_generate=Mock(return_value=response)) + workflow_generator_class = Mock(return_value=generator) + workflow_generator_class.convert_to_event_stream = Mock(side_effect=lambda item: item) + + monkeypatch.setattr( + "services.snippet_generate_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)), + ) + monkeypatch.setattr("services.snippet_generate_service.WorkflowAppGenerator", workflow_generator_class) + + result = SnippetGenerateService.generate_single_iteration( + snippet=snippet, + user=user, + node_id="iteration-1", + args={"inputs": {"items": [1]}}, + ) + + assert list(result) == ["event"] + generator.single_iteration_generate.assert_called_once() + kwargs = generator.single_iteration_generate.call_args.kwargs + assert kwargs["app_model"].id == "snippet-1" + assert kwargs["workflow"] is workflow + assert kwargs["node_id"] == "iteration-1" + assert kwargs["user"] is user + assert kwargs["streaming"] is True + workflow_generator_class.convert_to_event_stream.assert_called_once_with(response) + + +def test_generate_single_iteration_raises_when_draft_workflow_missing(monkeypatch): + monkeypatch.setattr( + "services.snippet_generate_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)), + ) + + with pytest.raises(ValueError, match="Workflow not initialized"): + SnippetGenerateService.generate_single_iteration( + snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), + user=SimpleNamespace(id="user-1"), + node_id="iteration-1", + args={"inputs": {}}, + ) + + +def test_generate_single_loop_delegates_to_workflow_generator(monkeypatch): + workflow = _workflow({"nodes": [{"id": "loop-1", "data": {"type": "loop"}}], "edges": []}) + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + user = SimpleNamespace(id="user-1") + response = iter(["event"]) + generator = SimpleNamespace(single_loop_generate=Mock(return_value=response)) + workflow_generator_class = Mock(return_value=generator) + workflow_generator_class.convert_to_event_stream = Mock(side_effect=lambda item: item) + + monkeypatch.setattr( + "services.snippet_generate_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=workflow)), + ) + monkeypatch.setattr("services.snippet_generate_service.WorkflowAppGenerator", workflow_generator_class) + + result = SnippetGenerateService.generate_single_loop( + snippet=snippet, + user=user, + node_id="loop-1", + args=SimpleNamespace(inputs={"items": [1]}), + ) + + assert list(result) == ["event"] + generator.single_loop_generate.assert_called_once() + kwargs = generator.single_loop_generate.call_args.kwargs + assert kwargs["app_model"].id == "snippet-1" + assert kwargs["workflow"] is workflow + assert kwargs["node_id"] == "loop-1" + assert kwargs["user"] is user + assert kwargs["streaming"] is True + workflow_generator_class.convert_to_event_stream.assert_called_once_with(response) + + +def test_generate_single_loop_raises_when_draft_workflow_missing(monkeypatch): + monkeypatch.setattr( + "services.snippet_generate_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_draft_workflow=Mock(return_value=None)), + ) + + with pytest.raises(ValueError, match="Workflow not initialized"): + SnippetGenerateService.generate_single_loop( + snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), + user=SimpleNamespace(id="user-1"), + node_id="loop-1", + args=SimpleNamespace(inputs={}), + ) + + +def test_run_published_raises_when_published_workflow_missing(monkeypatch): + monkeypatch.setattr( + "services.snippet_generate_service.SnippetService", + lambda *_args, **_kwargs: SimpleNamespace(get_published_workflow=Mock(return_value=None)), + ) + + with pytest.raises(ValueError, match="No published workflow found"): + SnippetGenerateService.run_published( + snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), + user=SimpleNamespace(id="user-1"), + args={"inputs": {}}, + invoke_from="service-api", + ) diff --git a/api/tests/unit_tests/services/test_snippet_service.py b/api/tests/unit_tests/services/test_snippet_service.py new file mode 100644 index 0000000000..31146790d3 --- /dev/null +++ b/api/tests/unit_tests/services/test_snippet_service.py @@ -0,0 +1,636 @@ +from __future__ import annotations + +import json +from types import SimpleNamespace +from unittest.mock import Mock + +import pytest + +from models.snippet import SnippetType +from models.workflow import Workflow, WorkflowKind, WorkflowType +from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError, WorkflowNotFoundError +from services.snippet_service import SnippetService + + +class _SessionWithoutNameLookup: + def __init__(self) -> None: + self.add = Mock() + self.commit = Mock() + + def query(self, *args, **kwargs): + raise AssertionError("snippet name uniqueness lookup should not be used") + + +class _SessionContext: + def __init__(self, session) -> None: + self._session = session + + def __enter__(self): + return self._session + + def __exit__(self, *args) -> None: + return None + + +def _session_maker(session): + return lambda: _SessionContext(session) + + +def _create_workflow(*, workflow_id: str, version: str, graph: dict, features: dict) -> Workflow: + return Workflow( + id=workflow_id, + tenant_id="tenant-1", + app_id="snippet-1", + type=WorkflowType.WORKFLOW.value, + kind=WorkflowKind.SNIPPET.value, + version=version, + graph=json.dumps(graph), + features=json.dumps(features), + created_by="account-1", + environment_variables=[], + conversation_variables=[], + rag_pipeline_variables=[], + ) + + +def test_create_snippet_allows_duplicate_names(monkeypatch: pytest.MonkeyPatch) -> None: + session = _SessionWithoutNameLookup() + account = SimpleNamespace(id="account-1") + + service = SnippetService.__new__(SnippetService) + service._session_maker = _session_maker(session) + + snippet = service.create_snippet( + tenant_id="tenant-1", + name="shared name", + description=None, + snippet_type=SnippetType.NODE, + icon_info=None, + input_fields=None, + account=account, + ) + + assert snippet.name == "shared name" + session.add.assert_called_once_with(snippet) + session.commit.assert_called_once() + + +def test_validate_snippet_graph_forbidden_nodes_ignores_malformed_nodes() -> None: + SnippetService.validate_snippet_graph_forbidden_nodes( + { + "nodes": [ + "not-a-node", + {"id": "empty-data", "data": {}}, + {"id": "bad-type", "data": {"type": 123}}, + {"id": "llm-1", "data": {"type": "llm"}}, + ] + } + ) + + +def test_validate_snippet_graph_forbidden_nodes_raises_with_node_details() -> None: + with pytest.raises(ValueError, match="start-1:start"): + SnippetService.validate_snippet_graph_forbidden_nodes({"nodes": [{"id": "start-1", "data": {"type": "start"}}]}) + + +def test_get_snippets_returns_empty_when_tag_filter_has_no_targets(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("services.snippet_service.TagService.get_target_ids_by_tag_ids", Mock(return_value=[])) + service = SnippetService.__new__(SnippetService) + + result = service.get_snippets(tenant_id="tenant-1", tag_ids=["tag-1"]) + + assert result == ([], 0, False) + + +def test_get_snippets_applies_filters_and_paginates(monkeypatch: pytest.MonkeyPatch) -> None: + snippets = [ + SimpleNamespace(id="snippet-1"), + SimpleNamespace(id="snippet-2"), + SimpleNamespace(id="snippet-3"), + ] + session = SimpleNamespace( + scalar=Mock(return_value=3), + scalars=Mock(return_value=SimpleNamespace(all=Mock(return_value=snippets))), + ) + service = SnippetService.__new__(SnippetService) + service._session_maker = _session_maker(session) + monkeypatch.setattr( + "services.snippet_service.TagService.get_target_ids_by_tag_ids", + Mock(return_value=["snippet-1", "snippet-2", "snippet-3"]), + ) + + result, total, has_more = service.get_snippets( + tenant_id="tenant-1", + page=2, + limit=2, + keyword="search", + is_published=True, + creators=["account-1"], + tag_ids=["tag-1"], + ) + + assert result == snippets[:2] + assert total == 3 + assert has_more is True + session.scalar.assert_called_once() + session.scalars.assert_called_once() + + +def test_update_snippet_allows_duplicate_names() -> None: + session = _SessionWithoutNameLookup() + snippet = SimpleNamespace( + id="snippet-1", + tenant_id="tenant-1", + name="old name", + description="", + icon_info=None, + ) + + result = SnippetService.update_snippet( + session=session, + snippet=snippet, + account_id="account-1", + data={"name": "shared name"}, + ) + + assert result is snippet + assert snippet.name == "shared name" + session.add.assert_called_once_with(snippet) + + +def test_update_snippet_updates_optional_fields() -> None: + session = _SessionWithoutNameLookup() + snippet = SimpleNamespace( + id="snippet-1", + tenant_id="tenant-1", + name="old name", + description="old description", + icon_info=None, + ) + + result = SnippetService.update_snippet( + session=session, + snippet=snippet, + account_id="account-1", + data={"description": "new description", "icon_info": {"icon": "star"}}, + ) + + assert result is snippet + assert snippet.description == "new description" + assert snippet.icon_info == {"icon": "star"} + assert snippet.updated_by == "account-1" + session.add.assert_called_once_with(snippet) + + +def test_sync_draft_workflow_creates_draft_and_updates_input_fields(monkeypatch: pytest.MonkeyPatch) -> None: + service = SnippetService.__new__(SnippetService) + monkeypatch.setattr(service, "get_draft_workflow", Mock(return_value=None)) + session = SimpleNamespace(add=Mock(), commit=Mock()) + service._session_maker = _session_maker(session) + snippet = SimpleNamespace( + id="snippet-1", + tenant_id="tenant-1", + input_fields=None, + updated_by=None, + updated_at=None, + ) + account = SimpleNamespace(id="account-1") + + workflow = service.sync_draft_workflow( + snippet=snippet, + graph={"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []}, + unique_hash=None, + account=account, + input_fields=[{"variable": "query"}], + ) + + assert workflow.app_id == snippet.id + assert workflow.kind == WorkflowKind.SNIPPET + assert json.loads(snippet.input_fields) == [{"variable": "query"}] + session.add.assert_any_call(workflow) + session.add.assert_any_call(snippet) + session.commit.assert_called_once() + + +def test_sync_draft_workflow_raises_when_hash_mismatches() -> None: + service = SnippetService.__new__(SnippetService) + service._session_maker = _session_maker(SimpleNamespace(commit=Mock(), add=Mock())) + service.get_draft_workflow = Mock(return_value=SimpleNamespace(unique_hash="server-hash")) + + with pytest.raises(WorkflowHashNotEqualError): + service.sync_draft_workflow( + snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), + graph={"nodes": [], "edges": []}, + unique_hash="client-hash", + account=SimpleNamespace(id="account-1"), + ) + + +def test_sync_draft_workflow_updates_existing_draft_and_clears_variables(monkeypatch: pytest.MonkeyPatch) -> None: + service = SnippetService.__new__(SnippetService) + workflow = _create_workflow( + workflow_id="workflow-1", + version=Workflow.VERSION_DRAFT, + graph={"nodes": [], "edges": []}, + features={}, + ) + unique_hash = workflow.unique_hash + snippet = SimpleNamespace( + id="snippet-1", + tenant_id="tenant-1", + input_fields=None, + updated_by=None, + updated_at=None, + ) + account = SimpleNamespace(id="account-1") + session = SimpleNamespace(add=Mock(), commit=Mock()) + + monkeypatch.setattr(service, "get_draft_workflow", Mock(return_value=workflow)) + service._session_maker = _session_maker(session) + + result = service.sync_draft_workflow( + snippet=snippet, + graph={"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []}, + unique_hash=unique_hash, + account=account, + input_fields=[{"variable": "query"}], + ) + + assert result is workflow + assert workflow.graph_dict["nodes"][0]["id"] == "llm-1" + assert workflow.type == WorkflowType.WORKFLOW + assert workflow.kind == WorkflowKind.SNIPPET + assert workflow.updated_by == account.id + assert workflow.environment_variables == [] + assert workflow.conversation_variables == [] + assert json.loads(snippet.input_fields) == [{"variable": "query"}] + session.commit.assert_called_once() + + +def test_get_default_block_configs_skips_empty_defaults(monkeypatch: pytest.MonkeyPatch) -> None: + node_with_default = SimpleNamespace(get_default_config=Mock(return_value={"type": "llm"})) + node_without_default = SimpleNamespace(get_default_config=Mock(return_value=None)) + monkeypatch.setattr( + "services.snippet_service.NODE_TYPE_CLASSES_MAPPING", + { + "llm": {"1": node_with_default}, + "empty": {"1": node_without_default}, + }, + ) + monkeypatch.setattr("services.snippet_service.LATEST_VERSION", "1") + service = SnippetService.__new__(SnippetService) + + assert service.get_default_block_configs() == [{"type": "llm"}] + + +def test_get_default_block_config_returns_none_for_unknown_node(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr("services.snippet_service.NODE_TYPE_CLASSES_MAPPING", {}) + service = SnippetService.__new__(SnippetService) + + assert service.get_default_block_config("missing") is None + + +def test_get_default_block_config_returns_node_default(monkeypatch: pytest.MonkeyPatch) -> None: + node_class = SimpleNamespace(get_default_config=Mock(return_value={"type": "llm"})) + monkeypatch.setattr("services.snippet_service.NODE_TYPE_CLASSES_MAPPING", {"llm": {"1": node_class}}) + monkeypatch.setattr("services.snippet_service.LATEST_VERSION", "1") + service = SnippetService.__new__(SnippetService) + + assert service.get_default_block_config("llm", filters={"k": "v"}) == {"type": "llm"} + node_class.get_default_config.assert_called_once_with(filters={"k": "v"}) + + +def test_get_default_block_config_returns_none_for_empty_default(monkeypatch: pytest.MonkeyPatch) -> None: + node_class = SimpleNamespace(get_default_config=Mock(return_value=None)) + monkeypatch.setattr("services.snippet_service.NODE_TYPE_CLASSES_MAPPING", {"llm": {"1": node_class}}) + monkeypatch.setattr("services.snippet_service.LATEST_VERSION", "1") + service = SnippetService.__new__(SnippetService) + + assert service.get_default_block_config("llm") is None + + +def test_restore_published_snippet_workflow_to_draft_copies_source_snapshot( + monkeypatch: pytest.MonkeyPatch, +) -> None: + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + account = SimpleNamespace(id="account-2") + source_graph = {"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []} + source_features = {"opening_statement": "hello"} + source_workflow = _create_workflow( + workflow_id="published-workflow", + version="2026-04-28 00:00:00", + graph=source_graph, + features=source_features, + ) + draft_workflow = _create_workflow( + workflow_id="draft-workflow", + version=Workflow.VERSION_DRAFT, + graph={"nodes": [], "edges": []}, + features={}, + ) + service = SnippetService.__new__(SnippetService) + session = SimpleNamespace(add=Mock(), commit=Mock()) + service._session_maker = _session_maker(session) + + monkeypatch.setattr(service, "get_published_workflow_by_id", Mock(return_value=source_workflow)) + monkeypatch.setattr(service, "get_draft_workflow", Mock(return_value=draft_workflow)) + + result = service.restore_published_workflow_to_draft( + snippet=snippet, + workflow_id=source_workflow.id, + account=account, + ) + + assert result is draft_workflow + assert draft_workflow.graph_dict == source_graph + assert draft_workflow.features_dict == source_features + assert draft_workflow.updated_by == account.id + session.add.assert_called_once_with(draft_workflow) + session.commit.assert_called_once() + + +def test_restore_published_snippet_workflow_to_draft_raises_when_source_missing( + monkeypatch: pytest.MonkeyPatch, +) -> None: + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + account = SimpleNamespace(id="account-2") + service = SnippetService.__new__(SnippetService) + service._session_maker = _session_maker(SimpleNamespace(add=Mock(), commit=Mock())) + + monkeypatch.setattr(service, "get_published_workflow_by_id", Mock(return_value=None)) + + with pytest.raises(WorkflowNotFoundError): + service.restore_published_workflow_to_draft( + snippet=snippet, + workflow_id="missing-workflow", + account=account, + ) + + +def test_restore_published_snippet_workflow_to_draft_adds_new_draft(monkeypatch: pytest.MonkeyPatch) -> None: + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + account = SimpleNamespace(id="account-2") + source_workflow = _create_workflow( + workflow_id="published-workflow", + version="2026-04-28 00:00:00", + graph={"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []}, + features={}, + ) + new_draft_workflow = _create_workflow( + workflow_id="draft-workflow", + version=Workflow.VERSION_DRAFT, + graph={"nodes": [], "edges": []}, + features={}, + ) + service = SnippetService.__new__(SnippetService) + session = SimpleNamespace(add=Mock(), commit=Mock()) + service._session_maker = _session_maker(session) + + monkeypatch.setattr(service, "get_published_workflow_by_id", Mock(return_value=source_workflow)) + monkeypatch.setattr(service, "get_draft_workflow", Mock(return_value=None)) + monkeypatch.setattr( + "services.snippet_service.apply_published_workflow_snapshot_to_draft", + Mock(return_value=(new_draft_workflow, True)), + ) + + result = service.restore_published_workflow_to_draft( + snippet=snippet, + workflow_id=source_workflow.id, + account=account, + ) + + assert result is new_draft_workflow + session.add.assert_called_once_with(new_draft_workflow) + session.commit.assert_called_once() + + +def test_get_published_workflow_returns_none_without_workflow_id() -> None: + service = SnippetService.__new__(SnippetService) + + result = service.get_published_workflow(SimpleNamespace(id="snippet-1", tenant_id="tenant-1", workflow_id=None)) + + assert result is None + + +def test_get_published_workflow_by_id_raises_for_draft(monkeypatch: pytest.MonkeyPatch) -> None: + draft_workflow = SimpleNamespace(version=Workflow.VERSION_DRAFT) + session = SimpleNamespace(scalar=Mock(return_value=draft_workflow)) + service = SnippetService.__new__(SnippetService) + service._session_maker = _session_maker(session) + + with pytest.raises(IsDraftWorkflowError): + service.get_published_workflow_by_id( + snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), + workflow_id="workflow-1", + ) + + +def test_publish_workflow_raises_when_draft_missing() -> None: + service = SnippetService.__new__(SnippetService) + session = SimpleNamespace(scalar=Mock(return_value=None)) + + with pytest.raises(ValueError, match="No valid workflow found"): + service.publish_workflow( + session=session, + snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), + account=SimpleNamespace(id="account-1"), + ) + + +def test_publish_workflow_creates_snapshot_and_updates_snippet(monkeypatch: pytest.MonkeyPatch) -> None: + service = SnippetService.__new__(SnippetService) + draft_workflow = _create_workflow( + workflow_id="draft-workflow", + version=Workflow.VERSION_DRAFT, + graph={"nodes": [{"id": "llm-1", "data": {"type": "llm"}}], "edges": []}, + features={"opening_statement": "hello"}, + ) + snippet = SimpleNamespace( + id="snippet-1", + tenant_id="tenant-1", + version=1, + is_published=False, + workflow_id=None, + updated_by=None, + ) + session = SimpleNamespace(scalar=Mock(return_value=draft_workflow), add=Mock()) + + result = service.publish_workflow( + session=session, + snippet=snippet, + account=SimpleNamespace(id="account-1"), + ) + + assert result.kind == WorkflowKind.SNIPPET + assert snippet.version == 2 + assert snippet.is_published is True + assert snippet.workflow_id == result.id + assert snippet.updated_by == "account-1" + assert session.add.call_args_list[-1].args == (snippet,) + + +def test_get_all_published_workflows_returns_empty_without_current_workflow() -> None: + service = SnippetService.__new__(SnippetService) + + result = service.get_all_published_workflows( + session=SimpleNamespace(), + snippet=SimpleNamespace(id="snippet-1", workflow_id=None), + page=1, + limit=20, + ) + + assert result == ([], False) + + +def test_get_all_published_workflows_paginates() -> None: + service = SnippetService.__new__(SnippetService) + workflows = [SimpleNamespace(id="workflow-1"), SimpleNamespace(id="workflow-2"), SimpleNamespace(id="workflow-3")] + session = SimpleNamespace(scalars=Mock(return_value=SimpleNamespace(all=Mock(return_value=workflows)))) + + result, has_more = service.get_all_published_workflows( + session=session, + snippet=SimpleNamespace(id="snippet-1", workflow_id="workflow-current"), + page=1, + limit=2, + ) + + assert result == workflows[:2] + assert has_more is True + session.scalars.assert_called_once() + + +def test_delete_snippet_removes_related_records() -> None: + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + session = SimpleNamespace( + execute=Mock(), + scalars=Mock(return_value=SimpleNamespace(all=Mock(return_value=[]))), + delete=Mock(), + ) + + result = SnippetService.delete_snippet(session=session, snippet=snippet) + + assert result is True + executed_sql = "\n".join(str(call.args[0]) for call in session.execute.call_args_list) + assert "workflow_draft_variables" in executed_sql + assert "tool_workflow_providers" in executed_sql + assert "workflow_app_logs" in executed_sql + assert "workflow_archive_logs" in executed_sql + assert "workflow_node_executions" in executed_sql + assert "workflow_runs" in executed_sql + assert "workflows" in executed_sql + assert "kind" in executed_sql + assert "tag_bindings" in executed_sql + session.delete.assert_called_once_with(snippet) + + +def test_delete_draft_variable_files_removes_storage_objects(monkeypatch) -> None: + from extensions.ext_storage import storage + + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + storage_delete = Mock() + monkeypatch.setattr(storage, "delete", storage_delete) + session = SimpleNamespace( + scalars=Mock(return_value=SimpleNamespace(all=Mock(return_value=["file-1"]))), + execute=Mock( + side_effect=[ + SimpleNamespace(all=Mock(return_value=[("file-1", "upload-1", "storage-key")])), + None, + None, + ] + ), + ) + + SnippetService._delete_draft_variable_files(session=session, snippet=snippet) + + storage_delete.assert_called_once_with("storage-key") + executed_sql = "\n".join(str(call.args[0]) for call in session.execute.call_args_list) + assert "upload_files" in executed_sql + assert "workflow_draft_variable_files" in executed_sql + + +def test_delete_archived_workflow_run_files_removes_prefixed_objects(monkeypatch) -> None: + from configs import dify_config + + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + archive_storage = SimpleNamespace( + list_objects=Mock(return_value=["tenant-1/app_id=snippet-1/run.json"]), + delete_object=Mock(), + ) + monkeypatch.setattr(dify_config, "BILLING_ENABLED", True) + monkeypatch.setattr(dify_config, "ARCHIVE_STORAGE_ENABLED", True) + monkeypatch.setattr("libs.archive_storage.get_archive_storage", Mock(return_value=archive_storage)) + + SnippetService._delete_archived_workflow_run_files(snippet=snippet) + + archive_storage.list_objects.assert_called_once_with("tenant-1/app_id=snippet-1/") + archive_storage.delete_object.assert_called_once_with("tenant-1/app_id=snippet-1/run.json") + + +def test_workflow_run_queries_delegate_to_repositories() -> None: + service = SnippetService.__new__(SnippetService) + workflow_run_repo = SimpleNamespace( + get_paginated_workflow_runs=Mock(return_value=SimpleNamespace(data=[])), + get_workflow_run_by_id=Mock(return_value=SimpleNamespace(id="run-1")), + ) + node_execution_repo = SimpleNamespace( + get_executions_by_workflow_run=Mock(return_value=[SimpleNamespace(id="node-execution-1")]), + get_node_last_execution=Mock(return_value=SimpleNamespace(id="last-run-1")), + ) + service._workflow_run_repo = workflow_run_repo + service._node_execution_service_repo = node_execution_repo + snippet = SimpleNamespace(id="snippet-1", tenant_id="tenant-1") + + assert service.get_snippet_workflow_runs(snippet=snippet, args={"limit": "5", "last_id": "run-0"}).data == [] + assert service.get_snippet_workflow_run(snippet=snippet, run_id="run-1").id == "run-1" + assert service.get_snippet_workflow_run_node_executions(snippet=snippet, run_id="run-1")[0].id == ( + "node-execution-1" + ) + assert ( + service.get_snippet_node_last_run( + snippet=snippet, + workflow=SimpleNamespace(id="workflow-1"), + node_id="llm-1", + ).id + == "last-run-1" + ) + workflow_run_repo.get_paginated_workflow_runs.assert_called_once() + workflow_run_repo.get_workflow_run_by_id.assert_called_with( + tenant_id="tenant-1", + app_id="snippet-1", + run_id="run-1", + ) + node_execution_repo.get_executions_by_workflow_run.assert_called_once_with( + tenant_id="tenant-1", + app_id="snippet-1", + workflow_run_id="run-1", + ) + node_execution_repo.get_node_last_execution.assert_called_once_with( + tenant_id="tenant-1", + app_id="snippet-1", + workflow_id="workflow-1", + node_id="llm-1", + ) + + +def test_workflow_run_node_executions_returns_empty_when_run_missing() -> None: + service = SnippetService.__new__(SnippetService) + service._node_execution_service_repo = SimpleNamespace(get_executions_by_workflow_run=Mock()) + service.get_snippet_workflow_run = Mock(return_value=None) + + result = service.get_snippet_workflow_run_node_executions( + snippet=SimpleNamespace(id="snippet-1", tenant_id="tenant-1"), + run_id="missing-run", + ) + + assert result == [] + service._node_execution_service_repo.get_executions_by_workflow_run.assert_not_called() + + +def test_increment_use_count_adds_updated_snippet() -> None: + snippet = SimpleNamespace(use_count=2) + session = SimpleNamespace(add=Mock()) + + SnippetService.increment_use_count(session=session, snippet=snippet) + + assert snippet.use_count == 3 + session.add.assert_called_once_with(snippet) diff --git a/api/tests/unit_tests/services/test_tag_service.py b/api/tests/unit_tests/services/test_tag_service.py new file mode 100644 index 0000000000..282b32a7e5 --- /dev/null +++ b/api/tests/unit_tests/services/test_tag_service.py @@ -0,0 +1,103 @@ +from types import SimpleNamespace + +import pytest +from werkzeug.exceptions import NotFound + +from models.enums import TagType +from services.tag_service import TagBindingCreatePayload, TagBindingDeletePayload, TagService + + +@pytest.fixture +def current_user(mocker): + user = SimpleNamespace(id="user-1", current_tenant_id="tenant-1") + mocker.patch("services.tag_service.current_user", user) + return user + + +@pytest.fixture +def db_session(mocker): + mock_db = mocker.patch("services.tag_service.db") + return mock_db.session + + +def test_save_tag_binding_only_creates_bindings_for_valid_snippet_tags(mocker, current_user, db_session): + mocker.patch("services.tag_service.TagService.check_target_exists") + db_session.scalars.return_value.all.return_value = ["tag-1"] + db_session.scalar.return_value = None + + TagService.save_tag_binding( + TagBindingCreatePayload( + tag_ids=["tag-1", "tag-from-other-tenant"], + target_id="snippet-1", + type=TagType.SNIPPET, + ) + ) + + db_session.add.assert_called_once() + tag_binding = db_session.add.call_args.args[0] + assert tag_binding.tag_id == "tag-1" + assert tag_binding.target_id == "snippet-1" + assert tag_binding.tenant_id == current_user.current_tenant_id + assert tag_binding.created_by == current_user.id + db_session.commit.assert_called_once() + + +def test_delete_tag_binding_limits_deletion_to_valid_snippet_tags(mocker, current_user, db_session): + mocker.patch("services.tag_service.TagService.check_target_exists") + db_session.execute.return_value = SimpleNamespace(rowcount=1) + + TagService.delete_tag_binding( + TagBindingDeletePayload( + tag_ids=["tag-1", "tag-from-other-tenant"], + target_id="snippet-1", + type=TagType.SNIPPET, + ) + ) + + db_session.execute.assert_called_once() + db_session.commit.assert_called_once() + + +def test_delete_tag_binding_does_not_commit_when_no_rows_deleted(mocker, current_user, db_session): + mocker.patch("services.tag_service.TagService.check_target_exists") + db_session.execute.return_value = SimpleNamespace(rowcount=0) + + TagService.delete_tag_binding( + TagBindingDeletePayload( + tag_ids=["tag-1"], + target_id="snippet-1", + type=TagType.SNIPPET, + ) + ) + + db_session.execute.assert_called_once() + db_session.commit.assert_not_called() + + +def test_get_target_ids_by_tag_ids_returns_empty_without_query_for_empty_input(db_session): + result = TagService.get_target_ids_by_tag_ids(TagType.SNIPPET, "tenant-1", []) + + assert result == [] + db_session.scalars.assert_not_called() + + +def test_check_target_exists_accepts_existing_snippet(current_user, db_session): + db_session.scalar.return_value = SimpleNamespace(id="snippet-1") + + TagService.check_target_exists("snippet", "snippet-1") + + db_session.scalar.assert_called_once() + + +def test_check_target_exists_raises_when_snippet_missing(current_user, db_session): + db_session.scalar.return_value = None + + with pytest.raises(NotFound, match="Snippet not found"): + TagService.check_target_exists("snippet", "missing-snippet") + + +def test_check_target_exists_raises_for_invalid_binding_type(current_user, db_session): + with pytest.raises(NotFound, match="Invalid binding type"): + TagService.check_target_exists("unknown", "target-1") + + db_session.scalar.assert_not_called() diff --git a/api/tests/unit_tests/services/workflow/test_workflow_draft_variable_service.py b/api/tests/unit_tests/services/workflow/test_workflow_draft_variable_service.py index b5b9f0bd97..4e10dddd2b 100644 --- a/api/tests/unit_tests/services/workflow/test_workflow_draft_variable_service.py +++ b/api/tests/unit_tests/services/workflow/test_workflow_draft_variable_service.py @@ -343,6 +343,34 @@ class TestWorkflowDraftVariableService: rag_pipeline_variables=[], ) + def test_list_variables_without_values_excludes_node_ids(self, mock_session): + service = WorkflowDraftVariableService(mock_session) + variable = WorkflowDraftVariable.new_node_variable( + app_id="app-1", + node_id="node-1", + name="output", + value=StringSegment(value="value"), + node_execution_id="execution-1", + ) + mock_session.scalar.return_value = 1 + mock_session.scalars.return_value = [variable] + + result = service.list_variables_without_values( + app_id="app-1", + page=1, + limit=20, + user_id="user-1", + exclude_node_ids={SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID}, + ) + + assert result.total == 1 + assert result.variables == [variable] + + stmt = mock_session.scalars.call_args.args[0] + compiled = stmt.compile() + excluded_node_ids = next(value for value in compiled.params.values() if isinstance(value, (list, tuple))) + assert set(excluded_node_ids) == {SYSTEM_VARIABLE_NODE_ID, CONVERSATION_VARIABLE_NODE_ID} + def test_reset_conversation_variable(self, mock_session): """Test resetting a conversation variable""" service = WorkflowDraftVariableService(mock_session) diff --git a/eslint-suppressions.json b/eslint-suppressions.json index dafa2bdd87..3ac5d5e4e7 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -145,6 +145,11 @@ "count": 1 } }, + "web/app/(commonLayout)/snippets/[snippetId]/page.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/app/(shareLayout)/components/splash.tsx": { "react/set-state-in-effect": { "count": 1 @@ -250,6 +255,11 @@ "count": 1 } }, + "web/app/components/app-sidebar/nav-link/index.tsx": { + "tailwindcss/enforce-consistent-class-order": { + "count": 3 + } + }, "web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": { "erasable-syntax-only/enums": { "count": 1 @@ -322,11 +332,6 @@ "count": 1 } }, - "web/app/components/app/app-publisher/features-wrapper.tsx": { - "ts/no-explicit-any": { - "count": 4 - } - }, "web/app/components/app/configuration/base/var-highlight/index.tsx": { "react-refresh/only-export-components": { "count": 1 @@ -657,11 +662,6 @@ "count": 1 } }, - "web/app/components/apps/list.tsx": { - "no-restricted-globals": { - "count": 2 - } - }, "web/app/components/apps/new-app-card.tsx": { "react-hooks-extra/no-direct-set-state-in-use-effect": { "count": 1 @@ -3146,6 +3146,16 @@ "count": 4 } }, + "web/app/components/snippets/hooks/use-nodes-sync-draft.ts": { + "no-restricted-imports": { + "count": 1 + } + }, + "web/app/components/snippets/hooks/use-snippet-run.ts": { + "no-restricted-imports": { + "count": 2 + } + }, "web/app/components/tools/edit-custom-collection-modal/get-schema.tsx": { "no-restricted-imports": { "count": 1 @@ -5153,6 +5163,11 @@ "count": 1 } }, + "web/service/__tests__/use-snippet-workflows.spec.tsx": { + "no-restricted-imports": { + "count": 1 + } + }, "web/service/access-control.ts": { "@tanstack/query/exhaustive-deps": { "count": 1 @@ -5420,6 +5435,11 @@ "count": 3 } }, + "web/service/use-snippet-workflows.ts": { + "no-restricted-imports": { + "count": 1 + } + }, "web/service/use-tools.ts": { "no-restricted-imports": { "count": 1 @@ -5468,9 +5488,6 @@ } }, "web/types/app.ts": { - "erasable-syntax-only/enums": { - "count": 9 - }, "ts/no-explicit-any": { "count": 1 } diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index 09a3428f0b..41c8fdc9dd 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -892,8 +892,9 @@ export type WorkflowDraftVariableUpdatePayload = { } export type PublishWorkflowPayload = { - marked_comment?: string | null - marked_name?: string | null + knowledge_base_setting?: { + [key: string]: unknown + } | null } export type WebhookTriggerResponse = { @@ -2028,6 +2029,7 @@ export type GetAppsData = { body?: never path?: never query?: { + creator_ids?: Array | null is_created_by_me?: boolean | null limit?: number mode?: diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index e5fb9ae2a4..31753f203b 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -577,10 +577,11 @@ export const zWorkflowDraftVariableUpdatePayload = z.object({ /** * PublishWorkflowPayload + * + * Payload for publishing snippet workflow. */ export const zPublishWorkflowPayload = z.object({ - marked_comment: z.string().max(100).nullish(), - marked_name: z.string().max(20).nullish(), + knowledge_base_setting: z.record(z.string(), z.unknown()).nullish(), }) /** @@ -2710,6 +2711,7 @@ export const zWorkflowCommentDetailWritable = z.object({ }) export const zGetAppsQuery = z.object({ + creator_ids: z.array(z.string()).nullish(), is_created_by_me: z.boolean().nullish(), limit: z.int().gte(1).lte(100).optional().default(20), mode: z diff --git a/packages/contracts/generated/api/console/orpc.gen.ts b/packages/contracts/generated/api/console/orpc.gen.ts index bfd0a3aef4..ef5f95f637 100644 --- a/packages/contracts/generated/api/console/orpc.gen.ts +++ b/packages/contracts/generated/api/console/orpc.gen.ts @@ -38,6 +38,7 @@ import { resetPassword } from './reset-password/orpc.gen' import { ruleCodeGenerate } from './rule-code-generate/orpc.gen' import { ruleGenerate } from './rule-generate/orpc.gen' import { ruleStructuredOutputGenerate } from './rule-structured-output-generate/orpc.gen' +import { snippets } from './snippets/orpc.gen' import { spec } from './spec/orpc.gen' import { systemFeatures } from './system-features/orpc.gen' import { tagBindings } from './tag-bindings/orpc.gen' @@ -89,6 +90,7 @@ export const contract = { ruleCodeGenerate, ruleGenerate, ruleStructuredOutputGenerate, + snippets, spec, systemFeatures, tagBindings, diff --git a/packages/contracts/generated/api/console/snippets/orpc.gen.ts b/packages/contracts/generated/api/console/snippets/orpc.gen.ts new file mode 100644 index 0000000000..d64e39b211 --- /dev/null +++ b/packages/contracts/generated/api/console/snippets/orpc.gen.ts @@ -0,0 +1,878 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import { oc } from '@orpc/contract' +import * as z from 'zod' + +import { + zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath, + zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse, + zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath, + zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse, + zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesPath, + zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse, + zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsPath, + zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponse, + zGetSnippetsBySnippetIdWorkflowRunsByRunIdPath, + zGetSnippetsBySnippetIdWorkflowRunsByRunIdResponse, + zGetSnippetsBySnippetIdWorkflowRunsPath, + zGetSnippetsBySnippetIdWorkflowRunsResponse, + zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsPath, + zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse, + zGetSnippetsBySnippetIdWorkflowsDraftConfigPath, + zGetSnippetsBySnippetIdWorkflowsDraftConfigResponse, + zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesPath, + zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse, + zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesPath, + zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse, + zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunPath, + zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse, + zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath, + zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse, + zGetSnippetsBySnippetIdWorkflowsDraftPath, + zGetSnippetsBySnippetIdWorkflowsDraftResponse, + zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesPath, + zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse, + zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath, + zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse, + zGetSnippetsBySnippetIdWorkflowsDraftVariablesPath, + zGetSnippetsBySnippetIdWorkflowsDraftVariablesQuery, + zGetSnippetsBySnippetIdWorkflowsDraftVariablesResponse, + zGetSnippetsBySnippetIdWorkflowsPath, + zGetSnippetsBySnippetIdWorkflowsPublishPath, + zGetSnippetsBySnippetIdWorkflowsPublishResponse, + zGetSnippetsBySnippetIdWorkflowsQuery, + zGetSnippetsBySnippetIdWorkflowsResponse, + zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdBody, + zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath, + zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse, + zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopPath, + zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse, + zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestorePath, + zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse, + zPostSnippetsBySnippetIdWorkflowsDraftBody, + zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunBody, + zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunPath, + zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse, + zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunBody, + zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunPath, + zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunBody, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunPath, + zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse, + zPostSnippetsBySnippetIdWorkflowsDraftPath, + zPostSnippetsBySnippetIdWorkflowsDraftResponse, + zPostSnippetsBySnippetIdWorkflowsDraftRunBody, + zPostSnippetsBySnippetIdWorkflowsDraftRunPath, + zPostSnippetsBySnippetIdWorkflowsDraftRunResponse, + zPostSnippetsBySnippetIdWorkflowsPublishBody, + zPostSnippetsBySnippetIdWorkflowsPublishPath, + zPostSnippetsBySnippetIdWorkflowsPublishResponse, + zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetPath, + zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse, +} from './zod.gen' + +/** + * Stop a running snippet workflow task + * + * Uses both the legacy stop flag mechanism and the graph engine + * command channel for backward compatibility. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post = oc + .route({ + deprecated: true, + description: + 'Uses both the legacy stop flag mechanism and the graph engine\ncommand channel for backward compatibility.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStop', + path: '/snippets/{snippet_id}/workflow-runs/tasks/{task_id}/stop', + summary: 'Stop a running snippet workflow task', + tags: ['console'], + }) + .input(z.object({ params: zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopPath })) + .output(zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse) + +export const stop = { + post, +} + +export const byTaskId = { + stop, +} + +export const tasks = { + byTaskId, +} + +/** + * List node executions for a workflow run + */ +export const get = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutions', + path: '/snippets/{snippet_id}/workflow-runs/{run_id}/node-executions', + summary: 'List node executions for a workflow run', + tags: ['console'], + }) + .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsPath })) + .output(zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponse) + +export const nodeExecutions = { + get, +} + +/** + * Get workflow run detail for snippet + */ +export const get2 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowRunsByRunId', + path: '/snippets/{snippet_id}/workflow-runs/{run_id}', + summary: 'Get workflow run detail for snippet', + tags: ['console'], + }) + .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowRunsByRunIdPath })) + .output(zGetSnippetsBySnippetIdWorkflowRunsByRunIdResponse) + +export const byRunId = { + get: get2, + nodeExecutions, +} + +/** + * List workflow runs for snippet + */ +export const get3 = oc + .route({ + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowRuns', + path: '/snippets/{snippet_id}/workflow-runs', + summary: 'List workflow runs for snippet', + tags: ['console'], + }) + .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowRunsPath })) + .output(zGetSnippetsBySnippetIdWorkflowRunsResponse) + +export const workflowRuns = { + get: get3, + tasks, + byRunId, +} + +/** + * Get default block configurations for snippet workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get4 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigs', + path: '/snippets/{snippet_id}/workflows/default-workflow-block-configs', + summary: 'Get default block configurations for snippet workflow', + tags: ['console'], + }) + .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsPath })) + .output(zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse) + +export const defaultWorkflowBlockConfigs = { + get: get4, +} + +/** + * Get snippet draft workflow configuration limits + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get5 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowsDraftConfig', + path: '/snippets/{snippet_id}/workflows/draft/config', + summary: 'Get snippet draft workflow configuration limits', + tags: ['console'], + }) + .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftConfigPath })) + .output(zGetSnippetsBySnippetIdWorkflowsDraftConfigResponse) + +export const config = { + get: get5, +} + +/** + * Conversation variables are not used in snippet workflows; returns an empty list for API parity + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get6 = oc + .route({ + deprecated: true, + description: + 'Conversation variables are not used in snippet workflows; returns an empty list for API parity\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowsDraftConversationVariables', + path: '/snippets/{snippet_id}/workflows/draft/conversation-variables', + tags: ['console'], + }) + .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesPath })) + .output(zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse) + +export const conversationVariables = { + get: get6, +} + +/** + * Get environment variables from snippet draft workflow graph + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get7 = oc + .route({ + deprecated: true, + description: + 'Get environment variables from snippet draft workflow graph\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowsDraftEnvironmentVariables', + path: '/snippets/{snippet_id}/workflows/draft/environment-variables', + tags: ['console'], + }) + .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesPath })) + .output(zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse) + +export const environmentVariables = { + get: get7, +} + +/** + * Run a draft workflow iteration node for snippet + * + * Run draft workflow iteration node for snippet + * Iteration nodes execute their internal sub-graph multiple times over an input list. + * Returns an SSE event stream with iteration progress and results. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post2 = oc + .route({ + deprecated: true, + description: + 'Run draft workflow iteration node for snippet\nIteration nodes execute their internal sub-graph multiple times over an input list.\nReturns an SSE event stream with iteration progress and results.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRun', + path: '/snippets/{snippet_id}/workflows/draft/iteration/nodes/{node_id}/run', + summary: 'Run a draft workflow iteration node for snippet', + tags: ['console'], + }) + .input( + z.object({ + body: zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunBody, + params: zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunPath, + }), + ) + .output(zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse) + +export const run = { + post: post2, +} + +export const byNodeId = { + run, +} + +export const nodes = { + byNodeId, +} + +export const iteration = { + nodes, +} + +/** + * Run a draft workflow loop node for snippet + * + * Run draft workflow loop node for snippet + * Loop nodes execute their internal sub-graph repeatedly until a condition is met. + * Returns an SSE event stream with loop progress and results. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post3 = oc + .route({ + deprecated: true, + description: + 'Run draft workflow loop node for snippet\nLoop nodes execute their internal sub-graph repeatedly until a condition is met.\nReturns an SSE event stream with loop progress and results.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRun', + path: '/snippets/{snippet_id}/workflows/draft/loop/nodes/{node_id}/run', + summary: 'Run a draft workflow loop node for snippet', + tags: ['console'], + }) + .input( + z.object({ + body: zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunBody, + params: zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunPath, + }), + ) + .output(zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse) + +export const run2 = { + post: post3, +} + +export const byNodeId2 = { + run: run2, +} + +export const nodes2 = { + byNodeId: byNodeId2, +} + +export const loop = { + nodes: nodes2, +} + +/** + * Get the last run result for a specific node in snippet draft workflow + * + * Get last run result for a node in snippet draft workflow + * Returns the most recent execution record for the given node, + * including status, inputs, outputs, and timing information. + */ +export const get8 = oc + .route({ + description: + 'Get last run result for a node in snippet draft workflow\nReturns the most recent execution record for the given node,\nincluding status, inputs, outputs, and timing information.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRun', + path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/last-run', + summary: 'Get the last run result for a specific node in snippet draft workflow', + tags: ['console'], + }) + .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunPath })) + .output(zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse) + +export const lastRun = { + get: get8, +} + +/** + * Run a single node in snippet draft workflow + * + * Run a single node in snippet draft workflow (single-step debugging) + * Executes a specific node with provided inputs for single-step debugging. + * Returns the node execution result including status, outputs, and timing. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post4 = oc + .route({ + deprecated: true, + description: + 'Run a single node in snippet draft workflow (single-step debugging)\nExecutes a specific node with provided inputs for single-step debugging.\nReturns the node execution result including status, outputs, and timing.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRun', + path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/run', + summary: 'Run a single node in snippet draft workflow', + tags: ['console'], + }) + .input( + z.object({ + body: zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunBody, + params: zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunPath, + }), + ) + .output(zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse) + +export const run3 = { + post: post4, +} + +/** + * Delete all variables for a specific node (snippet draft workflow) + */ +export const delete_ = oc + .route({ + description: 'Delete all variables for a specific node (snippet draft workflow)', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariables', + path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/variables', + successStatus: 204, + tags: ['console'], + }) + .input(z.object({ params: zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath })) + .output(zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse) + +/** + * Get variables for a specific node (snippet draft workflow) + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get9 = oc + .route({ + deprecated: true, + description: + 'Get variables for a specific node (snippet draft workflow)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariables', + path: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/variables', + tags: ['console'], + }) + .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath })) + .output(zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse) + +export const variables = { + delete: delete_, + get: get9, +} + +export const byNodeId3 = { + lastRun, + run: run3, + variables, +} + +export const nodes3 = { + byNodeId: byNodeId3, +} + +/** + * Run draft workflow for snippet + * + * Executes the snippet's draft workflow with the provided inputs + * and returns an SSE event stream with execution progress and results. + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post5 = oc + .route({ + deprecated: true, + description: + 'Executes the snippet\'s draft workflow with the provided inputs\nand returns an SSE event stream with execution progress and results.\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postSnippetsBySnippetIdWorkflowsDraftRun', + path: '/snippets/{snippet_id}/workflows/draft/run', + summary: 'Run draft workflow for snippet', + tags: ['console'], + }) + .input( + z.object({ + body: zPostSnippetsBySnippetIdWorkflowsDraftRunBody, + params: zPostSnippetsBySnippetIdWorkflowsDraftRunPath, + }), + ) + .output(zPostSnippetsBySnippetIdWorkflowsDraftRunResponse) + +export const run4 = { + post: post5, +} + +/** + * System variables are not used in snippet workflows; returns an empty list for API parity + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get10 = oc + .route({ + deprecated: true, + description: + 'System variables are not used in snippet workflows; returns an empty list for API parity\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowsDraftSystemVariables', + path: '/snippets/{snippet_id}/workflows/draft/system-variables', + tags: ['console'], + }) + .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesPath })) + .output(zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse) + +export const systemVariables = { + get: get10, +} + +/** + * Reset a draft workflow variable to its default value (snippet scope) + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const put = oc + .route({ + deprecated: true, + description: + 'Reset a draft workflow variable to its default value (snippet scope)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'PUT', + operationId: 'putSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdReset', + path: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}/reset', + tags: ['console'], + }) + .input(z.object({ params: zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetPath })) + .output(zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse) + +export const reset = { + put, +} + +/** + * Delete a draft workflow variable (snippet scope) + */ +export const delete2 = oc + .route({ + description: 'Delete a draft workflow variable (snippet scope)', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableId', + path: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}', + successStatus: 204, + tags: ['console'], + }) + .input(z.object({ params: zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath })) + .output(zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse) + +/** + * Get a specific draft workflow variable (snippet scope) + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get11 = oc + .route({ + deprecated: true, + description: + 'Get a specific draft workflow variable (snippet scope)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowsDraftVariablesByVariableId', + path: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath })) + .output(zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse) + +/** + * Update a draft workflow variable (snippet scope) + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const patch = oc + .route({ + deprecated: true, + description: + 'Update a draft workflow variable (snippet scope)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableId', + path: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}', + tags: ['console'], + }) + .input( + z.object({ + body: zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdBody, + params: zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath, + }), + ) + .output(zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse) + +export const byVariableId = { + delete: delete2, + get: get11, + patch, + reset, +} + +/** + * Delete all draft workflow variables for the current user (snippet scope) + */ +export const delete3 = oc + .route({ + description: 'Delete all draft workflow variables for the current user (snippet scope)', + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteSnippetsBySnippetIdWorkflowsDraftVariables', + path: '/snippets/{snippet_id}/workflows/draft/variables', + successStatus: 204, + tags: ['console'], + }) + .input(z.object({ params: zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesPath })) + .output(zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse) + +/** + * List draft workflow variables without values (paginated, snippet scope) + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get12 = oc + .route({ + deprecated: true, + description: + 'List draft workflow variables without values (paginated, snippet scope)\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowsDraftVariables', + path: '/snippets/{snippet_id}/workflows/draft/variables', + tags: ['console'], + }) + .input( + z.object({ + params: zGetSnippetsBySnippetIdWorkflowsDraftVariablesPath, + query: zGetSnippetsBySnippetIdWorkflowsDraftVariablesQuery.optional(), + }), + ) + .output(zGetSnippetsBySnippetIdWorkflowsDraftVariablesResponse) + +export const variables2 = { + delete: delete3, + get: get12, + byVariableId, +} + +/** + * Get draft workflow for snippet + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get13 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowsDraft', + path: '/snippets/{snippet_id}/workflows/draft', + summary: 'Get draft workflow for snippet', + tags: ['console'], + }) + .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsDraftPath })) + .output(zGetSnippetsBySnippetIdWorkflowsDraftResponse) + +/** + * Sync draft workflow for snippet + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post6 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postSnippetsBySnippetIdWorkflowsDraft', + path: '/snippets/{snippet_id}/workflows/draft', + summary: 'Sync draft workflow for snippet', + tags: ['console'], + }) + .input( + z.object({ + body: zPostSnippetsBySnippetIdWorkflowsDraftBody, + params: zPostSnippetsBySnippetIdWorkflowsDraftPath, + }), + ) + .output(zPostSnippetsBySnippetIdWorkflowsDraftResponse) + +export const draft = { + get: get13, + post: post6, + config, + conversationVariables, + environmentVariables, + iteration, + loop, + nodes: nodes3, + run: run4, + systemVariables, + variables: variables2, +} + +/** + * Get published workflow for snippet + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get14 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflowsPublish', + path: '/snippets/{snippet_id}/workflows/publish', + summary: 'Get published workflow for snippet', + tags: ['console'], + }) + .input(z.object({ params: zGetSnippetsBySnippetIdWorkflowsPublishPath })) + .output(zGetSnippetsBySnippetIdWorkflowsPublishResponse) + +/** + * Publish snippet workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post7 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postSnippetsBySnippetIdWorkflowsPublish', + path: '/snippets/{snippet_id}/workflows/publish', + summary: 'Publish snippet workflow', + tags: ['console'], + }) + .input( + z.object({ + body: zPostSnippetsBySnippetIdWorkflowsPublishBody, + params: zPostSnippetsBySnippetIdWorkflowsPublishPath, + }), + ) + .output(zPostSnippetsBySnippetIdWorkflowsPublishResponse) + +export const publish = { + get: get14, + post: post7, +} + +/** + * Restore a published snippet workflow version into the draft workflow + * + * Restore a published snippet workflow version into the draft workflow + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post8 = oc + .route({ + deprecated: true, + description: + 'Restore a published snippet workflow version into the draft workflow\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postSnippetsBySnippetIdWorkflowsByWorkflowIdRestore', + path: '/snippets/{snippet_id}/workflows/{workflow_id}/restore', + summary: 'Restore a published snippet workflow version into the draft workflow', + tags: ['console'], + }) + .input(z.object({ params: zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestorePath })) + .output(zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse) + +export const restore = { + post: post8, +} + +export const byWorkflowId = { + restore, +} + +/** + * Get all published workflow versions for snippet + * + * Get all published workflows for a snippet + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get15 = oc + .route({ + deprecated: true, + description: + 'Get all published workflows for a snippet\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getSnippetsBySnippetIdWorkflows', + path: '/snippets/{snippet_id}/workflows', + summary: 'Get all published workflow versions for snippet', + tags: ['console'], + }) + .input( + z.object({ + params: zGetSnippetsBySnippetIdWorkflowsPath, + query: zGetSnippetsBySnippetIdWorkflowsQuery.optional(), + }), + ) + .output(zGetSnippetsBySnippetIdWorkflowsResponse) + +export const workflows = { + get: get15, + defaultWorkflowBlockConfigs, + draft, + publish, + byWorkflowId, +} + +export const bySnippetId = { + workflowRuns, + workflows, +} + +export const snippets = { + bySnippetId, +} + +export const contract = { + snippets, +} diff --git a/packages/contracts/generated/api/console/snippets/types.gen.ts b/packages/contracts/generated/api/console/snippets/types.gen.ts new file mode 100644 index 0000000000..ae46ee9813 --- /dev/null +++ b/packages/contracts/generated/api/console/snippets/types.gen.ts @@ -0,0 +1,928 @@ +// This file is auto-generated by @hey-api/openapi-ts + +export type ClientOptions = { + baseUrl: `${string}://${string}/console/api` | (string & {}) +} + +export type WorkflowRunPaginationResponse = { + data: Array + has_more: boolean + limit: number +} + +export type WorkflowRunDetailResponse = { + created_at?: number | null + created_by_account?: SimpleAccount + created_by_end_user?: SimpleEndUser + created_by_role?: string | null + elapsed_time?: number | null + error?: string | null + exceptions_count?: number | null + finished_at?: number | null + graph: unknown + id: string + inputs: unknown + outputs: unknown + status?: string | null + total_steps?: number | null + total_tokens?: number | null + version?: string | null +} + +export type WorkflowRunNodeExecutionListResponse = { + data: Array +} + +export type WorkflowPaginationResponse = { + has_more: boolean + items: Array + limit: number + page: number +} + +export type SnippetWorkflowResponse = { + conversation_variables: Array + created_at: number + created_by?: SimpleAccount + environment_variables: Array + features: { + [key: string]: unknown + } + graph: { + [key: string]: unknown + } + hash: string + id: string + input_fields?: Array<{ + [key: string]: unknown + }> + marked_comment: string + marked_name: string + rag_pipeline_variables: Array + tool_published: boolean + updated_at: number + updated_by?: SimpleAccount + version: string +} + +export type SnippetDraftSyncPayload = { + conversation_variables?: Array<{ + [key: string]: unknown + }> | null + graph: { + [key: string]: unknown + } + hash?: string | null + input_fields?: Array<{ + [key: string]: unknown + }> | null +} + +export type WorkflowDraftVariableList = { + items?: Array +} + +export type SnippetIterationNodeRunPayload = { + inputs?: { + [key: string]: unknown + } | null +} + +export type SnippetLoopNodeRunPayload = { + inputs?: { + [key: string]: unknown + } | null +} + +export type WorkflowRunNodeExecutionResponse = { + created_at?: number | null + created_by_account?: SimpleAccount + created_by_end_user?: SimpleEndUser + created_by_role?: string | null + elapsed_time?: number | null + error?: string | null + execution_metadata?: unknown + extras?: unknown + finished_at?: number | null + id: string + index?: number | null + inputs?: unknown + inputs_truncated?: boolean | null + node_id?: string | null + node_type?: string | null + outputs?: unknown + outputs_truncated?: boolean | null + predecessor_node_id?: string | null + process_data?: unknown + process_data_truncated?: boolean | null + status?: string | null + title?: string | null +} + +export type SnippetDraftNodeRunPayload = { + files?: Array<{ + [key: string]: unknown + }> | null + inputs: { + [key: string]: unknown + } + query?: string +} + +export type SnippetDraftRunPayload = { + files?: Array<{ + [key: string]: unknown + }> | null + inputs: { + [key: string]: unknown + } +} + +export type WorkflowDraftVariableListWithoutValue = { + items?: Array + total?: { + [key: string]: unknown + } +} + +export type WorkflowDraftVariable = { + description?: string + edited?: boolean + full_content?: { + [key: string]: unknown + } + id?: string + is_truncated?: boolean + name?: string + selector?: Array + type?: string + value?: { + [key: string]: unknown + } + value_type?: string + visible?: boolean +} + +export type WorkflowDraftVariableUpdatePayload = { + name?: string | null + value?: unknown +} + +export type PublishWorkflowPayload = { + knowledge_base_setting?: { + [key: string]: unknown + } | null +} + +export type WorkflowRunForListResponse = { + created_at?: number | null + created_by_account?: SimpleAccount + elapsed_time?: number | null + exceptions_count?: number | null + finished_at?: number | null + id: string + retry_index?: number | null + status?: string | null + total_steps?: number | null + total_tokens?: number | null + version?: string | null +} + +export type SimpleAccount = { + email: string + id: string + name: string +} + +export type SimpleEndUser = { + id: string + is_anonymous: boolean + session_id?: string | null + type: string +} + +export type WorkflowResponse = { + conversation_variables: Array + created_at: number + created_by?: SimpleAccount + environment_variables: Array + features: { + [key: string]: unknown + } + graph: { + [key: string]: unknown + } + hash: string + id: string + marked_comment: string + marked_name: string + rag_pipeline_variables: Array + tool_published: boolean + updated_at: number + updated_by?: SimpleAccount + version: string +} + +export type WorkflowConversationVariableResponse = { + description: string + id: string + name: string + value: { + [key: string]: unknown + } + value_type: string +} + +export type WorkflowEnvironmentVariableResponse = { + description: string + id: string + name: string + value: { + [key: string]: unknown + } + value_type: string +} + +export type PipelineVariableResponse = { + allowed_file_extensions?: Array | null + allowed_file_types?: Array | null + allowed_file_upload_methods?: Array | null + belong_to_node_id: string + default_value?: { + [key: string]: unknown + } + label: string + max_length?: number | null + options?: Array | null + placeholder?: string | null + required: boolean + tooltips?: string | null + type: string + unit?: string | null + variable: string +} + +export type WorkflowDraftVariableWithoutValue = { + description?: string + edited?: boolean + id?: string + is_truncated?: boolean + name?: string + selector?: Array + type?: string + value_type?: string + visible?: boolean +} + +export type GetSnippetsBySnippetIdWorkflowRunsData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflow-runs' +} + +export type GetSnippetsBySnippetIdWorkflowRunsResponses = { + 200: WorkflowRunPaginationResponse +} + +export type GetSnippetsBySnippetIdWorkflowRunsResponse + = GetSnippetsBySnippetIdWorkflowRunsResponses[keyof GetSnippetsBySnippetIdWorkflowRunsResponses] + +export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopData = { + body?: never + path: { + snippet_id: string + task_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflow-runs/tasks/{task_id}/stop' +} + +export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopErrors = { + 404: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopError + = PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopErrors[keyof PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopErrors] + +export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse + = PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponses[keyof PostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponses] + +export type GetSnippetsBySnippetIdWorkflowRunsByRunIdData = { + body?: never + path: { + run_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflow-runs/{run_id}' +} + +export type GetSnippetsBySnippetIdWorkflowRunsByRunIdErrors = { + 404: { + [key: string]: unknown + } +} + +export type GetSnippetsBySnippetIdWorkflowRunsByRunIdError + = GetSnippetsBySnippetIdWorkflowRunsByRunIdErrors[keyof GetSnippetsBySnippetIdWorkflowRunsByRunIdErrors] + +export type GetSnippetsBySnippetIdWorkflowRunsByRunIdResponses = { + 200: WorkflowRunDetailResponse +} + +export type GetSnippetsBySnippetIdWorkflowRunsByRunIdResponse + = GetSnippetsBySnippetIdWorkflowRunsByRunIdResponses[keyof GetSnippetsBySnippetIdWorkflowRunsByRunIdResponses] + +export type GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsData = { + body?: never + path: { + run_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflow-runs/{run_id}/node-executions' +} + +export type GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponses = { + 200: WorkflowRunNodeExecutionListResponse +} + +export type GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponse + = GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponses[keyof GetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponses] + +export type GetSnippetsBySnippetIdWorkflowsData = { + body?: never + path: { + snippet_id: string + } + query?: { + limit?: number + page?: number + } + url: '/snippets/{snippet_id}/workflows' +} + +export type GetSnippetsBySnippetIdWorkflowsResponses = { + 200: WorkflowPaginationResponse +} + +export type GetSnippetsBySnippetIdWorkflowsResponse + = GetSnippetsBySnippetIdWorkflowsResponses[keyof GetSnippetsBySnippetIdWorkflowsResponses] + +export type GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/default-workflow-block-configs' +} + +export type GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse + = GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponses[keyof GetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponses] + +export type GetSnippetsBySnippetIdWorkflowsDraftData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft' +} + +export type GetSnippetsBySnippetIdWorkflowsDraftErrors = { + 404: { + [key: string]: unknown + } +} + +export type GetSnippetsBySnippetIdWorkflowsDraftError + = GetSnippetsBySnippetIdWorkflowsDraftErrors[keyof GetSnippetsBySnippetIdWorkflowsDraftErrors] + +export type GetSnippetsBySnippetIdWorkflowsDraftResponses = { + 200: SnippetWorkflowResponse +} + +export type GetSnippetsBySnippetIdWorkflowsDraftResponse + = GetSnippetsBySnippetIdWorkflowsDraftResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftResponses] + +export type PostSnippetsBySnippetIdWorkflowsDraftData = { + body: SnippetDraftSyncPayload + path: { + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft' +} + +export type PostSnippetsBySnippetIdWorkflowsDraftErrors = { + 400: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowsDraftError + = PostSnippetsBySnippetIdWorkflowsDraftErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftErrors] + +export type PostSnippetsBySnippetIdWorkflowsDraftResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowsDraftResponse + = PostSnippetsBySnippetIdWorkflowsDraftResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftResponses] + +export type GetSnippetsBySnippetIdWorkflowsDraftConfigData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/config' +} + +export type GetSnippetsBySnippetIdWorkflowsDraftConfigResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetSnippetsBySnippetIdWorkflowsDraftConfigResponse + = GetSnippetsBySnippetIdWorkflowsDraftConfigResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftConfigResponses] + +export type GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/conversation-variables' +} + +export type GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponses = { + 200: WorkflowDraftVariableList +} + +export type GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse + = GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponses] + +export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/environment-variables' +} + +export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesErrors = { + 404: { + [key: string]: unknown + } +} + +export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesError + = GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesErrors[keyof GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesErrors] + +export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse + = GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponses] + +export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunData = { + body: SnippetIterationNodeRunPayload + path: { + node_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/iteration/nodes/{node_id}/run' +} + +export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunErrors = { + 404: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunError + = PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunErrors] + +export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse + = PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponses] + +export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunData = { + body: SnippetLoopNodeRunPayload + path: { + node_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/loop/nodes/{node_id}/run' +} + +export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunErrors = { + 404: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunError + = PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunErrors] + +export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse + = PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponses] + +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunData = { + body?: never + path: { + node_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/last-run' +} + +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunErrors = { + 404: { + [key: string]: unknown + } +} + +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunError + = GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunErrors[keyof GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunErrors] + +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponses = { + 200: WorkflowRunNodeExecutionResponse +} + +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse + = GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponses] + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunData = { + body: SnippetDraftNodeRunPayload + path: { + node_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/run' +} + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunErrors = { + 404: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunError + = PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunErrors] + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponses = { + 200: WorkflowRunNodeExecutionResponse +} + +export type PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse + = PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponses] + +export type DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesData = { + body?: never + path: { + node_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/variables' +} + +export type DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses = { + 204: { + [key: string]: never + } +} + +export type DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse + = DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof DeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses] + +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesData = { + body?: never + path: { + node_id: string + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/nodes/{node_id}/variables' +} + +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses = { + 200: WorkflowDraftVariableList +} + +export type GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse + = GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponses] + +export type PostSnippetsBySnippetIdWorkflowsDraftRunData = { + body: SnippetDraftRunPayload + path: { + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/run' +} + +export type PostSnippetsBySnippetIdWorkflowsDraftRunErrors = { + 404: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowsDraftRunError + = PostSnippetsBySnippetIdWorkflowsDraftRunErrors[keyof PostSnippetsBySnippetIdWorkflowsDraftRunErrors] + +export type PostSnippetsBySnippetIdWorkflowsDraftRunResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowsDraftRunResponse + = PostSnippetsBySnippetIdWorkflowsDraftRunResponses[keyof PostSnippetsBySnippetIdWorkflowsDraftRunResponses] + +export type GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/system-variables' +} + +export type GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponses = { + 200: WorkflowDraftVariableList +} + +export type GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse + = GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponses] + +export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/variables' +} + +export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponses = { + 204: { + [key: string]: never + } +} + +export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse + = DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponses[keyof DeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponses] + +export type GetSnippetsBySnippetIdWorkflowsDraftVariablesData = { + body?: never + path: { + snippet_id: string + } + query?: { + limit?: number + page?: number + } + url: '/snippets/{snippet_id}/workflows/draft/variables' +} + +export type GetSnippetsBySnippetIdWorkflowsDraftVariablesResponses = { + 200: WorkflowDraftVariableListWithoutValue +} + +export type GetSnippetsBySnippetIdWorkflowsDraftVariablesResponse + = GetSnippetsBySnippetIdWorkflowsDraftVariablesResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftVariablesResponses] + +export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdData = { + body?: never + path: { + snippet_id: string + variable_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}' +} + +export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors = { + 404: { + [key: string]: unknown + } +} + +export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdError + = DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors[keyof DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors] + +export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses = { + 204: { + [key: string]: never + } +} + +export type DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse + = DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses[keyof DeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses] + +export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdData = { + body?: never + path: { + snippet_id: string + variable_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}' +} + +export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors = { + 404: { + [key: string]: unknown + } +} + +export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdError + = GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors[keyof GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors] + +export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses = { + 200: WorkflowDraftVariable +} + +export type GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse + = GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses[keyof GetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses] + +export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdData = { + body: WorkflowDraftVariableUpdatePayload + path: { + snippet_id: string + variable_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}' +} + +export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors = { + 404: { + [key: string]: unknown + } +} + +export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdError + = PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors[keyof PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdErrors] + +export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses = { + 200: WorkflowDraftVariable +} + +export type PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse + = PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses[keyof PatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponses] + +export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetData = { + body?: never + path: { + snippet_id: string + variable_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/draft/variables/{variable_id}/reset' +} + +export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetErrors = { + 404: { + [key: string]: unknown + } +} + +export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetError + = PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetErrors[keyof PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetErrors] + +export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponses = { + 200: WorkflowDraftVariable + 204: { + [key: string]: never + } +} + +export type PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse + = PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponses[keyof PutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponses] + +export type GetSnippetsBySnippetIdWorkflowsPublishData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/publish' +} + +export type GetSnippetsBySnippetIdWorkflowsPublishErrors = { + 404: { + [key: string]: unknown + } +} + +export type GetSnippetsBySnippetIdWorkflowsPublishError + = GetSnippetsBySnippetIdWorkflowsPublishErrors[keyof GetSnippetsBySnippetIdWorkflowsPublishErrors] + +export type GetSnippetsBySnippetIdWorkflowsPublishResponses = { + 200: SnippetWorkflowResponse +} + +export type GetSnippetsBySnippetIdWorkflowsPublishResponse + = GetSnippetsBySnippetIdWorkflowsPublishResponses[keyof GetSnippetsBySnippetIdWorkflowsPublishResponses] + +export type PostSnippetsBySnippetIdWorkflowsPublishData = { + body: PublishWorkflowPayload + path: { + snippet_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/publish' +} + +export type PostSnippetsBySnippetIdWorkflowsPublishErrors = { + 400: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowsPublishError + = PostSnippetsBySnippetIdWorkflowsPublishErrors[keyof PostSnippetsBySnippetIdWorkflowsPublishErrors] + +export type PostSnippetsBySnippetIdWorkflowsPublishResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowsPublishResponse + = PostSnippetsBySnippetIdWorkflowsPublishResponses[keyof PostSnippetsBySnippetIdWorkflowsPublishResponses] + +export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreData = { + body?: never + path: { + snippet_id: string + workflow_id: string + } + query?: never + url: '/snippets/{snippet_id}/workflows/{workflow_id}/restore' +} + +export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreErrors = { + 400: { + [key: string]: unknown + } + 404: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreError + = PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreErrors[keyof PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreErrors] + +export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse + = PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponses[keyof PostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponses] diff --git a/packages/contracts/generated/api/console/snippets/zod.gen.ts b/packages/contracts/generated/api/console/snippets/zod.gen.ts new file mode 100644 index 0000000000..20b932592a --- /dev/null +++ b/packages/contracts/generated/api/console/snippets/zod.gen.ts @@ -0,0 +1,633 @@ +// This file is auto-generated by @hey-api/openapi-ts + +import * as z from 'zod' + +/** + * SnippetDraftSyncPayload + * + * Payload for syncing snippet draft workflow. + */ +export const zSnippetDraftSyncPayload = z.object({ + conversation_variables: z.array(z.record(z.string(), z.unknown())).nullish(), + graph: z.record(z.string(), z.unknown()), + hash: z.string().nullish(), + input_fields: z.array(z.record(z.string(), z.unknown())).nullish(), +}) + +/** + * SnippetIterationNodeRunPayload + * + * Payload for running an iteration node in snippet draft workflow. + */ +export const zSnippetIterationNodeRunPayload = z.object({ + inputs: z.record(z.string(), z.unknown()).nullish(), +}) + +/** + * SnippetLoopNodeRunPayload + * + * Payload for running a loop node in snippet draft workflow. + */ +export const zSnippetLoopNodeRunPayload = z.object({ + inputs: z.record(z.string(), z.unknown()).nullish(), +}) + +/** + * SnippetDraftNodeRunPayload + * + * Payload for running a single node in snippet draft workflow. + */ +export const zSnippetDraftNodeRunPayload = z.object({ + files: z.array(z.record(z.string(), z.unknown())).nullish(), + inputs: z.record(z.string(), z.unknown()), + query: z.string().optional().default(''), +}) + +/** + * SnippetDraftRunPayload + * + * Payload for running snippet draft workflow. + */ +export const zSnippetDraftRunPayload = z.object({ + files: z.array(z.record(z.string(), z.unknown())).nullish(), + inputs: z.record(z.string(), z.unknown()), +}) + +export const zWorkflowDraftVariable = z.object({ + description: z.string().optional(), + edited: z.boolean().optional(), + full_content: z.record(z.string(), z.unknown()).optional(), + id: z.string().optional(), + is_truncated: z.boolean().optional(), + name: z.string().optional(), + selector: z.array(z.string()).optional(), + type: z.string().optional(), + value: z.record(z.string(), z.unknown()).optional(), + value_type: z.string().optional(), + visible: z.boolean().optional(), +}) + +export const zWorkflowDraftVariableList = z.object({ + items: z.array(zWorkflowDraftVariable).optional(), +}) + +/** + * WorkflowDraftVariableUpdatePayload + */ +export const zWorkflowDraftVariableUpdatePayload = z.object({ + name: z.string().nullish(), + value: z.unknown().optional(), +}) + +/** + * PublishWorkflowPayload + * + * Payload for publishing snippet workflow. + */ +export const zPublishWorkflowPayload = z.object({ + knowledge_base_setting: z.record(z.string(), z.unknown()).nullish(), +}) + +/** + * SimpleAccount + */ +export const zSimpleAccount = z.object({ + email: z.string(), + id: z.string(), + name: z.string(), +}) + +/** + * WorkflowRunForListResponse + */ +export const zWorkflowRunForListResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccount.optional(), + elapsed_time: z.number().nullish(), + exceptions_count: z.int().nullish(), + finished_at: z.int().nullish(), + id: z.string(), + retry_index: z.int().nullish(), + status: z.string().nullish(), + total_steps: z.int().nullish(), + total_tokens: z.int().nullish(), + version: z.string().nullish(), +}) + +/** + * WorkflowRunPaginationResponse + */ +export const zWorkflowRunPaginationResponse = z.object({ + data: z.array(zWorkflowRunForListResponse), + has_more: z.boolean(), + limit: z.int(), +}) + +/** + * SimpleEndUser + */ +export const zSimpleEndUser = z.object({ + id: z.string(), + is_anonymous: z.boolean(), + session_id: z.string().nullish(), + type: z.string(), +}) + +/** + * WorkflowRunDetailResponse + */ +export const zWorkflowRunDetailResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccount.optional(), + created_by_end_user: zSimpleEndUser.optional(), + created_by_role: z.string().nullish(), + elapsed_time: z.number().nullish(), + error: z.string().nullish(), + exceptions_count: z.int().nullish(), + finished_at: z.int().nullish(), + graph: z.unknown(), + id: z.string(), + inputs: z.unknown(), + outputs: z.unknown(), + status: z.string().nullish(), + total_steps: z.int().nullish(), + total_tokens: z.int().nullish(), + version: z.string().nullish(), +}) + +/** + * WorkflowRunNodeExecutionResponse + */ +export const zWorkflowRunNodeExecutionResponse = z.object({ + created_at: z.int().nullish(), + created_by_account: zSimpleAccount.optional(), + created_by_end_user: zSimpleEndUser.optional(), + created_by_role: z.string().nullish(), + elapsed_time: z.number().nullish(), + error: z.string().nullish(), + execution_metadata: z.unknown().optional(), + extras: z.unknown().optional(), + finished_at: z.int().nullish(), + id: z.string(), + index: z.int().nullish(), + inputs: z.unknown().optional(), + inputs_truncated: z.boolean().nullish(), + node_id: z.string().nullish(), + node_type: z.string().nullish(), + outputs: z.unknown().optional(), + outputs_truncated: z.boolean().nullish(), + predecessor_node_id: z.string().nullish(), + process_data: z.unknown().optional(), + process_data_truncated: z.boolean().nullish(), + status: z.string().nullish(), + title: z.string().nullish(), +}) + +/** + * WorkflowRunNodeExecutionListResponse + */ +export const zWorkflowRunNodeExecutionListResponse = z.object({ + data: z.array(zWorkflowRunNodeExecutionResponse), +}) + +/** + * WorkflowConversationVariableResponse + */ +export const zWorkflowConversationVariableResponse = z.object({ + description: z.string(), + id: z.string(), + name: z.string(), + value: z.record(z.string(), z.unknown()), + value_type: z.string(), +}) + +/** + * WorkflowEnvironmentVariableResponse + */ +export const zWorkflowEnvironmentVariableResponse = z.object({ + description: z.string(), + id: z.string(), + name: z.string(), + value: z.record(z.string(), z.unknown()), + value_type: z.string(), +}) + +/** + * PipelineVariableResponse + */ +export const zPipelineVariableResponse = z.object({ + allowed_file_extensions: z.array(z.string()).nullish(), + allowed_file_types: z.array(z.string()).nullish(), + allowed_file_upload_methods: z.array(z.string()).nullish(), + belong_to_node_id: z.string(), + default_value: z.record(z.string(), z.unknown()).optional(), + label: z.string(), + max_length: z.int().nullish(), + options: z.array(z.string()).nullish(), + placeholder: z.string().nullish(), + required: z.boolean(), + tooltips: z.string().nullish(), + type: z.string(), + unit: z.string().nullish(), + variable: z.string(), +}) + +/** + * SnippetWorkflowResponse + */ +export const zSnippetWorkflowResponse = z.object({ + conversation_variables: z.array(zWorkflowConversationVariableResponse), + created_at: z.int(), + created_by: zSimpleAccount.optional(), + environment_variables: z.array(zWorkflowEnvironmentVariableResponse), + features: z.record(z.string(), z.unknown()), + graph: z.record(z.string(), z.unknown()), + hash: z.string(), + id: z.string(), + input_fields: z.array(z.record(z.string(), z.unknown())).optional(), + marked_comment: z.string(), + marked_name: z.string(), + rag_pipeline_variables: z.array(zPipelineVariableResponse), + tool_published: z.boolean(), + updated_at: z.int(), + updated_by: zSimpleAccount.optional(), + version: z.string(), +}) + +/** + * WorkflowResponse + */ +export const zWorkflowResponse = z.object({ + conversation_variables: z.array(zWorkflowConversationVariableResponse), + created_at: z.int(), + created_by: zSimpleAccount.optional(), + environment_variables: z.array(zWorkflowEnvironmentVariableResponse), + features: z.record(z.string(), z.unknown()), + graph: z.record(z.string(), z.unknown()), + hash: z.string(), + id: z.string(), + marked_comment: z.string(), + marked_name: z.string(), + rag_pipeline_variables: z.array(zPipelineVariableResponse), + tool_published: z.boolean(), + updated_at: z.int(), + updated_by: zSimpleAccount.optional(), + version: z.string(), +}) + +/** + * WorkflowPaginationResponse + */ +export const zWorkflowPaginationResponse = z.object({ + has_more: z.boolean(), + items: z.array(zWorkflowResponse), + limit: z.int(), + page: z.int(), +}) + +export const zWorkflowDraftVariableWithoutValue = z.object({ + description: z.string().optional(), + edited: z.boolean().optional(), + id: z.string().optional(), + is_truncated: z.boolean().optional(), + name: z.string().optional(), + selector: z.array(z.string()).optional(), + type: z.string().optional(), + value_type: z.string().optional(), + visible: z.boolean().optional(), +}) + +export const zWorkflowDraftVariableListWithoutValue = z.object({ + items: z.array(zWorkflowDraftVariableWithoutValue).optional(), + total: z.record(z.string(), z.unknown()).optional(), +}) + +export const zGetSnippetsBySnippetIdWorkflowRunsPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Workflow runs retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowRunsResponse = zWorkflowRunPaginationResponse + +export const zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopPath = z.object({ + snippet_id: z.string(), + task_id: z.string(), +}) + +/** + * Task stopped successfully + */ +export const zPostSnippetsBySnippetIdWorkflowRunsTasksByTaskIdStopResponse = z.record( + z.string(), + z.unknown(), +) + +export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdPath = z.object({ + run_id: z.string(), + snippet_id: z.string(), +}) + +/** + * Workflow run detail retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdResponse = zWorkflowRunDetailResponse + +export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsPath = z.object({ + run_id: z.string(), + snippet_id: z.string(), +}) + +/** + * Node executions retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowRunsByRunIdNodeExecutionsResponse + = zWorkflowRunNodeExecutionListResponse + +export const zGetSnippetsBySnippetIdWorkflowsPath = z.object({ + snippet_id: z.string(), +}) + +export const zGetSnippetsBySnippetIdWorkflowsQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(10), + page: z.int().gte(1).lte(99999).optional().default(1), +}) + +/** + * Published workflows retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowsResponse = zWorkflowPaginationResponse + +export const zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Default block configs retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowsDefaultWorkflowBlockConfigsResponse = z.record( + z.string(), + z.unknown(), +) + +export const zGetSnippetsBySnippetIdWorkflowsDraftPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Draft workflow retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowsDraftResponse = zSnippetWorkflowResponse + +export const zPostSnippetsBySnippetIdWorkflowsDraftBody = zSnippetDraftSyncPayload + +export const zPostSnippetsBySnippetIdWorkflowsDraftPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Draft workflow synced successfully + */ +export const zPostSnippetsBySnippetIdWorkflowsDraftResponse = z.record(z.string(), z.unknown()) + +export const zGetSnippetsBySnippetIdWorkflowsDraftConfigPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Draft config retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowsDraftConfigResponse = z.record(z.string(), z.unknown()) + +export const zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Conversation variables retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowsDraftConversationVariablesResponse + = zWorkflowDraftVariableList + +export const zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Environment variables retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowsDraftEnvironmentVariablesResponse = z.record( + z.string(), + z.unknown(), +) + +export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunBody + = zSnippetIterationNodeRunPayload + +export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunPath = z.object({ + node_id: z.string(), + snippet_id: z.string(), +}) + +/** + * Iteration node run started successfully (SSE stream) + */ +export const zPostSnippetsBySnippetIdWorkflowsDraftIterationNodesByNodeIdRunResponse = z.record( + z.string(), + z.unknown(), +) + +export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunBody + = zSnippetLoopNodeRunPayload + +export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunPath = z.object({ + node_id: z.string(), + snippet_id: z.string(), +}) + +/** + * Loop node run started successfully (SSE stream) + */ +export const zPostSnippetsBySnippetIdWorkflowsDraftLoopNodesByNodeIdRunResponse = z.record( + z.string(), + z.unknown(), +) + +export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunPath = z.object({ + node_id: z.string(), + snippet_id: z.string(), +}) + +/** + * Node last run retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdLastRunResponse + = zWorkflowRunNodeExecutionResponse + +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunBody + = zSnippetDraftNodeRunPayload + +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunPath = z.object({ + node_id: z.string(), + snippet_id: z.string(), +}) + +/** + * Node run completed successfully + */ +export const zPostSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdRunResponse + = zWorkflowRunNodeExecutionResponse + +export const zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ + node_id: z.string(), + snippet_id: z.string(), +}) + +/** + * Node variables deleted successfully + */ +export const zDeleteSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse = z.record( + z.string(), + z.never(), +) + +export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesPath = z.object({ + node_id: z.string(), + snippet_id: z.string(), +}) + +/** + * Node variables retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowsDraftNodesByNodeIdVariablesResponse + = zWorkflowDraftVariableList + +export const zPostSnippetsBySnippetIdWorkflowsDraftRunBody = zSnippetDraftRunPayload + +export const zPostSnippetsBySnippetIdWorkflowsDraftRunPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Draft workflow run started successfully (SSE stream) + */ +export const zPostSnippetsBySnippetIdWorkflowsDraftRunResponse = z.record(z.string(), z.unknown()) + +export const zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesPath = z.object({ + snippet_id: z.string(), +}) + +/** + * System variables retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowsDraftSystemVariablesResponse + = zWorkflowDraftVariableList + +export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Workflow variables deleted successfully + */ +export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesResponse = z.record( + z.string(), + z.never(), +) + +export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesPath = z.object({ + snippet_id: z.string(), +}) + +export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesQuery = z.object({ + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).lte(100000).optional().default(1), +}) + +/** + * Workflow variables retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesResponse + = zWorkflowDraftVariableListWithoutValue + +export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = z.object({ + snippet_id: z.string(), + variable_id: z.string(), +}) + +/** + * Variable deleted successfully + */ +export const zDeleteSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse = z.record( + z.string(), + z.never(), +) + +export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = z.object({ + snippet_id: z.string(), + variable_id: z.string(), +}) + +/** + * Variable retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse + = zWorkflowDraftVariable + +export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdBody + = zWorkflowDraftVariableUpdatePayload + +export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdPath = z.object({ + snippet_id: z.string(), + variable_id: z.string(), +}) + +/** + * Variable updated successfully + */ +export const zPatchSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResponse + = zWorkflowDraftVariable + +export const zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetPath = z.object({ + snippet_id: z.string(), + variable_id: z.string(), +}) + +export const zPutSnippetsBySnippetIdWorkflowsDraftVariablesByVariableIdResetResponse = z.union([ + zWorkflowDraftVariable, + z.record(z.string(), z.never()), +]) + +export const zGetSnippetsBySnippetIdWorkflowsPublishPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Published workflow retrieved successfully + */ +export const zGetSnippetsBySnippetIdWorkflowsPublishResponse = zSnippetWorkflowResponse + +export const zPostSnippetsBySnippetIdWorkflowsPublishBody = zPublishWorkflowPayload + +export const zPostSnippetsBySnippetIdWorkflowsPublishPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Workflow published successfully + */ +export const zPostSnippetsBySnippetIdWorkflowsPublishResponse = z.record(z.string(), z.unknown()) + +export const zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestorePath = z.object({ + snippet_id: z.string(), + workflow_id: z.string(), +}) + +/** + * Workflow restored successfully + */ +export const zPostSnippetsBySnippetIdWorkflowsByWorkflowIdRestoreResponse = z.record( + z.string(), + z.unknown(), +) diff --git a/packages/contracts/generated/api/console/tag-bindings/types.gen.ts b/packages/contracts/generated/api/console/tag-bindings/types.gen.ts index 98f7424449..967be7d0f4 100644 --- a/packages/contracts/generated/api/console/tag-bindings/types.gen.ts +++ b/packages/contracts/generated/api/console/tag-bindings/types.gen.ts @@ -20,7 +20,7 @@ export type TagBindingRemovePayload = { type: TagType } -export type TagType = 'app' | 'knowledge' +export type TagType = 'app' | 'knowledge' | 'snippet' export type PostTagBindingsData = { body: TagBindingPayload diff --git a/packages/contracts/generated/api/console/tag-bindings/zod.gen.ts b/packages/contracts/generated/api/console/tag-bindings/zod.gen.ts index a734d0874f..566922edcf 100644 --- a/packages/contracts/generated/api/console/tag-bindings/zod.gen.ts +++ b/packages/contracts/generated/api/console/tag-bindings/zod.gen.ts @@ -14,7 +14,7 @@ export const zSimpleResultResponse = z.object({ * * Tag type */ -export const zTagType = z.enum(['app', 'knowledge']) +export const zTagType = z.enum(['app', 'knowledge', 'snippet']) /** * TagBindingPayload diff --git a/packages/contracts/generated/api/console/tags/types.gen.ts b/packages/contracts/generated/api/console/tags/types.gen.ts index c43abdbb00..8ecf1a55e7 100644 --- a/packages/contracts/generated/api/console/tags/types.gen.ts +++ b/packages/contracts/generated/api/console/tags/types.gen.ts @@ -20,7 +20,7 @@ export type TagUpdateRequestPayload = { name: string } -export type TagType = 'app' | 'knowledge' +export type TagType = 'app' | 'knowledge' | 'snippet' export type GetTagsData = { body?: never diff --git a/packages/contracts/generated/api/console/tags/zod.gen.ts b/packages/contracts/generated/api/console/tags/zod.gen.ts index 263b67b083..b0479b0950 100644 --- a/packages/contracts/generated/api/console/tags/zod.gen.ts +++ b/packages/contracts/generated/api/console/tags/zod.gen.ts @@ -24,7 +24,7 @@ export const zTagUpdateRequestPayload = z.object({ * * Tag type */ -export const zTagType = z.enum(['app', 'knowledge']) +export const zTagType = z.enum(['app', 'knowledge', 'snippet']) /** * TagBasePayload diff --git a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts index 766859ec81..91a36f10b6 100644 --- a/packages/contracts/generated/api/console/workspaces/orpc.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/orpc.gen.ts @@ -4,6 +4,8 @@ import { oc } from '@orpc/contract' import * as z from 'zod' import { + zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdPath, + zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse, zDeleteWorkspacesCurrentEndpointsByIdPath, zDeleteWorkspacesCurrentEndpointsByIdResponse, zDeleteWorkspacesCurrentMembersByMemberIdPath, @@ -28,6 +30,14 @@ import { zGetWorkspacesCurrentAgentProviderByProviderNamePath, zGetWorkspacesCurrentAgentProviderByProviderNameResponse, zGetWorkspacesCurrentAgentProvidersResponse, + zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesPath, + zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse, + zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportPath, + zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse, + zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdPath, + zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse, + zGetWorkspacesCurrentCustomizedSnippetsQuery, + zGetWorkspacesCurrentCustomizedSnippetsResponse, zGetWorkspacesCurrentDatasetOperatorsResponse, zGetWorkspacesCurrentDefaultModelQuery, zGetWorkspacesCurrentDefaultModelResponse, @@ -122,6 +132,9 @@ import { zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse, zGetWorkspacesCurrentTriggersResponse, zGetWorkspacesResponse, + zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdBody, + zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdPath, + zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse, zPatchWorkspacesCurrentEndpointsByIdBody, zPatchWorkspacesCurrentEndpointsByIdPath, zPatchWorkspacesCurrentEndpointsByIdResponse, @@ -131,6 +144,14 @@ import { zPatchWorkspacesCurrentModelProvidersByProviderModelsEnableBody, zPatchWorkspacesCurrentModelProvidersByProviderModelsEnablePath, zPatchWorkspacesCurrentModelProvidersByProviderModelsEnableResponse, + zPostWorkspacesCurrentCustomizedSnippetsBody, + zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementPath, + zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponse, + zPostWorkspacesCurrentCustomizedSnippetsImportsBody, + zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmPath, + zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponse, + zPostWorkspacesCurrentCustomizedSnippetsImportsResponse, + zPostWorkspacesCurrentCustomizedSnippetsResponse, zPostWorkspacesCurrentDefaultModelBody, zPostWorkspacesCurrentDefaultModelResponse, zPostWorkspacesCurrentEndpointsBody, @@ -349,7 +370,286 @@ export const agentProviders = { get: get2, } +/** + * Confirm a pending snippet import + * + * Confirm a pending snippet import + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post = oc + .route({ + deprecated: true, + description: + 'Confirm a pending snippet import\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirm', + path: '/workspaces/current/customized-snippets/imports/{import_id}/confirm', + summary: 'Confirm a pending snippet import', + tags: ['console'], + }) + .input(z.object({ params: zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmPath })) + .output(zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponse) + +export const confirm = { + post, +} + +export const byImportId = { + confirm, +} + +/** + * Import snippet from DSL + * + * Import snippet from DSL + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post2 = oc + .route({ + deprecated: true, + description: + 'Import snippet from DSL\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentCustomizedSnippetsImports', + path: '/workspaces/current/customized-snippets/imports', + summary: 'Import snippet from DSL', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentCustomizedSnippetsImportsBody })) + .output(zPostWorkspacesCurrentCustomizedSnippetsImportsResponse) + +export const imports = { + post: post2, + byImportId, +} + +/** + * Check dependencies for a snippet + * + * Check dependencies for a snippet + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ export const get3 = oc + .route({ + deprecated: true, + description: + 'Check dependencies for a snippet\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependencies', + path: '/workspaces/current/customized-snippets/{snippet_id}/check-dependencies', + summary: 'Check dependencies for a snippet', + tags: ['console'], + }) + .input( + z.object({ params: zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesPath }), + ) + .output(zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse) + +export const checkDependencies = { + get: get3, +} + +/** + * Export snippet as DSL + * + * Export snippet configuration as DSL + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get4 = oc + .route({ + deprecated: true, + description: + 'Export snippet configuration as DSL\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentCustomizedSnippetsBySnippetIdExport', + path: '/workspaces/current/customized-snippets/{snippet_id}/export', + summary: 'Export snippet as DSL', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportPath })) + .output(zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse) + +export const export_ = { + get: get4, +} + +/** + * Increment snippet use count when it is inserted into a workflow + * + * Increment snippet use count by 1 + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post3 = oc + .route({ + deprecated: true, + description: + 'Increment snippet use count by 1\n\nGenerated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrement', + path: '/workspaces/current/customized-snippets/{snippet_id}/use-count/increment', + summary: 'Increment snippet use count when it is inserted into a workflow', + tags: ['console'], + }) + .input( + z.object({ params: zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementPath }), + ) + .output(zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponse) + +export const increment = { + post: post3, +} + +export const useCount = { + increment, +} + +/** + * Delete customized snippet + */ +export const delete_ = oc + .route({ + inputStructure: 'detailed', + method: 'DELETE', + operationId: 'deleteWorkspacesCurrentCustomizedSnippetsBySnippetId', + path: '/workspaces/current/customized-snippets/{snippet_id}', + successStatus: 204, + summary: 'Delete customized snippet', + tags: ['console'], + }) + .input(z.object({ params: zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdPath })) + .output(zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse) + +/** + * Get customized snippet details + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get5 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentCustomizedSnippetsBySnippetId', + path: '/workspaces/current/customized-snippets/{snippet_id}', + summary: 'Get customized snippet details', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdPath })) + .output(zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse) + +/** + * Update customized snippet + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const patch = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'PATCH', + operationId: 'patchWorkspacesCurrentCustomizedSnippetsBySnippetId', + path: '/workspaces/current/customized-snippets/{snippet_id}', + summary: 'Update customized snippet', + tags: ['console'], + }) + .input( + z.object({ + body: zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdBody, + params: zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdPath, + }), + ) + .output(zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse) + +export const bySnippetId = { + delete: delete_, + get: get5, + patch, + checkDependencies, + export: export_, + useCount, +} + +/** + * List customized snippets with pagination and search + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get6 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentCustomizedSnippets', + path: '/workspaces/current/customized-snippets', + summary: 'List customized snippets with pagination and search', + tags: ['console'], + }) + .input(z.object({ query: zGetWorkspacesCurrentCustomizedSnippetsQuery.optional() })) + .output(zGetWorkspacesCurrentCustomizedSnippetsResponse) + +/** + * Create a new customized snippet + * + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post4 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentCustomizedSnippets', + path: '/workspaces/current/customized-snippets', + successStatus: 201, + summary: 'Create a new customized snippet', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentCustomizedSnippetsBody })) + .output(zPostWorkspacesCurrentCustomizedSnippetsResponse) + +export const customizedSnippets = { + get: get6, + post: post4, + imports, + bySnippetId, +} + +export const get7 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -360,7 +660,7 @@ export const get3 = oc .output(zGetWorkspacesCurrentDatasetOperatorsResponse) export const datasetOperators = { - get: get3, + get: get7, } /** @@ -368,7 +668,7 @@ export const datasetOperators = { * * @deprecated */ -export const get4 = oc +export const get8 = oc .route({ deprecated: true, description: @@ -382,7 +682,7 @@ export const get4 = oc .input(z.object({ query: zGetWorkspacesCurrentDefaultModelQuery })) .output(zGetWorkspacesCurrentDefaultModelResponse) -export const post = oc +export const post5 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -394,8 +694,8 @@ export const post = oc .output(zPostWorkspacesCurrentDefaultModelResponse) export const defaultModel = { - get: get4, - post, + get: get8, + post: post5, } /** @@ -405,7 +705,7 @@ export const defaultModel = { * * @deprecated */ -export const post2 = oc +export const post6 = oc .route({ deprecated: true, description: @@ -420,7 +720,7 @@ export const post2 = oc .output(zPostWorkspacesCurrentEndpointsCreateResponse) export const create = { - post: post2, + post: post6, } /** @@ -428,7 +728,7 @@ export const create = { * * @deprecated */ -export const post3 = oc +export const post7 = oc .route({ deprecated: true, description: @@ -442,14 +742,14 @@ export const post3 = oc .input(z.object({ body: zPostWorkspacesCurrentEndpointsDeleteBody })) .output(zPostWorkspacesCurrentEndpointsDeleteResponse) -export const delete_ = { - post: post3, +export const delete2 = { + post: post7, } /** * Disable a plugin endpoint */ -export const post4 = oc +export const post8 = oc .route({ description: 'Disable a plugin endpoint', inputStructure: 'detailed', @@ -462,13 +762,13 @@ export const post4 = oc .output(zPostWorkspacesCurrentEndpointsDisableResponse) export const disable = { - post: post4, + post: post8, } /** * Enable a plugin endpoint */ -export const post5 = oc +export const post9 = oc .route({ description: 'Enable a plugin endpoint', inputStructure: 'detailed', @@ -481,7 +781,7 @@ export const post5 = oc .output(zPostWorkspacesCurrentEndpointsEnableResponse) export const enable = { - post: post5, + post: post9, } /** @@ -491,7 +791,7 @@ export const enable = { * * @deprecated */ -export const get5 = oc +export const get9 = oc .route({ deprecated: true, description: @@ -506,7 +806,7 @@ export const get5 = oc .output(zGetWorkspacesCurrentEndpointsListPluginResponse) export const plugin = { - get: get5, + get: get9, } /** @@ -516,7 +816,7 @@ export const plugin = { * * @deprecated */ -export const get6 = oc +export const get10 = oc .route({ deprecated: true, description: @@ -531,7 +831,7 @@ export const get6 = oc .output(zGetWorkspacesCurrentEndpointsListResponse) export const list = { - get: get6, + get: get10, plugin, } @@ -542,7 +842,7 @@ export const list = { * * @deprecated */ -export const post6 = oc +export const post10 = oc .route({ deprecated: true, description: @@ -557,13 +857,13 @@ export const post6 = oc .output(zPostWorkspacesCurrentEndpointsUpdateResponse) export const update = { - post: post6, + post: post10, } /** * Delete a plugin endpoint */ -export const delete2 = oc +export const delete3 = oc .route({ description: 'Delete a plugin endpoint', inputStructure: 'detailed', @@ -582,7 +882,7 @@ export const delete2 = oc * * @deprecated */ -export const patch = oc +export const patch2 = oc .route({ deprecated: true, description: @@ -602,8 +902,8 @@ export const patch = oc .output(zPatchWorkspacesCurrentEndpointsByIdResponse) export const byId = { - delete: delete2, - patch, + delete: delete3, + patch: patch2, } /** @@ -613,7 +913,7 @@ export const byId = { * * @deprecated */ -export const post7 = oc +export const post11 = oc .route({ deprecated: true, description: @@ -628,9 +928,9 @@ export const post7 = oc .output(zPostWorkspacesCurrentEndpointsResponse) export const endpoints = { - post: post7, + post: post11, create, - delete: delete_, + delete: delete2, disable, enable, list, @@ -643,7 +943,7 @@ export const endpoints = { * * @deprecated */ -export const post8 = oc +export const post12 = oc .route({ deprecated: true, description: @@ -658,10 +958,10 @@ export const post8 = oc .output(zPostWorkspacesCurrentMembersInviteEmailResponse) export const inviteEmail = { - post: post8, + post: post12, } -export const post9 = oc +export const post13 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -673,10 +973,10 @@ export const post9 = oc .output(zPostWorkspacesCurrentMembersOwnerTransferCheckResponse) export const ownerTransferCheck = { - post: post9, + post: post13, } -export const post10 = oc +export const post14 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -688,7 +988,7 @@ export const post10 = oc .output(zPostWorkspacesCurrentMembersSendOwnerTransferConfirmEmailResponse) export const sendOwnerTransferConfirmEmail = { - post: post10, + post: post14, } /** @@ -696,7 +996,7 @@ export const sendOwnerTransferConfirmEmail = { * * @deprecated */ -export const post11 = oc +export const post15 = oc .route({ deprecated: true, description: @@ -716,7 +1016,7 @@ export const post11 = oc .output(zPostWorkspacesCurrentMembersByMemberIdOwnerTransferResponse) export const ownerTransfer = { - post: post11, + post: post15, } /** @@ -752,7 +1052,7 @@ export const updateRole = { * * @deprecated */ -export const delete3 = oc +export const delete4 = oc .route({ deprecated: true, description: @@ -767,12 +1067,12 @@ export const delete3 = oc .output(zDeleteWorkspacesCurrentMembersByMemberIdResponse) export const byMemberId = { - delete: delete3, + delete: delete4, ownerTransfer, updateRole, } -export const get7 = oc +export const get11 = oc .route({ inputStructure: 'detailed', method: 'GET', @@ -783,7 +1083,7 @@ export const get7 = oc .output(zGetWorkspacesCurrentMembersResponse) export const members = { - get: get7, + get: get11, inviteEmail, ownerTransferCheck, sendOwnerTransferConfirmEmail, @@ -795,7 +1095,7 @@ export const members = { * * @deprecated */ -export const get8 = oc +export const get12 = oc .route({ deprecated: true, description: @@ -810,10 +1110,10 @@ export const get8 = oc .output(zGetWorkspacesCurrentModelProvidersByProviderCheckoutUrlResponse) export const checkoutUrl = { - get: get8, + get: get12, } -export const post12 = oc +export const post16 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -830,7 +1130,7 @@ export const post12 = oc .output(zPostWorkspacesCurrentModelProvidersByProviderCredentialsSwitchResponse) export const switch_ = { - post: post12, + post: post16, } /** @@ -838,7 +1138,7 @@ export const switch_ = { * * @deprecated */ -export const post13 = oc +export const post17 = oc .route({ deprecated: true, description: @@ -858,10 +1158,10 @@ export const post13 = oc .output(zPostWorkspacesCurrentModelProvidersByProviderCredentialsValidateResponse) export const validate = { - post: post13, + post: post17, } -export const delete4 = oc +export const delete5 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -883,7 +1183,7 @@ export const delete4 = oc * * @deprecated */ -export const get9 = oc +export const get13 = oc .route({ deprecated: true, description: @@ -907,7 +1207,7 @@ export const get9 = oc * * @deprecated */ -export const post14 = oc +export const post18 = oc .route({ deprecated: true, description: @@ -951,15 +1251,15 @@ export const put2 = oc .output(zPutWorkspacesCurrentModelProvidersByProviderCredentialsResponse) export const credentials = { - delete: delete4, - get: get9, - post: post14, + delete: delete5, + get: get13, + post: post18, put: put2, switch: switch_, validate, } -export const post15 = oc +export const post19 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -976,7 +1276,7 @@ export const post15 = oc .output(zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsSwitchResponse) export const switch2 = { - post: post15, + post: post19, } /** @@ -984,7 +1284,7 @@ export const switch2 = { * * @deprecated */ -export const post16 = oc +export const post20 = oc .route({ deprecated: true, description: @@ -1004,10 +1304,10 @@ export const post16 = oc .output(zPostWorkspacesCurrentModelProvidersByProviderModelsCredentialsValidateResponse) export const validate2 = { - post: post16, + post: post20, } -export const delete5 = oc +export const delete6 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1029,7 +1329,7 @@ export const delete5 = oc * * @deprecated */ -export const get10 = oc +export const get14 = oc .route({ deprecated: true, description: @@ -1053,7 +1353,7 @@ export const get10 = oc * * @deprecated */ -export const post17 = oc +export const post21 = oc .route({ deprecated: true, description: @@ -1097,15 +1397,15 @@ export const put3 = oc .output(zPutWorkspacesCurrentModelProvidersByProviderModelsCredentialsResponse) export const credentials2 = { - delete: delete5, - get: get10, - post: post17, + delete: delete6, + get: get14, + post: post21, put: put3, switch: switch2, validate: validate2, } -export const patch2 = oc +export const patch3 = oc .route({ inputStructure: 'detailed', method: 'PATCH', @@ -1122,10 +1422,10 @@ export const patch2 = oc .output(zPatchWorkspacesCurrentModelProvidersByProviderModelsDisableResponse) export const disable2 = { - patch: patch2, + patch: patch3, } -export const patch3 = oc +export const patch4 = oc .route({ inputStructure: 'detailed', method: 'PATCH', @@ -1142,7 +1442,7 @@ export const patch3 = oc .output(zPatchWorkspacesCurrentModelProvidersByProviderModelsEnableResponse) export const enable2 = { - patch: patch3, + patch: patch4, } /** @@ -1150,7 +1450,7 @@ export const enable2 = { * * @deprecated */ -export const post18 = oc +export const post22 = oc .route({ deprecated: true, description: @@ -1174,7 +1474,7 @@ export const post18 = oc ) export const credentialsValidate = { - post: post18, + post: post22, } /** @@ -1182,7 +1482,7 @@ export const credentialsValidate = { * * @deprecated */ -export const post19 = oc +export const post23 = oc .route({ deprecated: true, description: @@ -1206,7 +1506,7 @@ export const post19 = oc ) export const credentialsValidate2 = { - post: post19, + post: post23, } export const byConfigId = { @@ -1223,7 +1523,7 @@ export const loadBalancingConfigs = { * * @deprecated */ -export const get11 = oc +export const get15 = oc .route({ deprecated: true, description: @@ -1243,10 +1543,10 @@ export const get11 = oc .output(zGetWorkspacesCurrentModelProvidersByProviderModelsParameterRulesResponse) export const parameterRules = { - get: get11, + get: get15, } -export const delete6 = oc +export const delete7 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -1268,7 +1568,7 @@ export const delete6 = oc * * @deprecated */ -export const get12 = oc +export const get16 = oc .route({ deprecated: true, description: @@ -1287,7 +1587,7 @@ export const get12 = oc * * @deprecated */ -export const post20 = oc +export const post24 = oc .route({ deprecated: true, description: @@ -1307,9 +1607,9 @@ export const post20 = oc .output(zPostWorkspacesCurrentModelProvidersByProviderModelsResponse) export const models = { - delete: delete6, - get: get12, - post: post20, + delete: delete7, + get: get16, + post: post24, credentials: credentials2, disable: disable2, enable: enable2, @@ -1317,7 +1617,7 @@ export const models = { parameterRules, } -export const post21 = oc +export const post25 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1334,7 +1634,7 @@ export const post21 = oc .output(zPostWorkspacesCurrentModelProvidersByProviderPreferredProviderTypeResponse) export const preferredProviderType = { - post: post21, + post: post25, } export const byProvider = { @@ -1349,7 +1649,7 @@ export const byProvider = { * * @deprecated */ -export const get13 = oc +export const get17 = oc .route({ deprecated: true, description: @@ -1364,7 +1664,7 @@ export const get13 = oc .output(zGetWorkspacesCurrentModelProvidersResponse) export const modelProviders = { - get: get13, + get: get17, byProvider, } @@ -1373,7 +1673,7 @@ export const modelProviders = { * * @deprecated */ -export const get14 = oc +export const get18 = oc .route({ deprecated: true, description: @@ -1388,7 +1688,7 @@ export const get14 = oc .output(zGetWorkspacesCurrentModelsModelTypesByModelTypeResponse) export const byModelType = { - get: get14, + get: get18, } export const modelTypes = { @@ -1404,7 +1704,7 @@ export const models2 = { * * Returns permission flags that control workspace features like member invitations and owner transfer. */ -export const get15 = oc +export const get19 = oc .route({ description: 'Returns permission flags that control workspace features like member invitations and owner transfer.', @@ -1418,217 +1718,9 @@ export const get15 = oc .output(zGetWorkspacesCurrentPermissionResponse) export const permission = { - get: get15, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get16 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentPluginAsset', - path: '/workspaces/current/plugin/asset', - tags: ['console'], - }) - .input(z.object({ query: zGetWorkspacesCurrentPluginAssetQuery })) - .output(zGetWorkspacesCurrentPluginAssetResponse) - -export const asset = { - get: get16, -} - -export const get17 = oc - .route({ - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentPluginDebuggingKey', - path: '/workspaces/current/plugin/debugging-key', - tags: ['console'], - }) - .output(zGetWorkspacesCurrentPluginDebuggingKeyResponse) - -export const debuggingKey = { - get: get17, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get18 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentPluginFetchManifest', - path: '/workspaces/current/plugin/fetch-manifest', - tags: ['console'], - }) - .input(z.object({ query: zGetWorkspacesCurrentPluginFetchManifestQuery })) - .output(zGetWorkspacesCurrentPluginFetchManifestResponse) - -export const fetchManifest = { - get: get18, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get19 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentPluginIcon', - path: '/workspaces/current/plugin/icon', - tags: ['console'], - }) - .input(z.object({ query: zGetWorkspacesCurrentPluginIconQuery })) - .output(zGetWorkspacesCurrentPluginIconResponse) - -export const icon = { get: get19, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post22 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginInstallGithub', - path: '/workspaces/current/plugin/install/github', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginInstallGithubBody })) - .output(zPostWorkspacesCurrentPluginInstallGithubResponse) - -export const github = { - post: post22, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post23 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginInstallMarketplace', - path: '/workspaces/current/plugin/install/marketplace', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginInstallMarketplaceBody })) - .output(zPostWorkspacesCurrentPluginInstallMarketplaceResponse) - -export const marketplace = { - post: post23, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post24 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginInstallPkg', - path: '/workspaces/current/plugin/install/pkg', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginInstallPkgBody })) - .output(zPostWorkspacesCurrentPluginInstallPkgResponse) - -export const pkg = { - post: post24, -} - -export const install = { - github, - marketplace, - pkg, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post25 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginListInstallationsIds', - path: '/workspaces/current/plugin/list/installations/ids', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginListInstallationsIdsBody })) - .output(zPostWorkspacesCurrentPluginListInstallationsIdsResponse) - -export const ids = { - post: post25, -} - -export const installations = { - ids, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post26 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginListLatestVersions', - path: '/workspaces/current/plugin/list/latest-versions', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginListLatestVersionsBody })) - .output(zPostWorkspacesCurrentPluginListLatestVersionsResponse) - -export const latestVersions = { - post: post26, -} - /** * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. * @@ -1641,52 +1733,260 @@ export const get20 = oc 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', - operationId: 'getWorkspacesCurrentPluginList', - path: '/workspaces/current/plugin/list', + operationId: 'getWorkspacesCurrentPluginAsset', + path: '/workspaces/current/plugin/asset', tags: ['console'], }) - .input(z.object({ query: zGetWorkspacesCurrentPluginListQuery.optional() })) - .output(zGetWorkspacesCurrentPluginListResponse) + .input(z.object({ query: zGetWorkspacesCurrentPluginAssetQuery })) + .output(zGetWorkspacesCurrentPluginAssetResponse) -export const list2 = { +export const asset = { get: get20, - installations, - latestVersions, } -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ export const get21 = oc .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', inputStructure: 'detailed', method: 'GET', - operationId: 'getWorkspacesCurrentPluginMarketplacePkg', - path: '/workspaces/current/plugin/marketplace/pkg', + operationId: 'getWorkspacesCurrentPluginDebuggingKey', + path: '/workspaces/current/plugin/debugging-key', tags: ['console'], }) - .input(z.object({ query: zGetWorkspacesCurrentPluginMarketplacePkgQuery })) - .output(zGetWorkspacesCurrentPluginMarketplacePkgResponse) + .output(zGetWorkspacesCurrentPluginDebuggingKeyResponse) -export const pkg2 = { +export const debuggingKey = { get: get21, } -export const marketplace2 = { - pkg: pkg2, -} - /** * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. * * @deprecated */ export const get22 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentPluginFetchManifest', + path: '/workspaces/current/plugin/fetch-manifest', + tags: ['console'], + }) + .input(z.object({ query: zGetWorkspacesCurrentPluginFetchManifestQuery })) + .output(zGetWorkspacesCurrentPluginFetchManifestResponse) + +export const fetchManifest = { + get: get22, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get23 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentPluginIcon', + path: '/workspaces/current/plugin/icon', + tags: ['console'], + }) + .input(z.object({ query: zGetWorkspacesCurrentPluginIconQuery })) + .output(zGetWorkspacesCurrentPluginIconResponse) + +export const icon = { + get: get23, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post26 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginInstallGithub', + path: '/workspaces/current/plugin/install/github', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginInstallGithubBody })) + .output(zPostWorkspacesCurrentPluginInstallGithubResponse) + +export const github = { + post: post26, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post27 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginInstallMarketplace', + path: '/workspaces/current/plugin/install/marketplace', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginInstallMarketplaceBody })) + .output(zPostWorkspacesCurrentPluginInstallMarketplaceResponse) + +export const marketplace = { + post: post27, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post28 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginInstallPkg', + path: '/workspaces/current/plugin/install/pkg', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginInstallPkgBody })) + .output(zPostWorkspacesCurrentPluginInstallPkgResponse) + +export const pkg = { + post: post28, +} + +export const install = { + github, + marketplace, + pkg, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post29 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginListInstallationsIds', + path: '/workspaces/current/plugin/list/installations/ids', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginListInstallationsIdsBody })) + .output(zPostWorkspacesCurrentPluginListInstallationsIdsResponse) + +export const ids = { + post: post29, +} + +export const installations = { + ids, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post30 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginListLatestVersions', + path: '/workspaces/current/plugin/list/latest-versions', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginListLatestVersionsBody })) + .output(zPostWorkspacesCurrentPluginListLatestVersionsResponse) + +export const latestVersions = { + post: post30, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get24 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentPluginList', + path: '/workspaces/current/plugin/list', + tags: ['console'], + }) + .input(z.object({ query: zGetWorkspacesCurrentPluginListQuery.optional() })) + .output(zGetWorkspacesCurrentPluginListResponse) + +export const list2 = { + get: get24, + installations, + latestVersions, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get25 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentPluginMarketplacePkg', + path: '/workspaces/current/plugin/marketplace/pkg', + tags: ['console'], + }) + .input(z.object({ query: zGetWorkspacesCurrentPluginMarketplacePkgQuery })) + .output(zGetWorkspacesCurrentPluginMarketplacePkgResponse) + +export const pkg2 = { + get: get25, +} + +export const marketplace2 = { + pkg: pkg2, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get26 = oc .route({ deprecated: true, description: @@ -1701,7 +2001,7 @@ export const get22 = oc .output(zGetWorkspacesCurrentPluginParametersDynamicOptionsResponse) export const dynamicOptions = { - get: get22, + get: get26, } /** @@ -1711,7 +2011,7 @@ export const dynamicOptions = { * * @deprecated */ -export const post27 = oc +export const post31 = oc .route({ deprecated: true, description: @@ -1729,7 +2029,7 @@ export const post27 = oc .output(zPostWorkspacesCurrentPluginParametersDynamicOptionsWithCredentialsResponse) export const dynamicOptionsWithCredentials = { - post: post27, + post: post31, } export const parameters = { @@ -1737,7 +2037,7 @@ export const parameters = { dynamicOptionsWithCredentials, } -export const post28 = oc +export const post32 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1749,212 +2049,212 @@ export const post28 = oc .output(zPostWorkspacesCurrentPluginPermissionChangeResponse) export const change = { - post: post28, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get23 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentPluginPermissionFetch', - path: '/workspaces/current/plugin/permission/fetch', - tags: ['console'], - }) - .output(zGetWorkspacesCurrentPluginPermissionFetchResponse) - -export const fetch_ = { - get: get23, -} - -export const permission2 = { - change, - fetch: fetch_, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post29 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginPreferencesAutoupgradeExclude', - path: '/workspaces/current/plugin/preferences/autoupgrade/exclude', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeBody })) - .output(zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponse) - -export const exclude = { - post: post29, -} - -export const autoupgrade = { - exclude, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post30 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginPreferencesChange', - path: '/workspaces/current/plugin/preferences/change', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginPreferencesChangeBody })) - .output(zPostWorkspacesCurrentPluginPreferencesChangeResponse) - -export const change2 = { - post: post30, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get24 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentPluginPreferencesFetch', - path: '/workspaces/current/plugin/preferences/fetch', - tags: ['console'], - }) - .output(zGetWorkspacesCurrentPluginPreferencesFetchResponse) - -export const fetch2 = { - get: get24, -} - -export const preferences = { - autoupgrade, - change: change2, - fetch: fetch2, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get25 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentPluginReadme', - path: '/workspaces/current/plugin/readme', - tags: ['console'], - }) - .input(z.object({ query: zGetWorkspacesCurrentPluginReadmeQuery })) - .output(zGetWorkspacesCurrentPluginReadmeResponse) - -export const readme = { - get: get25, -} - -export const post31 = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginTasksDeleteAll', - path: '/workspaces/current/plugin/tasks/delete_all', - tags: ['console'], - }) - .output(zPostWorkspacesCurrentPluginTasksDeleteAllResponse) - -export const deleteAll = { - post: post31, -} - -export const post32 = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifier', - path: '/workspaces/current/plugin/tasks/{task_id}/delete/{identifier}', - tags: ['console'], - }) - .input(z.object({ params: zPostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierPath })) - .output(zPostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierResponse) - -export const byIdentifier = { post: post32, } -export const post33 = oc - .route({ - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginTasksByTaskIdDelete', - path: '/workspaces/current/plugin/tasks/{task_id}/delete', - tags: ['console'], - }) - .input(z.object({ params: zPostWorkspacesCurrentPluginTasksByTaskIdDeletePath })) - .output(zPostWorkspacesCurrentPluginTasksByTaskIdDeleteResponse) - -export const delete7 = { - post: post33, - byIdentifier, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get26 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentPluginTasksByTaskId', - path: '/workspaces/current/plugin/tasks/{task_id}', - tags: ['console'], - }) - .input(z.object({ params: zGetWorkspacesCurrentPluginTasksByTaskIdPath })) - .output(zGetWorkspacesCurrentPluginTasksByTaskIdResponse) - -export const byTaskId = { - get: get26, - delete: delete7, -} - /** * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. * * @deprecated */ export const get27 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentPluginPermissionFetch', + path: '/workspaces/current/plugin/permission/fetch', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentPluginPermissionFetchResponse) + +export const fetch_ = { + get: get27, +} + +export const permission2 = { + change, + fetch: fetch_, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post33 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginPreferencesAutoupgradeExclude', + path: '/workspaces/current/plugin/preferences/autoupgrade/exclude', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeBody })) + .output(zPostWorkspacesCurrentPluginPreferencesAutoupgradeExcludeResponse) + +export const exclude = { + post: post33, +} + +export const autoupgrade = { + exclude, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post34 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginPreferencesChange', + path: '/workspaces/current/plugin/preferences/change', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginPreferencesChangeBody })) + .output(zPostWorkspacesCurrentPluginPreferencesChangeResponse) + +export const change2 = { + post: post34, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get28 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentPluginPreferencesFetch', + path: '/workspaces/current/plugin/preferences/fetch', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentPluginPreferencesFetchResponse) + +export const fetch2 = { + get: get28, +} + +export const preferences = { + autoupgrade, + change: change2, + fetch: fetch2, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get29 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentPluginReadme', + path: '/workspaces/current/plugin/readme', + tags: ['console'], + }) + .input(z.object({ query: zGetWorkspacesCurrentPluginReadmeQuery })) + .output(zGetWorkspacesCurrentPluginReadmeResponse) + +export const readme = { + get: get29, +} + +export const post35 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginTasksDeleteAll', + path: '/workspaces/current/plugin/tasks/delete_all', + tags: ['console'], + }) + .output(zPostWorkspacesCurrentPluginTasksDeleteAllResponse) + +export const deleteAll = { + post: post35, +} + +export const post36 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifier', + path: '/workspaces/current/plugin/tasks/{task_id}/delete/{identifier}', + tags: ['console'], + }) + .input(z.object({ params: zPostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierPath })) + .output(zPostWorkspacesCurrentPluginTasksByTaskIdDeleteByIdentifierResponse) + +export const byIdentifier = { + post: post36, +} + +export const post37 = oc + .route({ + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginTasksByTaskIdDelete', + path: '/workspaces/current/plugin/tasks/{task_id}/delete', + tags: ['console'], + }) + .input(z.object({ params: zPostWorkspacesCurrentPluginTasksByTaskIdDeletePath })) + .output(zPostWorkspacesCurrentPluginTasksByTaskIdDeleteResponse) + +export const delete8 = { + post: post37, + byIdentifier, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get30 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentPluginTasksByTaskId', + path: '/workspaces/current/plugin/tasks/{task_id}', + tags: ['console'], + }) + .input(z.object({ params: zGetWorkspacesCurrentPluginTasksByTaskIdPath })) + .output(zGetWorkspacesCurrentPluginTasksByTaskIdResponse) + +export const byTaskId = { + get: get30, + delete: delete8, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get31 = oc .route({ deprecated: true, description: @@ -1969,12 +2269,12 @@ export const get27 = oc .output(zGetWorkspacesCurrentPluginTasksResponse) export const tasks = { - get: get27, + get: get31, deleteAll, byTaskId, } -export const post34 = oc +export const post38 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -1986,102 +2286,6 @@ export const post34 = oc .output(zPostWorkspacesCurrentPluginUninstallResponse) export const uninstall = { - post: post34, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post35 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginUpgradeGithub', - path: '/workspaces/current/plugin/upgrade/github', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginUpgradeGithubBody })) - .output(zPostWorkspacesCurrentPluginUpgradeGithubResponse) - -export const github2 = { - post: post35, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post36 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginUpgradeMarketplace', - path: '/workspaces/current/plugin/upgrade/marketplace', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginUpgradeMarketplaceBody })) - .output(zPostWorkspacesCurrentPluginUpgradeMarketplaceResponse) - -export const marketplace3 = { - post: post36, -} - -export const upgrade = { - github: github2, - marketplace: marketplace3, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post37 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginUploadBundle', - path: '/workspaces/current/plugin/upload/bundle', - tags: ['console'], - }) - .output(zPostWorkspacesCurrentPluginUploadBundleResponse) - -export const bundle = { - post: post37, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post38 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentPluginUploadGithub', - path: '/workspaces/current/plugin/upload/github', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentPluginUploadGithubBody })) - .output(zPostWorkspacesCurrentPluginUploadGithubResponse) - -export const github3 = { post: post38, } @@ -2091,6 +2295,102 @@ export const github3 = { * @deprecated */ export const post39 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginUpgradeGithub', + path: '/workspaces/current/plugin/upgrade/github', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginUpgradeGithubBody })) + .output(zPostWorkspacesCurrentPluginUpgradeGithubResponse) + +export const github2 = { + post: post39, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post40 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginUpgradeMarketplace', + path: '/workspaces/current/plugin/upgrade/marketplace', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginUpgradeMarketplaceBody })) + .output(zPostWorkspacesCurrentPluginUpgradeMarketplaceResponse) + +export const marketplace3 = { + post: post40, +} + +export const upgrade = { + github: github2, + marketplace: marketplace3, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post41 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginUploadBundle', + path: '/workspaces/current/plugin/upload/bundle', + tags: ['console'], + }) + .output(zPostWorkspacesCurrentPluginUploadBundleResponse) + +export const bundle = { + post: post41, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post42 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentPluginUploadGithub', + path: '/workspaces/current/plugin/upload/github', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentPluginUploadGithubBody })) + .output(zPostWorkspacesCurrentPluginUploadGithubResponse) + +export const github3 = { + post: post42, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post43 = oc .route({ deprecated: true, description: @@ -2104,7 +2404,7 @@ export const post39 = oc .output(zPostWorkspacesCurrentPluginUploadPkgResponse) export const pkg3 = { - post: post39, + post: post43, } export const upload = { @@ -2136,7 +2436,7 @@ export const plugin2 = { * * @deprecated */ -export const get28 = oc +export const get32 = oc .route({ deprecated: true, description: @@ -2150,168 +2450,6 @@ export const get28 = oc .output(zGetWorkspacesCurrentToolLabelsResponse) export const toolLabels = { - get: get28, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post40 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentToolProviderApiAdd', - path: '/workspaces/current/tool-provider/api/add', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentToolProviderApiAddBody })) - .output(zPostWorkspacesCurrentToolProviderApiAddResponse) - -export const add = { - post: post40, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post41 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentToolProviderApiDelete', - path: '/workspaces/current/tool-provider/api/delete', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentToolProviderApiDeleteBody })) - .output(zPostWorkspacesCurrentToolProviderApiDeleteResponse) - -export const delete8 = { - post: post41, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get29 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentToolProviderApiGet', - path: '/workspaces/current/tool-provider/api/get', - tags: ['console'], - }) - .output(zGetWorkspacesCurrentToolProviderApiGetResponse) - -export const get30 = { - get: get29, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get31 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentToolProviderApiRemote', - path: '/workspaces/current/tool-provider/api/remote', - tags: ['console'], - }) - .output(zGetWorkspacesCurrentToolProviderApiRemoteResponse) - -export const remote = { - get: get31, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post42 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentToolProviderApiSchema', - path: '/workspaces/current/tool-provider/api/schema', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentToolProviderApiSchemaBody })) - .output(zPostWorkspacesCurrentToolProviderApiSchemaResponse) - -export const schema = { - post: post42, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const post43 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'POST', - operationId: 'postWorkspacesCurrentToolProviderApiTestPre', - path: '/workspaces/current/tool-provider/api/test/pre', - tags: ['console'], - }) - .input(z.object({ body: zPostWorkspacesCurrentToolProviderApiTestPreBody })) - .output(zPostWorkspacesCurrentToolProviderApiTestPreResponse) - -export const pre = { - post: post43, -} - -export const test = { - pre, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get32 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentToolProviderApiTools', - path: '/workspaces/current/tool-provider/api/tools', - tags: ['console'], - }) - .output(zGetWorkspacesCurrentToolProviderApiToolsResponse) - -export const tools = { get: get32, } @@ -2321,6 +2459,168 @@ export const tools = { * @deprecated */ export const post44 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentToolProviderApiAdd', + path: '/workspaces/current/tool-provider/api/add', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentToolProviderApiAddBody })) + .output(zPostWorkspacesCurrentToolProviderApiAddResponse) + +export const add = { + post: post44, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post45 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentToolProviderApiDelete', + path: '/workspaces/current/tool-provider/api/delete', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentToolProviderApiDeleteBody })) + .output(zPostWorkspacesCurrentToolProviderApiDeleteResponse) + +export const delete9 = { + post: post45, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get33 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentToolProviderApiGet', + path: '/workspaces/current/tool-provider/api/get', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentToolProviderApiGetResponse) + +export const get34 = { + get: get33, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get35 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentToolProviderApiRemote', + path: '/workspaces/current/tool-provider/api/remote', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentToolProviderApiRemoteResponse) + +export const remote = { + get: get35, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post46 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentToolProviderApiSchema', + path: '/workspaces/current/tool-provider/api/schema', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentToolProviderApiSchemaBody })) + .output(zPostWorkspacesCurrentToolProviderApiSchemaResponse) + +export const schema = { + post: post46, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post47 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'POST', + operationId: 'postWorkspacesCurrentToolProviderApiTestPre', + path: '/workspaces/current/tool-provider/api/test/pre', + tags: ['console'], + }) + .input(z.object({ body: zPostWorkspacesCurrentToolProviderApiTestPreBody })) + .output(zPostWorkspacesCurrentToolProviderApiTestPreResponse) + +export const pre = { + post: post47, +} + +export const test = { + pre, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get36 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentToolProviderApiTools', + path: '/workspaces/current/tool-provider/api/tools', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentToolProviderApiToolsResponse) + +export const tools = { + get: get36, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const post48 = oc .route({ deprecated: true, description: @@ -2335,13 +2635,13 @@ export const post44 = oc .output(zPostWorkspacesCurrentToolProviderApiUpdateResponse) export const update2 = { - post: post44, + post: post48, } export const api = { add, - delete: delete8, - get: get30, + delete: delete9, + get: get34, remote, schema, test, @@ -2354,7 +2654,7 @@ export const api = { * * @deprecated */ -export const post45 = oc +export const post49 = oc .route({ deprecated: true, description: @@ -2374,7 +2674,7 @@ export const post45 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderAddResponse) export const add2 = { - post: post45, + post: post49, } /** @@ -2382,7 +2682,7 @@ export const add2 = { * * @deprecated */ -export const get33 = oc +export const get37 = oc .route({ deprecated: true, description: @@ -2397,7 +2697,7 @@ export const get33 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialInfoResponse) export const info = { - get: get33, + get: get37, } /** @@ -2405,7 +2705,7 @@ export const info = { * * @deprecated */ -export const get34 = oc +export const get38 = oc .route({ deprecated: true, description: @@ -2428,7 +2728,7 @@ export const get34 = oc ) export const byCredentialType = { - get: get34, + get: get38, } export const schema2 = { @@ -2445,7 +2745,7 @@ export const credential = { * * @deprecated */ -export const get35 = oc +export const get39 = oc .route({ deprecated: true, description: @@ -2460,7 +2760,7 @@ export const get35 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderCredentialsResponse) export const credentials3 = { - get: get35, + get: get39, } /** @@ -2468,7 +2768,7 @@ export const credentials3 = { * * @deprecated */ -export const post46 = oc +export const post50 = oc .route({ deprecated: true, description: @@ -2488,7 +2788,7 @@ export const post46 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDefaultCredentialResponse) export const defaultCredential = { - post: post46, + post: post50, } /** @@ -2496,7 +2796,7 @@ export const defaultCredential = { * * @deprecated */ -export const post47 = oc +export const post51 = oc .route({ deprecated: true, description: @@ -2515,8 +2815,8 @@ export const post47 = oc ) .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderDeleteResponse) -export const delete9 = { - post: post47, +export const delete10 = { + post: post51, } /** @@ -2524,7 +2824,7 @@ export const delete9 = { * * @deprecated */ -export const get36 = oc +export const get40 = oc .route({ deprecated: true, description: @@ -2539,7 +2839,7 @@ export const get36 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderIconResponse) export const icon2 = { - get: get36, + get: get40, } /** @@ -2547,7 +2847,7 @@ export const icon2 = { * * @deprecated */ -export const get37 = oc +export const get41 = oc .route({ deprecated: true, description: @@ -2562,7 +2862,7 @@ export const get37 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderInfoResponse) export const info2 = { - get: get37, + get: get41, } /** @@ -2570,7 +2870,7 @@ export const info2 = { * * @deprecated */ -export const get38 = oc +export const get42 = oc .route({ deprecated: true, description: @@ -2587,7 +2887,7 @@ export const get38 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderOauthClientSchemaResponse) export const clientSchema = { - get: get38, + get: get42, } /** @@ -2595,7 +2895,7 @@ export const clientSchema = { * * @deprecated */ -export const delete10 = oc +export const delete11 = oc .route({ deprecated: true, description: @@ -2618,7 +2918,7 @@ export const delete10 = oc * * @deprecated */ -export const get39 = oc +export const get43 = oc .route({ deprecated: true, description: @@ -2639,7 +2939,7 @@ export const get39 = oc * * @deprecated */ -export const post48 = oc +export const post52 = oc .route({ deprecated: true, description: @@ -2659,9 +2959,9 @@ export const post48 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderOauthCustomClientResponse) export const customClient = { - delete: delete10, - get: get39, - post: post48, + delete: delete11, + get: get43, + post: post52, } export const oauth = { @@ -2674,7 +2974,7 @@ export const oauth = { * * @deprecated */ -export const get40 = oc +export const get44 = oc .route({ deprecated: true, description: @@ -2689,7 +2989,7 @@ export const get40 = oc .output(zGetWorkspacesCurrentToolProviderBuiltinByProviderToolsResponse) export const tools2 = { - get: get40, + get: get44, } /** @@ -2697,7 +2997,7 @@ export const tools2 = { * * @deprecated */ -export const post49 = oc +export const post53 = oc .route({ deprecated: true, description: @@ -2717,7 +3017,7 @@ export const post49 = oc .output(zPostWorkspacesCurrentToolProviderBuiltinByProviderUpdateResponse) export const update3 = { - post: post49, + post: post53, } export const byProvider2 = { @@ -2725,7 +3025,7 @@ export const byProvider2 = { credential, credentials: credentials3, defaultCredential, - delete: delete9, + delete: delete10, icon: icon2, info: info2, oauth, @@ -2742,7 +3042,7 @@ export const builtin = { * * @deprecated */ -export const post50 = oc +export const post54 = oc .route({ deprecated: true, description: @@ -2757,7 +3057,7 @@ export const post50 = oc .output(zPostWorkspacesCurrentToolProviderMcpAuthResponse) export const auth = { - post: post50, + post: post54, } /** @@ -2765,7 +3065,7 @@ export const auth = { * * @deprecated */ -export const get41 = oc +export const get45 = oc .route({ deprecated: true, description: @@ -2780,7 +3080,7 @@ export const get41 = oc .output(zGetWorkspacesCurrentToolProviderMcpToolsByProviderIdResponse) export const byProviderId = { - get: get41, + get: get45, } export const tools3 = { @@ -2792,7 +3092,7 @@ export const tools3 = { * * @deprecated */ -export const get42 = oc +export const get46 = oc .route({ deprecated: true, description: @@ -2807,14 +3107,14 @@ export const get42 = oc .output(zGetWorkspacesCurrentToolProviderMcpUpdateByProviderIdResponse) export const byProviderId2 = { - get: get42, + get: get46, } export const update4 = { byProviderId: byProviderId2, } -export const delete11 = oc +export const delete12 = oc .route({ inputStructure: 'detailed', method: 'DELETE', @@ -2830,7 +3130,7 @@ export const delete11 = oc * * @deprecated */ -export const post51 = oc +export const post55 = oc .route({ deprecated: true, description: @@ -2864,8 +3164,8 @@ export const put4 = oc .output(zPutWorkspacesCurrentToolProviderMcpResponse) export const mcp = { - delete: delete11, - post: post51, + delete: delete12, + post: post55, put: put4, auth, tools: tools3, @@ -2877,7 +3177,7 @@ export const mcp = { * * @deprecated */ -export const post52 = oc +export const post56 = oc .route({ deprecated: true, description: @@ -2892,7 +3192,7 @@ export const post52 = oc .output(zPostWorkspacesCurrentToolProviderWorkflowCreateResponse) export const create2 = { - post: post52, + post: post56, } /** @@ -2900,7 +3200,7 @@ export const create2 = { * * @deprecated */ -export const post53 = oc +export const post57 = oc .route({ deprecated: true, description: @@ -2914,8 +3214,8 @@ export const post53 = oc .input(z.object({ body: zPostWorkspacesCurrentToolProviderWorkflowDeleteBody })) .output(zPostWorkspacesCurrentToolProviderWorkflowDeleteResponse) -export const delete12 = { - post: post53, +export const delete13 = { + post: post57, } /** @@ -2923,7 +3223,7 @@ export const delete12 = { * * @deprecated */ -export const get43 = oc +export const get47 = oc .route({ deprecated: true, description: @@ -2936,8 +3236,8 @@ export const get43 = oc }) .output(zGetWorkspacesCurrentToolProviderWorkflowGetResponse) -export const get44 = { - get: get43, +export const get48 = { + get: get47, } /** @@ -2945,7 +3245,7 @@ export const get44 = { * * @deprecated */ -export const get45 = oc +export const get49 = oc .route({ deprecated: true, description: @@ -2959,7 +3259,7 @@ export const get45 = oc .output(zGetWorkspacesCurrentToolProviderWorkflowToolsResponse) export const tools4 = { - get: get45, + get: get49, } /** @@ -2967,7 +3267,7 @@ export const tools4 = { * * @deprecated */ -export const post54 = oc +export const post58 = oc .route({ deprecated: true, description: @@ -2982,13 +3282,13 @@ export const post54 = oc .output(zPostWorkspacesCurrentToolProviderWorkflowUpdateResponse) export const update5 = { - post: post54, + post: post58, } export const workflow = { create: create2, - delete: delete12, - get: get44, + delete: delete13, + get: get48, tools: tools4, update: update5, } @@ -3005,7 +3305,7 @@ export const toolProvider = { * * @deprecated */ -export const get46 = oc +export const get50 = oc .route({ deprecated: true, description: @@ -3019,110 +3319,110 @@ export const get46 = oc .output(zGetWorkspacesCurrentToolProvidersResponse) export const toolProviders = { - get: get46, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get47 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentToolsApi', - path: '/workspaces/current/tools/api', - tags: ['console'], - }) - .output(zGetWorkspacesCurrentToolsApiResponse) - -export const api2 = { - get: get47, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get48 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentToolsBuiltin', - path: '/workspaces/current/tools/builtin', - tags: ['console'], - }) - .output(zGetWorkspacesCurrentToolsBuiltinResponse) - -export const builtin2 = { - get: get48, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get49 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentToolsMcp', - path: '/workspaces/current/tools/mcp', - tags: ['console'], - }) - .output(zGetWorkspacesCurrentToolsMcpResponse) - -export const mcp2 = { - get: get49, -} - -/** - * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. - * - * @deprecated - */ -export const get50 = oc - .route({ - deprecated: true, - description: - 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', - inputStructure: 'detailed', - method: 'GET', - operationId: 'getWorkspacesCurrentToolsWorkflow', - path: '/workspaces/current/tools/workflow', - tags: ['console'], - }) - .output(zGetWorkspacesCurrentToolsWorkflowResponse) - -export const workflow2 = { get: get50, } -export const tools5 = { - api: api2, - builtin: builtin2, - mcp: mcp2, - workflow: workflow2, -} - /** * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. * * @deprecated */ export const get51 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentToolsApi', + path: '/workspaces/current/tools/api', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentToolsApiResponse) + +export const api2 = { + get: get51, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get52 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentToolsBuiltin', + path: '/workspaces/current/tools/builtin', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentToolsBuiltinResponse) + +export const builtin2 = { + get: get52, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get53 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentToolsMcp', + path: '/workspaces/current/tools/mcp', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentToolsMcpResponse) + +export const mcp2 = { + get: get53, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get54 = oc + .route({ + deprecated: true, + description: + 'Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate.', + inputStructure: 'detailed', + method: 'GET', + operationId: 'getWorkspacesCurrentToolsWorkflow', + path: '/workspaces/current/tools/workflow', + tags: ['console'], + }) + .output(zGetWorkspacesCurrentToolsWorkflowResponse) + +export const workflow2 = { + get: get54, +} + +export const tools5 = { + api: api2, + builtin: builtin2, + mcp: mcp2, + workflow: workflow2, +} + +/** + * Generated contract types may be inaccurate because backend OpenAPI annotations are incomplete. Do not migrate callers until the generated contract is accurate. + * + * @deprecated + */ +export const get55 = oc .route({ deprecated: true, description: @@ -3137,7 +3437,7 @@ export const get51 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderIconResponse) export const icon3 = { - get: get51, + get: get55, } /** @@ -3147,7 +3447,7 @@ export const icon3 = { * * @deprecated */ -export const get52 = oc +export const get56 = oc .route({ deprecated: true, description: @@ -3163,7 +3463,7 @@ export const get52 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderInfoResponse) export const info3 = { - get: get52, + get: get56, } /** @@ -3173,7 +3473,7 @@ export const info3 = { * * @deprecated */ -export const delete13 = oc +export const delete14 = oc .route({ deprecated: true, description: @@ -3195,7 +3495,7 @@ export const delete13 = oc * * @deprecated */ -export const get53 = oc +export const get57 = oc .route({ deprecated: true, description: @@ -3217,7 +3517,7 @@ export const get53 = oc * * @deprecated */ -export const post55 = oc +export const post59 = oc .route({ deprecated: true, description: @@ -3238,9 +3538,9 @@ export const post55 = oc .output(zPostWorkspacesCurrentTriggerProviderByProviderOauthClientResponse) export const client = { - delete: delete13, - get: get53, - post: post55, + delete: delete14, + get: get57, + post: post59, } export const oauth2 = { @@ -3254,7 +3554,7 @@ export const oauth2 = { * * @deprecated */ -export const post56 = oc +export const post60 = oc .route({ deprecated: true, description: @@ -3279,7 +3579,7 @@ export const post56 = oc ) export const bySubscriptionBuilderId = { - post: post56, + post: post60, } export const build = { @@ -3293,7 +3593,7 @@ export const build = { * * @deprecated */ -export const post57 = oc +export const post61 = oc .route({ deprecated: true, description: @@ -3314,7 +3614,7 @@ export const post57 = oc .output(zPostWorkspacesCurrentTriggerProviderByProviderSubscriptionsBuilderCreateResponse) export const create3 = { - post: post57, + post: post61, } /** @@ -3324,7 +3624,7 @@ export const create3 = { * * @deprecated */ -export const get54 = oc +export const get58 = oc .route({ deprecated: true, description: @@ -3348,7 +3648,7 @@ export const get54 = oc ) export const bySubscriptionBuilderId2 = { - get: get54, + get: get58, } export const logs = { @@ -3362,7 +3662,7 @@ export const logs = { * * @deprecated */ -export const post58 = oc +export const post62 = oc .route({ deprecated: true, description: @@ -3387,7 +3687,7 @@ export const post58 = oc ) export const bySubscriptionBuilderId3 = { - post: post58, + post: post62, } export const update6 = { @@ -3401,7 +3701,7 @@ export const update6 = { * * @deprecated */ -export const post59 = oc +export const post63 = oc .route({ deprecated: true, description: @@ -3426,7 +3726,7 @@ export const post59 = oc ) export const bySubscriptionBuilderId4 = { - post: post59, + post: post63, } export const verifyAndUpdate = { @@ -3440,7 +3740,7 @@ export const verifyAndUpdate = { * * @deprecated */ -export const get55 = oc +export const get59 = oc .route({ deprecated: true, description: @@ -3464,7 +3764,7 @@ export const get55 = oc ) export const bySubscriptionBuilderId5 = { - get: get55, + get: get59, } export const builder = { @@ -3483,7 +3783,7 @@ export const builder = { * * @deprecated */ -export const get56 = oc +export const get60 = oc .route({ deprecated: true, description: @@ -3499,7 +3799,7 @@ export const get56 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsListResponse) export const list3 = { - get: get56, + get: get60, } /** @@ -3509,7 +3809,7 @@ export const list3 = { * * @deprecated */ -export const get57 = oc +export const get61 = oc .route({ deprecated: true, description: @@ -3529,7 +3829,7 @@ export const get57 = oc .output(zGetWorkspacesCurrentTriggerProviderByProviderSubscriptionsOauthAuthorizeResponse) export const authorize = { - get: get57, + get: get61, } export const oauth3 = { @@ -3543,7 +3843,7 @@ export const oauth3 = { * * @deprecated */ -export const post60 = oc +export const post64 = oc .route({ deprecated: true, description: @@ -3568,7 +3868,7 @@ export const post60 = oc ) export const bySubscriptionId = { - post: post60, + post: post64, } export const verify = { @@ -3592,7 +3892,7 @@ export const byProvider3 = { /** * Delete a subscription instance */ -export const post61 = oc +export const post65 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3608,8 +3908,8 @@ export const post61 = oc ) .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsDeleteResponse) -export const delete14 = { - post: post61, +export const delete15 = { + post: post65, } /** @@ -3619,7 +3919,7 @@ export const delete14 = { * * @deprecated */ -export const post62 = oc +export const post66 = oc .route({ deprecated: true, description: @@ -3640,11 +3940,11 @@ export const post62 = oc .output(zPostWorkspacesCurrentTriggerProviderBySubscriptionIdSubscriptionsUpdateResponse) export const update7 = { - post: post62, + post: post66, } export const subscriptions2 = { - delete: delete14, + delete: delete15, update: update7, } @@ -3664,7 +3964,7 @@ export const triggerProvider = { * * @deprecated */ -export const get58 = oc +export const get62 = oc .route({ deprecated: true, description: @@ -3679,10 +3979,10 @@ export const get58 = oc .output(zGetWorkspacesCurrentTriggersResponse) export const triggers = { - get: get58, + get: get62, } -export const post63 = oc +export const post67 = oc .route({ inputStructure: 'detailed', method: 'POST', @@ -3693,9 +3993,10 @@ export const post63 = oc .output(zPostWorkspacesCurrentResponse) export const current = { - post: post63, + post: post67, agentProvider, agentProviders, + customizedSnippets, datasetOperators, defaultModel, endpoints, @@ -3717,7 +4018,7 @@ export const current = { * * @deprecated */ -export const post64 = oc +export const post68 = oc .route({ deprecated: true, description: @@ -3731,7 +4032,7 @@ export const post64 = oc .output(zPostWorkspacesCustomConfigWebappLogoUploadResponse) export const upload2 = { - post: post64, + post: post68, } export const webappLogo = { @@ -3743,7 +4044,7 @@ export const webappLogo = { * * @deprecated */ -export const post65 = oc +export const post69 = oc .route({ deprecated: true, description: @@ -3758,7 +4059,7 @@ export const post65 = oc .output(zPostWorkspacesCustomConfigResponse) export const customConfig = { - post: post65, + post: post69, webappLogo, } @@ -3767,7 +4068,7 @@ export const customConfig = { * * @deprecated */ -export const post66 = oc +export const post70 = oc .route({ deprecated: true, description: @@ -3782,7 +4083,7 @@ export const post66 = oc .output(zPostWorkspacesInfoResponse) export const info4 = { - post: post66, + post: post70, } /** @@ -3790,7 +4091,7 @@ export const info4 = { * * @deprecated */ -export const post67 = oc +export const post71 = oc .route({ deprecated: true, description: @@ -3805,7 +4106,7 @@ export const post67 = oc .output(zPostWorkspacesSwitchResponse) export const switch3 = { - post: post67, + post: post71, } /** @@ -3813,7 +4114,7 @@ export const switch3 = { * * @deprecated */ -export const get59 = oc +export const get63 = oc .route({ deprecated: true, description: @@ -3828,7 +4129,7 @@ export const get59 = oc .output(zGetWorkspacesByTenantIdModelProvidersByProviderByIconTypeByLangResponse) export const byLang = { - get: get59, + get: get63, } export const byIconType = { @@ -3852,7 +4153,7 @@ export const byTenantId = { * * @deprecated */ -export const get60 = oc +export const get64 = oc .route({ deprecated: true, description: @@ -3866,7 +4167,7 @@ export const get60 = oc .output(zGetWorkspacesResponse) export const workspaces = { - get: get60, + get: get64, current, customConfig, info: info4, diff --git a/packages/contracts/generated/api/console/workspaces/types.gen.ts b/packages/contracts/generated/api/console/workspaces/types.gen.ts index 8336951b51..dd29605d68 100644 --- a/packages/contracts/generated/api/console/workspaces/types.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/types.gen.ts @@ -19,6 +19,68 @@ export type TenantInfoResponse = { trial_end_reason?: string | null } +export type SnippetPagination = { + data?: Array + has_more?: boolean + limit?: number + page?: number + total?: number +} + +export type CreateSnippetPayload = { + description?: string | null + graph?: { + [key: string]: unknown + } | null + icon_info?: IconInfo + input_fields?: Array | null + name: string + type?: 'group' | 'node' +} + +export type Snippet = { + created_at?: { + [key: string]: unknown + } + created_by?: AnonymousInlineModelB0Fd3F86D9D5 + description?: string + graph?: { + [key: string]: unknown + } + icon_info?: { + [key: string]: unknown + } + id?: string + input_fields?: { + [key: string]: unknown + } + is_published?: boolean + name?: string + tags?: Array + type?: string + updated_at?: { + [key: string]: unknown + } + updated_by?: AnonymousInlineModelB0Fd3F86D9D5 + use_count?: number + version?: number +} + +export type SnippetImportPayload = { + description?: string | null + mode: string + name?: string | null + snippet_id?: string | null + yaml_content?: string | null + yaml_url?: string | null +} + +export type UpdateSnippetPayload = { + description?: string | null + icon_info?: IconInfo + name?: string | null +} + export type AccountWithRoleList = { accounts: Array } @@ -504,6 +566,59 @@ export type WorkspaceCustomConfigResponse = { replace_webapp_logo?: string | null } +export type AnonymousInlineModel7B67Ac8A4Db8 = { + author_name?: string + created_at?: { + [key: string]: unknown + } + created_by?: string + description?: string + icon_info?: { + [key: string]: unknown + } + id?: string + is_published?: boolean + name?: string + tags?: Array + type?: string + updated_at?: { + [key: string]: unknown + } + updated_by?: string + use_count?: number + version?: number +} + +export type IconInfo = { + icon?: string | null + icon_background?: string | null + icon_type?: 'emoji' | 'image' | null + icon_url?: string | null +} + +export type InputFieldDefinition = { + default?: string | null + hint?: boolean | null + label?: string | null + max_length?: number | null + options?: Array | null + placeholder?: string | null + required?: boolean | null + type?: string | null +} + +export type AnonymousInlineModelB0Fd3F86D9D5 = { + email?: string + id?: string + name?: string +} + +export type AnonymousInlineModel7B8B49Ca164e = { + id?: string + name?: string + type?: string +} + export type AccountWithRole = { avatar?: string | null created_at?: number | null @@ -629,6 +744,266 @@ export type GetWorkspacesCurrentAgentProvidersResponses = { export type GetWorkspacesCurrentAgentProvidersResponse = GetWorkspacesCurrentAgentProvidersResponses[keyof GetWorkspacesCurrentAgentProvidersResponses] +export type GetWorkspacesCurrentCustomizedSnippetsData = { + body?: never + path?: never + query?: { + creators?: Array | null + is_published?: boolean | null + keyword?: string | null + limit?: number + page?: number + tag_ids?: Array | null + } + url: '/workspaces/current/customized-snippets' +} + +export type GetWorkspacesCurrentCustomizedSnippetsResponses = { + 200: SnippetPagination +} + +export type GetWorkspacesCurrentCustomizedSnippetsResponse + = GetWorkspacesCurrentCustomizedSnippetsResponses[keyof GetWorkspacesCurrentCustomizedSnippetsResponses] + +export type PostWorkspacesCurrentCustomizedSnippetsData = { + body: CreateSnippetPayload + path?: never + query?: never + url: '/workspaces/current/customized-snippets' +} + +export type PostWorkspacesCurrentCustomizedSnippetsErrors = { + 400: { + [key: string]: unknown + } +} + +export type PostWorkspacesCurrentCustomizedSnippetsError + = PostWorkspacesCurrentCustomizedSnippetsErrors[keyof PostWorkspacesCurrentCustomizedSnippetsErrors] + +export type PostWorkspacesCurrentCustomizedSnippetsResponses = { + 201: Snippet +} + +export type PostWorkspacesCurrentCustomizedSnippetsResponse + = PostWorkspacesCurrentCustomizedSnippetsResponses[keyof PostWorkspacesCurrentCustomizedSnippetsResponses] + +export type PostWorkspacesCurrentCustomizedSnippetsImportsData = { + body: SnippetImportPayload + path?: never + query?: never + url: '/workspaces/current/customized-snippets/imports' +} + +export type PostWorkspacesCurrentCustomizedSnippetsImportsErrors = { + 400: { + [key: string]: unknown + } +} + +export type PostWorkspacesCurrentCustomizedSnippetsImportsError + = PostWorkspacesCurrentCustomizedSnippetsImportsErrors[keyof PostWorkspacesCurrentCustomizedSnippetsImportsErrors] + +export type PostWorkspacesCurrentCustomizedSnippetsImportsResponses = { + 200: { + [key: string]: unknown + } + 202: { + [key: string]: unknown + } +} + +export type PostWorkspacesCurrentCustomizedSnippetsImportsResponse + = PostWorkspacesCurrentCustomizedSnippetsImportsResponses[keyof PostWorkspacesCurrentCustomizedSnippetsImportsResponses] + +export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmData = { + body?: never + path: { + import_id: string + } + query?: never + url: '/workspaces/current/customized-snippets/imports/{import_id}/confirm' +} + +export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmErrors = { + 400: { + [key: string]: unknown + } +} + +export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmError + = PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmErrors[keyof PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmErrors] + +export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponse + = PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponses[keyof PostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponses] + +export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/workspaces/current/customized-snippets/{snippet_id}' +} + +export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors = { + 404: { + [key: string]: unknown + } +} + +export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdError + = DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors[keyof DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors] + +export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses = { + 204: { + [key: string]: never + } +} + +export type DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse + = DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses[keyof DeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses] + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/workspaces/current/customized-snippets/{snippet_id}' +} + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors = { + 404: { + [key: string]: unknown + } +} + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdError + = GetWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors] + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses = { + 200: Snippet +} + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse + = GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses] + +export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdData = { + body: UpdateSnippetPayload + path: { + snippet_id: string + } + query?: never + url: '/workspaces/current/customized-snippets/{snippet_id}' +} + +export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors = { + 400: { + [key: string]: unknown + } + 404: { + [key: string]: unknown + } +} + +export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdError + = PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors[keyof PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdErrors] + +export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses = { + 200: Snippet +} + +export type PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse + = PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses[keyof PatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponses] + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/workspaces/current/customized-snippets/{snippet_id}/check-dependencies' +} + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesErrors = { + 404: { + [key: string]: unknown + } +} + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesError + = GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesErrors[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesErrors] + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse + = GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponses[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponses] + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/workspaces/current/customized-snippets/{snippet_id}/export' +} + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportErrors = { + 404: { + [key: string]: unknown + } +} + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportError + = GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportErrors[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportErrors] + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponses = { + 200: { + [key: string]: unknown + } +} + +export type GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse + = GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponses[keyof GetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponses] + +export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementData = { + body?: never + path: { + snippet_id: string + } + query?: never + url: '/workspaces/current/customized-snippets/{snippet_id}/use-count/increment' +} + +export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementErrors = { + 404: { + [key: string]: unknown + } +} + +export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementError + = PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementErrors[keyof PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementErrors] + +export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponses = { + 200: { + [key: string]: unknown + } +} + +export type PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponse + = PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponses[keyof PostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponses] + export type GetWorkspacesCurrentDatasetOperatorsData = { body?: never path?: never diff --git a/packages/contracts/generated/api/console/workspaces/zod.gen.ts b/packages/contracts/generated/api/console/workspaces/zod.gen.ts index a6f133a349..eb93856b37 100644 --- a/packages/contracts/generated/api/console/workspaces/zod.gen.ts +++ b/packages/contracts/generated/api/console/workspaces/zod.gen.ts @@ -2,6 +2,20 @@ import * as z from 'zod' +/** + * SnippetImportPayload + * + * Payload for importing snippet from DSL. + */ +export const zSnippetImportPayload = z.object({ + description: z.string().nullish(), + mode: z.string(), + name: z.string().nullish(), + snippet_id: z.string().nullish(), + yaml_content: z.string().nullish(), + yaml_url: z.string().nullish(), +}) + /** * SimpleResultResponse */ @@ -463,6 +477,114 @@ export const zTenantInfoResponse = z.object({ trial_end_reason: z.string().nullish(), }) +/** + * IconInfo + * + * Icon information model. + */ +export const zIconInfo = z.object({ + icon: z.string().nullish(), + icon_background: z.string().nullish(), + icon_type: z.enum(['emoji', 'image']).nullish(), + icon_url: z.string().nullish(), +}) + +/** + * UpdateSnippetPayload + * + * Payload for updating a snippet. + */ +export const zUpdateSnippetPayload = z.object({ + description: z.string().max(2000).nullish(), + icon_info: zIconInfo.optional(), + name: z.string().min(1).max(255).nullish(), +}) + +/** + * InputFieldDefinition + * + * Input field definition for snippet parameters. + */ +export const zInputFieldDefinition = z.object({ + default: z.string().nullish(), + hint: z.boolean().nullish(), + label: z.string().nullish(), + max_length: z.int().nullish(), + options: z.array(z.string()).nullish(), + placeholder: z.string().nullish(), + required: z.boolean().nullish(), + type: z.string().nullish(), +}) + +/** + * CreateSnippetPayload + * + * Payload for creating a new snippet. + */ +export const zCreateSnippetPayload = z.object({ + description: z.string().max(2000).nullish(), + graph: z.record(z.string(), z.unknown()).nullish(), + icon_info: zIconInfo.optional(), + input_fields: z.array(zInputFieldDefinition).nullish(), + name: z.string().min(1).max(255), + type: z.enum(['group', 'node']).optional().default('node'), +}) + +export const zAnonymousInlineModelB0Fd3F86D9D5 = z.object({ + email: z.string().optional(), + id: z.string().optional(), + name: z.string().optional(), +}) + +export const zAnonymousInlineModel7B8B49Ca164e = z.object({ + id: z.string().optional(), + name: z.string().optional(), + type: z.string().optional(), +}) + +export const zSnippet = z.object({ + created_at: z.record(z.string(), z.unknown()).optional(), + created_by: zAnonymousInlineModelB0Fd3F86D9D5.optional(), + description: z.string().optional(), + graph: z.record(z.string(), z.unknown()).optional(), + icon_info: z.record(z.string(), z.unknown()).optional(), + id: z.string().optional(), + input_fields: z.record(z.string(), z.unknown()).optional(), + is_published: z.boolean().optional(), + name: z.string().optional(), + tags: z.array(zAnonymousInlineModel7B8B49Ca164e).optional(), + type: z.string().optional(), + updated_at: z.record(z.string(), z.unknown()).optional(), + updated_by: zAnonymousInlineModelB0Fd3F86D9D5.optional(), + use_count: z.int().optional(), + version: z.int().optional(), +}) + +export const zAnonymousInlineModel7B67Ac8A4Db8 = z.object({ + author_name: z.string().optional(), + created_at: z.record(z.string(), z.unknown()).optional(), + created_by: z.string().optional(), + description: z.string().optional(), + icon_info: z.record(z.string(), z.unknown()).optional(), + id: z.string().optional(), + is_published: z.boolean().optional(), + name: z.string().optional(), + tags: z.array(zAnonymousInlineModel7B8B49Ca164e).optional(), + type: z.string().optional(), + updated_at: z.record(z.string(), z.unknown()).optional(), + updated_by: z.string().optional(), + use_count: z.int().optional(), + version: z.int().optional(), +}) + +export const zSnippetPagination = z.object({ + data: z.array(zAnonymousInlineModel7B67Ac8A4Db8).optional(), + has_more: z.boolean().optional(), + limit: z.int().optional(), + page: z.int().optional(), + total: z.int().optional(), +}) + /** * AccountWithRole */ @@ -809,6 +931,112 @@ export const zGetWorkspacesCurrentAgentProvidersResponse = z.array( z.record(z.string(), z.unknown()), ) +export const zGetWorkspacesCurrentCustomizedSnippetsQuery = z.object({ + creators: z.array(z.string()).nullish(), + is_published: z.boolean().nullish(), + keyword: z.string().nullish(), + limit: z.int().gte(1).lte(100).optional().default(20), + page: z.int().gte(1).lte(99999).optional().default(1), + tag_ids: z.array(z.string()).nullish(), +}) + +/** + * Snippets retrieved successfully + */ +export const zGetWorkspacesCurrentCustomizedSnippetsResponse = zSnippetPagination + +export const zPostWorkspacesCurrentCustomizedSnippetsBody = zCreateSnippetPayload + +/** + * Snippet created successfully + */ +export const zPostWorkspacesCurrentCustomizedSnippetsResponse = zSnippet + +export const zPostWorkspacesCurrentCustomizedSnippetsImportsBody = zSnippetImportPayload + +export const zPostWorkspacesCurrentCustomizedSnippetsImportsResponse = z.union([ + z.record(z.string(), z.unknown()), + z.record(z.string(), z.unknown()), +]) + +export const zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmPath = z.object({ + import_id: z.string(), +}) + +/** + * Import confirmed successfully + */ +export const zPostWorkspacesCurrentCustomizedSnippetsImportsByImportIdConfirmResponse = z.record( + z.string(), + z.unknown(), +) + +export const zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Snippet deleted successfully + */ +export const zDeleteWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = z.record( + z.string(), + z.never(), +) + +export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Snippet retrieved successfully + */ +export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = zSnippet + +export const zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdBody = zUpdateSnippetPayload + +export const zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Snippet updated successfully + */ +export const zPatchWorkspacesCurrentCustomizedSnippetsBySnippetIdResponse = zSnippet + +export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Dependencies checked successfully + */ +export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdCheckDependenciesResponse = z.record( + z.string(), + z.unknown(), +) + +export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Snippet exported successfully + */ +export const zGetWorkspacesCurrentCustomizedSnippetsBySnippetIdExportResponse = z.record( + z.string(), + z.unknown(), +) + +export const zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementPath = z.object({ + snippet_id: z.string(), +}) + +/** + * Use count incremented successfully + */ +export const zPostWorkspacesCurrentCustomizedSnippetsBySnippetIdUseCountIncrementResponse + = z.record(z.string(), z.unknown()) + /** * Success */ diff --git a/packages/iconify-collections/assets/vender/line/others/dhs.svg b/packages/iconify-collections/assets/vender/line/others/dhs.svg new file mode 100644 index 0000000000..54e8eff8c2 --- /dev/null +++ b/packages/iconify-collections/assets/vender/line/others/dhs.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/iconify-collections/assets/vender/line/others/dvs.svg b/packages/iconify-collections/assets/vender/line/others/dvs.svg new file mode 100644 index 0000000000..3b1c9f2f4c --- /dev/null +++ b/packages/iconify-collections/assets/vender/line/others/dvs.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/iconify-collections/assets/vender/line/others/evaluation.svg b/packages/iconify-collections/assets/vender/line/others/evaluation.svg new file mode 100644 index 0000000000..3856b8b176 --- /dev/null +++ b/packages/iconify-collections/assets/vender/line/others/evaluation.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/assets/vender/workflow/input-field.svg b/packages/iconify-collections/assets/vender/workflow/input-field.svg new file mode 100644 index 0000000000..47ef58181e --- /dev/null +++ b/packages/iconify-collections/assets/vender/workflow/input-field.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/iconify-collections/custom-vender/icons.json b/packages/iconify-collections/custom-vender/icons.json index bbed34e313..c8427ff479 100644 --- a/packages/iconify-collections/custom-vender/icons.json +++ b/packages/iconify-collections/custom-vender/icons.json @@ -513,12 +513,27 @@ "width": 14, "height": 14 }, + "line-others-dhs": { + "body": "", + "width": 18, + "height": 18 + }, "line-others-drag-handle": { "body": "" }, + "line-others-dvs": { + "body": "", + "width": 18, + "height": 18 + }, "line-others-env": { "body": "" }, + "line-others-evaluation": { + "body": "", + "width": 18, + "height": 18 + }, "line-others-global-variable": { "body": "" }, @@ -1025,6 +1040,11 @@ "workflow-if-else": { "body": "" }, + "workflow-input-field": { + "body": "", + "width": 16, + "height": 16 + }, "workflow-iteration": { "body": "" }, diff --git a/packages/iconify-collections/custom-vender/info.json b/packages/iconify-collections/custom-vender/info.json index 0a84c45bbd..52df22b171 100644 --- a/packages/iconify-collections/custom-vender/info.json +++ b/packages/iconify-collections/custom-vender/info.json @@ -1,7 +1,7 @@ { "prefix": "custom-vender", "name": "Dify Custom Vender", - "total": 277, + "total": 281, "version": "0.0.0-private", "author": { "name": "LangGenius, Inc.", diff --git a/web/__tests__/apps/app-list-browsing-flow.test.tsx b/web/__tests__/apps/app-list-browsing-flow.test.tsx index b16f7a2bc0..0c43472020 100644 --- a/web/__tests__/apps/app-list-browsing-flow.test.tsx +++ b/web/__tests__/apps/app-list-browsing-flow.test.tsx @@ -341,16 +341,11 @@ describe('App List Browsing Flow', () => { // -- Tab navigation -- describe('Tab Navigation', () => { - it('should render all category tabs', () => { + it('should render the app type dropdown trigger', () => { mockPages = [createPage([createMockApp()])] renderList() - expect(screen.getByText('app.types.all')).toBeInTheDocument() - expect(screen.getByText('app.types.workflow')).toBeInTheDocument() - expect(screen.getByText('app.types.advanced')).toBeInTheDocument() - expect(screen.getByText('app.types.chatbot')).toBeInTheDocument() - expect(screen.getByText('app.types.agent')).toBeInTheDocument() - expect(screen.getByText('app.types.completion')).toBeInTheDocument() + expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument() }) }) @@ -381,21 +376,19 @@ describe('App List Browsing Flow', () => { // -- "Created by me" filter -- describe('Created By Me Filter', () => { - it('should render the "created by me" checkbox', () => { + it('should not render a standalone "created by me" checkbox in the current header layout', () => { mockPages = [createPage([createMockApp()])] renderList() - expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() + expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument() }) - it('should toggle the "created by me" filter on click', () => { + it('should keep the current layout stable without a "created by me" control', () => { mockPages = [createPage([createMockApp()])] renderList() - const checkbox = screen.getByText('app.showMyCreatedAppsOnly') - fireEvent.click(checkbox) - - expect(screen.getByText('app.showMyCreatedAppsOnly')).toBeInTheDocument() + expect(screen.getByText('app.studio.filters.types')).toBeInTheDocument() + expect(screen.queryByText('app.showMyCreatedAppsOnly')).not.toBeInTheDocument() }) }) diff --git a/web/app/(commonLayout)/role-route-guard.tsx b/web/app/(commonLayout)/role-route-guard.tsx index a39a121d68..441f8dad81 100644 --- a/web/app/(commonLayout)/role-route-guard.tsx +++ b/web/app/(commonLayout)/role-route-guard.tsx @@ -6,7 +6,7 @@ import Loading from '@/app/components/base/loading' import { redirect, usePathname } from '@/next/navigation' import { consoleQuery } from '@/service/client' -const datasetOperatorRedirectRoutes = ['/apps', '/app', '/explore', '/tools'] as const +const datasetOperatorRedirectRoutes = ['/apps', '/app', '/snippets', '/explore', '/tools'] as const const isPathUnderRoute = (pathname: string, route: string) => pathname === route || pathname.startsWith(`${route}/`) diff --git a/web/app/(commonLayout)/snippets/[snippetId]/orchestrate/page.tsx b/web/app/(commonLayout)/snippets/[snippetId]/orchestrate/page.tsx new file mode 100644 index 0000000000..8a39dc710b --- /dev/null +++ b/web/app/(commonLayout)/snippets/[snippetId]/orchestrate/page.tsx @@ -0,0 +1,11 @@ +import SnippetPage from '@/app/components/snippets' + +const Page = async (props: { + params: Promise<{ snippetId: string }> +}) => { + const { snippetId } = await props.params + + return +} + +export default Page diff --git a/web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts b/web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts new file mode 100644 index 0000000000..578c562848 --- /dev/null +++ b/web/app/(commonLayout)/snippets/[snippetId]/page.spec.ts @@ -0,0 +1,21 @@ +import Page from './page' + +const mockRedirect = vi.fn() + +vi.mock('next/navigation', () => ({ + redirect: (path: string) => mockRedirect(path), +})) + +describe('snippet detail redirect page', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('should redirect legacy snippet detail routes to orchestrate', async () => { + await Page({ + params: Promise.resolve({ snippetId: 'snippet-1' }), + }) + + expect(mockRedirect).toHaveBeenCalledWith('/snippets/snippet-1/orchestrate') + }) +}) diff --git a/web/app/(commonLayout)/snippets/[snippetId]/page.tsx b/web/app/(commonLayout)/snippets/[snippetId]/page.tsx new file mode 100644 index 0000000000..3b35e29360 --- /dev/null +++ b/web/app/(commonLayout)/snippets/[snippetId]/page.tsx @@ -0,0 +1,11 @@ +import { redirect } from 'next/navigation' + +const Page = async (props: { + params: Promise<{ snippetId: string }> +}) => { + const { snippetId } = await props.params + + redirect(`/snippets/${snippetId}/orchestrate`) +} + +export default Page diff --git a/web/app/(commonLayout)/snippets/page.tsx b/web/app/(commonLayout)/snippets/page.tsx new file mode 100644 index 0000000000..4a23af07b9 --- /dev/null +++ b/web/app/(commonLayout)/snippets/page.tsx @@ -0,0 +1,7 @@ +import SnippetList from '@/app/components/snippet-list' + +const SnippetsPage = () => { + return +} + +export default SnippetsPage diff --git a/web/app/components/app-sidebar/__tests__/index.spec.tsx b/web/app/components/app-sidebar/__tests__/index.spec.tsx index 10aedeb071..7ba7b7b259 100644 --- a/web/app/components/app-sidebar/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/__tests__/index.spec.tsx @@ -168,6 +168,21 @@ describe('AppDetailNav', () => { ) expect(screen.queryByTestId('extra-info')).not.toBeInTheDocument() }) + + it('should render custom header and navigation when provided', () => { + render( +
} + renderNavigation={mode =>
} + />, + ) + + expect(screen.getByTestId('custom-header')).toHaveAttribute('data-mode', 'expand') + expect(screen.getByTestId('custom-navigation')).toHaveAttribute('data-mode', 'expand') + expect(screen.queryByTestId('app-info')).not.toBeInTheDocument() + expect(screen.queryByTestId('nav-link-Overview')).not.toBeInTheDocument() + }) }) describe('Workflow canvas mode', () => { diff --git a/web/app/components/app-sidebar/index.tsx b/web/app/components/app-sidebar/index.tsx index a4a2bd441f..abc9cb2702 100644 --- a/web/app/components/app-sidebar/index.tsx +++ b/web/app/components/app-sidebar/index.tsx @@ -28,12 +28,16 @@ type IAppDetailNavProps = { disabled?: boolean }> extraInfo?: (modeState: string) => React.ReactNode + renderHeader?: (modeState: string) => React.ReactNode + renderNavigation?: (modeState: string) => React.ReactNode appInfoActions?: AppInfoActions } const AppDetailNav = ({ navigation, extraInfo, + renderHeader, + renderNavigation, iconType = 'app', appInfoActions, }: IAppDetailNavProps) => { @@ -112,18 +116,20 @@ const AppDetailNav = ({ expand ? 'p-2' : 'p-1', )} > - {iconType === 'app' && ( - appInfoActions - ? ( - - ) - : - )} - {iconType !== 'app' && ( + {renderHeader + ? renderHeader(appSidebarExpand) + : iconType === 'app' && ( + appInfoActions + ? ( + + ) + : + )} + {!renderHeader && iconType !== 'app' && ( )}
@@ -152,18 +158,20 @@ const AppDetailNav = ({ expand ? 'px-3 py-2' : 'p-3', )} > - {navigation.map((item, index) => { - return ( - - ) - })} + {renderNavigation + ? renderNavigation(appSidebarExpand) + : navigation.map((item, index) => { + return ( + + ) + })} {iconType !== 'app' && extraInfo && extraInfo(appSidebarExpand)}
diff --git a/web/app/components/app-sidebar/nav-link/__tests__/index.spec.tsx b/web/app/components/app-sidebar/nav-link/__tests__/index.spec.tsx index 1a3e826e10..11f3907527 100644 --- a/web/app/components/app-sidebar/nav-link/__tests__/index.spec.tsx +++ b/web/app/components/app-sidebar/nav-link/__tests__/index.spec.tsx @@ -262,4 +262,20 @@ describe('NavLink Animation and Layout Issues', () => { expect(iconWrapper).toHaveClass('-ml-1') }) }) + + describe('Button Mode', () => { + it('should render as an interactive button when href is omitted', () => { + const onClick = vi.fn() + + render() + + const buttonElement = screen.getByText('Orchestrate').closest('button') + expect(buttonElement).not.toBeNull() + expect(buttonElement).toHaveClass('bg-components-menu-item-bg-active') + expect(buttonElement).toHaveClass('text-text-accent-light-mode-only') + + buttonElement?.click() + expect(onClick).toHaveBeenCalledTimes(1) + }) + }) }) diff --git a/web/app/components/app-sidebar/nav-link/index.tsx b/web/app/components/app-sidebar/nav-link/index.tsx index 38845ed746..a2bdb09f7b 100644 --- a/web/app/components/app-sidebar/nav-link/index.tsx +++ b/web/app/components/app-sidebar/nav-link/index.tsx @@ -14,13 +14,15 @@ export type NavIcon = React.ComponentType< export type NavLinkProps = { name: string - href: string + href?: string iconMap: { selected: NavIcon normal: NavIcon } mode?: string disabled?: boolean + active?: boolean + onClick?: () => void } const NavLink = ({ @@ -29,6 +31,8 @@ const NavLink = ({ iconMap, mode = 'expand', disabled = false, + active, + onClick, }: NavLinkProps) => { const segment = useSelectedLayoutSegment() const formattedSegment = (() => { @@ -39,8 +43,11 @@ const NavLink = ({ return res })() - const isActive = href.toLowerCase().split('/')?.pop() === formattedSegment + const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false) const NavIcon = isActive ? iconMap.selected : iconMap.normal + const linkClassName = cn(isActive + ? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold' + : 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1') const renderIcon = () => (
@@ -70,13 +77,32 @@ const NavLink = ({ ) } + if (!href) { + return ( + + ) + } + return ( {renderIcon()} diff --git a/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx new file mode 100644 index 0000000000..51f1f0c341 --- /dev/null +++ b/web/app/components/app-sidebar/snippet-info/__tests__/dropdown.spec.tsx @@ -0,0 +1,270 @@ +import type { CreateSnippetDialogPayload } from '@/app/components/snippets/create-snippet-dialog' +import type { SnippetDetail } from '@/models/snippet' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import * as React from 'react' +import SnippetInfoDropdown from '../dropdown' + +const mockReplace = vi.fn() +const mockDownloadBlob = vi.fn() +const mockToastSuccess = vi.fn() +const mockToastError = vi.fn() +const mockUpdateMutate = vi.fn() +const mockExportMutateAsync = vi.fn() +const mockDeleteMutate = vi.fn() +let mockDropdownOpen = false +let mockDropdownOnOpenChange: ((open: boolean) => void) | undefined + +vi.mock('@/next/navigation', () => ({ + useRouter: () => ({ + replace: mockReplace, + }), +})) + +vi.mock('@/utils/download', () => ({ + downloadBlob: (args: { data: Blob, fileName: string }) => mockDownloadBlob(args), +})) + +vi.mock('@langgenius/dify-ui/toast', () => ({ + toast: { + success: (...args: unknown[]) => mockToastSuccess(...args), + error: (...args: unknown[]) => mockToastError(...args), + }, +})) + +vi.mock('@langgenius/dify-ui/dropdown-menu', () => ({ + DropdownMenu: ({ + open, + onOpenChange, + children, + }: { + open?: boolean + onOpenChange?: (open: boolean) => void + children: React.ReactNode + }) => { + mockDropdownOpen = !!open + mockDropdownOnOpenChange = onOpenChange + return
{children}
+ }, + DropdownMenuTrigger: ({ + children, + className, + }: { + children: React.ReactNode + className?: string + }) => ( + + ), + DropdownMenuContent: ({ children }: { children: React.ReactNode }) => ( + mockDropdownOpen ?
{children}
: null + ), + DropdownMenuItem: ({ + children, + onClick, + }: { + children: React.ReactNode + onClick?: () => void + }) => ( + + ), + DropdownMenuSeparator: () =>
, +})) + +vi.mock('@/service/use-snippets', () => ({ + useUpdateSnippetMutation: () => ({ + mutate: mockUpdateMutate, + isPending: false, + }), + useExportSnippetMutation: () => ({ + mutateAsync: mockExportMutateAsync, + isPending: false, + }), + useDeleteSnippetMutation: () => ({ + mutate: mockDeleteMutate, + isPending: false, + }), +})) + +type MockCreateSnippetDialogProps = { + isOpen: boolean + title?: string + confirmText?: string + initialValue?: { + name?: string + description?: string + } + onClose: () => void + onConfirm: (payload: CreateSnippetDialogPayload) => void +} + +vi.mock('@/app/components/snippets/create-snippet-dialog', () => ({ + default: ({ + isOpen, + title, + confirmText, + initialValue, + onClose, + onConfirm, + }: MockCreateSnippetDialogProps) => { + if (!isOpen) + return null + + return ( +
+
{title}
+
{confirmText}
+
{initialValue?.name}
+
{initialValue?.description}
+ + +
+ ) + }, +})) + +const mockSnippet: SnippetDetail = { + id: 'snippet-1', + name: 'Social Media Repurposer', + description: 'Turn one blog post into multiple social media variations.', + updatedAt: '2026-03-25 10:00', + usage: '12', + tags: [], + status: undefined, +} + +describe('SnippetInfoDropdown', () => { + beforeEach(() => { + vi.clearAllMocks() + mockDropdownOpen = false + mockDropdownOnOpenChange = undefined + }) + + // Rendering coverage for the menu trigger itself. + describe('Rendering', () => { + it('should render the dropdown trigger button', () => { + render() + + expect(screen.getByRole('button')).toBeInTheDocument() + }) + }) + + // Edit flow should seed the dialog with current snippet info and submit updates. + describe('Edit Snippet', () => { + it('should open the edit dialog and submit snippet updates', async () => { + const user = userEvent.setup() + mockUpdateMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + + render() + await user.click(screen.getByRole('button')) + await user.click(screen.getByText('snippet.menu.editInfo')) + + expect(screen.getByTestId('create-snippet-dialog')).toBeInTheDocument() + expect(screen.getByText('snippet.editDialogTitle')).toBeInTheDocument() + expect(screen.getByText('common.operation.save')).toBeInTheDocument() + expect(screen.getByText(mockSnippet.name)).toBeInTheDocument() + expect(screen.getByText(mockSnippet.description)).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'submit-edit' })) + + expect(mockUpdateMutate).toHaveBeenCalledWith({ + params: { snippetId: mockSnippet.id }, + body: { + name: 'Updated snippet', + description: 'Updated description', + }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + })) + expect(mockToastSuccess).toHaveBeenCalledWith('snippet.editDone') + }) + }) + + // Export should call the export hook and download the returned YAML blob. + describe('Export Snippet', () => { + it('should export and download the snippet yaml', async () => { + const user = userEvent.setup() + mockExportMutateAsync.mockResolvedValue('yaml: content') + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getByText('snippet.menu.exportSnippet')) + + await waitFor(() => { + expect(mockExportMutateAsync).toHaveBeenCalledWith({ snippetId: mockSnippet.id }) + }) + + expect(mockDownloadBlob).toHaveBeenCalledWith({ + data: expect.any(Blob), + fileName: `${mockSnippet.name}.yml`, + }) + }) + + it('should show an error toast when export fails', async () => { + const user = userEvent.setup() + mockExportMutateAsync.mockRejectedValue(new Error('export failed')) + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getByText('snippet.menu.exportSnippet')) + + await waitFor(() => { + expect(mockToastError).toHaveBeenCalledWith('snippet.exportFailed') + }) + }) + }) + + // Delete should require confirmation and redirect after a successful mutation. + describe('Delete Snippet', () => { + it('should confirm deletion and redirect to the snippets list', async () => { + const user = userEvent.setup() + mockDeleteMutate.mockImplementation((_variables: unknown, options?: { onSuccess?: () => void }) => { + options?.onSuccess?.() + }) + + render() + + await user.click(screen.getByRole('button')) + await user.click(screen.getByText('snippet.menu.deleteSnippet')) + + expect(screen.getByText('snippet.deleteConfirmTitle')).toBeInTheDocument() + expect(screen.getByText('snippet.deleteConfirmContent')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: 'snippet.menu.deleteSnippet' })) + + expect(mockDeleteMutate).toHaveBeenCalledWith({ + params: { snippetId: mockSnippet.id }, + }, expect.objectContaining({ + onSuccess: expect.any(Function), + onError: expect.any(Function), + })) + expect(mockToastSuccess).toHaveBeenCalledWith('snippet.deleted') + expect(mockReplace).toHaveBeenCalledWith('/snippets') + }) + }) +}) diff --git a/web/app/components/app-sidebar/snippet-info/__tests__/index.spec.tsx b/web/app/components/app-sidebar/snippet-info/__tests__/index.spec.tsx new file mode 100644 index 0000000000..0c3925c901 --- /dev/null +++ b/web/app/components/app-sidebar/snippet-info/__tests__/index.spec.tsx @@ -0,0 +1,60 @@ +import type { SnippetDetail } from '@/models/snippet' +import { render, screen } from '@testing-library/react' +import * as React from 'react' +import SnippetInfo from '..' + +vi.mock('../dropdown', () => ({ + default: () =>
, +})) + +const mockSnippet: SnippetDetail = { + id: 'snippet-1', + name: 'Social Media Repurposer', + description: 'Turn one blog post into multiple social media variations.', + updatedAt: '2026-03-25 10:00', + usage: '12', + tags: [], + status: undefined, +} + +describe('SnippetInfo', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Rendering tests for the collapsed and expanded sidebar header states. + describe('Rendering', () => { + it('should render the expanded snippet details and dropdown when expand is true', () => { + render() + + expect(screen.getByText(mockSnippet.name)).toBeInTheDocument() + expect(screen.getByText('snippet.typeLabel')).toBeInTheDocument() + expect(screen.getByText(mockSnippet.description)).toBeInTheDocument() + expect(screen.getByTestId('snippet-info-dropdown')).toBeInTheDocument() + }) + + it('should hide the expanded-only content when expand is false', () => { + render() + + expect(screen.queryByText(mockSnippet.name)).not.toBeInTheDocument() + expect(screen.queryByText('snippet.typeLabel')).not.toBeInTheDocument() + expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument() + expect(screen.queryByTestId('snippet-info-dropdown')).not.toBeInTheDocument() + }) + }) + + // Edge cases around optional snippet fields should not break the header layout. + describe('Edge Cases', () => { + it('should omit the description block when the snippet has no description', () => { + render( + , + ) + + expect(screen.getByText(mockSnippet.name)).toBeInTheDocument() + expect(screen.queryByText(mockSnippet.description)).not.toBeInTheDocument() + }) + }) +}) diff --git a/web/app/components/app-sidebar/snippet-info/dropdown.tsx b/web/app/components/app-sidebar/snippet-info/dropdown.tsx new file mode 100644 index 0000000000..eb108fd200 --- /dev/null +++ b/web/app/components/app-sidebar/snippet-info/dropdown.tsx @@ -0,0 +1,177 @@ +'use client' + +import type { SnippetDetail } from '@/models/snippet' +import { + AlertDialog, + AlertDialogActions, + AlertDialogCancelButton, + AlertDialogConfirmButton, + AlertDialogContent, + AlertDialogDescription, + AlertDialogTitle, +} from '@langgenius/dify-ui/alert-dialog' +import { cn } from '@langgenius/dify-ui/cn' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@langgenius/dify-ui/dropdown-menu' +import { toast } from '@langgenius/dify-ui/toast' +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import CreateSnippetDialog from '@/app/components/snippets/create-snippet-dialog' +import { useRouter } from '@/next/navigation' +import { useDeleteSnippetMutation, useExportSnippetMutation, useUpdateSnippetMutation } from '@/service/use-snippets' + +import { downloadBlob } from '@/utils/download' + +type SnippetInfoDropdownProps = { + snippet: SnippetDetail +} + +const SnippetInfoDropdown = ({ snippet }: SnippetInfoDropdownProps) => { + const { t } = useTranslation('snippet') + const { replace } = useRouter() + const [open, setOpen] = React.useState(false) + const [isEditDialogOpen, setIsEditDialogOpen] = React.useState(false) + const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false) + const updateSnippetMutation = useUpdateSnippetMutation() + const exportSnippetMutation = useExportSnippetMutation() + const deleteSnippetMutation = useDeleteSnippetMutation() + + const initialValue = React.useMemo(() => ({ + name: snippet.name, + description: snippet.description, + }), [snippet.description, snippet.name]) + + const handleOpenEditDialog = React.useCallback(() => { + setOpen(false) + setIsEditDialogOpen(true) + }, []) + + const handleExportSnippet = React.useCallback(async () => { + setOpen(false) + try { + const data = await exportSnippetMutation.mutateAsync({ snippetId: snippet.id }) + const file = new Blob([data], { type: 'application/yaml' }) + downloadBlob({ data: file, fileName: `${snippet.name}.yml` }) + } + catch { + toast.error(t('exportFailed')) + } + }, [exportSnippetMutation, snippet.id, snippet.name, t]) + + const handleEditSnippet = React.useCallback(async ({ name, description }: { + name: string + description: string + }) => { + updateSnippetMutation.mutate({ + params: { snippetId: snippet.id }, + body: { + name, + description: description || undefined, + }, + }, { + onSuccess: () => { + toast.success(t('editDone')) + setIsEditDialogOpen(false) + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : t('editFailed')) + }, + }) + }, [snippet.id, t, updateSnippetMutation]) + + const handleDeleteSnippet = React.useCallback(() => { + deleteSnippetMutation.mutate({ + params: { snippetId: snippet.id }, + }, { + onSuccess: () => { + toast.success(t('deleted')) + setIsDeleteDialogOpen(false) + replace('/snippets') + }, + onError: (error) => { + toast.error(error instanceof Error ? error.message : t('deleteFailed')) + }, + }) + }, [deleteSnippetMutation, replace, snippet.id, t]) + + return ( + <> + + + + + + + + {t('menu.editInfo')} + + + + {t('menu.exportSnippet')} + + + { + setOpen(false) + setIsDeleteDialogOpen(true) + }} + > + + {t('menu.deleteSnippet')} + + + + + {isEditDialogOpen && ( + setIsEditDialogOpen(false)} + onConfirm={handleEditSnippet} + /> + )} + + + +
+ + {t('deleteConfirmTitle')} + + + {t('deleteConfirmContent')} + +
+ + + {t('operation.cancel', { ns: 'common' })} + + + {t('menu.deleteSnippet')} + + +
+
+ + ) +} + +export default React.memo(SnippetInfoDropdown) diff --git a/web/app/components/app-sidebar/snippet-info/index.tsx b/web/app/components/app-sidebar/snippet-info/index.tsx new file mode 100644 index 0000000000..417807cdae --- /dev/null +++ b/web/app/components/app-sidebar/snippet-info/index.tsx @@ -0,0 +1,46 @@ +'use client' + +import type { SnippetDetail } from '@/models/snippet' +import * as React from 'react' +import { useTranslation } from 'react-i18next' +import SnippetInfoDropdown from './dropdown' + +type SnippetInfoProps = { + expand: boolean + snippet: SnippetDetail +} + +const SnippetInfo = ({ + expand, + snippet, +}: SnippetInfoProps) => { + const { t } = useTranslation('snippet') + + if (!expand) + return null + + return ( +
+
+
+ +
+
+
+ {snippet.name} +
+
+ {t('typeLabel')} +
+
+ {snippet.description && ( +

+ {snippet.description} +

+ )} +
+
+ ) +} + +export default React.memo(SnippetInfo) diff --git a/web/app/components/app/app-publisher/features-wrapper.tsx b/web/app/components/app/app-publisher/features-wrapper.tsx index 8679b2830d..3189eb8990 100644 --- a/web/app/components/app/app-publisher/features-wrapper.tsx +++ b/web/app/components/app/app-publisher/features-wrapper.tsx @@ -1,7 +1,6 @@ -import type { AppPublisherProps } from '@/app/components/app/app-publisher' -import type { ModelAndParameter } from '@/app/components/app/configuration/debug/types' -import type { FileUpload } from '@/app/components/base/features/types' -import type { PublishWorkflowParams } from '@/types/workflow' +import type { AppPublisherProps, AppPublisherPublishParams } from '@/app/components/app/app-publisher' +import type { Features, FileUpload } from '@/app/components/base/features/types' +import type { ModelConfig } from '@/models/debug' import { AlertDialog, AlertDialogActions, @@ -21,9 +20,15 @@ import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants' import { SupportUploadFileTypes } from '@/app/components/workflow/types' import { Resolution } from '@/types/app' +type PublishedModelConfig = ModelConfig & { + resetAppConfig?: () => void +} + type Props = Omit & { - onPublish?: (params?: ModelAndParameter | PublishWorkflowParams, features?: any) => Promise | any - publishedConfig?: any + onPublish?: (params?: AppPublisherPublishParams, features?: Features) => Promise | unknown + publishedConfig: { + modelConfig: PublishedModelConfig + } resetAppConfig?: () => void } @@ -71,7 +76,7 @@ const FeaturesWrappedAppPublisher = (props: Props) => { setRestoreConfirmOpen(false) }, [featuresStore, props]) - const handlePublish = useCallback((params?: ModelAndParameter | PublishWorkflowParams) => { + const handlePublish = useCallback((params?: AppPublisherPublishParams) => { return props.onPublish?.(params, features) }, [features, props]) diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index bdd24d9412..748f04ebeb 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -85,8 +85,10 @@ export type AppPublisherProps = { const PUBLISH_SHORTCUT = ['Mod', 'Shift', 'P'] +export type AppPublisherPublishParams = ModelAndParameter | PublishWorkflowParams + type AppPublisherPublishHandler - = | ((params?: ModelAndParameter | PublishWorkflowParams) => Promise | unknown) + = | ((params?: AppPublisherPublishParams) => Promise | unknown) | ((params?: unknown) => Promise | unknown) type AppPublisherRestoreHandler = () => Promise | unknown diff --git a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx index 7a77821c18..bf5d97abfc 100644 --- a/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/__tests__/form-fields.spec.tsx @@ -211,6 +211,12 @@ describe('ConfigModalFormFields', () => { expect(docLink).toHaveAttribute('rel', 'noopener noreferrer') textInputView.unmount() + const hiddenFieldDisabledProps = createBaseProps() + const hiddenFieldDisabledView = render() + expect(screen.queryByText('variableConfig.hidden')).not.toBeInTheDocument() + expect(screen.queryByText('variableConfig.hiddenDescription')).not.toBeInTheDocument() + hiddenFieldDisabledView.unmount() + const singleFileProps = createBaseProps() singleFileProps.tempPayload = { ...singleFileProps.tempPayload, diff --git a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx index da345a777b..e559679df6 100644 --- a/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx +++ b/web/app/components/app/configuration/config-var/config-modal/form-fields.tsx @@ -49,6 +49,7 @@ type ConfigModalFormFieldsProps = { onVarNameChange: (event: ChangeEvent) => void options?: string[] selectOptions: SelectOptionItem[] + showHiddenField?: boolean tempPayload: InputVar t: Translate } @@ -67,6 +68,7 @@ const ConfigModalFormFields: FC = ({ onVarNameChange, options, selectOptions, + showHiddenField = true, tempPayload, t, }) => { @@ -242,7 +244,7 @@ const ConfigModalFormFields: FC = ({ {t('variableConfig.required', { ns: 'appDebug' })} - {!isFileInput && ( + {showHiddenField && !isFileInput && (