Compare commits
2 Commits
test/real-
...
feat/opena
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa876cc95f | ||
|
|
94d9134719 |
@@ -87,7 +87,6 @@ from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from graphon.variables import SecretVariable, SegmentType, VariableBase
|
||||
from graphon.variables.exc import VariableError
|
||||
from libs import helper
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.helper import TimestampField, dump_response, to_timestamp, uuid_value
|
||||
from libs.login import login_required
|
||||
from models import Account, App
|
||||
@@ -101,10 +100,7 @@ from services.errors.app import IsDraftWorkflowError, WorkflowHashNotEqualError,
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
from services.workflow_ref_service import WorkflowRefService
|
||||
from services.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError, WorkflowService
|
||||
from services.workflow_variable_reference_validator import (
|
||||
format_variable_reference_errors,
|
||||
validate_variable_references,
|
||||
)
|
||||
from services.workflow_variable_reference_validator import advisory_variable_reference_warning
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -1274,21 +1270,6 @@ class DraftWorkflowNodeRunApi(Resource):
|
||||
).model_dump(mode="json")
|
||||
|
||||
|
||||
def _advisory_variable_reference_warning(graph_text: str | None) -> str | None:
|
||||
"""Return a non-blocking publish warning. A checker failure must not fail publish."""
|
||||
if not graph_text:
|
||||
return None
|
||||
try:
|
||||
graph = json.loads(graph_text)
|
||||
if not isinstance(graph, dict):
|
||||
return None
|
||||
issues = validate_variable_references(graph)
|
||||
return format_variable_reference_errors(issues) if issues else None
|
||||
except Exception:
|
||||
logger.warning("Skipped advisory variable reference check", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
@console_ns.route("/apps/<uuid:app_id>/workflows/publish")
|
||||
class PublishedWorkflowApi(Resource):
|
||||
@console_ns.doc("get_published_workflow")
|
||||
@@ -1337,7 +1318,7 @@ class PublishedWorkflowApi(Resource):
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
workflow = workflow_service.publish_workflow(
|
||||
workflow = workflow_service.publish_app_workflow(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
account=current_user,
|
||||
@@ -1345,17 +1326,10 @@ class PublishedWorkflowApi(Resource):
|
||||
marked_comment=args.marked_comment or "",
|
||||
)
|
||||
|
||||
# Update app_model within the same session to ensure atomicity
|
||||
app_model_in_session = session.get(App, app_model.id)
|
||||
if app_model_in_session:
|
||||
app_model_in_session.workflow_id = workflow.id
|
||||
app_model_in_session.updated_by = current_user.id
|
||||
app_model_in_session.updated_at = naive_utc_now()
|
||||
|
||||
workflow_created_at = TimestampField().format(workflow.created_at)
|
||||
graph_text = workflow.graph
|
||||
|
||||
warning = _advisory_variable_reference_warning(graph_text)
|
||||
warning = advisory_variable_reference_warning(graph_text)
|
||||
payload: dict[str, object] = {
|
||||
"result": "success",
|
||||
"created_at": workflow_created_at,
|
||||
|
||||
@@ -61,6 +61,9 @@ from controllers.openapi._models import (
|
||||
DeviceMutateResponse,
|
||||
DevicePollRequest,
|
||||
DeviceTokenResponse,
|
||||
DraftWorkflowRunPayload,
|
||||
EnvVariableListResponse,
|
||||
EnvVariableSetPayload,
|
||||
FileUploadPayload,
|
||||
FormSubmitResponse,
|
||||
HealthResponse,
|
||||
@@ -77,13 +80,20 @@ from controllers.openapi._models import (
|
||||
OpenApiFormSubmitPayload,
|
||||
PermittedExternalAppsListQuery,
|
||||
PermittedExternalAppsListResponse,
|
||||
PublishPayload,
|
||||
PublishResponse,
|
||||
RestoreResponse,
|
||||
RevokeResponse,
|
||||
RunListQuery,
|
||||
RunListResponse,
|
||||
ServerVersionResponse,
|
||||
SessionListQuery,
|
||||
SessionListResponse,
|
||||
SessionRow,
|
||||
TaskStopResponse,
|
||||
UsageInfo,
|
||||
VersionListQuery,
|
||||
VersionListResponse,
|
||||
WorkflowRunData,
|
||||
WorkflowRunPayload,
|
||||
WorkspaceDetailResponse,
|
||||
@@ -93,6 +103,7 @@ from controllers.openapi._models import (
|
||||
WorkspaceSummaryResponse,
|
||||
)
|
||||
from fields.file_fields import FileResponse
|
||||
from fields.workflow_run_fields import WorkflowRunDetailResponse, WorkflowRunNodeExecutionListResponse
|
||||
from services.entities.dsl_entities import CheckDependenciesResult, Import
|
||||
|
||||
register_schema_models(
|
||||
@@ -108,13 +119,18 @@ register_schema_models(
|
||||
DevicePollRequest,
|
||||
DeviceLookupQuery,
|
||||
DeviceMutateRequest,
|
||||
DraftWorkflowRunPayload,
|
||||
FileUploadPayload,
|
||||
MemberInvitePayload,
|
||||
MemberListQuery,
|
||||
MemberRoleUpdatePayload,
|
||||
OpenApiFormSubmitPayload,
|
||||
EnvVariableSetPayload,
|
||||
PermittedExternalAppsListQuery,
|
||||
PublishPayload,
|
||||
RunListQuery,
|
||||
SessionListQuery,
|
||||
VersionListQuery,
|
||||
WorkflowRunPayload,
|
||||
WorkspaceListQuery,
|
||||
)
|
||||
@@ -160,6 +176,13 @@ register_response_schema_models(
|
||||
FileResponse,
|
||||
ServerVersionResponse,
|
||||
HealthResponse,
|
||||
RunListResponse,
|
||||
PublishResponse,
|
||||
VersionListResponse,
|
||||
RestoreResponse,
|
||||
EnvVariableListResponse,
|
||||
WorkflowRunDetailResponse,
|
||||
WorkflowRunNodeExecutionListResponse,
|
||||
)
|
||||
# Standalone definition for contract codegen; ErrorBody.code stays an open
|
||||
# string on the wire so old clients keep parsing future codes.
|
||||
@@ -169,6 +192,7 @@ from . import (
|
||||
account,
|
||||
app_dsl,
|
||||
app_run,
|
||||
app_workflow,
|
||||
apps,
|
||||
apps_permitted_external,
|
||||
files,
|
||||
@@ -186,6 +210,7 @@ __all__ = [
|
||||
"account",
|
||||
"app_dsl",
|
||||
"app_run",
|
||||
"app_workflow",
|
||||
"apps",
|
||||
"apps_permitted_external",
|
||||
"files",
|
||||
|
||||
@@ -68,6 +68,12 @@ class OpenApiErrorCode(StrEnum):
|
||||
RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch"
|
||||
CATALOG_STALE = "catalog_stale"
|
||||
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE = "trigger_workflow_service_mode_unavailable"
|
||||
RUN_NOT_FOUND = "run_not_found"
|
||||
VERSION_NOT_FOUND = "version_not_found"
|
||||
VERSION_NOT_RESTORABLE = "version_not_restorable"
|
||||
DRAFT_NOT_FOUND = "draft_not_found"
|
||||
SECRET_MASK_UNKNOWN_ID = "secret_mask_unknown_id"
|
||||
SECRET_MASK_NOT_SECRET = "secret_mask_not_secret"
|
||||
|
||||
|
||||
class ErrorDetail(BaseModel):
|
||||
@@ -270,3 +276,39 @@ class InvalidFilePart(OpenApiError): # noqa: N818
|
||||
code = 422
|
||||
error_code = OpenApiErrorCode.INVALID_PARAM
|
||||
description = "A request part could not be used"
|
||||
|
||||
|
||||
class RunNotFound(OpenApiError): # noqa: N818
|
||||
code = 404
|
||||
error_code = OpenApiErrorCode.RUN_NOT_FOUND
|
||||
description = "No run with this id exists for this app."
|
||||
|
||||
|
||||
class VersionNotFound(OpenApiError): # noqa: N818
|
||||
code = 404
|
||||
error_code = OpenApiErrorCode.VERSION_NOT_FOUND
|
||||
description = "No version with this id exists for this app."
|
||||
|
||||
|
||||
class VersionNotRestorable(OpenApiError): # noqa: N818
|
||||
code = 400
|
||||
error_code = OpenApiErrorCode.VERSION_NOT_RESTORABLE
|
||||
description = "Only a published version can be restored."
|
||||
|
||||
|
||||
class DraftNotFound(OpenApiError): # noqa: N818
|
||||
code = 404
|
||||
error_code = OpenApiErrorCode.DRAFT_NOT_FOUND
|
||||
description = "This app has no draft."
|
||||
|
||||
|
||||
class SecretMaskUnknownId(OpenApiError): # noqa: N818
|
||||
code = 400
|
||||
error_code = OpenApiErrorCode.SECRET_MASK_UNKNOWN_ID
|
||||
description = "The value is the masked secret placeholder, but env_id is not an existing secret."
|
||||
|
||||
|
||||
class SecretMaskNotSecret(OpenApiError): # noqa: N818
|
||||
code = 400
|
||||
error_code = OpenApiErrorCode.SECRET_MASK_NOT_SECRET
|
||||
description = "The value is the masked secret placeholder, which only a secret value_type can keep."
|
||||
|
||||
@@ -12,7 +12,9 @@ from constants.oauth_bearer import SubjectType
|
||||
from controllers.common.human_input import HumanInputFormSubmitPayload
|
||||
from controllers.openapi._upload import UploadPart, UploadParts
|
||||
from enums import DeploymentEdition
|
||||
from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, uuid_value
|
||||
from fields.workflow_run_fields import WorkflowRunPaginationResponse
|
||||
from graphon.variables import SegmentType
|
||||
from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, to_timestamp, uuid_value
|
||||
from models.model import AppMode
|
||||
from services.app_dsl_service import Import
|
||||
|
||||
@@ -366,6 +368,10 @@ class WorkflowRunPayload(RunPayloadBase, _WorkflowVersionFields):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class DraftWorkflowRunPayload(RunPayloadBase):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class ChatRunPayload(RunPayloadBase, _ConversationFields):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
@@ -498,6 +504,11 @@ class AppDslImportPayload(BaseModel):
|
||||
icon: str | None = Field(None)
|
||||
icon_background: str | None = Field(None)
|
||||
app_id: str | None = Field(None, description="Existing app ID to overwrite (workflow/advanced-chat apps only)")
|
||||
draft_hash: str | None = Field(
|
||||
None,
|
||||
description="draft_hash from the export or restore this import is based on. The import fails if the "
|
||||
"draft's graph, features, environment variables or conversation variables changed since. Requires app_id",
|
||||
)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_source_by_mode(self) -> AppDslImportPayload:
|
||||
@@ -521,6 +532,11 @@ class AppDslExportResponse(BaseModel):
|
||||
"""Export DSL response."""
|
||||
|
||||
data: str = Field(..., description="DSL YAML string")
|
||||
draft_hash: str | None = Field(
|
||||
None,
|
||||
description="Hash of the draft's graph, features, environment variables and conversation variables; "
|
||||
"pass it to the import to refuse overwriting newer edits",
|
||||
)
|
||||
|
||||
|
||||
class AppDslImportResponse(Import, Hinted):
|
||||
@@ -550,3 +566,99 @@ class HumanInputFormDefinitionResponse(BaseModel):
|
||||
resolved_default_values: dict[str, str]
|
||||
user_actions: list[dict[str, Any]] = Field(default_factory=list)
|
||||
expiration_time: int | None = None
|
||||
|
||||
|
||||
class RunListQuery(BaseModel):
|
||||
last_id: UUIDStr | None = Field(None, description="Cursor: id of the last run on the previous page")
|
||||
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
|
||||
status: Literal["running", "succeeded", "failed", "stopped", "partial-succeeded"] | None = None
|
||||
triggered_from: Literal["debugging", "app-run"] | None = Field(
|
||||
None, description="debugging: draft test runs; app-run: real use. Omitted: debugging, as in the console"
|
||||
)
|
||||
|
||||
|
||||
class RunListResponse(WorkflowRunPaginationResponse, Hinted):
|
||||
"""Cursor page of runs; `hints` carries the next page."""
|
||||
|
||||
|
||||
class PublishPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
marked_name: str = Field("", max_length=20, description="Version name")
|
||||
marked_comment: str = Field("", max_length=100, description="Version note")
|
||||
|
||||
|
||||
class PublishResponse(BaseModel):
|
||||
version_id: str
|
||||
created_at: int
|
||||
warning: str | None = Field(None, description="Variable references that may read a skipped branch")
|
||||
|
||||
|
||||
class VersionListQuery(PageQuery):
|
||||
named_only: bool = Field(False, description="Only versions that have a name")
|
||||
|
||||
|
||||
class VersionRow(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: str
|
||||
marked_name: str
|
||||
marked_comment: str
|
||||
created_by: str | None = None
|
||||
created_at: int
|
||||
|
||||
@field_validator("created_at", mode="before")
|
||||
@classmethod
|
||||
def _timestamp(cls, value: datetime | int) -> int | None:
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class VersionListResponse(Hinted):
|
||||
"""Page of published versions, newest first; there is no total, `hints` carries the next page."""
|
||||
|
||||
page: int
|
||||
limit: int
|
||||
has_more: bool
|
||||
data: list[VersionRow]
|
||||
|
||||
|
||||
class EnvVariableRow(BaseModel):
|
||||
id: str = Field(description="What set and delete take to address this variable")
|
||||
name: str = Field(description="What nodes use to refer to this variable")
|
||||
description: str = ""
|
||||
value_type: str
|
||||
value: Any = Field(description="The value; a secret with a value is masked, an empty one reads as empty")
|
||||
|
||||
|
||||
class EnvVariableListResponse(BaseModel):
|
||||
data: list[EnvVariableRow]
|
||||
|
||||
|
||||
class EnvVariableValueType(StrEnum):
|
||||
"""Value types the draft environment-variable ``set`` op accepts.
|
||||
|
||||
A curated subset of ``SegmentType``: what the console's environment-variable editor
|
||||
allows (``ENVIRONMENT_VARIABLE_SUPPORTED_TYPES`` in controllers/console/app/workflow.py).
|
||||
Members reference ``SegmentType.*.value`` so the subset relationship is type-checked.
|
||||
"""
|
||||
|
||||
STRING = SegmentType.STRING.value
|
||||
NUMBER = SegmentType.NUMBER.value
|
||||
SECRET = SegmentType.SECRET.value
|
||||
|
||||
|
||||
class EnvVariableSetPayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
name: str = Field(description="Variable name")
|
||||
value_type: EnvVariableValueType = Field(description="string, number or secret")
|
||||
value: Any = Field(description="The value; sending the masked value of an existing secret keeps the stored one")
|
||||
description: str = Field("", description="What the variable is for")
|
||||
|
||||
|
||||
class RestoreResponse(BaseModel):
|
||||
result: Literal["success"]
|
||||
draft_hash: str = Field(
|
||||
description="Hash of the restored draft's graph, features, environment variables and conversation "
|
||||
"variables; pass it to a DSL import as draft_hash"
|
||||
)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from http import HTTPStatus
|
||||
from typing import Final
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from constants.oauth_bearer import Scope
|
||||
from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission, Workspace
|
||||
from controllers.common.rbac import RBACCheck, RBACPermission, Workspace
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import Example, Kind, endpoint, op_of
|
||||
from controllers.openapi._models import (
|
||||
@@ -19,22 +20,25 @@ from controllers.openapi._models import (
|
||||
)
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.requirements import (
|
||||
CheckAppApiEnabled,
|
||||
EDITOR_ROLES,
|
||||
CheckRBACPermission,
|
||||
CheckScope,
|
||||
CheckSubject,
|
||||
CheckWorkspaceMember,
|
||||
CheckWorkspaceRole,
|
||||
account_app_guards,
|
||||
)
|
||||
from controllers.openapi.auth.subjects import AccountSubject
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from machinery.context import RequestContext
|
||||
from models.account import TenantAccountRole
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.app import WorkflowNotFoundError
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
_DSL_READ_GUARDS: Final = account_app_guards(RBACPermission.APP_IMPORT_EXPORT_DSL, scope=Scope.APPS_READ, editor=True)
|
||||
|
||||
|
||||
def _import_response(result: Import, *, workspace_id: str) -> AppDslImportResponse:
|
||||
@@ -88,7 +92,7 @@ class AppDslImportApi(Resource):
|
||||
CheckScope(Scope.WORKSPACE_WRITE),
|
||||
CheckWorkspaceMember(),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())),
|
||||
CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})),
|
||||
CheckWorkspaceRole(EDITOR_ROLES),
|
||||
),
|
||||
body=AppDslImportPayload,
|
||||
returns=(
|
||||
@@ -138,7 +142,7 @@ class AppDslImportConfirmApi(Resource):
|
||||
CheckScope(Scope.WORKSPACE_WRITE),
|
||||
CheckWorkspaceMember(),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace())),
|
||||
CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})),
|
||||
CheckWorkspaceRole(EDITOR_ROLES),
|
||||
),
|
||||
returns=((HTTPStatus.OK, Import, "Import confirmed"), (HTTPStatus.BAD_REQUEST, Import, "Import failed")),
|
||||
)
|
||||
@@ -177,18 +181,15 @@ class AppDslExportApi(Resource):
|
||||
input={"app_id": "<app_id>", "workflow_id": "<workflow_id>", "include_secret": True},
|
||||
),
|
||||
),
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_READ),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, PlainApp())),
|
||||
CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})),
|
||||
),
|
||||
requirements=_DSL_READ_GUARDS,
|
||||
query=AppDslExportQuery,
|
||||
returns=(200, AppDslExportResponse, "Export successful"),
|
||||
)
|
||||
def get(self, ctx: Context, app_id: str, *, query: AppDslExportQuery):
|
||||
# Capture the draft hash before building the DSL: an edit that commits in between would
|
||||
# otherwise be described by a hash newer than the exported YAML, defeating the guard
|
||||
# draft_hash exists for (a later import silently overwriting that edit).
|
||||
draft = WorkflowService().get_draft_workflow(app_model=ctx.app, session=db.session())
|
||||
try:
|
||||
data = AppDslService.export_dsl(
|
||||
app_model=ctx.app,
|
||||
@@ -198,7 +199,7 @@ class AppDslExportApi(Resource):
|
||||
)
|
||||
except WorkflowNotFoundError as exc:
|
||||
return str(exc), 404
|
||||
return AppDslExportResponse(data=data), 200
|
||||
return AppDslExportResponse(data=data, draft_hash=draft.content_hash if draft else None), 200
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/dependencies:check")
|
||||
@@ -217,14 +218,7 @@ class AppDslCheckDependenciesApi(Resource):
|
||||
kind=Kind.OBJECT,
|
||||
summary="Check plugin dependencies of an app",
|
||||
examples=(Example(title="Check which plugins an app needs", input={"app_id": "<app_id>"}),),
|
||||
requirements=(
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_READ),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, PlainApp())),
|
||||
CheckWorkspaceRole(frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})),
|
||||
),
|
||||
requirements=_DSL_READ_GUARDS,
|
||||
returns=(HTTPStatus.OK, CheckDependenciesResult, "Dependencies checked"),
|
||||
)
|
||||
def get(self, ctx: RequestContext, app_id: str):
|
||||
|
||||
@@ -32,6 +32,7 @@ from controllers.openapi._models import (
|
||||
AdvancedChatRunPayload,
|
||||
ChatRunPayload,
|
||||
CompletionRunPayload,
|
||||
DraftWorkflowRunPayload,
|
||||
Hint,
|
||||
RunPayloadBase,
|
||||
TaskStopResponse,
|
||||
@@ -45,6 +46,8 @@ from controllers.openapi.auth.requirements import (
|
||||
CheckScope,
|
||||
CheckSubject,
|
||||
CheckWorkspaceMember,
|
||||
Requirement,
|
||||
account_app_guards,
|
||||
)
|
||||
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
|
||||
from controllers.openapi.human_input_form import with_form_hints
|
||||
@@ -140,16 +143,19 @@ _RUN_GUARDS: Final = (
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())),
|
||||
CheckAppAccess(),
|
||||
)
|
||||
_DRAFT_RUN_GUARDS: Final = account_app_guards(RBACPermission.APP_TEST_AND_RUN, scope=Scope.APPS_RUN, editor=True)
|
||||
# Draft runs raise debugger forms, which the form submit op does not accept.
|
||||
_DRAFT_FORM_NOTE: Final = "A human-input pause in a draft run cannot be resumed over openapi."
|
||||
_STREAM_RESULT: Final = (200, EventStreamResponse, "Run result (SSE stream)")
|
||||
|
||||
|
||||
def _generate(app: App, caller: Any, args: dict[str, Any], session: Session):
|
||||
def _generate(app: App, caller: Any, args: dict[str, Any], session: Session, invoke_from: InvokeFrom):
|
||||
return AppGenerateService.generate(
|
||||
session=session,
|
||||
app_model=app,
|
||||
user=caller,
|
||||
args=args,
|
||||
invoke_from=InvokeFrom.OPENAPI,
|
||||
invoke_from=invoke_from,
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
@@ -164,12 +170,12 @@ def _generate_args(ctx: Context, payload: RunPayloadBase, *, exclude: Collection
|
||||
return args
|
||||
|
||||
|
||||
def _stream(ctx: Context, args: dict[str, Any]):
|
||||
def _stream(ctx: Context, args: dict[str, Any], invoke_from: InvokeFrom):
|
||||
with _translate_service_errors():
|
||||
return _generate(ctx.app, ctx.caller, args, ctx.session)
|
||||
return _generate(ctx.app, ctx.caller, args, ctx.session, invoke_from)
|
||||
|
||||
|
||||
def _require_mode(app: App, *modes: AppMode) -> None:
|
||||
def require_mode(app: App, *modes: AppMode) -> None:
|
||||
if app.mode not in modes:
|
||||
raise UnprocessableEntity("app_mode_mismatch")
|
||||
|
||||
@@ -231,6 +237,8 @@ class _RunRoute:
|
||||
modes: tuple[AppMode, ...]
|
||||
hints: tuple[HintLayer, ...] = ()
|
||||
examples: tuple[Example, ...] = ()
|
||||
invoke_from: InvokeFrom = InvokeFrom.OPENAPI
|
||||
requirements: tuple[Requirement, ...] = _RUN_GUARDS
|
||||
|
||||
|
||||
_RUN_ROUTES: Final = (
|
||||
@@ -332,6 +340,35 @@ _RUN_ROUTES: Final = (
|
||||
),
|
||||
),
|
||||
),
|
||||
_RunRoute(
|
||||
resource="WorkflowDraftRunApi",
|
||||
segment="draft/workflow",
|
||||
op="run.console_app.draft.workflow",
|
||||
summary="Test-run the draft of a workflow app; streams workflow events. " + _DRAFT_FORM_NOTE,
|
||||
payload=DraftWorkflowRunPayload,
|
||||
modes=(AppMode.WORKFLOW,),
|
||||
examples=(
|
||||
Example(title="Test the draft with one input", input={"app_id": "<app_id>", "inputs": {"topic": "test"}}),
|
||||
),
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
requirements=_DRAFT_RUN_GUARDS,
|
||||
),
|
||||
_RunRoute(
|
||||
resource="AdvancedChatDraftRunApi",
|
||||
segment="draft/advanced-chat",
|
||||
op="run.console_app.draft.advanced_chat",
|
||||
summary="Test-run the draft of an advanced-chat app; streams message and workflow events. " + _DRAFT_FORM_NOTE,
|
||||
payload=ChatRunPayload,
|
||||
modes=(AppMode.ADVANCED_CHAT,),
|
||||
hints=(_reply_layer,),
|
||||
examples=(
|
||||
Example(
|
||||
title="Test the draft with one message", input={"app_id": "<app_id>", "query": "hello", "inputs": {}}
|
||||
),
|
||||
),
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
requirements=_DRAFT_RUN_GUARDS,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -340,14 +377,14 @@ def _run_api(route: _RunRoute) -> type[Resource]:
|
||||
op=route.op,
|
||||
kind=Kind.SSE,
|
||||
summary=route.summary,
|
||||
requirements=_RUN_GUARDS,
|
||||
requirements=route.requirements,
|
||||
body=route.payload,
|
||||
examples=route.examples,
|
||||
returns=_STREAM_RESULT,
|
||||
)
|
||||
def post(self: Resource, ctx: Context, app_id: str, *, body: RunPayloadBase):
|
||||
_require_mode(ctx.app, *route.modes)
|
||||
stream = _stream(ctx, _generate_args(ctx, body))
|
||||
require_mode(ctx.app, *route.modes)
|
||||
stream = _stream(ctx, _generate_args(ctx, body), route.invoke_from)
|
||||
for layer in route.hints:
|
||||
stream = layer(stream, route.op, ctx.app.id)
|
||||
return _respond(ctx, stream)
|
||||
@@ -356,7 +393,14 @@ def _run_api(route: _RunRoute) -> type[Resource]:
|
||||
return openapi_ns.route(f"/apps/<string:app_id>/{route.segment}:run")(resource)
|
||||
|
||||
|
||||
WorkflowRunApi, ChatRunApi, AdvancedChatRunApi, CompletionRunApi = (_run_api(route) for route in _RUN_ROUTES)
|
||||
(
|
||||
WorkflowRunApi,
|
||||
ChatRunApi,
|
||||
AdvancedChatRunApi,
|
||||
CompletionRunApi,
|
||||
WorkflowDraftRunApi,
|
||||
AdvancedChatDraftRunApi,
|
||||
) = (_run_api(route) for route in _RUN_ROUTES)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>:stop")
|
||||
|
||||
394
api/controllers/openapi/app_workflow.py
Normal file
394
api/controllers/openapi/app_workflow.py
Normal file
@@ -0,0 +1,394 @@
|
||||
"""Build routes on /openapi/v1 for workflow and advanced-chat apps: run history,
|
||||
publishing and versions, and draft environment variables."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterable
|
||||
from http import HTTPStatus
|
||||
from typing import Final
|
||||
from uuid import UUID
|
||||
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from werkzeug.exceptions import BadRequest
|
||||
|
||||
from constants import HIDDEN_VALUE
|
||||
from constants.oauth_bearer import Scope
|
||||
from controllers.common.fields import SimpleResultResponse
|
||||
from controllers.common.rbac import RBACPermission
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import Example, Kind, endpoint
|
||||
from controllers.openapi._errors import (
|
||||
DraftNotFound,
|
||||
OpenApiError,
|
||||
RunNotFound,
|
||||
SecretMaskNotSecret,
|
||||
SecretMaskUnknownId,
|
||||
VersionNotFound,
|
||||
VersionNotRestorable,
|
||||
)
|
||||
from controllers.openapi._models import (
|
||||
EnvVariableListResponse,
|
||||
EnvVariableRow,
|
||||
EnvVariableSetPayload,
|
||||
EnvVariableValueType,
|
||||
Hint,
|
||||
PublishPayload,
|
||||
PublishResponse,
|
||||
RestoreResponse,
|
||||
RunListQuery,
|
||||
RunListResponse,
|
||||
VersionListQuery,
|
||||
VersionListResponse,
|
||||
VersionRow,
|
||||
)
|
||||
from controllers.openapi.app_run import require_mode
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.requirements import account_app_guards
|
||||
from core.helper import encrypter
|
||||
from core.logging.context import get_request_id, get_trace_id
|
||||
from core.workflow.llm_environment_variable import environment_variable_value_type
|
||||
from extensions.ext_application_services import application_services
|
||||
from extensions.ext_database import db
|
||||
from factories import variable_factory
|
||||
from fields.workflow_run_fields import (
|
||||
WorkflowRunDetailResponse,
|
||||
WorkflowRunNodeExecutionListResponse,
|
||||
workflow_run_pagination_response_source,
|
||||
workflow_run_response_source,
|
||||
)
|
||||
from graphon.variables import SecretVariable, VariableBase
|
||||
from graphon.variables.exc import VariableError
|
||||
from libs.helper import to_timestamp
|
||||
from machinery.context import RequestContext
|
||||
from models import AppMode, WorkflowRunTriggeredFrom
|
||||
from models.workflow import Workflow, WorkflowRun
|
||||
from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError
|
||||
from services.workflow_run_service import WorkflowRunListArgs
|
||||
from services.workflow_service import WorkflowService
|
||||
from services.workflow_variable_reference_validator import advisory_variable_reference_warning
|
||||
|
||||
GRAPH_MODES: Final = (AppMode.WORKFLOW, AppMode.ADVANCED_CHAT)
|
||||
_RUN_LIST_OP: Final = "get.console_app.run"
|
||||
_VERSION_LIST_OP: Final = "get.console_app.version"
|
||||
|
||||
_RUN_READ_GUARDS: Final = account_app_guards(
|
||||
RBACPermission.APP_CREATE_AND_MANAGEMENT, scope=Scope.APPS_READ, editor=False
|
||||
)
|
||||
_VERSION_READ_GUARDS: Final = account_app_guards(RBACPermission.APP_VIEW_LAYOUT, scope=Scope.APPS_READ, editor=True)
|
||||
_RELEASE_GUARDS: Final = account_app_guards(
|
||||
RBACPermission.APP_RELEASE_AND_VERSION, scope=Scope.WORKSPACE_WRITE, editor=True
|
||||
)
|
||||
_ENV_READ_GUARDS: Final = account_app_guards(RBACPermission.APP_VIEW_LAYOUT, scope=Scope.APPS_READ, editor=True)
|
||||
_ENV_WRITE_GUARDS: Final = account_app_guards(RBACPermission.APP_EDIT, scope=Scope.WORKSPACE_WRITE, editor=True)
|
||||
|
||||
|
||||
def _request_context(ctx: Context) -> RequestContext:
|
||||
return RequestContext(get_request_id(), get_trace_id(), ctx.account.id, ctx.workspace.id)
|
||||
|
||||
|
||||
def _require_uuid(value: str, *, not_found: type[OpenApiError]) -> str:
|
||||
"""Parse a path id as a UUID or raise 404; an unparsed id would otherwise reach
|
||||
a UUID-typed database column and fail as a 500."""
|
||||
try:
|
||||
return str(UUID(value))
|
||||
except ValueError:
|
||||
raise not_found()
|
||||
|
||||
|
||||
def _require_run(ctx: Context, run_id: str) -> WorkflowRun:
|
||||
run = application_services().workflow_runs.get_workflow_run(
|
||||
_request_context(ctx), app_id=ctx.app.id, run_id=_require_uuid(run_id, not_found=RunNotFound)
|
||||
)
|
||||
if run is None:
|
||||
raise RunNotFound()
|
||||
return run
|
||||
|
||||
|
||||
def with_next_cursor(page: RunListResponse, *, op: str, app_id: str, query: RunListQuery) -> RunListResponse:
|
||||
if page.has_more and page.data:
|
||||
next_input = {"app_id": app_id, **query.model_dump(exclude_none=True), "last_id": page.data[-1].id}
|
||||
page.hints = [Hint(summary="Next page", op=op, input=next_input)]
|
||||
return page
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/runs")
|
||||
class AppRunListApi(Resource):
|
||||
@endpoint(
|
||||
op=_RUN_LIST_OP,
|
||||
kind=Kind.OBJECT,
|
||||
summary="List runs of a workflow or advanced-chat app, newest first",
|
||||
examples=(
|
||||
Example(
|
||||
title="Latest failed runs from real use",
|
||||
input={"app_id": "<app_id>", "status": "failed", "triggered_from": "app-run"},
|
||||
),
|
||||
Example(
|
||||
title="My latest draft test run",
|
||||
input={"app_id": "<app_id>", "triggered_from": "debugging", "limit": 1},
|
||||
),
|
||||
),
|
||||
requirements=_RUN_READ_GUARDS,
|
||||
query=RunListQuery,
|
||||
returns=(HTTPStatus.OK, RunListResponse, "Run list"),
|
||||
)
|
||||
def get(self, ctx: Context, app_id: str, *, query: RunListQuery):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
args: WorkflowRunListArgs = {"limit": query.limit}
|
||||
if query.last_id is not None:
|
||||
args["last_id"] = query.last_id
|
||||
if query.status is not None:
|
||||
args["status"] = query.status
|
||||
pagination = application_services().workflow_runs.get_paginate_workflow_runs(
|
||||
_request_context(ctx),
|
||||
app_id=ctx.app.id,
|
||||
args=args,
|
||||
triggered_from=WorkflowRunTriggeredFrom(query.triggered_from or WorkflowRunTriggeredFrom.DEBUGGING),
|
||||
)
|
||||
page = RunListResponse.model_validate(workflow_run_pagination_response_source(pagination, session=ctx.session))
|
||||
return with_next_cursor(page, op=_RUN_LIST_OP, app_id=ctx.app.id, query=query)
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/runs/<string:run_id>")
|
||||
class AppRunDescribeApi(Resource):
|
||||
@endpoint(
|
||||
op="describe.console_app.run",
|
||||
kind=Kind.OBJECT,
|
||||
summary="One run: inputs, outputs, error, tokens and timing",
|
||||
examples=(Example(title="Read one run", input={"app_id": "<app_id>", "run_id": "<run_id>"}),),
|
||||
requirements=_RUN_READ_GUARDS,
|
||||
returns=(HTTPStatus.OK, WorkflowRunDetailResponse, "Run detail"),
|
||||
)
|
||||
def get(self, ctx: Context, app_id: str, run_id: str):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
run = _require_run(ctx, run_id)
|
||||
return WorkflowRunDetailResponse.model_validate(workflow_run_response_source(run, session=ctx.session))
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/runs/<string:run_id>/nodes")
|
||||
class AppRunNodeListApi(Resource):
|
||||
@endpoint(
|
||||
op="get.console_app.run.node",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Each node step of a run, to find the one that failed",
|
||||
examples=(Example(title="Node steps of one run", input={"app_id": "<app_id>", "run_id": "<run_id>"}),),
|
||||
requirements=_RUN_READ_GUARDS,
|
||||
returns=(HTTPStatus.OK, WorkflowRunNodeExecutionListResponse, "Node steps"),
|
||||
)
|
||||
def get(self, ctx: Context, app_id: str, run_id: str):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
run = _require_run(ctx, run_id)
|
||||
steps = application_services().workflow_runs.get_workflow_run_node_executions(
|
||||
_request_context(ctx), app_id=ctx.app.id, run_id=run.id
|
||||
)
|
||||
return WorkflowRunNodeExecutionListResponse.model_validate({"data": steps})
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>:publish")
|
||||
class AppPublishApi(Resource):
|
||||
@endpoint(
|
||||
op="publish.console_app",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Publish the draft as a new version and make the app use it",
|
||||
examples=(
|
||||
Example(title="Publish the draft", input={"app_id": "<app_id>"}),
|
||||
Example(
|
||||
title="Publish a named version",
|
||||
input={"app_id": "<app_id>", "marked_name": "v2", "marked_comment": "Adds a retry"},
|
||||
),
|
||||
),
|
||||
requirements=_RELEASE_GUARDS,
|
||||
body=PublishPayload,
|
||||
returns=(HTTPStatus.OK, PublishResponse, "Published"),
|
||||
)
|
||||
def post(self, ctx: Context, app_id: str, *, body: PublishPayload):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
try:
|
||||
with sessionmaker(db.engine).begin() as session:
|
||||
workflow = WorkflowService().publish_app_workflow(
|
||||
session=session,
|
||||
app_model=ctx.app,
|
||||
account=ctx.account,
|
||||
marked_name=body.marked_name,
|
||||
marked_comment=body.marked_comment,
|
||||
)
|
||||
published = PublishResponse(
|
||||
version_id=workflow.id,
|
||||
created_at=to_timestamp(workflow.created_at),
|
||||
warning=advisory_variable_reference_warning(workflow.graph),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
return published
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/versions")
|
||||
class AppVersionListApi(Resource):
|
||||
@endpoint(
|
||||
op=_VERSION_LIST_OP,
|
||||
kind=Kind.OBJECT,
|
||||
summary="List published versions, newest first",
|
||||
examples=(
|
||||
Example(title="Latest versions", input={"app_id": "<app_id>"}),
|
||||
Example(title="Only named versions", input={"app_id": "<app_id>", "named_only": True}),
|
||||
),
|
||||
requirements=_VERSION_READ_GUARDS,
|
||||
query=VersionListQuery,
|
||||
returns=(HTTPStatus.OK, VersionListResponse, "Version list"),
|
||||
)
|
||||
def get(self, ctx: Context, app_id: str, *, query: VersionListQuery):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
workflows, has_more = WorkflowService().get_all_published_workflow(
|
||||
session=ctx.session,
|
||||
app_model=ctx.app,
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
user_id=None,
|
||||
named_only=query.named_only,
|
||||
include_draft=False,
|
||||
)
|
||||
page = VersionListResponse(
|
||||
page=query.page,
|
||||
limit=query.limit,
|
||||
has_more=has_more,
|
||||
data=[VersionRow.model_validate(workflow) for workflow in workflows],
|
||||
)
|
||||
if has_more:
|
||||
next_input = {"app_id": ctx.app.id, **query.model_dump(), "page": query.page + 1}
|
||||
page.hints = [Hint(summary="Next page", op=_VERSION_LIST_OP, input=next_input)]
|
||||
return page
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/versions/<string:version_id>:restore")
|
||||
class AppVersionRestoreApi(Resource):
|
||||
@endpoint(
|
||||
op="restore.console_app.version",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Copy a published version into the draft; publish again to make it live",
|
||||
examples=(
|
||||
Example(title="Restore an older version", input={"app_id": "<app_id>", "version_id": "<version_id>"}),
|
||||
),
|
||||
requirements=_RELEASE_GUARDS,
|
||||
returns=(HTTPStatus.OK, RestoreResponse, "Restored into the draft"),
|
||||
)
|
||||
def post(self, ctx: Context, app_id: str, version_id: str):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
version_id = _require_uuid(version_id, not_found=VersionNotFound)
|
||||
try:
|
||||
draft = WorkflowService().restore_published_workflow_to_draft(
|
||||
app_model=ctx.app, workflow_id=version_id, account=ctx.account, session=db.session()
|
||||
)
|
||||
except IsDraftWorkflowError as exc:
|
||||
raise VersionNotRestorable() from exc
|
||||
except WorkflowNotFoundError as exc:
|
||||
raise VersionNotFound() from exc
|
||||
except ValueError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
return RestoreResponse(result="success", draft_hash=draft.content_hash)
|
||||
|
||||
|
||||
def env_variable_rows(variables: Iterable[VariableBase]) -> list[EnvVariableRow]:
|
||||
return [
|
||||
EnvVariableRow(
|
||||
id=variable.id,
|
||||
name=variable.name,
|
||||
description=variable.description,
|
||||
value_type=environment_variable_value_type(variable),
|
||||
value=encrypter.full_mask_token()
|
||||
if isinstance(variable, SecretVariable) and variable.value
|
||||
else variable.value,
|
||||
)
|
||||
for variable in variables
|
||||
]
|
||||
|
||||
|
||||
def _stored_secret_ids(draft: Workflow | None) -> set[str]:
|
||||
if draft is None:
|
||||
return set()
|
||||
return {variable.id for variable in draft.environment_variables if isinstance(variable, SecretVariable)}
|
||||
|
||||
|
||||
def _patch_env(ctx: Context, *, upserts: list[VariableBase], deletions: list[str]) -> None:
|
||||
try:
|
||||
WorkflowService().patch_draft_workflow_environment_variables(
|
||||
app_model=ctx.app,
|
||||
environment_variables=upserts,
|
||||
deleted_environment_variable_ids=deletions,
|
||||
account=ctx.account,
|
||||
session=db.session(),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/env")
|
||||
class AppEnvListApi(Resource):
|
||||
@endpoint(
|
||||
op="get.console_app.env",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Environment variables of the draft. Each has an id, which set and delete take, "
|
||||
"and a name, which nodes use. Secrets with a value are masked; empty secrets read as empty",
|
||||
examples=(Example(title="List environment variables", input={"app_id": "<app_id>"}),),
|
||||
requirements=_ENV_READ_GUARDS,
|
||||
returns=(HTTPStatus.OK, EnvVariableListResponse, "Environment variables"),
|
||||
)
|
||||
def get(self, ctx: Context, app_id: str):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
draft = WorkflowService().get_draft_workflow(app_model=ctx.app, session=ctx.session)
|
||||
if draft is None:
|
||||
raise DraftNotFound()
|
||||
return EnvVariableListResponse(data=env_variable_rows(draft.environment_variables))
|
||||
|
||||
|
||||
@openapi_ns.route("/apps/<string:app_id>/env/<string:env_id>")
|
||||
class AppEnvItemApi(Resource):
|
||||
@endpoint(
|
||||
op="set.console_app.env",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Add or replace one environment variable of the draft, keyed by its id from get console_app env, "
|
||||
"not its name. A new id (e.g. a fresh UUID) adds a variable. Others are left alone",
|
||||
examples=(
|
||||
Example(
|
||||
title="Set an API key as a secret",
|
||||
input={
|
||||
"app_id": "<app_id>",
|
||||
"env_id": "<env_id>",
|
||||
"name": "API_KEY",
|
||||
"value_type": "secret",
|
||||
"value": "sk-...",
|
||||
},
|
||||
),
|
||||
),
|
||||
requirements=_ENV_WRITE_GUARDS,
|
||||
body=EnvVariableSetPayload,
|
||||
returns=(HTTPStatus.OK, SimpleResultResponse, "Variable set"),
|
||||
)
|
||||
def put(self, ctx: Context, app_id: str, env_id: str, *, body: EnvVariableSetPayload):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
if body.value == encrypter.full_mask_token() and body.value_type != EnvVariableValueType.SECRET:
|
||||
raise SecretMaskNotSecret()
|
||||
[mapping] = Workflow.normalize_environment_variable_mappings([{**body.model_dump(mode="json"), "id": env_id}])
|
||||
if mapping["value"] == HIDDEN_VALUE:
|
||||
draft = WorkflowService().get_draft_workflow(app_model=ctx.app, session=ctx.session)
|
||||
if env_id not in _stored_secret_ids(draft):
|
||||
raise SecretMaskUnknownId()
|
||||
try:
|
||||
variable = variable_factory.build_environment_variable_from_mapping(mapping)
|
||||
except VariableError as exc:
|
||||
raise BadRequest(str(exc)) from exc
|
||||
_patch_env(ctx, upserts=[variable], deletions=[])
|
||||
return SimpleResultResponse(result="success")
|
||||
|
||||
@endpoint(
|
||||
op="delete.console_app.env",
|
||||
kind=Kind.OBJECT,
|
||||
summary="Remove one environment variable from the draft, keyed by its id from get console_app env, "
|
||||
"not its name. Others are left alone",
|
||||
examples=(Example(title="Remove a variable", input={"app_id": "<app_id>", "env_id": "<env_id>"}),),
|
||||
requirements=_ENV_WRITE_GUARDS,
|
||||
returns=(HTTPStatus.OK, SimpleResultResponse, "Variable removed"),
|
||||
)
|
||||
def delete(self, ctx: Context, app_id: str, env_id: str):
|
||||
require_mode(ctx.app, *GRAPH_MODES)
|
||||
_patch_env(ctx, upserts=[], deletions=[env_id])
|
||||
return SimpleResultResponse(result="success")
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Sequence
|
||||
from enum import IntEnum
|
||||
from typing import ClassVar, override
|
||||
from typing import ClassVar, Final, override
|
||||
|
||||
from flask import request
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -19,11 +19,11 @@ from werkzeug.exceptions import Forbidden
|
||||
|
||||
from configs import dify_config
|
||||
from constants.oauth_bearer import Scope
|
||||
from controllers.common.rbac import RBACCheck, enforce_rbac_checks
|
||||
from controllers.common.rbac import PlainApp, RBACCheck, RBACPermission, enforce_rbac_checks
|
||||
from controllers.openapi._audit import emit_wrong_surface
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.loaders import load_app, load_caller, load_workspace, load_workspace_role
|
||||
from controllers.openapi.auth.subjects import Subject
|
||||
from controllers.openapi.auth.subjects import AccountSubject, Subject
|
||||
from enums import DeploymentEdition
|
||||
from models.account import TenantAccountRole
|
||||
from models.enums import CreatorUserRole
|
||||
@@ -141,6 +141,9 @@ class CheckRBACPermission(Requirement):
|
||||
)
|
||||
|
||||
|
||||
EDITOR_ROLES: Final = frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER})
|
||||
|
||||
|
||||
class CheckWorkspaceRole(Requirement):
|
||||
"""The workspace-role gate that predates RBAC. Inert wherever RBAC is on;
|
||||
a route that needs a check there declares a `CheckRBACPermission` beside this.
|
||||
@@ -206,3 +209,15 @@ class ResolveCaller(Requirement):
|
||||
if not subject.mounts_caller(ctx):
|
||||
return
|
||||
load_caller(ctx)
|
||||
|
||||
|
||||
def account_app_guards(permission: RBACPermission, *, scope: Scope, editor: bool) -> tuple[Requirement, ...]:
|
||||
"""Guards for an account-only op on one app; `editor` adds the pre-RBAC editor-role gate."""
|
||||
guards: tuple[Requirement, ...] = (
|
||||
CheckSubject(allowed=(AccountSubject,)),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(scope),
|
||||
CheckRBACPermission(RBACCheck(permission, PlainApp())),
|
||||
)
|
||||
return (*guards, CheckWorkspaceRole(EDITOR_ROLES)) if editor else guards
|
||||
|
||||
@@ -560,6 +560,18 @@ class Workflow(Base): # bug
|
||||
|
||||
return helper.generate_text_hash(json.dumps(entity, sort_keys=True))
|
||||
|
||||
@property
|
||||
def content_hash(self) -> str:
|
||||
"""Hash of everything a DSL import replaces: graph, features, environment and conversation
|
||||
variables. Hashes the stored column text, so encrypted secrets never leave the row."""
|
||||
entity = {
|
||||
"graph": self.graph,
|
||||
"features": self.features,
|
||||
"environment_variables": self._environment_variables,
|
||||
"conversation_variables": self._conversation_variables,
|
||||
}
|
||||
return helper.generate_text_hash(json.dumps(entity, sort_keys=True))
|
||||
|
||||
@deprecated(
|
||||
"This method is not accurate for determining if a workflow is published as a tool."
|
||||
"It only checks if there's a WorkflowToolProvider for the app, "
|
||||
|
||||
@@ -16366,6 +16366,7 @@ This class is used to store the schema information of an api based tool.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| app_id | string | | No |
|
||||
| description | string | | No |
|
||||
| draft_hash | string | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
|
||||
@@ -195,6 +195,48 @@ Machine-readable catalog of every op on this surface
|
||||
| 200 | Dependencies checked | **application/json**: [CheckDependenciesResult](#checkdependenciesresult)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/draft/advanced-chat:run
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [ChatRunPayload](#chatrunpayload)<br>**multipart/form-data**: [ChatRunPayload](#chatrunpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/draft/workflow:run
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [DraftWorkflowRunPayload](#draftworkflowrunpayload)<br>**multipart/form-data**: [DraftWorkflowRunPayload](#draftworkflowrunpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Run result (SSE stream) | **application/json**: [EventStreamResponse](#eventstreamresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/dsl
|
||||
#### Parameters
|
||||
|
||||
@@ -212,6 +254,57 @@ Machine-readable catalog of every op on this surface
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/env
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Environment variables | **application/json**: [EnvVariableListResponse](#envvariablelistresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [DELETE] /apps/{app_id}/env/{env_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| env_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Variable removed | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [PUT] /apps/{app_id}/env/{env_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| env_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [EnvVariableSetPayload](#envvariablesetpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Variable set | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/files
|
||||
Upload a file to use as an input variable when running the app
|
||||
|
||||
@@ -276,6 +369,55 @@ Upload a file to use as an input variable when running the app
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/runs
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| last_id | query | Cursor: id of the last run on the previous page | No | string (uuid) |
|
||||
| limit | query | | No | integer, <br>**Default:** 20 |
|
||||
| status | query | | No | string, <br>**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" |
|
||||
| triggered_from | query | debugging: draft test runs; app-run: real use. Omitted: debugging, as in the console | No | string, <br>**Available values:** "app-run", "debugging" |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Run list | **application/json**: [RunListResponse](#runlistresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/runs/{run_id}
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| run_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Run detail | **application/json**: [WorkflowRunDetailResponse](#workflowrundetailresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/runs/{run_id}/nodes
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| run_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Node steps | **application/json**: [WorkflowRunNodeExecutionListResponse](#workflowrunnodeexecutionlistresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/tasks/{task_id}/events
|
||||
#### Parameters
|
||||
|
||||
@@ -309,6 +451,39 @@ Upload a file to use as an input variable when running the app
|
||||
| 200 | Task stopped | **application/json**: [TaskStopResponse](#taskstopresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [GET] /apps/{app_id}/versions
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| limit | query | | No | integer, <br>**Default:** 20 |
|
||||
| named_only | query | Only versions that have a name | No | boolean |
|
||||
| page | query | | No | integer, <br>**Default:** 1 |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Version list | **application/json**: [VersionListResponse](#versionlistresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/versions/{version_id}:restore
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
| version_id | path | | Yes | string |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Restored into the draft | **application/json**: [RestoreResponse](#restoreresponse)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}/workflow:run
|
||||
#### Parameters
|
||||
|
||||
@@ -330,6 +505,27 @@ Upload a file to use as an input variable when running the app
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /apps/{app_id}:publish
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| app_id | path | | Yes | string |
|
||||
|
||||
#### Request Body
|
||||
|
||||
| Required | Schema |
|
||||
| -------- | ------ |
|
||||
| Yes | **application/json**: [PublishPayload](#publishpayload)<br> |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Published | **application/json**: [PublishResponse](#publishresponse)<br> |
|
||||
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
|
||||
|
||||
### [POST] /oauth/device/approve
|
||||
#### Request Body
|
||||
|
||||
@@ -670,6 +866,7 @@ Export DSL response.
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | string | DSL YAML string | Yes |
|
||||
| draft_hash | string | Hash of the draft's graph, features, environment variables and conversation variables; pass it to the import to refuse overwriting newer edits | No |
|
||||
|
||||
#### AppDslImportPayload
|
||||
|
||||
@@ -679,6 +876,7 @@ Request body for POST /workspaces/<workspace_id>/apps/imports.
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| app_id | string | Existing app ID to overwrite (workflow/advanced-chat apps only) | No |
|
||||
| description | string | Override the app description from the DSL | No |
|
||||
| draft_hash | string | draft_hash from the export or restore this import is based on. The import fails if the draft's graph, features, environment variables or conversation variables changed since. Requires app_id | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | string | | No |
|
||||
@@ -854,6 +1052,15 @@ Enum representing the deployment edition of the platform.
|
||||
| token_id | string | | Yes |
|
||||
| workspaces | [ [WorkspacePayload](#workspacepayload) ], <br>**Default:** | | No |
|
||||
|
||||
#### DraftWorkflowRunPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| attachments | [ string ] | Local files attached to the run itself (the app's `sys.files`), not to a variable | No |
|
||||
| files | object | Local files keyed by the app's file variable name; the server uploads each one and sets `inputs[<name>]`. Send a list (part name `files[<name>][]`) for a file-list variable | No |
|
||||
| inputs | object | Variables declared by the app. The exact shape is per app: read `input_schema` from describe.console_app. A file variable takes a Dify file mapping (remote url or upload id) here, or a local file in `files`, not both. | Yes |
|
||||
| workspace_id | string | Workspace that owns the app | No |
|
||||
|
||||
#### DslImportWarning
|
||||
|
||||
Portable DSL reference that could not be restored in the target workspace.
|
||||
@@ -865,6 +1072,43 @@ Portable DSL reference that could not be restored in the target workspace.
|
||||
| message | string | | Yes |
|
||||
| path | string | | Yes |
|
||||
|
||||
#### EnvVariableListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [EnvVariableRow](#envvariablerow) ] | | Yes |
|
||||
|
||||
#### EnvVariableRow
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | | No |
|
||||
| id | string | What set and delete take to address this variable | Yes |
|
||||
| name | string | What nodes use to refer to this variable | Yes |
|
||||
| value | | The value; a secret with a value is masked, an empty one reads as empty | Yes |
|
||||
| value_type | string | | Yes |
|
||||
|
||||
#### EnvVariableSetPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| description | string | What the variable is for | No |
|
||||
| name | string | Variable name | Yes |
|
||||
| value | | The value; sending the masked value of an existing secret keeps the stored one | Yes |
|
||||
| value_type | [EnvVariableValueType](#envvariablevaluetype) | string, number or secret | Yes |
|
||||
|
||||
#### EnvVariableValueType
|
||||
|
||||
Value types the draft environment-variable ``set`` op accepts.
|
||||
|
||||
A curated subset of ``SegmentType``: what the console's environment-variable editor
|
||||
allows (``ENVIRONMENT_VARIABLE_SUPPORTED_TYPES`` in controllers/console/app/workflow.py).
|
||||
Members reference ``SegmentType.*.value`` so the subset relationship is type-checked.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| EnvVariableValueType | string | Value types the draft environment-variable ``set`` op accepts. A curated subset of ``SegmentType``: what the console's environment-variable editor allows (``ENVIRONMENT_VARIABLE_SUPPORTED_TYPES`` in controllers/console/app/workflow.py). Members reference ``SegmentType.*.value`` so the subset relationship is type-checked. | |
|
||||
|
||||
#### ErrorBody
|
||||
|
||||
Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the
|
||||
@@ -1126,12 +1370,54 @@ Strict (extra='forbid').
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| PluginDependencyType | string | | |
|
||||
|
||||
#### PublishPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| marked_comment | string | Version note | No |
|
||||
| marked_name | string | Version name | No |
|
||||
|
||||
#### PublishResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | Yes |
|
||||
| version_id | string | | Yes |
|
||||
| warning | string | Variable references that may read a skipped branch | No |
|
||||
|
||||
#### RestoreResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| draft_hash | string | Hash of the restored draft's graph, features, environment variables and conversation variables; pass it to a DSL import as draft_hash | Yes |
|
||||
| result | string | | Yes |
|
||||
|
||||
#### RevokeResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| status | string | | Yes |
|
||||
|
||||
#### RunListQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| last_id | string | Cursor: id of the last run on the previous page | No |
|
||||
| limit | integer, <br>**Default:** 20 | | No |
|
||||
| status | string, <br>**Available values:** "failed", "partial-succeeded", "running", "stopped", "succeeded" | | No |
|
||||
| triggered_from | string, <br>**Available values:** "app-run", "debugging" | debugging: draft test runs; app-run: real use. Omitted: debugging, as in the console | No |
|
||||
|
||||
#### RunListResponse
|
||||
|
||||
Cursor page of runs; `hints` carries the next page.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [WorkflowRunForListResponse](#workflowrunforlistresponse) ] | | Yes |
|
||||
| has_more | boolean | | Yes |
|
||||
| hints | [ [Hint](#hint) ] | Next steps the caller can take | No |
|
||||
| limit | integer | | Yes |
|
||||
|
||||
#### ServerVersionResponse
|
||||
|
||||
Meta endpoint payload for `GET /openapi/v1/_version` — no auth required.
|
||||
@@ -1173,6 +1459,23 @@ Pagination for GET /account/sessions. Strict (extra='forbid').
|
||||
| last_used_at | string | | No |
|
||||
| prefix | string | | Yes |
|
||||
|
||||
#### SimpleAccountResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| email | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| name | string | | Yes |
|
||||
|
||||
#### SimpleEndUser
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| id | string | | Yes |
|
||||
| is_anonymous | boolean | | Yes |
|
||||
| session_id | string | | No |
|
||||
| type | string | | Yes |
|
||||
|
||||
#### SimpleResultResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -1221,6 +1524,36 @@ types it as a required `'success'` rather than an optional field.
|
||||
| prompt_tokens | integer | | No |
|
||||
| total_tokens | integer | | No |
|
||||
|
||||
#### VersionListQuery
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| limit | integer, <br>**Default:** 20 | | No |
|
||||
| named_only | boolean | Only versions that have a name | No |
|
||||
| page | integer, <br>**Default:** 1 | | No |
|
||||
|
||||
#### VersionListResponse
|
||||
|
||||
Page of published versions, newest first; there is no total, `hints` carries the next page.
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [VersionRow](#versionrow) ] | | Yes |
|
||||
| has_more | boolean | | Yes |
|
||||
| hints | [ [Hint](#hint) ] | Next steps the caller can take | No |
|
||||
| limit | integer | | Yes |
|
||||
| page | integer | | Yes |
|
||||
|
||||
#### VersionRow
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | Yes |
|
||||
| created_by | string | | No |
|
||||
| id | string | | Yes |
|
||||
| marked_comment | string | | Yes |
|
||||
| marked_name | string | | Yes |
|
||||
|
||||
#### WorkflowRunData
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
@@ -1236,6 +1569,77 @@ types it as a required `'success'` rather than an optional field.
|
||||
| total_tokens | integer | | No |
|
||||
| workflow_id | string | | Yes |
|
||||
|
||||
#### WorkflowRunDetailResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | No |
|
||||
| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No |
|
||||
| created_by_end_user | [SimpleEndUser](#simpleenduser) | | No |
|
||||
| created_by_role | string | | No |
|
||||
| elapsed_time | number | | No |
|
||||
| error | string | | No |
|
||||
| exceptions_count | integer | | No |
|
||||
| finished_at | integer | | No |
|
||||
| graph | | | Yes |
|
||||
| id | string | | Yes |
|
||||
| inputs | | | Yes |
|
||||
| outputs | | | Yes |
|
||||
| status | string | | No |
|
||||
| total_steps | integer | | No |
|
||||
| total_tokens | integer | | No |
|
||||
| version | string | | No |
|
||||
|
||||
#### WorkflowRunForListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | No |
|
||||
| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No |
|
||||
| elapsed_time | number | | No |
|
||||
| exceptions_count | integer | | No |
|
||||
| finished_at | integer | | No |
|
||||
| id | string | | Yes |
|
||||
| retry_index | integer | | No |
|
||||
| status | string | | No |
|
||||
| total_steps | integer | | No |
|
||||
| total_tokens | integer | | No |
|
||||
| version | string | | No |
|
||||
|
||||
#### WorkflowRunNodeExecutionListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [WorkflowRunNodeExecutionResponse](#workflowrunnodeexecutionresponse) ] | | Yes |
|
||||
|
||||
#### WorkflowRunNodeExecutionResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| created_at | integer | | No |
|
||||
| created_by_account | [SimpleAccountResponse](#simpleaccountresponse) | | No |
|
||||
| created_by_end_user | [SimpleEndUser](#simpleenduser) | | No |
|
||||
| created_by_role | string | | No |
|
||||
| elapsed_time | number | | No |
|
||||
| error | string | | No |
|
||||
| execution_metadata | | | No |
|
||||
| extras | | | No |
|
||||
| finished_at | integer | | No |
|
||||
| id | string | | Yes |
|
||||
| index | integer | | No |
|
||||
| inputs | | | No |
|
||||
| inputs_truncated | boolean | | No |
|
||||
| node_id | string | | No |
|
||||
| node_type | string | | No |
|
||||
| outputs | | | No |
|
||||
| outputs_truncated | boolean | | No |
|
||||
| predecessor_node_id | string | | No |
|
||||
| process_data | | | No |
|
||||
| process_data_truncated | boolean | | No |
|
||||
| retry_index | integer | | No |
|
||||
| status | string | | No |
|
||||
| title | string | | No |
|
||||
|
||||
#### WorkflowRunPayload
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
@@ -255,6 +255,7 @@ class AppTransferGateway(AppTransfers, AppDefinitionImports):
|
||||
icon=params.icon,
|
||||
icon_background=params.icon_background,
|
||||
app_id=params.app_id,
|
||||
draft_hash=params.draft_hash,
|
||||
package=package,
|
||||
)
|
||||
if result.status == ImportStatus.FAILED or (as_copy and result.status == ImportStatus.PENDING):
|
||||
|
||||
@@ -2,7 +2,7 @@ import base64
|
||||
import hashlib
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Mapping
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any, cast
|
||||
from urllib.parse import urlparse
|
||||
from uuid import uuid4
|
||||
@@ -16,6 +16,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from constants import HIDDEN_VALUE
|
||||
from constants.dsl_version import CURRENT_APP_DSL_VERSION
|
||||
from core.app.app_config.features.suggested_questions_after_answer.manager import (
|
||||
SuggestedQuestionsAfterAnswerConfigManager,
|
||||
@@ -46,6 +47,7 @@ from graphon.nodes.llm.entities import LLMNodeData, ModelConfig
|
||||
from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData
|
||||
from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData
|
||||
from graphon.nodes.tool.entities import ToolNodeData
|
||||
from graphon.variables import SecretVariable, SegmentType
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models import Account, App, AppMode
|
||||
from models.agent import AgentScope
|
||||
@@ -72,7 +74,7 @@ from services.entities.dsl_entities import (
|
||||
)
|
||||
from services.entities.site_dsl import SiteDsl, apply_site_dsl
|
||||
from services.errors.account import NoPermissionError
|
||||
from services.errors.app import WorkflowNotFoundError
|
||||
from services.errors.app import WorkflowHashNotEqualError, WorkflowNotFoundError
|
||||
from services.feature_service import FeatureService
|
||||
from services.icon_configuration import (
|
||||
DEFAULT_ICON,
|
||||
@@ -88,6 +90,8 @@ logger = logging.getLogger(__name__)
|
||||
IMPORT_INFO_REDIS_KEY_PREFIX = "app_import_info:"
|
||||
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "app_check_dependencies:"
|
||||
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
|
||||
DRAFT_CHANGED_ERROR = "The draft changed after it was exported. Export it again and reapply your edits."
|
||||
DRAFT_HASH_NEEDS_APP_ID_ERROR = "draft_hash is only valid when app_id names the app to overwrite."
|
||||
CURRENT_DSL_VERSION = CURRENT_APP_DSL_VERSION
|
||||
|
||||
|
||||
@@ -107,6 +111,18 @@ def missing_app_section_error(top_level_keys: list[str]) -> str:
|
||||
)
|
||||
|
||||
|
||||
def _keep_stored_secrets(mappings: Sequence[Mapping[str, Any]], stored_secret_ids: set[str]) -> list[Mapping[str, Any]]:
|
||||
# An export without include_secret blanks secrets; on overwrite, keep the stored value instead.
|
||||
return [
|
||||
{**mapping, "value": HIDDEN_VALUE}
|
||||
if mapping.get("value_type") == SegmentType.SECRET
|
||||
and mapping.get("value") == ""
|
||||
and mapping.get("id") in stored_secret_ids
|
||||
else mapping
|
||||
for mapping in mappings
|
||||
]
|
||||
|
||||
|
||||
class PendingData(PendingImportOwner):
|
||||
import_mode: str
|
||||
yaml_content: str
|
||||
@@ -116,6 +132,7 @@ class PendingData(PendingImportOwner):
|
||||
icon: str | None = None
|
||||
icon_background: str | None = None
|
||||
app_id: str | None = None
|
||||
draft_hash: str | None = None
|
||||
warnings: list[DslImportWarning] = Field(default_factory=list)
|
||||
|
||||
|
||||
@@ -144,6 +161,7 @@ class AppDslService:
|
||||
icon: str | None = None,
|
||||
icon_background: str | None = None,
|
||||
app_id: str | None = None,
|
||||
draft_hash: str | None = None,
|
||||
import_app_id: str | None = None,
|
||||
package: AppImportPackage | None = None,
|
||||
) -> Import:
|
||||
@@ -157,6 +175,9 @@ class AppDslService:
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid import_mode: {import_mode}")
|
||||
|
||||
if draft_hash is not None and not app_id:
|
||||
return Import(id=import_id, status=ImportStatus.FAILED, error=DRAFT_HASH_NEEDS_APP_ID_ERROR)
|
||||
|
||||
# Get YAML content
|
||||
content: str = ""
|
||||
if mode == ImportMode.YAML_URL:
|
||||
@@ -311,6 +332,7 @@ class AppDslService:
|
||||
icon=icon,
|
||||
icon_background=icon_background,
|
||||
app_id=app_id,
|
||||
draft_hash=draft_hash,
|
||||
warnings=self._warnings,
|
||||
)
|
||||
redis_client.setex(
|
||||
@@ -355,6 +377,7 @@ class AppDslService:
|
||||
dependencies=check_dependencies_pending_data,
|
||||
import_app_id=import_app_id,
|
||||
allow_premium_site_settings=allow_premium_site_settings,
|
||||
draft_hash=draft_hash,
|
||||
)
|
||||
|
||||
draft_var_srv = WorkflowDraftVariableService(session=self._session)
|
||||
@@ -376,6 +399,9 @@ class AppDslService:
|
||||
error=f"Invalid YAML format: {str(e)}",
|
||||
)
|
||||
|
||||
except WorkflowHashNotEqualError:
|
||||
return Import(id=import_id, status=ImportStatus.FAILED, error=DRAFT_CHANGED_ERROR)
|
||||
|
||||
except NoPermissionError:
|
||||
raise
|
||||
|
||||
@@ -453,6 +479,7 @@ class AppDslService:
|
||||
icon=pending_data.icon,
|
||||
icon_background=pending_data.icon_background,
|
||||
allow_premium_site_settings=allow_premium_site_settings,
|
||||
draft_hash=pending_data.draft_hash,
|
||||
)
|
||||
|
||||
# Delete import info from Redis
|
||||
@@ -468,6 +495,9 @@ class AppDslService:
|
||||
warnings=self._warnings,
|
||||
)
|
||||
|
||||
except WorkflowHashNotEqualError:
|
||||
return Import(id=import_id, status=ImportStatus.FAILED, error=DRAFT_CHANGED_ERROR)
|
||||
|
||||
except NoPermissionError:
|
||||
raise
|
||||
|
||||
@@ -577,8 +607,23 @@ class AppDslService:
|
||||
else {BuiltinNodeTypes.ANSWER}
|
||||
)
|
||||
nodes = data.get("workflow", {}).get("graph", {}).get("nodes", [])
|
||||
if any(node.get("data", {}).get("type") in invalid_types for node in nodes):
|
||||
raise ValueError("Workflow contains node types incompatible with the target App")
|
||||
incompatible = [
|
||||
f"{node['id']} ({node_type})" if "id" in node else node_type
|
||||
for node in nodes
|
||||
if (node_type := node.get("data", {}).get("type")) in invalid_types
|
||||
]
|
||||
if incompatible:
|
||||
raise ValueError(
|
||||
f"Workflow contains node types incompatible with the target App: {', '.join(incompatible)}"
|
||||
)
|
||||
|
||||
def _lock_draft_at_hash(self, app: App | None, draft_hash: str) -> Workflow:
|
||||
"""Row-lock the draft and require it to be the one the caller exported. The lock holds until
|
||||
the import's transaction ends, so no edit can land between this check and the overwrite."""
|
||||
draft = WorkflowService().get_draft_workflow_for_update(app_model=app, session=self._session) if app else None
|
||||
if draft is None or draft.content_hash != draft_hash:
|
||||
raise WorkflowHashNotEqualError()
|
||||
return draft
|
||||
|
||||
def _create_or_update_app(
|
||||
self,
|
||||
@@ -594,6 +639,7 @@ class AppDslService:
|
||||
dependencies: list[PluginDependency] | None = None,
|
||||
import_app_id: str | None = None,
|
||||
allow_premium_site_settings: bool = True,
|
||||
draft_hash: str | None = None,
|
||||
) -> App:
|
||||
"""Create a new app or update an existing one."""
|
||||
app_data = data.get("app", {})
|
||||
@@ -609,6 +655,8 @@ class AppDslService:
|
||||
if target_tenant_id is None:
|
||||
raise ValueError("Current tenant is not set")
|
||||
|
||||
locked_draft = self._lock_draft_at_hash(app, draft_hash) if draft_hash is not None else None
|
||||
|
||||
# Set icon type
|
||||
icon_type_value = icon_type or app_data.get("icon_type")
|
||||
resolved_icon_type: IconType
|
||||
@@ -680,7 +728,18 @@ class AppDslService:
|
||||
if not workflow_data or not isinstance(workflow_data, dict):
|
||||
raise ValueError("Missing workflow data for workflow/advanced chat app")
|
||||
|
||||
environment_variables_list = workflow_data.get("environment_variables", [])
|
||||
workflow_service = WorkflowService()
|
||||
current_draft_workflow = locked_draft or workflow_service.get_draft_workflow(
|
||||
app_model=app, session=self._session
|
||||
)
|
||||
stored_secret_ids = (
|
||||
{v.id for v in current_draft_workflow.environment_variables if isinstance(v, SecretVariable)}
|
||||
if current_draft_workflow
|
||||
else set()
|
||||
)
|
||||
environment_variables_list = _keep_stored_secrets(
|
||||
workflow_data.get("environment_variables", []), stored_secret_ids
|
||||
)
|
||||
environment_variables = [
|
||||
variable_factory.build_environment_variable_from_mapping(obj) for obj in environment_variables_list
|
||||
]
|
||||
@@ -690,8 +749,6 @@ class AppDslService:
|
||||
for obj in conversation_variables_list
|
||||
]
|
||||
|
||||
workflow_service = WorkflowService()
|
||||
current_draft_workflow = workflow_service.get_draft_workflow(app_model=app, session=self._session)
|
||||
if current_draft_workflow:
|
||||
unique_hash = current_draft_workflow.unique_hash
|
||||
else:
|
||||
|
||||
@@ -35,6 +35,7 @@ class AppImportParams(BaseModel):
|
||||
icon: str | None = None
|
||||
icon_background: str | None = None
|
||||
app_id: str | None = None
|
||||
draft_hash: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
|
||||
@@ -158,6 +158,7 @@ from .workflow_draft_variable_service import DraftVariableSaver, DraftVarLoader,
|
||||
from .workflow_restore import apply_published_workflow_snapshot_to_draft
|
||||
|
||||
_file_access_controller = DatabaseFileAccessController()
|
||||
ENVIRONMENT_VARIABLE_NAME_TAKEN_ERROR = "Environment variable names must be unique."
|
||||
|
||||
|
||||
def _merge_environment_variable_patch(
|
||||
@@ -198,7 +199,7 @@ def _merge_environment_variable_patch(
|
||||
)
|
||||
names = [variable.name for variable in merged_variables]
|
||||
if len(set(names)) != len(names):
|
||||
raise ValueError("Environment variable names must be unique.")
|
||||
raise ValueError(ENVIRONMENT_VARIABLE_NAME_TAKEN_ERROR)
|
||||
return merged_variables
|
||||
|
||||
|
||||
@@ -269,7 +270,7 @@ class WorkflowService:
|
||||
# return draft workflow
|
||||
return workflow
|
||||
|
||||
def _get_draft_workflow_for_update(self, app_model: App, *, session: Session) -> Workflow | None:
|
||||
def get_draft_workflow_for_update(self, app_model: App, *, session: Session) -> Workflow | None:
|
||||
"""Return the app draft while holding its row lock for the caller's transaction."""
|
||||
return session.scalar(
|
||||
select(Workflow)
|
||||
@@ -361,9 +362,14 @@ class WorkflowService:
|
||||
limit: int,
|
||||
user_id: str | None,
|
||||
named_only: bool = False,
|
||||
include_draft: bool = True,
|
||||
) -> tuple[Sequence[Workflow], bool]:
|
||||
"""
|
||||
Get published workflow with pagination
|
||||
|
||||
The console lists the draft alongside published versions (``include_draft=True``,
|
||||
the default); callers that only want published versions, where the draft would
|
||||
break restore or ordering, pass ``include_draft=False``.
|
||||
"""
|
||||
if not app_model.workflow_id:
|
||||
return [], False
|
||||
@@ -391,6 +397,9 @@ class WorkflowService:
|
||||
if named_only:
|
||||
stmt = stmt.where(Workflow.marked_name != "")
|
||||
|
||||
if not include_draft:
|
||||
stmt = stmt.where(Workflow.version != Workflow.VERSION_DRAFT)
|
||||
|
||||
workflows = session.scalars(stmt).all()
|
||||
|
||||
has_more = len(workflows) > limit
|
||||
@@ -434,7 +443,7 @@ class WorkflowService:
|
||||
raise ValueError("Deleted environment variable ids require an environment variable patch.")
|
||||
|
||||
# fetch draft workflow by app_model
|
||||
workflow = self._get_draft_workflow_for_update(app_model=app_model, session=session)
|
||||
workflow = self.get_draft_workflow_for_update(app_model=app_model, session=session)
|
||||
|
||||
if workflow and workflow.unique_hash != unique_hash:
|
||||
raise WorkflowHashNotEqualError()
|
||||
@@ -555,7 +564,7 @@ class WorkflowService:
|
||||
variables they do not touch. Existing variables keep their order and new variables are appended.
|
||||
The transaction is committed before this method returns.
|
||||
"""
|
||||
workflow = self._get_draft_workflow_for_update(app_model=app_model, session=session)
|
||||
workflow = self.get_draft_workflow_for_update(app_model=app_model, session=session)
|
||||
if not workflow:
|
||||
raise ValueError("No draft workflow found.")
|
||||
|
||||
@@ -766,6 +775,29 @@ class WorkflowService:
|
||||
# return new workflow
|
||||
return workflow
|
||||
|
||||
def publish_app_workflow(
|
||||
self,
|
||||
*,
|
||||
session: Session,
|
||||
app_model: App,
|
||||
account: Account,
|
||||
marked_name: str = "",
|
||||
marked_comment: str = "",
|
||||
) -> Workflow:
|
||||
workflow = self.publish_workflow(
|
||||
session=session,
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
marked_name=marked_name,
|
||||
marked_comment=marked_comment,
|
||||
)
|
||||
app_in_session = session.get(App, app_model.id)
|
||||
if app_in_session:
|
||||
app_in_session.workflow_id = workflow.id
|
||||
app_in_session.updated_by = account.id
|
||||
app_in_session.updated_at = naive_utc_now()
|
||||
return workflow
|
||||
|
||||
def _validate_workflow_credentials(self, workflow: Workflow, *, session: Session) -> None:
|
||||
"""
|
||||
Validate all credentials in workflow nodes before publishing.
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Iterator, Mapping, Sequence
|
||||
@@ -10,6 +12,8 @@ from core.trigger.constants import TRIGGER_NODE_TYPES
|
||||
from core.workflow.nodes.human_input.constants import TIMEOUT_HANDLE
|
||||
from graphon.enums import BuiltinNodeTypes, ErrorStrategy
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_RESERVED_SELECTOR_HEADS: frozenset[str] = frozenset({"sys", "env", "conversation", "start"})
|
||||
|
||||
_REFERENCE_EXEMPT_NODE_TYPES: frozenset[str] = frozenset(
|
||||
@@ -169,6 +173,21 @@ def format_variable_reference_errors(issues: Sequence[VariableReferenceIssue]) -
|
||||
)
|
||||
|
||||
|
||||
def advisory_variable_reference_warning(graph_text: str | None) -> str | None:
|
||||
"""Return a non-blocking publish warning. A checker failure must not fail publish."""
|
||||
if not graph_text:
|
||||
return None
|
||||
try:
|
||||
graph = json.loads(graph_text)
|
||||
if not isinstance(graph, dict):
|
||||
return None
|
||||
issues = validate_variable_references(graph)
|
||||
return format_variable_reference_errors(issues) if issues else None
|
||||
except Exception:
|
||||
logger.warning("Skipped advisory variable reference check", exc_info=True)
|
||||
return None
|
||||
|
||||
|
||||
def _reachable_from(starts: Sequence[str], successors: Mapping[str, list[str]]) -> set[str]:
|
||||
visited: set[str] = set()
|
||||
queue: deque[str] = deque(starts)
|
||||
|
||||
@@ -4,6 +4,7 @@ import inspect
|
||||
import json
|
||||
from contextlib import contextmanager, nullcontext
|
||||
from datetime import datetime
|
||||
from functools import partial
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
@@ -23,6 +24,7 @@ from graphon.variables.variables import RAGPipelineVariable
|
||||
from models.account import Account
|
||||
from models.model import App, AppMode
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
from services import workflow_variable_reference_validator as validator_module
|
||||
from tests.unit_tests.config_override import apply_config_overrides
|
||||
|
||||
|
||||
@@ -166,18 +168,16 @@ def test_publish_workflow_returns_success(
|
||||
graph={"clean": "{}", "empty": None, "non-object": "[]", "invalid-json": "{"}.get(advisory, json.dumps(graph)),
|
||||
)
|
||||
if advisory == "checker-error":
|
||||
monkeypatch.setattr(workflow_module, "validate_variable_references", Mock(side_effect=RuntimeError("checker")))
|
||||
monkeypatch.setattr(validator_module, "validate_variable_references", Mock(side_effect=RuntimeError("checker")))
|
||||
elif advisory == "formatter-error":
|
||||
monkeypatch.setattr(
|
||||
workflow_module, "format_variable_reference_errors", Mock(side_effect=RuntimeError("format"))
|
||||
validator_module, "format_variable_reference_errors", Mock(side_effect=RuntimeError("format"))
|
||||
)
|
||||
session = Mock()
|
||||
session.get.return_value = app_model
|
||||
monkeypatch.setattr(
|
||||
workflow_module,
|
||||
"WorkflowService",
|
||||
Mock(return_value=SimpleNamespace(publish_workflow=Mock(return_value=workflow))),
|
||||
)
|
||||
service = SimpleNamespace(publish_workflow=Mock(return_value=workflow))
|
||||
service.publish_app_workflow = partial(workflow_module.WorkflowService.publish_app_workflow, service)
|
||||
monkeypatch.setattr(workflow_module, "WorkflowService", Mock(return_value=service))
|
||||
monkeypatch.setattr(
|
||||
workflow_module,
|
||||
"sessionmaker",
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import Protocol, cast
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import Forbidden
|
||||
|
||||
from controllers.openapi import app_dsl
|
||||
from controllers.openapi._contract import op_of
|
||||
from controllers.openapi._models import AppDslImportPayload, AppDslImportResponse, Hint
|
||||
from controllers.openapi.app_dsl import AppDslImportApi, AppDslImportConfirmApi
|
||||
from controllers.openapi._models import (
|
||||
AppDslExportQuery,
|
||||
AppDslExportResponse,
|
||||
AppDslImportPayload,
|
||||
AppDslImportResponse,
|
||||
Hint,
|
||||
)
|
||||
from controllers.openapi.app_dsl import AppDslExportApi, AppDslImportApi, AppDslImportConfirmApi
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.spec import EndpointSpec
|
||||
from controllers.openapi.auth.subjects import Subject
|
||||
from extensions.ext_database import db
|
||||
from machinery.context import RequestContext
|
||||
from models.model import App, AppMode
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.entities.dsl_entities import Import, ImportStatus
|
||||
from services.errors.account import NoPermissionError
|
||||
from tests.unit_tests.controllers.conftest import ControllerTestServices
|
||||
@@ -22,6 +36,12 @@ class _EndpointView(Protocol):
|
||||
__handler__: Callable[..., tuple[AppDslImportResponse, int]]
|
||||
|
||||
|
||||
class _ExportView(Protocol):
|
||||
"""Same stand-in, typed for the export endpoint's response."""
|
||||
|
||||
__handler__: Callable[..., tuple[AppDslExportResponse, int]]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("api", "kwargs"),
|
||||
[
|
||||
@@ -97,3 +117,37 @@ def test_import_hints_a_confirm_only_while_pending(
|
||||
)
|
||||
|
||||
assert (code, response.hints) == (status, hints)
|
||||
|
||||
|
||||
def test_export_reads_draft_hash_before_building_dsl(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The hash must describe the draft as of before the export, not after: an edit that
|
||||
commits in between would otherwise make a later import silently overwrite it."""
|
||||
call_order: list[str] = []
|
||||
|
||||
def fake_export_dsl(**_kwargs):
|
||||
call_order.append("export")
|
||||
return "app: {}"
|
||||
|
||||
def fake_get_draft_workflow(**_kwargs):
|
||||
call_order.append("draft_hash")
|
||||
return SimpleNamespace(unique_hash="graph-only-hash", content_hash="hash-before-edit")
|
||||
|
||||
# `WorkflowService()` needs a live db.engine; stand in for the whole service so the
|
||||
# bare Flask app these tests run under does not need one.
|
||||
service = Mock()
|
||||
service.get_draft_workflow.side_effect = fake_get_draft_workflow
|
||||
|
||||
monkeypatch.setattr(AppDslService, "export_dsl", fake_export_dsl)
|
||||
monkeypatch.setattr(app_dsl, "WorkflowService", lambda: service)
|
||||
monkeypatch.setattr(db, "session", lambda: None)
|
||||
|
||||
ctx = Context(cast(Subject, SimpleNamespace()), Mock(), {"app_id": "app-1"})
|
||||
ctx._app = App(id="app-1", tenant_id="tenant-1", name="a", mode=AppMode.WORKFLOW, enable_site=True, enable_api=True)
|
||||
|
||||
api = AppDslExportApi()
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/dsl"):
|
||||
response, status = cast(_ExportView, api.get).__handler__(api, ctx, "app-1", query=AppDslExportQuery())
|
||||
|
||||
assert call_order == ["draft_hash", "export"]
|
||||
assert response.draft_hash == "hash-before-edit"
|
||||
assert status == 200
|
||||
|
||||
@@ -15,6 +15,7 @@ from pydantic import BaseModel, ValidationError
|
||||
from werkzeug.datastructures import FileStorage
|
||||
from werkzeug.exceptions import UnprocessableEntity
|
||||
|
||||
from controllers.openapi import app_run
|
||||
from controllers.openapi._models import (
|
||||
AdvancedChatRunPayload,
|
||||
ChatRunPayload,
|
||||
@@ -23,6 +24,7 @@ from controllers.openapi._models import (
|
||||
WorkflowRunPayload,
|
||||
)
|
||||
from controllers.openapi.app_run import AdvancedChatRunApi, AppRunTaskStopApi, ChatRunApi, CompletionRunApi
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from graphon.file import FileType
|
||||
from models import Account
|
||||
from models.enums import CreatorUserRole
|
||||
@@ -222,3 +224,13 @@ def test_chat_route_passes_a_message_end_without_conversation_through(app: Flask
|
||||
with app.test_request_context(f"/openapi/v1/apps/{_TEST_APP_ID}/chat:run", method="POST"):
|
||||
response = api.post.__handler__(api, _ctx(AppMode.CHAT), app_id=_TEST_APP_ID, body=body)
|
||||
assert list(response.response) == [end]
|
||||
|
||||
|
||||
def test_draft_runs_do_not_hint_the_form_submit_op() -> None:
|
||||
"""A draft run's human-input form is a debugger form; the submit op only takes web-app
|
||||
forms, so hinting it would send the agent straight into a 403."""
|
||||
draft_routes = [route for route in app_run._RUN_ROUTES if route.invoke_from == InvokeFrom.DEBUGGER]
|
||||
assert draft_routes
|
||||
for route in draft_routes:
|
||||
assert app_run._form_layer not in route.hints
|
||||
assert "cannot be resumed over openapi" in route.summary
|
||||
|
||||
174
api/tests/unit_tests/controllers/openapi/test_app_workflow.py
Normal file
174
api/tests/unit_tests/controllers/openapi/test_app_workflow.py
Normal file
@@ -0,0 +1,174 @@
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import Protocol, cast
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.openapi import app_workflow
|
||||
from controllers.openapi._errors import RunNotFound, SecretMaskNotSecret, SecretMaskUnknownId, VersionNotFound
|
||||
from controllers.openapi._models import (
|
||||
EnvVariableSetPayload,
|
||||
RestoreResponse,
|
||||
RunListQuery,
|
||||
RunListResponse,
|
||||
VersionListQuery,
|
||||
)
|
||||
from controllers.openapi.app_workflow import (
|
||||
AppEnvItemApi,
|
||||
AppRunNodeListApi,
|
||||
AppVersionListApi,
|
||||
AppVersionRestoreApi,
|
||||
env_variable_rows,
|
||||
with_next_cursor,
|
||||
)
|
||||
from controllers.openapi.auth.context import Context
|
||||
from controllers.openapi.auth.subjects import Subject
|
||||
from core.helper import encrypter
|
||||
from graphon.variables import SecretVariable, StringVariable
|
||||
from models.account import Account
|
||||
from models.model import App, AppMode
|
||||
|
||||
|
||||
class _EndpointView(Protocol):
|
||||
"""Structural stand-in for a `view` carrying the attributes `@endpoint` attaches."""
|
||||
|
||||
__handler__: Callable[..., object]
|
||||
|
||||
|
||||
def _app_context(app_id: str = "app-1", **view_args: str) -> Context:
|
||||
ctx = Context(cast(Subject, SimpleNamespace()), Mock(), {"app_id": app_id, **view_args})
|
||||
ctx._app = App(id=app_id, tenant_id="tenant-1", name="a", mode=AppMode.WORKFLOW, enable_site=True, enable_api=True)
|
||||
account = Account(name="tester", email="tester@example.com")
|
||||
account.id = "account-1"
|
||||
ctx._caller = account
|
||||
return ctx
|
||||
|
||||
|
||||
def _fake_workflow_service(monkeypatch: pytest.MonkeyPatch) -> Mock:
|
||||
"""Stand in for `WorkflowService()`: the real constructor needs a live db.engine,
|
||||
which the bare Flask app these tests run under does not have."""
|
||||
service = Mock()
|
||||
monkeypatch.setattr(app_workflow, "WorkflowService", lambda: service)
|
||||
return service
|
||||
|
||||
|
||||
def test_env_variable_view_masks_only_secrets_that_have_a_value() -> None:
|
||||
"""An empty secret reads back empty, not masked: masking it would claim a value that is not there."""
|
||||
secret = SecretVariable(id="s", name="KEY", value="plain", selector=["env", "KEY"])
|
||||
empty_secret = SecretVariable(id="e", name="BLANK", value="", selector=["env", "BLANK"])
|
||||
plain = StringVariable(id="p", name="URL", value="https://x", selector=["env", "URL"])
|
||||
rows = env_variable_rows([secret, empty_secret, plain])
|
||||
assert [row.value for row in rows] == [encrypter.full_mask_token(), "", "https://x"]
|
||||
|
||||
|
||||
def test_run_list_hints_the_next_cursor() -> None:
|
||||
page = RunListResponse.model_validate({"limit": 1, "has_more": True, "data": [{"id": "run-1"}]})
|
||||
hinted = with_next_cursor(page, op="get.console_app.run", app_id="app-1", query=RunListQuery(limit=1))
|
||||
assert hinted.hints[0].input == {"app_id": "app-1", "limit": 1, "last_id": "run-1"}
|
||||
|
||||
|
||||
def test_set_env_rejects_mask_for_unknown_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Sending the mask for an id that is not a stored secret must not encrypt the
|
||||
literal mask into the draft (the bug: it silently succeeded, storing `[__HIDDEN__]`)."""
|
||||
service = _fake_workflow_service(monkeypatch)
|
||||
service.get_draft_workflow.return_value = SimpleNamespace(environment_variables=[])
|
||||
|
||||
body = EnvVariableSetPayload(name="KEY", value_type="secret", value=encrypter.full_mask_token())
|
||||
api = AppEnvItemApi()
|
||||
ctx = _app_context(env_id="unknown-id")
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/env/unknown-id", method="PUT"):
|
||||
with pytest.raises(SecretMaskUnknownId):
|
||||
cast(_EndpointView, api.put).__handler__(api, ctx, "app-1", "unknown-id", body=body)
|
||||
|
||||
service.patch_draft_workflow_environment_variables.assert_not_called()
|
||||
|
||||
|
||||
def test_set_env_accepts_mask_for_a_stored_secret(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The same mask for an id that *is* a stored secret keeps the existing flow working."""
|
||||
stored = SecretVariable(id="s-1", name="KEY", value="plain", selector=["env", "KEY"])
|
||||
service = _fake_workflow_service(monkeypatch)
|
||||
service.get_draft_workflow.return_value = SimpleNamespace(environment_variables=[stored])
|
||||
|
||||
body = EnvVariableSetPayload(name="KEY", value_type="secret", value=encrypter.full_mask_token())
|
||||
api = AppEnvItemApi()
|
||||
ctx = _app_context(env_id="s-1")
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/env/s-1", method="PUT"):
|
||||
cast(_EndpointView, api.put).__handler__(api, ctx, "app-1", "s-1", body=body)
|
||||
|
||||
service.patch_draft_workflow_environment_variables.assert_called_once()
|
||||
|
||||
|
||||
def test_set_env_rejects_the_mask_as_a_plain_value(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Re-typing a stored secret as a string while sending the mask would store the mask text
|
||||
and destroy the secret."""
|
||||
stored = SecretVariable(id="s-1", name="KEY", value="plain", selector=["env", "KEY"])
|
||||
service = _fake_workflow_service(monkeypatch)
|
||||
service.get_draft_workflow.return_value = SimpleNamespace(environment_variables=[stored])
|
||||
|
||||
body = EnvVariableSetPayload(name="KEY", value_type="string", value=encrypter.full_mask_token())
|
||||
api = AppEnvItemApi()
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/env/s-1", method="PUT"):
|
||||
with pytest.raises(SecretMaskNotSecret):
|
||||
cast(_EndpointView, api.put).__handler__(api, _app_context(env_id="s-1"), "app-1", "s-1", body=body)
|
||||
|
||||
service.patch_draft_workflow_environment_variables.assert_not_called()
|
||||
|
||||
|
||||
def test_set_env_payload_rejects_a_value_type_the_console_does_not_allow() -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
EnvVariableSetPayload(name="KEY", value_type="boolean", value=True)
|
||||
|
||||
|
||||
def test_restore_rejects_a_garbage_version_id(app: Flask) -> None:
|
||||
api = AppVersionRestoreApi()
|
||||
ctx = _app_context(version_id="not-a-uuid")
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/versions/not-a-uuid:restore", method="POST"):
|
||||
with pytest.raises(VersionNotFound):
|
||||
cast(_EndpointView, api.post).__handler__(api, ctx, "app-1", "not-a-uuid")
|
||||
|
||||
|
||||
def test_version_list_excludes_the_draft(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _fake_workflow_service(monkeypatch)
|
||||
service.get_all_published_workflow.return_value = ([], False)
|
||||
|
||||
api = AppVersionListApi()
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/versions"):
|
||||
cast(_EndpointView, api.get).__handler__(api, _app_context(), "app-1", query=VersionListQuery())
|
||||
|
||||
assert service.get_all_published_workflow.call_args.kwargs["include_draft"] is False
|
||||
|
||||
|
||||
def test_restore_returns_the_hash_an_import_checks(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
service = _fake_workflow_service(monkeypatch)
|
||||
service.restore_published_workflow_to_draft.return_value = SimpleNamespace(
|
||||
unique_hash="graph-only-hash", content_hash="whole-draft-hash"
|
||||
)
|
||||
monkeypatch.setattr(app_workflow.db, "session", lambda: None)
|
||||
version_id = "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
api = AppVersionRestoreApi()
|
||||
with app.test_request_context(f"/openapi/v1/apps/app-1/versions/{version_id}:restore", method="POST"):
|
||||
response = cast(_EndpointView, api.post).__handler__(
|
||||
api, _app_context(version_id=version_id), "app-1", version_id
|
||||
)
|
||||
|
||||
assert cast(RestoreResponse, response).draft_hash == "whole-draft-hash"
|
||||
|
||||
|
||||
def test_run_node_list_is_not_found_for_a_run_of_another_app(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Match describe: an unknown run is a 404, not an empty step list."""
|
||||
runs = Mock()
|
||||
runs.get_workflow_run.return_value = None
|
||||
monkeypatch.setattr(app_workflow, "application_services", lambda: SimpleNamespace(workflow_runs=runs))
|
||||
monkeypatch.setattr(app_workflow, "_request_context", lambda _ctx: None)
|
||||
run_id = "11111111-1111-4111-8111-111111111111"
|
||||
|
||||
api = AppRunNodeListApi()
|
||||
with app.test_request_context(f"/openapi/v1/apps/app-1/runs/{run_id}/nodes"):
|
||||
with pytest.raises(RunNotFound):
|
||||
cast(_EndpointView, api.get).__handler__(api, _app_context(run_id=run_id), "app-1", run_id)
|
||||
|
||||
runs.get_workflow_run_node_executions.assert_not_called()
|
||||
@@ -309,6 +309,59 @@ ROUTES: tuple[Route, ...] = (
|
||||
Route("app_run.chat", "POST", "/apps/{app_id}/chat:run", _RUN_TRAITS),
|
||||
Route("app_run.advanced_chat", "POST", "/apps/{app_id}/advanced-chat:run", _RUN_TRAITS),
|
||||
Route("app_run.completion", "POST", "/apps/{app_id}/completion:run", _RUN_TRAITS),
|
||||
Route(
|
||||
"app_run.draft.workflow",
|
||||
"POST",
|
||||
"/apps/{app_id}/draft/workflow:run",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route(
|
||||
"app_run.draft.advanced_chat",
|
||||
"POST",
|
||||
"/apps/{app_id}/draft/advanced-chat:run",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route("app_workflow.run.list", "GET", "/apps/{app_id}/runs", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED})),
|
||||
Route(
|
||||
"app_workflow.run.describe",
|
||||
"GET",
|
||||
"/apps/{app_id}/runs/{run_id}",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route(
|
||||
"app_workflow.run.nodes",
|
||||
"GET",
|
||||
"/apps/{app_id}/runs/{run_id}/nodes",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route(
|
||||
"app_workflow.publish", "POST", "/apps/{app_id}:publish", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED})
|
||||
),
|
||||
Route(
|
||||
"app_workflow.version.list",
|
||||
"GET",
|
||||
"/apps/{app_id}/versions",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route(
|
||||
"app_workflow.version.restore",
|
||||
"POST",
|
||||
"/apps/{app_id}/versions/{version_id}:restore",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route("app_workflow.env.list", "GET", "/apps/{app_id}/env", frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED})),
|
||||
Route(
|
||||
"app_workflow.env.set",
|
||||
"PUT",
|
||||
"/apps/{app_id}/env/{env_id}",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route(
|
||||
"app_workflow.env.delete",
|
||||
"DELETE",
|
||||
"/apps/{app_id}/env/{env_id}",
|
||||
frozenset({Trait.ACCOUNT_PRIMARY, Trait.APP_SCOPED}),
|
||||
),
|
||||
Route(
|
||||
"app_run.stop",
|
||||
"POST",
|
||||
@@ -563,6 +616,34 @@ _DUAL_SUBJECT_RUN: dict[Case, Expect] = {
|
||||
}
|
||||
|
||||
|
||||
_ACCOUNT_READER_APP: dict[Case, Expect] = {
|
||||
Case.MEMBER: ADMIT,
|
||||
Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT,
|
||||
Case.INSUFFICIENT_SCOPE: DENY_SCOPE,
|
||||
Case.NON_MEMBER: DENY_NON_MEMBER,
|
||||
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER,
|
||||
Case.LOW_ROLE: ADMIT,
|
||||
Case.APP_API_DISABLED: DENY_API_DISABLED,
|
||||
Case.UNKNOWN_APP: DENY_UNKNOWN_APP,
|
||||
Case.FOREIGN_WORKSPACE_QUERY: ADMIT,
|
||||
Case.EE_ACCOUNT_PUBLIC: ADMIT,
|
||||
Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_REFUSED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: ADMIT,
|
||||
Case.EE_ACCOUNT_MODE_UNRESOLVED: ADMIT,
|
||||
Case.RBAC_ON_DENIED: DENY_RBAC,
|
||||
Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE,
|
||||
}
|
||||
|
||||
_ACCOUNT_EDITOR_APP: dict[Case, Expect] = {
|
||||
**_ACCOUNT_READER_APP,
|
||||
Case.LOW_ROLE: DENY_ROLE,
|
||||
Case.RBAC_ON_LOW_ROLE: ADMIT,
|
||||
}
|
||||
|
||||
|
||||
MATRIX: dict[str, dict[Case, Expect]] = {
|
||||
"describe.account": dict(_ACCOUNT_ONLY_NO_WORKSPACE),
|
||||
"workspaces.list": dict(_ACCOUNT_ONLY_NO_WORKSPACE),
|
||||
@@ -573,46 +654,19 @@ MATRIX: dict[str, dict[Case, Expect]] = {
|
||||
"workspaces.members.invite": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
||||
"workspaces.members.update_role": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
||||
"app_dsl.import": dict(_ACCOUNT_MEMBER_WITH_ROLE),
|
||||
"apps.describe": {
|
||||
Case.MEMBER: ADMIT,
|
||||
Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT,
|
||||
Case.INSUFFICIENT_SCOPE: DENY_SCOPE,
|
||||
Case.NON_MEMBER: DENY_NON_MEMBER,
|
||||
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER,
|
||||
Case.LOW_ROLE: ADMIT,
|
||||
Case.APP_API_DISABLED: DENY_API_DISABLED,
|
||||
Case.UNKNOWN_APP: DENY_UNKNOWN_APP,
|
||||
Case.FOREIGN_WORKSPACE_QUERY: ADMIT,
|
||||
Case.EE_ACCOUNT_PUBLIC: ADMIT,
|
||||
Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_REFUSED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: ADMIT,
|
||||
Case.EE_ACCOUNT_MODE_UNRESOLVED: ADMIT,
|
||||
Case.RBAC_ON_DENIED: DENY_RBAC,
|
||||
Case.RBAC_ON_LOW_ROLE: ADMIT_NO_WORKSPACE_ROLE,
|
||||
},
|
||||
"app_dsl.export": {
|
||||
Case.MEMBER: ADMIT,
|
||||
Case.WRONG_SUBJECT: DENY_WRONG_SUBJECT,
|
||||
Case.INSUFFICIENT_SCOPE: DENY_SCOPE,
|
||||
Case.NON_MEMBER: DENY_NON_MEMBER,
|
||||
Case.NON_MEMBER_AND_INSUFFICIENT_SCOPE: DENY_NON_MEMBER,
|
||||
Case.LOW_ROLE: DENY_ROLE,
|
||||
Case.APP_API_DISABLED: DENY_API_DISABLED,
|
||||
Case.UNKNOWN_APP: DENY_UNKNOWN_APP,
|
||||
Case.FOREIGN_WORKSPACE_QUERY: ADMIT,
|
||||
Case.EE_ACCOUNT_PUBLIC: ADMIT,
|
||||
Case.EE_ACCOUNT_SSO_VERIFIED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_ALL: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_PERMITTED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_REFUSED: ADMIT,
|
||||
Case.EE_ACCOUNT_PRIVATE_REFUSED_WEBAPP_AUTH_OFF: ADMIT,
|
||||
Case.EE_ACCOUNT_MODE_UNRESOLVED: ADMIT,
|
||||
Case.RBAC_ON_LOW_ROLE: ADMIT,
|
||||
Case.RBAC_ON_DENIED: DENY_RBAC,
|
||||
},
|
||||
"apps.describe": dict(_ACCOUNT_READER_APP),
|
||||
"app_dsl.export": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_run.draft.workflow": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_run.draft.advanced_chat": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_workflow.run.list": dict(_ACCOUNT_READER_APP),
|
||||
"app_workflow.run.describe": dict(_ACCOUNT_READER_APP),
|
||||
"app_workflow.run.nodes": dict(_ACCOUNT_READER_APP),
|
||||
"app_workflow.publish": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_workflow.version.list": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_workflow.version.restore": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_workflow.env.list": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_workflow.env.set": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_workflow.env.delete": dict(_ACCOUNT_EDITOR_APP),
|
||||
"app_run.workflow": dict(_DUAL_SUBJECT_RUN),
|
||||
"app_run.chat": dict(_DUAL_SUBJECT_RUN),
|
||||
"app_run.advanced_chat": dict(_DUAL_SUBJECT_RUN),
|
||||
@@ -716,6 +770,45 @@ _REQ_RUN = (
|
||||
CheckAppAccess(),
|
||||
)
|
||||
_REQ_RUN_FORM = (*_REQ_RUN, CheckFormSurface())
|
||||
_REQ_RELEASE = (
|
||||
CheckSubject(allowed=_ACCOUNT),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.WORKSPACE_WRITE),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_RELEASE_AND_VERSION, PlainApp())),
|
||||
CheckWorkspaceRole(_EDITOR_UP),
|
||||
)
|
||||
_REQ_VERSION_READ = (
|
||||
CheckSubject(allowed=_ACCOUNT),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_READ),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())),
|
||||
CheckWorkspaceRole(_EDITOR_UP),
|
||||
)
|
||||
_REQ_ENV_WRITE = (
|
||||
CheckSubject(allowed=_ACCOUNT),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.WORKSPACE_WRITE),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_EDIT, PlainApp())),
|
||||
CheckWorkspaceRole(_EDITOR_UP),
|
||||
)
|
||||
_REQ_RUN_HISTORY = (
|
||||
CheckSubject(allowed=_ACCOUNT),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_READ),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_CREATE_AND_MANAGEMENT, PlainApp())),
|
||||
)
|
||||
_REQ_DRAFT_RUN = (
|
||||
CheckSubject(allowed=_ACCOUNT),
|
||||
CheckAppApiEnabled(),
|
||||
CheckWorkspaceMember(),
|
||||
CheckScope(Scope.APPS_RUN),
|
||||
CheckRBACPermission(RBACCheck(RBACPermission.APP_TEST_AND_RUN, PlainApp())),
|
||||
CheckWorkspaceRole(_EDITOR_UP),
|
||||
)
|
||||
_REQ_FILES = (
|
||||
CheckSubject(allowed=_ACCOUNT_OR_EXTERNAL),
|
||||
CheckAppApiEnabled(),
|
||||
@@ -756,6 +849,17 @@ DECLARED: dict[str, tuple[Requirement, ...]] = {
|
||||
"app_run.chat": _REQ_RUN,
|
||||
"app_run.advanced_chat": _REQ_RUN,
|
||||
"app_run.completion": _REQ_RUN,
|
||||
"app_run.draft.workflow": _REQ_DRAFT_RUN,
|
||||
"app_run.draft.advanced_chat": _REQ_DRAFT_RUN,
|
||||
"app_workflow.run.list": _REQ_RUN_HISTORY,
|
||||
"app_workflow.run.describe": _REQ_RUN_HISTORY,
|
||||
"app_workflow.run.nodes": _REQ_RUN_HISTORY,
|
||||
"app_workflow.publish": _REQ_RELEASE,
|
||||
"app_workflow.version.list": _REQ_VERSION_READ,
|
||||
"app_workflow.version.restore": _REQ_RELEASE,
|
||||
"app_workflow.env.list": _REQ_VERSION_READ,
|
||||
"app_workflow.env.set": _REQ_ENV_WRITE,
|
||||
"app_workflow.env.delete": _REQ_ENV_WRITE,
|
||||
"app_run.stop": _REQ_RUN,
|
||||
"files.upload": _REQ_FILES,
|
||||
"human_input_form.get": _REQ_RUN_FORM,
|
||||
@@ -1043,6 +1147,9 @@ def _url(route: Route, world: World, scenario: Scenario, bearer: Bearer | None)
|
||||
"import_id": str(uuid.uuid4()),
|
||||
"task_id": str(uuid.uuid4()),
|
||||
"form_token": uuid.uuid4().hex,
|
||||
"run_id": str(uuid.uuid4()),
|
||||
"version_id": str(uuid.uuid4()),
|
||||
"env_id": str(uuid.uuid4()),
|
||||
}
|
||||
query = route.query.format(**ids)
|
||||
if scenario.foreign_workspace_query:
|
||||
@@ -1270,6 +1377,17 @@ EXPECTED_RESPONSE_CODES: dict[tuple[str, str], frozenset[str]] = {
|
||||
("post", "/apps/{app_id}/chat:run"): frozenset({"200", "422", "default"}),
|
||||
("post", "/apps/{app_id}/advanced-chat:run"): frozenset({"200", "422", "default"}),
|
||||
("post", "/apps/{app_id}/completion:run"): frozenset({"200", "422", "default"}),
|
||||
("post", "/apps/{app_id}/draft/workflow:run"): frozenset({"200", "422", "default"}),
|
||||
("post", "/apps/{app_id}/draft/advanced-chat:run"): frozenset({"200", "422", "default"}),
|
||||
("get", "/apps/{app_id}/runs"): frozenset({"200", "422", "default"}),
|
||||
("get", "/apps/{app_id}/runs/{run_id}"): frozenset({"200", "default"}),
|
||||
("get", "/apps/{app_id}/runs/{run_id}/nodes"): frozenset({"200", "default"}),
|
||||
("post", "/apps/{app_id}:publish"): frozenset({"200", "422", "default"}),
|
||||
("get", "/apps/{app_id}/versions"): frozenset({"200", "422", "default"}),
|
||||
("post", "/apps/{app_id}/versions/{version_id}:restore"): frozenset({"200", "default"}),
|
||||
("get", "/apps/{app_id}/env"): frozenset({"200", "default"}),
|
||||
("put", "/apps/{app_id}/env/{env_id}"): frozenset({"200", "422", "default"}),
|
||||
("delete", "/apps/{app_id}/env/{env_id}"): frozenset({"200", "default"}),
|
||||
# The five device-flow rows are the only operations with no `default`: they
|
||||
# document their 200 with a raw `openapi_ns.response` rather than `@returns`,
|
||||
# so no `ErrorBody` schema is registered for them.
|
||||
|
||||
@@ -93,7 +93,8 @@ def test_every_guarded_route_declares_catalog_meta_once(app: Flask, ops: dict[st
|
||||
assert meta.kind is Kind.SSE, meta.op
|
||||
assert set(ops) <= set(seen)
|
||||
per_mode = {f"run.console_app.{mode}" for mode in ("workflow", "chat", "advanced_chat", "completion")}
|
||||
assert streaming == per_mode | {"get.run.event"}
|
||||
draft = {f"run.console_app.draft.{mode}" for mode in ("workflow", "advanced_chat")}
|
||||
assert streaming == per_mode | draft | {"get.run.event"}
|
||||
|
||||
|
||||
def test_run_entries_carry_path_bind_kind_and_flags(ops: dict[str, CatalogOp]) -> None:
|
||||
|
||||
@@ -9,6 +9,7 @@ This test suite covers:
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from datetime import UTC, datetime
|
||||
from uuid import uuid4
|
||||
|
||||
@@ -19,6 +20,7 @@ from graphon.enums import (
|
||||
WorkflowExecutionStatus,
|
||||
WorkflowNodeExecutionStatus,
|
||||
)
|
||||
from graphon.variables import StringVariable
|
||||
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
|
||||
from models.workflow import (
|
||||
Workflow,
|
||||
@@ -27,6 +29,7 @@ from models.workflow import (
|
||||
WorkflowRun,
|
||||
WorkflowType,
|
||||
)
|
||||
from tests.unit_tests.model_factories import make_workflow
|
||||
|
||||
|
||||
class TestWorkflowModelValidation:
|
||||
@@ -1042,3 +1045,43 @@ class TestGraphConfigurationValidation:
|
||||
|
||||
# Assert
|
||||
assert result is None
|
||||
|
||||
|
||||
def _add_env_variable(workflow: Workflow) -> None:
|
||||
workflow.environment_variables = [
|
||||
*workflow.environment_variables,
|
||||
StringVariable(id="env-2", name="ADDED", value="x", selector=["env", "ADDED"]),
|
||||
]
|
||||
|
||||
|
||||
def _change_features(workflow: Workflow) -> None:
|
||||
workflow.features = json.dumps({"opening_statement": "hi"})
|
||||
|
||||
|
||||
def _add_conversation_variable(workflow: Workflow) -> None:
|
||||
workflow.conversation_variables = [
|
||||
StringVariable(id="conv-1", name="topic", value="", selector=["conversation", "topic"])
|
||||
]
|
||||
|
||||
|
||||
def _change_graph(workflow: Workflow) -> None:
|
||||
workflow.graph = json.dumps({"nodes": [{"id": "start"}], "edges": []})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"edit", [_add_env_variable, _change_features, _add_conversation_variable, _change_graph], ids=lambda f: f.__name__
|
||||
)
|
||||
def test_content_hash_covers_everything_a_dsl_import_replaces(edit: Callable[[Workflow], None]) -> None:
|
||||
workflow = make_workflow(
|
||||
environment_variables=[StringVariable(id="env-1", name="URL", value="https://x", selector=["env", "URL"])]
|
||||
)
|
||||
before = workflow.content_hash
|
||||
edit(workflow)
|
||||
assert workflow.content_hash != before
|
||||
|
||||
|
||||
def test_unique_hash_still_covers_only_the_graph() -> None:
|
||||
workflow = make_workflow()
|
||||
before = workflow.unique_hash
|
||||
_add_env_variable(workflow)
|
||||
assert workflow.unique_hash == before
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
@@ -9,15 +10,17 @@ from pydantic import ValidationError
|
||||
from sqlalchemy import event, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from constants import HIDDEN_VALUE
|
||||
from constants.dsl_version import CURRENT_APP_DSL_VERSION
|
||||
from core.rbac import RBACPermission, RBACResourceScope
|
||||
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
|
||||
from graphon.variables import StringVariable
|
||||
from models import Account, App, AppMode
|
||||
from models.enums import CustomizeTokenStrategy
|
||||
from models.model import AppModelConfig, AppModelConfigDict, IconType, Site
|
||||
from models.workflow import Workflow
|
||||
from services.agent.dsl_entities import AgentPackage
|
||||
from services.app_dsl_service import AppDslService, PendingData
|
||||
from services.app_dsl_service import DRAFT_CHANGED_ERROR, AppDslService, PendingData, _keep_stored_secrets
|
||||
from services.entities.dsl_entities import ImportStatus
|
||||
from services.entities.site_dsl import SiteDsl
|
||||
from services.errors.account import NoPermissionError
|
||||
@@ -909,6 +912,149 @@ def test_create_or_update_app_gates_agent_mode_before_creation(
|
||||
assert not unbound_session.in_transaction()
|
||||
|
||||
|
||||
_OVERWRITE_DSL = yaml.safe_dump(
|
||||
{
|
||||
"version": CURRENT_APP_DSL_VERSION,
|
||||
"kind": "app",
|
||||
"app": {"mode": AppMode.WORKFLOW.value, "name": "Changed"},
|
||||
"workflow": {"graph": {"nodes": []}},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _exported_draft() -> Workflow:
|
||||
return make_workflow(
|
||||
tenant_id="tenant",
|
||||
app_id="target",
|
||||
environment_variables=[StringVariable(id="env-1", name="URL", value="https://x", selector=["env", "URL"])],
|
||||
)
|
||||
|
||||
|
||||
def _add_env_variable_after_export(draft: Workflow) -> None:
|
||||
draft.environment_variables = [
|
||||
*draft.environment_variables,
|
||||
StringVariable(id="env-2", name="ADDED", value="y", selector=["env", "ADDED"]),
|
||||
]
|
||||
|
||||
|
||||
def _change_graph_after_export(draft: Workflow) -> None:
|
||||
draft.graph = json.dumps({"nodes": [{"id": "start"}], "edges": []})
|
||||
|
||||
|
||||
def _overwrite_target_service(monkeypatch: pytest.MonkeyPatch, draft: Workflow | None) -> tuple[AppDslService, Mock]:
|
||||
workflow_service = Mock()
|
||||
workflow_service.get_draft_workflow_for_update.return_value = draft
|
||||
workflow_service.get_draft_workflow.return_value = draft
|
||||
workflow_service.sync_draft_workflow.return_value = SimpleNamespace(id="workflow-1")
|
||||
monkeypatch.setattr("services.app_dsl_service.WorkflowService", Mock(return_value=workflow_service))
|
||||
return AppDslService(Mock()), workflow_service
|
||||
|
||||
|
||||
@pytest.mark.parametrize("edit", [_add_env_variable_after_export, _change_graph_after_export], ids=lambda f: f.__name__)
|
||||
def test_overwrite_refuses_a_draft_changed_since_export(
|
||||
monkeypatch: pytest.MonkeyPatch, edit: Callable[[Workflow], None]
|
||||
) -> None:
|
||||
draft = _exported_draft()
|
||||
export_hash = draft.content_hash
|
||||
edit(draft)
|
||||
target = App(id="target", tenant_id="tenant", mode=AppMode.WORKFLOW, name="Original")
|
||||
service, workflow_service = _overwrite_target_service(monkeypatch, draft)
|
||||
service._load_app_for_overwrite = Mock(return_value=target)
|
||||
|
||||
result = service.import_app(
|
||||
account=_account(),
|
||||
import_mode="yaml-content",
|
||||
app_id=target.id,
|
||||
draft_hash=export_hash,
|
||||
yaml_content=_OVERWRITE_DSL,
|
||||
)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == DRAFT_CHANGED_ERROR
|
||||
workflow_service.sync_draft_workflow.assert_not_called()
|
||||
assert target.name == "Original"
|
||||
|
||||
|
||||
def test_overwrite_refuses_a_draft_hash_when_the_app_has_no_draft(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
target = App(id="target", tenant_id="tenant", mode=AppMode.WORKFLOW, name="Original")
|
||||
service, workflow_service = _overwrite_target_service(monkeypatch, None)
|
||||
service._load_app_for_overwrite = Mock(return_value=target)
|
||||
|
||||
result = service.import_app(
|
||||
account=_account(), import_mode="yaml-content", app_id=target.id, draft_hash="any", yaml_content=_OVERWRITE_DSL
|
||||
)
|
||||
|
||||
assert result.error == DRAFT_CHANGED_ERROR
|
||||
workflow_service.sync_draft_workflow.assert_not_called()
|
||||
|
||||
|
||||
def test_overwrite_with_the_current_draft_hash_syncs_against_the_locked_draft(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
draft = _exported_draft()
|
||||
target = App(id="target", tenant_id="tenant", mode=AppMode.WORKFLOW, name="Original")
|
||||
service, workflow_service = _overwrite_target_service(monkeypatch, draft)
|
||||
|
||||
service._create_or_update_app(
|
||||
app=target, data=yaml.safe_load(_OVERWRITE_DSL), account=_account(), draft_hash=draft.content_hash
|
||||
)
|
||||
|
||||
workflow_service.get_draft_workflow_for_update.assert_called_once()
|
||||
assert workflow_service.sync_draft_workflow.call_args.kwargs["unique_hash"] == draft.unique_hash
|
||||
|
||||
|
||||
def test_overwrite_without_draft_hash_does_not_lock_the_draft_early(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
draft = _exported_draft()
|
||||
target = App(id="target", tenant_id="tenant", mode=AppMode.WORKFLOW, name="Original")
|
||||
service, workflow_service = _overwrite_target_service(monkeypatch, draft)
|
||||
|
||||
service._create_or_update_app(app=target, data=yaml.safe_load(_OVERWRITE_DSL), account=_account())
|
||||
|
||||
workflow_service.get_draft_workflow_for_update.assert_not_called()
|
||||
assert workflow_service.sync_draft_workflow.call_args.kwargs["unique_hash"] == draft.unique_hash
|
||||
|
||||
|
||||
def test_confirm_import_with_a_stale_draft_hash_fails_and_keeps_the_pending_import(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
draft = _exported_draft()
|
||||
export_hash = draft.content_hash
|
||||
_add_env_variable_after_export(draft)
|
||||
account = _account(account_id=_CALLER_ID, tenant_id=_TENANT_ID)
|
||||
pending = PendingData(
|
||||
tenant_id=_TENANT_ID,
|
||||
account_id=_CALLER_ID,
|
||||
import_mode="yaml-content",
|
||||
yaml_content=_OVERWRITE_DSL,
|
||||
app_id=_OVERWRITE_APP_ID,
|
||||
draft_hash=export_hash,
|
||||
)
|
||||
monkeypatch.setattr("services.app_dsl_service.redis_client.get", Mock(return_value=pending.model_dump_json()))
|
||||
redis_delete = Mock()
|
||||
monkeypatch.setattr("services.app_dsl_service.redis_client.delete", redis_delete)
|
||||
target = App(id=_OVERWRITE_APP_ID, tenant_id=_TENANT_ID, mode=AppMode.WORKFLOW, name="Original")
|
||||
service, workflow_service = _overwrite_target_service(monkeypatch, draft)
|
||||
service._load_app_for_overwrite = Mock(return_value=target)
|
||||
|
||||
result = service.confirm_import(import_id="import-1", account=account)
|
||||
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error == DRAFT_CHANGED_ERROR
|
||||
workflow_service.sync_draft_workflow.assert_not_called()
|
||||
redis_delete.assert_not_called()
|
||||
assert target.name == "Original"
|
||||
|
||||
|
||||
def test_blank_secret_keeps_the_stored_value_only_when_it_is_stored() -> None:
|
||||
mappings = [
|
||||
{"id": "stored", "name": "KEY", "value_type": "secret", "value": ""},
|
||||
{"id": "new", "name": "NEW_KEY", "value_type": "secret", "value": ""},
|
||||
{"id": "plain", "name": "URL", "value_type": "string", "value": ""},
|
||||
{"id": "stored", "name": "KEY", "value_type": "secret", "value": "rotated"},
|
||||
]
|
||||
assert [m["value"] for m in _keep_stored_secrets(mappings, {"stored"})] == [HIDDEN_VALUE, "", "", "rotated"]
|
||||
|
||||
|
||||
def test_import_app_reraises_permission_denial_instead_of_failed_result(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
unbound_session: Session,
|
||||
@@ -1027,13 +1173,14 @@ def test_overwrite_rejects_incompatible_nodes_before_mutation(mode: AppMode, nod
|
||||
"version": CURRENT_APP_DSL_VERSION,
|
||||
"kind": "app",
|
||||
"app": {"mode": mode.value, "name": "Changed"},
|
||||
"workflow": {"graph": {"nodes": [{"data": {"type": node_type}}]}},
|
||||
"workflow": {"graph": {"nodes": [{"id": "n1", "data": {"type": node_type}}]}},
|
||||
}
|
||||
),
|
||||
)
|
||||
assert result.status == ImportStatus.FAILED
|
||||
assert result.error is not None
|
||||
assert "incompatible" in result.error
|
||||
assert f"n1 ({node_type})" in result.error
|
||||
assert target.name == "Original"
|
||||
session.add.assert_not_called()
|
||||
|
||||
|
||||
@@ -60,7 +60,9 @@ from services.errors.app import IsDraftWorkflowError, TriggerNodeLimitExceededEr
|
||||
from services.errors.workflow_service import DraftWorkflowDeletionError, WorkflowInUseError
|
||||
from services.workflow_ref_service import WorkflowRef
|
||||
from services.workflow_service import (
|
||||
ENVIRONMENT_VARIABLE_NAME_TAKEN_ERROR,
|
||||
WorkflowService,
|
||||
_merge_environment_variable_patch,
|
||||
_rebuild_file_for_user_inputs_in_start_node,
|
||||
_rebuild_single_file,
|
||||
_setup_variable_pool,
|
||||
@@ -1582,6 +1584,39 @@ class TestWorkflowService:
|
||||
|
||||
assert [workflow.id for workflow in workflows] == ["workflow-draft", "workflow-3"]
|
||||
|
||||
def test_get_all_published_workflow_excludes_draft_when_requested(
|
||||
self, workflow_service: WorkflowService, sqlite_session: Session
|
||||
):
|
||||
"""`include_draft=False` filters the draft out at query level so callers that
|
||||
only understand published versions (e.g. the openapi version-list op, where the
|
||||
draft breaks restore and ordering) get an exact page.
|
||||
"""
|
||||
app = TestWorkflowAssociatedDataFactory.create_app(workflow_id="workflow-3")
|
||||
app_created_at = datetime(2026, 1, 1)
|
||||
|
||||
sqlite_session.add(
|
||||
TestWorkflowAssociatedDataFactory.create_workflow(
|
||||
workflow_id="workflow-draft",
|
||||
version=Workflow.VERSION_DRAFT,
|
||||
created_at=app_created_at,
|
||||
)
|
||||
)
|
||||
sqlite_session.add(
|
||||
TestWorkflowAssociatedDataFactory.create_workflow(
|
||||
workflow_id="workflow-3",
|
||||
version="2026-02-01 00:00:00",
|
||||
created_at=app_created_at + timedelta(days=1),
|
||||
)
|
||||
)
|
||||
sqlite_session.commit()
|
||||
|
||||
workflows, has_more = workflow_service.get_all_published_workflow(
|
||||
session=sqlite_session, app_model=app, page=1, limit=10, user_id=None, include_draft=False
|
||||
)
|
||||
|
||||
assert [workflow.id for workflow in workflows] == ["workflow-3"]
|
||||
assert has_more is False
|
||||
|
||||
def test_get_all_published_workflow_has_more(self, workflow_service: WorkflowService, sqlite_session: Session):
|
||||
"""
|
||||
Test get_all_published_workflow indicates has_more when results exceed limit.
|
||||
@@ -3686,3 +3721,18 @@ class TestWorkflowServiceFreeNodeExecution:
|
||||
assert node.title == "Human Input"
|
||||
assert node.node_data is node_data
|
||||
assert node.variable_pool is variable_pool
|
||||
|
||||
|
||||
def _env(variable_id: str, name: str) -> StringVariable:
|
||||
return StringVariable(id=variable_id, name=name, value="v", selector=["env", name])
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"upsert",
|
||||
[_env("new-id", "TAKEN"), _env("other-id", "TAKEN")],
|
||||
ids=["new id reuses a name", "rename onto another variable's name"],
|
||||
)
|
||||
def test_environment_patch_rejects_a_name_another_id_uses(upsert: StringVariable) -> None:
|
||||
current = [_env("taken-id", "TAKEN"), _env("other-id", "OTHER")]
|
||||
with pytest.raises(ValueError, match=ENVIRONMENT_VARIABLE_NAME_TAKEN_ERROR):
|
||||
_merge_environment_variable_patch(current, [upsert], [])
|
||||
|
||||
@@ -55,6 +55,7 @@ export type AppDetailWithSite = {
|
||||
export type AppImportPayload = {
|
||||
app_id?: string | null
|
||||
description?: string | null
|
||||
draft_hash?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: string | null
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as z from 'zod'
|
||||
export const zAppImportPayload = z.object({
|
||||
app_id: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
draft_hash: z.string().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: z.string().nullish(),
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
zDeleteAccountSessionsBySessionIdPath,
|
||||
zDeleteAccountSessionsBySessionIdResponse,
|
||||
zDeleteAccountSessionsSelfResponse,
|
||||
zDeleteAppsByAppIdEnvByEnvIdPath,
|
||||
zDeleteAppsByAppIdEnvByEnvIdResponse,
|
||||
zDeleteWorkspacesByWorkspaceIdMembersByMemberIdPath,
|
||||
zDeleteWorkspacesByWorkspaceIdMembersByMemberIdResponse,
|
||||
zGetAccountResponse,
|
||||
@@ -16,14 +18,26 @@ import {
|
||||
zGetAppsByAppIdDslPath,
|
||||
zGetAppsByAppIdDslQuery,
|
||||
zGetAppsByAppIdDslResponse,
|
||||
zGetAppsByAppIdEnvPath,
|
||||
zGetAppsByAppIdEnvResponse,
|
||||
zGetAppsByAppIdHumanInputFormsByFormTokenPath,
|
||||
zGetAppsByAppIdHumanInputFormsByFormTokenResponse,
|
||||
zGetAppsByAppIdPath,
|
||||
zGetAppsByAppIdQuery,
|
||||
zGetAppsByAppIdResponse,
|
||||
zGetAppsByAppIdRunsByRunIdNodesPath,
|
||||
zGetAppsByAppIdRunsByRunIdNodesResponse,
|
||||
zGetAppsByAppIdRunsByRunIdPath,
|
||||
zGetAppsByAppIdRunsByRunIdResponse,
|
||||
zGetAppsByAppIdRunsPath,
|
||||
zGetAppsByAppIdRunsQuery,
|
||||
zGetAppsByAppIdRunsResponse,
|
||||
zGetAppsByAppIdTasksByTaskIdEventsPath,
|
||||
zGetAppsByAppIdTasksByTaskIdEventsQuery,
|
||||
zGetAppsByAppIdTasksByTaskIdEventsResponse,
|
||||
zGetAppsByAppIdVersionsPath,
|
||||
zGetAppsByAppIdVersionsQuery,
|
||||
zGetAppsByAppIdVersionsResponse,
|
||||
zGetAppsQuery,
|
||||
zGetAppsResponse,
|
||||
zGetCatalogResponse,
|
||||
@@ -55,14 +69,25 @@ import {
|
||||
zPostAppsByAppIdCompletionRunBody,
|
||||
zPostAppsByAppIdCompletionRunPath,
|
||||
zPostAppsByAppIdCompletionRunResponse,
|
||||
zPostAppsByAppIdDraftAdvancedChatRunBody,
|
||||
zPostAppsByAppIdDraftAdvancedChatRunPath,
|
||||
zPostAppsByAppIdDraftAdvancedChatRunResponse,
|
||||
zPostAppsByAppIdDraftWorkflowRunBody,
|
||||
zPostAppsByAppIdDraftWorkflowRunPath,
|
||||
zPostAppsByAppIdDraftWorkflowRunResponse,
|
||||
zPostAppsByAppIdFilesBody,
|
||||
zPostAppsByAppIdFilesPath,
|
||||
zPostAppsByAppIdFilesResponse,
|
||||
zPostAppsByAppIdHumanInputFormsByFormTokenSubmitBody,
|
||||
zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath,
|
||||
zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse,
|
||||
zPostAppsByAppIdPublishBody,
|
||||
zPostAppsByAppIdPublishPath,
|
||||
zPostAppsByAppIdPublishResponse,
|
||||
zPostAppsByAppIdTasksByTaskIdStopPath,
|
||||
zPostAppsByAppIdTasksByTaskIdStopResponse,
|
||||
zPostAppsByAppIdVersionsByVersionIdRestorePath,
|
||||
zPostAppsByAppIdVersionsByVersionIdRestoreResponse,
|
||||
zPostAppsByAppIdWorkflowRunBody,
|
||||
zPostAppsByAppIdWorkflowRunPath,
|
||||
zPostAppsByAppIdWorkflowRunResponse,
|
||||
@@ -84,6 +109,9 @@ import {
|
||||
zPostWorkspacesByWorkspaceIdMembersResponse,
|
||||
zPostWorkspacesByWorkspaceIdSwitchPath,
|
||||
zPostWorkspacesByWorkspaceIdSwitchResponse,
|
||||
zPutAppsByAppIdEnvByEnvIdBody,
|
||||
zPutAppsByAppIdEnvByEnvIdPath,
|
||||
zPutAppsByAppIdEnvByEnvIdResponse,
|
||||
} from './zod.gen.ts'
|
||||
|
||||
/**
|
||||
@@ -279,6 +307,59 @@ export const dependencies = {
|
||||
check,
|
||||
}
|
||||
|
||||
export const post4 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdDraftAdvancedChatRun',
|
||||
path: '/apps/{app_id}/draft/advanced-chat:run',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostAppsByAppIdDraftAdvancedChatRunBody,
|
||||
params: zPostAppsByAppIdDraftAdvancedChatRunPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostAppsByAppIdDraftAdvancedChatRunResponse)
|
||||
|
||||
export const run4 = {
|
||||
post: post4,
|
||||
}
|
||||
|
||||
export const advancedChat2 = {
|
||||
run: run4,
|
||||
}
|
||||
|
||||
export const post5 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdDraftWorkflowRun',
|
||||
path: '/apps/{app_id}/draft/workflow:run',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
body: zPostAppsByAppIdDraftWorkflowRunBody,
|
||||
params: zPostAppsByAppIdDraftWorkflowRunPath,
|
||||
}),
|
||||
)
|
||||
.output(zPostAppsByAppIdDraftWorkflowRunResponse)
|
||||
|
||||
export const run5 = {
|
||||
post: post5,
|
||||
}
|
||||
|
||||
export const workflow = {
|
||||
run: run5,
|
||||
}
|
||||
|
||||
export const draft = {
|
||||
advancedChat: advancedChat2,
|
||||
workflow,
|
||||
}
|
||||
|
||||
export const get7 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
@@ -294,10 +375,53 @@ export const dsl = {
|
||||
get: get7,
|
||||
}
|
||||
|
||||
export const delete3 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
operationId: 'deleteAppsByAppIdEnvByEnvId',
|
||||
path: '/apps/{app_id}/env/{env_id}',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zDeleteAppsByAppIdEnvByEnvIdPath }))
|
||||
.output(zDeleteAppsByAppIdEnvByEnvIdResponse)
|
||||
|
||||
export const put = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'PUT',
|
||||
operationId: 'putAppsByAppIdEnvByEnvId',
|
||||
path: '/apps/{app_id}/env/{env_id}',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ body: zPutAppsByAppIdEnvByEnvIdBody, params: zPutAppsByAppIdEnvByEnvIdPath }))
|
||||
.output(zPutAppsByAppIdEnvByEnvIdResponse)
|
||||
|
||||
export const byEnvId = {
|
||||
delete: delete3,
|
||||
put,
|
||||
}
|
||||
|
||||
export const get8 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdEnv',
|
||||
path: '/apps/{app_id}/env',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zGetAppsByAppIdEnvPath }))
|
||||
.output(zGetAppsByAppIdEnvResponse)
|
||||
|
||||
export const env = {
|
||||
get: get8,
|
||||
byEnvId,
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to use as an input variable when running the app
|
||||
*/
|
||||
export const post4 = oc
|
||||
export const post6 = oc
|
||||
.route({
|
||||
description: 'Upload a file to use as an input variable when running the app',
|
||||
inputStructure: 'detailed',
|
||||
@@ -311,10 +435,10 @@ export const post4 = oc
|
||||
.output(zPostAppsByAppIdFilesResponse)
|
||||
|
||||
export const files = {
|
||||
post: post4,
|
||||
post: post6,
|
||||
}
|
||||
|
||||
export const post5 = oc
|
||||
export const post7 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@@ -331,10 +455,10 @@ export const post5 = oc
|
||||
.output(zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse)
|
||||
|
||||
export const submit = {
|
||||
post: post5,
|
||||
post: post7,
|
||||
}
|
||||
|
||||
export const get8 = oc
|
||||
export const get9 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@@ -346,7 +470,7 @@ export const get8 = oc
|
||||
.output(zGetAppsByAppIdHumanInputFormsByFormTokenResponse)
|
||||
|
||||
export const byFormToken = {
|
||||
get: get8,
|
||||
get: get9,
|
||||
submit,
|
||||
}
|
||||
|
||||
@@ -354,7 +478,54 @@ export const humanInputForms = {
|
||||
byFormToken,
|
||||
}
|
||||
|
||||
export const get9 = oc
|
||||
export const get10 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdRunsByRunIdNodes',
|
||||
path: '/apps/{app_id}/runs/{run_id}/nodes',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zGetAppsByAppIdRunsByRunIdNodesPath }))
|
||||
.output(zGetAppsByAppIdRunsByRunIdNodesResponse)
|
||||
|
||||
export const nodes = {
|
||||
get: get10,
|
||||
}
|
||||
|
||||
export const get11 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdRunsByRunId',
|
||||
path: '/apps/{app_id}/runs/{run_id}',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zGetAppsByAppIdRunsByRunIdPath }))
|
||||
.output(zGetAppsByAppIdRunsByRunIdResponse)
|
||||
|
||||
export const byRunId = {
|
||||
get: get11,
|
||||
nodes,
|
||||
}
|
||||
|
||||
export const get12 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdRuns',
|
||||
path: '/apps/{app_id}/runs',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zGetAppsByAppIdRunsPath, query: zGetAppsByAppIdRunsQuery.optional() }))
|
||||
.output(zGetAppsByAppIdRunsResponse)
|
||||
|
||||
export const runs = {
|
||||
get: get12,
|
||||
byRunId,
|
||||
}
|
||||
|
||||
export const get13 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@@ -371,10 +542,10 @@ export const get9 = oc
|
||||
.output(zGetAppsByAppIdTasksByTaskIdEventsResponse)
|
||||
|
||||
export const events = {
|
||||
get: get9,
|
||||
get: get13,
|
||||
}
|
||||
|
||||
export const post6 = oc
|
||||
export const post8 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@@ -386,7 +557,7 @@ export const post6 = oc
|
||||
.output(zPostAppsByAppIdTasksByTaskIdStopResponse)
|
||||
|
||||
export const stop = {
|
||||
post: post6,
|
||||
post: post8,
|
||||
}
|
||||
|
||||
export const byTaskId = {
|
||||
@@ -398,7 +569,47 @@ export const tasks = {
|
||||
byTaskId,
|
||||
}
|
||||
|
||||
export const post7 = oc
|
||||
export const post9 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdVersionsByVersionIdRestore',
|
||||
path: '/apps/{app_id}/versions/{version_id}:restore',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ params: zPostAppsByAppIdVersionsByVersionIdRestorePath }))
|
||||
.output(zPostAppsByAppIdVersionsByVersionIdRestoreResponse)
|
||||
|
||||
export const restore = {
|
||||
post: post9,
|
||||
}
|
||||
|
||||
export const byVersionId = {
|
||||
restore,
|
||||
}
|
||||
|
||||
export const get14 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
operationId: 'getAppsByAppIdVersions',
|
||||
path: '/apps/{app_id}/versions',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(
|
||||
z.object({
|
||||
params: zGetAppsByAppIdVersionsPath,
|
||||
query: zGetAppsByAppIdVersionsQuery.optional(),
|
||||
}),
|
||||
)
|
||||
.output(zGetAppsByAppIdVersionsResponse)
|
||||
|
||||
export const versions = {
|
||||
get: get14,
|
||||
byVersionId,
|
||||
}
|
||||
|
||||
export const post10 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@@ -411,15 +622,30 @@ export const post7 = oc
|
||||
)
|
||||
.output(zPostAppsByAppIdWorkflowRunResponse)
|
||||
|
||||
export const run4 = {
|
||||
post: post7,
|
||||
export const run6 = {
|
||||
post: post10,
|
||||
}
|
||||
|
||||
export const workflow = {
|
||||
run: run4,
|
||||
export const workflow2 = {
|
||||
run: run6,
|
||||
}
|
||||
|
||||
export const get10 = oc
|
||||
export const post11 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
operationId: 'postAppsByAppIdPublish',
|
||||
path: '/apps/{app_id}:publish',
|
||||
tags: ['openapi'],
|
||||
})
|
||||
.input(z.object({ body: zPostAppsByAppIdPublishBody, params: zPostAppsByAppIdPublishPath }))
|
||||
.output(zPostAppsByAppIdPublishResponse)
|
||||
|
||||
export const publish = {
|
||||
post: post11,
|
||||
}
|
||||
|
||||
export const get15 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@@ -431,19 +657,24 @@ export const get10 = oc
|
||||
.output(zGetAppsByAppIdResponse)
|
||||
|
||||
export const byAppId = {
|
||||
get: get10,
|
||||
get: get15,
|
||||
advancedChat,
|
||||
chat,
|
||||
completion,
|
||||
dependencies,
|
||||
draft,
|
||||
dsl,
|
||||
env,
|
||||
files,
|
||||
humanInputForms,
|
||||
runs,
|
||||
tasks,
|
||||
workflow,
|
||||
versions,
|
||||
workflow: workflow2,
|
||||
publish,
|
||||
}
|
||||
|
||||
export const get11 = oc
|
||||
export const get16 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@@ -455,11 +686,11 @@ export const get11 = oc
|
||||
.output(zGetAppsResponse)
|
||||
|
||||
export const apps = {
|
||||
get: get11,
|
||||
get: get16,
|
||||
byAppId,
|
||||
}
|
||||
|
||||
export const post8 = oc
|
||||
export const post12 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@@ -471,10 +702,10 @@ export const post8 = oc
|
||||
.output(zPostOauthDeviceApproveResponse)
|
||||
|
||||
export const approve = {
|
||||
post: post8,
|
||||
post: post12,
|
||||
}
|
||||
|
||||
export const post9 = oc
|
||||
export const post13 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@@ -486,10 +717,10 @@ export const post9 = oc
|
||||
.output(zPostOauthDeviceCodeResponse)
|
||||
|
||||
export const code = {
|
||||
post: post9,
|
||||
post: post13,
|
||||
}
|
||||
|
||||
export const post10 = oc
|
||||
export const post14 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@@ -501,10 +732,10 @@ export const post10 = oc
|
||||
.output(zPostOauthDeviceDenyResponse)
|
||||
|
||||
export const deny = {
|
||||
post: post10,
|
||||
post: post14,
|
||||
}
|
||||
|
||||
export const get12 = oc
|
||||
export const get17 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@@ -516,10 +747,10 @@ export const get12 = oc
|
||||
.output(zGetOauthDeviceLookupResponse)
|
||||
|
||||
export const lookup = {
|
||||
get: get12,
|
||||
get: get17,
|
||||
}
|
||||
|
||||
export const post11 = oc
|
||||
export const post15 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@@ -531,7 +762,7 @@ export const post11 = oc
|
||||
.output(zPostOauthDeviceTokenResponse)
|
||||
|
||||
export const token = {
|
||||
post: post11,
|
||||
post: post15,
|
||||
}
|
||||
|
||||
export const device = {
|
||||
@@ -546,7 +777,7 @@ export const oauth = {
|
||||
device,
|
||||
}
|
||||
|
||||
export const get13 = oc
|
||||
export const get18 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@@ -563,10 +794,10 @@ export const get13 = oc
|
||||
.output(zGetPermittedExternalAppsByAppIdResponse)
|
||||
|
||||
export const byAppId2 = {
|
||||
get: get13,
|
||||
get: get18,
|
||||
}
|
||||
|
||||
export const get14 = oc
|
||||
export const get19 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@@ -578,11 +809,11 @@ export const get14 = oc
|
||||
.output(zGetPermittedExternalAppsResponse)
|
||||
|
||||
export const permittedExternalApps = {
|
||||
get: get14,
|
||||
get: get19,
|
||||
byAppId: byAppId2,
|
||||
}
|
||||
|
||||
export const post12 = oc
|
||||
export const post16 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@@ -594,14 +825,14 @@ export const post12 = oc
|
||||
.output(zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse)
|
||||
|
||||
export const confirm = {
|
||||
post: post12,
|
||||
post: post16,
|
||||
}
|
||||
|
||||
export const byImportId = {
|
||||
confirm,
|
||||
}
|
||||
|
||||
export const post13 = oc
|
||||
export const post17 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@@ -618,7 +849,7 @@ export const post13 = oc
|
||||
.output(zPostWorkspacesByWorkspaceIdAppsImportsResponse)
|
||||
|
||||
export const imports = {
|
||||
post: post13,
|
||||
post: post17,
|
||||
byImportId,
|
||||
}
|
||||
|
||||
@@ -626,7 +857,7 @@ export const apps2 = {
|
||||
imports,
|
||||
}
|
||||
|
||||
export const delete3 = oc
|
||||
export const delete4 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'DELETE',
|
||||
@@ -654,11 +885,11 @@ export const patch = oc
|
||||
.output(zPatchWorkspacesByWorkspaceIdMembersByMemberIdResponse)
|
||||
|
||||
export const byMemberId = {
|
||||
delete: delete3,
|
||||
delete: delete4,
|
||||
patch,
|
||||
}
|
||||
|
||||
export const get15 = oc
|
||||
export const get20 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@@ -674,7 +905,7 @@ export const get15 = oc
|
||||
)
|
||||
.output(zGetWorkspacesByWorkspaceIdMembersResponse)
|
||||
|
||||
export const post14 = oc
|
||||
export const post18 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@@ -692,12 +923,12 @@ export const post14 = oc
|
||||
.output(zPostWorkspacesByWorkspaceIdMembersResponse)
|
||||
|
||||
export const members = {
|
||||
get: get15,
|
||||
post: post14,
|
||||
get: get20,
|
||||
post: post18,
|
||||
byMemberId,
|
||||
}
|
||||
|
||||
export const post15 = oc
|
||||
export const post19 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'POST',
|
||||
@@ -709,10 +940,10 @@ export const post15 = oc
|
||||
.output(zPostWorkspacesByWorkspaceIdSwitchResponse)
|
||||
|
||||
export const switch_ = {
|
||||
post: post15,
|
||||
post: post19,
|
||||
}
|
||||
|
||||
export const get16 = oc
|
||||
export const get21 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@@ -724,13 +955,13 @@ export const get16 = oc
|
||||
.output(zGetWorkspacesByWorkspaceIdResponse)
|
||||
|
||||
export const byWorkspaceId = {
|
||||
get: get16,
|
||||
get: get21,
|
||||
apps: apps2,
|
||||
members,
|
||||
switch: switch_,
|
||||
}
|
||||
|
||||
export const get17 = oc
|
||||
export const get22 = oc
|
||||
.route({
|
||||
inputStructure: 'detailed',
|
||||
method: 'GET',
|
||||
@@ -742,7 +973,7 @@ export const get17 = oc
|
||||
.output(zGetWorkspacesResponse)
|
||||
|
||||
export const workspaces = {
|
||||
get: get17,
|
||||
get: get22,
|
||||
byWorkspaceId,
|
||||
}
|
||||
|
||||
|
||||
@@ -65,11 +65,13 @@ export type AppDslExportQuery = {
|
||||
|
||||
export type AppDslExportResponse = {
|
||||
data: string
|
||||
draft_hash?: string | null
|
||||
}
|
||||
|
||||
export type AppDslImportPayload = {
|
||||
app_id?: string | null
|
||||
description?: string | null
|
||||
draft_hash?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: string | null
|
||||
@@ -216,6 +218,17 @@ export type DeviceTokenResponse = {
|
||||
workspaces?: Array<WorkspacePayload>
|
||||
}
|
||||
|
||||
export type DraftWorkflowRunPayload = {
|
||||
attachments?: Array<Blob | File> | null
|
||||
files?: {
|
||||
[key: string]: Blob | File | Array<Blob | File>
|
||||
} | null
|
||||
inputs: {
|
||||
[key: string]: unknown
|
||||
}
|
||||
workspace_id?: string | null
|
||||
}
|
||||
|
||||
export type DslImportWarning = {
|
||||
code: string
|
||||
details?: {
|
||||
@@ -225,6 +238,27 @@ export type DslImportWarning = {
|
||||
path: string
|
||||
}
|
||||
|
||||
export type EnvVariableListResponse = {
|
||||
data: Array<EnvVariableRow>
|
||||
}
|
||||
|
||||
export type EnvVariableRow = {
|
||||
description?: string
|
||||
id: string
|
||||
name: string
|
||||
value: unknown
|
||||
value_type: string
|
||||
}
|
||||
|
||||
export type EnvVariableSetPayload = {
|
||||
description?: string
|
||||
name: string
|
||||
value: unknown
|
||||
value_type: EnvVariableValueType
|
||||
}
|
||||
|
||||
export type EnvVariableValueType = 'number' | 'secret' | 'string'
|
||||
|
||||
export type ErrorBody = {
|
||||
code: string
|
||||
details?: Array<ErrorDetail> | null
|
||||
@@ -385,6 +419,7 @@ export type OpenApiErrorCode =
|
||||
| 'completion_request_error'
|
||||
| 'conflict'
|
||||
| 'conversation_completed'
|
||||
| 'draft_not_found'
|
||||
| 'file_extension_blocked'
|
||||
| 'file_too_large'
|
||||
| 'filename_not_exists'
|
||||
@@ -404,6 +439,9 @@ export type OpenApiErrorCode =
|
||||
| 'rate_limit_error'
|
||||
| 'recipient_surface_mismatch'
|
||||
| 'request_entity_too_large'
|
||||
| 'run_not_found'
|
||||
| 'secret_mask_not_secret'
|
||||
| 'secret_mask_unknown_id'
|
||||
| 'too_many_files'
|
||||
| 'too_many_requests'
|
||||
| 'trigger_workflow_service_mode_unavailable'
|
||||
@@ -411,6 +449,8 @@ export type OpenApiErrorCode =
|
||||
| 'unknown'
|
||||
| 'unsupported_file_type'
|
||||
| 'unsupported_media_type'
|
||||
| 'version_not_found'
|
||||
| 'version_not_restorable'
|
||||
|
||||
export type OpenApiFormSubmitPayload = {
|
||||
action: string
|
||||
@@ -451,10 +491,40 @@ export type PluginDependency = {
|
||||
|
||||
export type PluginDependencyType = 'github' | 'marketplace' | 'package'
|
||||
|
||||
export type PublishPayload = {
|
||||
marked_comment?: string
|
||||
marked_name?: string
|
||||
}
|
||||
|
||||
export type PublishResponse = {
|
||||
created_at: number
|
||||
version_id: string
|
||||
warning?: string | null
|
||||
}
|
||||
|
||||
export type RestoreResponse = {
|
||||
draft_hash: string
|
||||
result: 'success'
|
||||
}
|
||||
|
||||
export type RevokeResponse = {
|
||||
status: string
|
||||
}
|
||||
|
||||
export type RunListQuery = {
|
||||
last_id?: string | null
|
||||
limit?: number
|
||||
status?: 'failed' | 'partial-succeeded' | 'running' | 'stopped' | 'succeeded' | null
|
||||
triggered_from?: 'app-run' | 'debugging' | null
|
||||
}
|
||||
|
||||
export type RunListResponse = {
|
||||
data: Array<WorkflowRunForListResponse>
|
||||
has_more: boolean
|
||||
hints?: Array<Hint>
|
||||
limit: number
|
||||
}
|
||||
|
||||
export type ServerVersionResponse = {
|
||||
edition: DeploymentEdition
|
||||
version: string
|
||||
@@ -484,6 +554,19 @@ export type SessionRow = {
|
||||
prefix: string
|
||||
}
|
||||
|
||||
export type SimpleAccountResponse = {
|
||||
email: string
|
||||
id: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export type SimpleEndUser = {
|
||||
id: string
|
||||
is_anonymous: boolean
|
||||
session_id?: string | null
|
||||
type: string
|
||||
}
|
||||
|
||||
export type SimpleResultResponse = {
|
||||
result: string
|
||||
}
|
||||
@@ -502,6 +585,28 @@ export type UsageInfo = {
|
||||
total_tokens?: number
|
||||
}
|
||||
|
||||
export type VersionListQuery = {
|
||||
limit?: number
|
||||
named_only?: boolean
|
||||
page?: number
|
||||
}
|
||||
|
||||
export type VersionListResponse = {
|
||||
data: Array<VersionRow>
|
||||
has_more: boolean
|
||||
hints?: Array<Hint>
|
||||
limit: number
|
||||
page: number
|
||||
}
|
||||
|
||||
export type VersionRow = {
|
||||
created_at: number
|
||||
created_by?: string | null
|
||||
id: string
|
||||
marked_comment: string
|
||||
marked_name: string
|
||||
}
|
||||
|
||||
export type WorkflowRunData = {
|
||||
created_at?: number | null
|
||||
elapsed_time?: number | null
|
||||
@@ -517,6 +622,69 @@ export type WorkflowRunData = {
|
||||
workflow_id: string
|
||||
}
|
||||
|
||||
export type WorkflowRunDetailResponse = {
|
||||
created_at?: number | null
|
||||
created_by_account?: SimpleAccountResponse | null
|
||||
created_by_end_user?: SimpleEndUser | null
|
||||
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 WorkflowRunForListResponse = {
|
||||
created_at?: number | null
|
||||
created_by_account?: SimpleAccountResponse | null
|
||||
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 WorkflowRunNodeExecutionListResponse = {
|
||||
data: Array<WorkflowRunNodeExecutionResponse>
|
||||
}
|
||||
|
||||
export type WorkflowRunNodeExecutionResponse = {
|
||||
created_at?: number | null
|
||||
created_by_account?: SimpleAccountResponse | null
|
||||
created_by_end_user?: SimpleEndUser | null
|
||||
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
|
||||
retry_index?: number | null
|
||||
status?: string | null
|
||||
title?: string | null
|
||||
}
|
||||
|
||||
export type WorkflowRunPayload = {
|
||||
attachments?: Array<Blob | File> | null
|
||||
files?: {
|
||||
@@ -851,6 +1019,54 @@ export type GetAppsByAppIdDependenciesCheckResponses = {
|
||||
export type GetAppsByAppIdDependenciesCheckResponse =
|
||||
GetAppsByAppIdDependenciesCheckResponses[keyof GetAppsByAppIdDependenciesCheckResponses]
|
||||
|
||||
export type PostAppsByAppIdDraftAdvancedChatRunData = {
|
||||
body: ChatRunPayload
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/draft/advanced-chat:run'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdDraftAdvancedChatRunErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdDraftAdvancedChatRunError =
|
||||
PostAppsByAppIdDraftAdvancedChatRunErrors[keyof PostAppsByAppIdDraftAdvancedChatRunErrors]
|
||||
|
||||
export type PostAppsByAppIdDraftAdvancedChatRunResponses = {
|
||||
200: EventStreamResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdDraftAdvancedChatRunResponse =
|
||||
PostAppsByAppIdDraftAdvancedChatRunResponses[keyof PostAppsByAppIdDraftAdvancedChatRunResponses]
|
||||
|
||||
export type PostAppsByAppIdDraftWorkflowRunData = {
|
||||
body: DraftWorkflowRunPayload
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/draft/workflow:run'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdDraftWorkflowRunErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdDraftWorkflowRunError =
|
||||
PostAppsByAppIdDraftWorkflowRunErrors[keyof PostAppsByAppIdDraftWorkflowRunErrors]
|
||||
|
||||
export type PostAppsByAppIdDraftWorkflowRunResponses = {
|
||||
200: EventStreamResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdDraftWorkflowRunResponse =
|
||||
PostAppsByAppIdDraftWorkflowRunResponses[keyof PostAppsByAppIdDraftWorkflowRunResponses]
|
||||
|
||||
export type GetAppsByAppIdDslData = {
|
||||
body?: never
|
||||
path: {
|
||||
@@ -876,6 +1092,76 @@ export type GetAppsByAppIdDslResponses = {
|
||||
|
||||
export type GetAppsByAppIdDslResponse = GetAppsByAppIdDslResponses[keyof GetAppsByAppIdDslResponses]
|
||||
|
||||
export type GetAppsByAppIdEnvData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/env'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdEnvErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdEnvError = GetAppsByAppIdEnvErrors[keyof GetAppsByAppIdEnvErrors]
|
||||
|
||||
export type GetAppsByAppIdEnvResponses = {
|
||||
200: EnvVariableListResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdEnvResponse = GetAppsByAppIdEnvResponses[keyof GetAppsByAppIdEnvResponses]
|
||||
|
||||
export type DeleteAppsByAppIdEnvByEnvIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
env_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/env/{env_id}'
|
||||
}
|
||||
|
||||
export type DeleteAppsByAppIdEnvByEnvIdErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type DeleteAppsByAppIdEnvByEnvIdError =
|
||||
DeleteAppsByAppIdEnvByEnvIdErrors[keyof DeleteAppsByAppIdEnvByEnvIdErrors]
|
||||
|
||||
export type DeleteAppsByAppIdEnvByEnvIdResponses = {
|
||||
200: SimpleResultResponse
|
||||
}
|
||||
|
||||
export type DeleteAppsByAppIdEnvByEnvIdResponse =
|
||||
DeleteAppsByAppIdEnvByEnvIdResponses[keyof DeleteAppsByAppIdEnvByEnvIdResponses]
|
||||
|
||||
export type PutAppsByAppIdEnvByEnvIdData = {
|
||||
body: EnvVariableSetPayload
|
||||
path: {
|
||||
app_id: string
|
||||
env_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/env/{env_id}'
|
||||
}
|
||||
|
||||
export type PutAppsByAppIdEnvByEnvIdErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PutAppsByAppIdEnvByEnvIdError =
|
||||
PutAppsByAppIdEnvByEnvIdErrors[keyof PutAppsByAppIdEnvByEnvIdErrors]
|
||||
|
||||
export type PutAppsByAppIdEnvByEnvIdResponses = {
|
||||
200: SimpleResultResponse
|
||||
}
|
||||
|
||||
export type PutAppsByAppIdEnvByEnvIdResponse =
|
||||
PutAppsByAppIdEnvByEnvIdResponses[keyof PutAppsByAppIdEnvByEnvIdResponses]
|
||||
|
||||
export type PostAppsByAppIdFilesData = {
|
||||
body: FileUploadPayload
|
||||
path: {
|
||||
@@ -952,6 +1238,82 @@ export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses = {
|
||||
export type PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse =
|
||||
PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses[keyof PostAppsByAppIdHumanInputFormsByFormTokenSubmitResponses]
|
||||
|
||||
export type GetAppsByAppIdRunsData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: {
|
||||
last_id?: string
|
||||
limit?: number
|
||||
status?: 'failed' | 'partial-succeeded' | 'running' | 'stopped' | 'succeeded'
|
||||
triggered_from?: 'app-run' | 'debugging'
|
||||
}
|
||||
url: '/apps/{app_id}/runs'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdRunsErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdRunsError = GetAppsByAppIdRunsErrors[keyof GetAppsByAppIdRunsErrors]
|
||||
|
||||
export type GetAppsByAppIdRunsResponses = {
|
||||
200: RunListResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdRunsResponse =
|
||||
GetAppsByAppIdRunsResponses[keyof GetAppsByAppIdRunsResponses]
|
||||
|
||||
export type GetAppsByAppIdRunsByRunIdData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
run_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/runs/{run_id}'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdRunsByRunIdErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdRunsByRunIdError =
|
||||
GetAppsByAppIdRunsByRunIdErrors[keyof GetAppsByAppIdRunsByRunIdErrors]
|
||||
|
||||
export type GetAppsByAppIdRunsByRunIdResponses = {
|
||||
200: WorkflowRunDetailResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdRunsByRunIdResponse =
|
||||
GetAppsByAppIdRunsByRunIdResponses[keyof GetAppsByAppIdRunsByRunIdResponses]
|
||||
|
||||
export type GetAppsByAppIdRunsByRunIdNodesData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
run_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/runs/{run_id}/nodes'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdRunsByRunIdNodesErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdRunsByRunIdNodesError =
|
||||
GetAppsByAppIdRunsByRunIdNodesErrors[keyof GetAppsByAppIdRunsByRunIdNodesErrors]
|
||||
|
||||
export type GetAppsByAppIdRunsByRunIdNodesResponses = {
|
||||
200: WorkflowRunNodeExecutionListResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdRunsByRunIdNodesResponse =
|
||||
GetAppsByAppIdRunsByRunIdNodesResponses[keyof GetAppsByAppIdRunsByRunIdNodesResponses]
|
||||
|
||||
export type GetAppsByAppIdTasksByTaskIdEventsData = {
|
||||
body?: never
|
||||
path: {
|
||||
@@ -1004,6 +1366,58 @@ export type PostAppsByAppIdTasksByTaskIdStopResponses = {
|
||||
export type PostAppsByAppIdTasksByTaskIdStopResponse =
|
||||
PostAppsByAppIdTasksByTaskIdStopResponses[keyof PostAppsByAppIdTasksByTaskIdStopResponses]
|
||||
|
||||
export type GetAppsByAppIdVersionsData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: {
|
||||
limit?: number
|
||||
named_only?: boolean
|
||||
page?: number
|
||||
}
|
||||
url: '/apps/{app_id}/versions'
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdVersionsErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdVersionsError =
|
||||
GetAppsByAppIdVersionsErrors[keyof GetAppsByAppIdVersionsErrors]
|
||||
|
||||
export type GetAppsByAppIdVersionsResponses = {
|
||||
200: VersionListResponse
|
||||
}
|
||||
|
||||
export type GetAppsByAppIdVersionsResponse =
|
||||
GetAppsByAppIdVersionsResponses[keyof GetAppsByAppIdVersionsResponses]
|
||||
|
||||
export type PostAppsByAppIdVersionsByVersionIdRestoreData = {
|
||||
body?: never
|
||||
path: {
|
||||
app_id: string
|
||||
version_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}/versions/{version_id}:restore'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdVersionsByVersionIdRestoreErrors = {
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdVersionsByVersionIdRestoreError =
|
||||
PostAppsByAppIdVersionsByVersionIdRestoreErrors[keyof PostAppsByAppIdVersionsByVersionIdRestoreErrors]
|
||||
|
||||
export type PostAppsByAppIdVersionsByVersionIdRestoreResponses = {
|
||||
200: RestoreResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdVersionsByVersionIdRestoreResponse =
|
||||
PostAppsByAppIdVersionsByVersionIdRestoreResponses[keyof PostAppsByAppIdVersionsByVersionIdRestoreResponses]
|
||||
|
||||
export type PostAppsByAppIdWorkflowRunData = {
|
||||
body: WorkflowRunPayload
|
||||
path: {
|
||||
@@ -1028,6 +1442,30 @@ export type PostAppsByAppIdWorkflowRunResponses = {
|
||||
export type PostAppsByAppIdWorkflowRunResponse =
|
||||
PostAppsByAppIdWorkflowRunResponses[keyof PostAppsByAppIdWorkflowRunResponses]
|
||||
|
||||
export type PostAppsByAppIdPublishData = {
|
||||
body: PublishPayload
|
||||
path: {
|
||||
app_id: string
|
||||
}
|
||||
query?: never
|
||||
url: '/apps/{app_id}:publish'
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdPublishErrors = {
|
||||
422: ErrorBody
|
||||
default: ErrorBody
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdPublishError =
|
||||
PostAppsByAppIdPublishErrors[keyof PostAppsByAppIdPublishErrors]
|
||||
|
||||
export type PostAppsByAppIdPublishResponses = {
|
||||
200: PublishResponse
|
||||
}
|
||||
|
||||
export type PostAppsByAppIdPublishResponse =
|
||||
PostAppsByAppIdPublishResponses[keyof PostAppsByAppIdPublishResponses]
|
||||
|
||||
export type PostOauthDeviceApproveData = {
|
||||
body: DeviceMutateRequest
|
||||
path?: never
|
||||
|
||||
@@ -87,6 +87,7 @@ export const zAppDslExportQuery = z.object({
|
||||
*/
|
||||
export const zAppDslExportResponse = z.object({
|
||||
data: z.string(),
|
||||
draft_hash: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
@@ -97,6 +98,7 @@ export const zAppDslExportResponse = z.object({
|
||||
export const zAppDslImportPayload = z.object({
|
||||
app_id: z.string().nullish(),
|
||||
description: z.string().nullish(),
|
||||
draft_hash: z.string().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: z.string().nullish(),
|
||||
@@ -251,6 +253,26 @@ export const zDevicePollRequest = z.object({
|
||||
device_code: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* DraftWorkflowRunPayload
|
||||
*/
|
||||
export const zDraftWorkflowRunPayload = z.object({
|
||||
attachments: z
|
||||
.array(z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File))
|
||||
.nullish(),
|
||||
files: z
|
||||
.record(
|
||||
z.string(),
|
||||
z.union([
|
||||
z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File),
|
||||
z.array(z.custom<Blob | File>((value) => value instanceof Blob || value instanceof File)),
|
||||
]),
|
||||
)
|
||||
.nullish(),
|
||||
inputs: z.record(z.string(), z.unknown()),
|
||||
workspace_id: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* DslImportWarning
|
||||
*
|
||||
@@ -263,6 +285,45 @@ export const zDslImportWarning = z.object({
|
||||
path: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* EnvVariableRow
|
||||
*/
|
||||
export const zEnvVariableRow = z.object({
|
||||
description: z.string().optional().default(''),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
value: z.unknown(),
|
||||
value_type: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* EnvVariableListResponse
|
||||
*/
|
||||
export const zEnvVariableListResponse = z.object({
|
||||
data: z.array(zEnvVariableRow),
|
||||
})
|
||||
|
||||
/**
|
||||
* EnvVariableValueType
|
||||
*
|
||||
* Value types the draft environment-variable ``set`` op accepts.
|
||||
*
|
||||
* A curated subset of ``SegmentType``: what the console's environment-variable editor
|
||||
* allows (``ENVIRONMENT_VARIABLE_SUPPORTED_TYPES`` in controllers/console/app/workflow.py).
|
||||
* Members reference ``SegmentType.*.value`` so the subset relationship is type-checked.
|
||||
*/
|
||||
export const zEnvVariableValueType = z.enum(['number', 'secret', 'string'])
|
||||
|
||||
/**
|
||||
* EnvVariableSetPayload
|
||||
*/
|
||||
export const zEnvVariableSetPayload = z.object({
|
||||
description: z.string().optional().default(''),
|
||||
name: z.string(),
|
||||
value: z.unknown(),
|
||||
value_type: zEnvVariableValueType,
|
||||
})
|
||||
|
||||
/**
|
||||
* ErrorDetail
|
||||
*/
|
||||
@@ -514,6 +575,7 @@ export const zOpenApiErrorCode = z.enum([
|
||||
'completion_request_error',
|
||||
'conflict',
|
||||
'conversation_completed',
|
||||
'draft_not_found',
|
||||
'file_extension_blocked',
|
||||
'file_too_large',
|
||||
'filename_not_exists',
|
||||
@@ -533,6 +595,9 @@ export const zOpenApiErrorCode = z.enum([
|
||||
'rate_limit_error',
|
||||
'recipient_surface_mismatch',
|
||||
'request_entity_too_large',
|
||||
'run_not_found',
|
||||
'secret_mask_not_secret',
|
||||
'secret_mask_unknown_id',
|
||||
'too_many_files',
|
||||
'too_many_requests',
|
||||
'trigger_workflow_service_mode_unavailable',
|
||||
@@ -540,6 +605,8 @@ export const zOpenApiErrorCode = z.enum([
|
||||
'unknown',
|
||||
'unsupported_file_type',
|
||||
'unsupported_media_type',
|
||||
'version_not_found',
|
||||
'version_not_restorable',
|
||||
])
|
||||
|
||||
/**
|
||||
@@ -602,6 +669,31 @@ export const zCheckDependenciesResult = z.object({
|
||||
leaked_dependencies: z.array(zPluginDependency).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* PublishPayload
|
||||
*/
|
||||
export const zPublishPayload = z.object({
|
||||
marked_comment: z.string().max(100).optional().default(''),
|
||||
marked_name: z.string().max(20).optional().default(''),
|
||||
})
|
||||
|
||||
/**
|
||||
* PublishResponse
|
||||
*/
|
||||
export const zPublishResponse = z.object({
|
||||
created_at: z.int(),
|
||||
version_id: z.string(),
|
||||
warning: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* RestoreResponse
|
||||
*/
|
||||
export const zRestoreResponse = z.object({
|
||||
draft_hash: z.string(),
|
||||
result: z.literal('success'),
|
||||
})
|
||||
|
||||
/**
|
||||
* RevokeResponse
|
||||
*/
|
||||
@@ -609,6 +701,16 @@ export const zRevokeResponse = z.object({
|
||||
status: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* RunListQuery
|
||||
*/
|
||||
export const zRunListQuery = z.object({
|
||||
last_id: z.uuid().nullish(),
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
status: z.enum(['failed', 'partial-succeeded', 'running', 'stopped', 'succeeded']).nullish(),
|
||||
triggered_from: z.enum(['app-run', 'debugging']).nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* ServerVersionResponse
|
||||
*
|
||||
@@ -654,6 +756,25 @@ export const zSessionListResponse = z.object({
|
||||
total: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleAccountResponse
|
||||
*/
|
||||
export const zSimpleAccountResponse = z.object({
|
||||
email: z.string(),
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleEndUser
|
||||
*/
|
||||
export const zSimpleEndUser = z.object({
|
||||
id: z.string(),
|
||||
is_anonymous: z.boolean(),
|
||||
session_id: z.string().nullish(),
|
||||
type: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* SimpleResultResponse
|
||||
*/
|
||||
@@ -742,6 +863,39 @@ export const zMessageMetadata = z.object({
|
||||
usage: zUsageInfo.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* VersionListQuery
|
||||
*/
|
||||
export const zVersionListQuery = z.object({
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
named_only: z.boolean().optional().default(false),
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* VersionRow
|
||||
*/
|
||||
export const zVersionRow = z.object({
|
||||
created_at: z.int(),
|
||||
created_by: z.string().nullish(),
|
||||
id: z.string(),
|
||||
marked_comment: z.string(),
|
||||
marked_name: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* VersionListResponse
|
||||
*
|
||||
* Page of published versions, newest first; there is no total, `hints` carries the next page.
|
||||
*/
|
||||
export const zVersionListResponse = z.object({
|
||||
data: z.array(zVersionRow),
|
||||
has_more: z.boolean(),
|
||||
hints: z.array(zHint).optional(),
|
||||
limit: z.int(),
|
||||
page: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunData
|
||||
*/
|
||||
@@ -758,6 +912,93 @@ export const zWorkflowRunData = z.object({
|
||||
workflow_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunDetailResponse
|
||||
*/
|
||||
export const zWorkflowRunDetailResponse = z.object({
|
||||
created_at: z.int().nullish(),
|
||||
created_by_account: zSimpleAccountResponse.nullish(),
|
||||
created_by_end_user: zSimpleEndUser.nullish(),
|
||||
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(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunForListResponse
|
||||
*/
|
||||
export const zWorkflowRunForListResponse = z.object({
|
||||
created_at: z.int().nullish(),
|
||||
created_by_account: zSimpleAccountResponse.nullish(),
|
||||
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(),
|
||||
})
|
||||
|
||||
/**
|
||||
* RunListResponse
|
||||
*
|
||||
* Cursor page of runs; `hints` carries the next page.
|
||||
*/
|
||||
export const zRunListResponse = z.object({
|
||||
data: z.array(zWorkflowRunForListResponse),
|
||||
has_more: z.boolean(),
|
||||
hints: z.array(zHint).optional(),
|
||||
limit: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunNodeExecutionResponse
|
||||
*/
|
||||
export const zWorkflowRunNodeExecutionResponse = z.object({
|
||||
created_at: z.int().nullish(),
|
||||
created_by_account: zSimpleAccountResponse.nullish(),
|
||||
created_by_end_user: zSimpleEndUser.nullish(),
|
||||
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(),
|
||||
retry_index: z.int().nullish(),
|
||||
status: z.string().nullish(),
|
||||
title: z.string().nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunNodeExecutionListResponse
|
||||
*/
|
||||
export const zWorkflowRunNodeExecutionListResponse = z.object({
|
||||
data: z.array(zWorkflowRunNodeExecutionResponse),
|
||||
})
|
||||
|
||||
/**
|
||||
* WorkflowRunPayload
|
||||
*/
|
||||
@@ -972,6 +1213,28 @@ export const zGetAppsByAppIdDependenciesCheckPath = z.object({
|
||||
*/
|
||||
export const zGetAppsByAppIdDependenciesCheckResponse = zCheckDependenciesResult
|
||||
|
||||
export const zPostAppsByAppIdDraftAdvancedChatRunBody = zChatRunPayload
|
||||
|
||||
export const zPostAppsByAppIdDraftAdvancedChatRunPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Run result (SSE stream)
|
||||
*/
|
||||
export const zPostAppsByAppIdDraftAdvancedChatRunResponse = zEventStreamResponse
|
||||
|
||||
export const zPostAppsByAppIdDraftWorkflowRunBody = zDraftWorkflowRunPayload
|
||||
|
||||
export const zPostAppsByAppIdDraftWorkflowRunPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Run result (SSE stream)
|
||||
*/
|
||||
export const zPostAppsByAppIdDraftWorkflowRunResponse = zEventStreamResponse
|
||||
|
||||
export const zGetAppsByAppIdDslPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
@@ -986,6 +1249,37 @@ export const zGetAppsByAppIdDslQuery = z.object({
|
||||
*/
|
||||
export const zGetAppsByAppIdDslResponse = zAppDslExportResponse
|
||||
|
||||
export const zGetAppsByAppIdEnvPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Environment variables
|
||||
*/
|
||||
export const zGetAppsByAppIdEnvResponse = zEnvVariableListResponse
|
||||
|
||||
export const zDeleteAppsByAppIdEnvByEnvIdPath = z.object({
|
||||
app_id: z.string(),
|
||||
env_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Variable removed
|
||||
*/
|
||||
export const zDeleteAppsByAppIdEnvByEnvIdResponse = zSimpleResultResponse
|
||||
|
||||
export const zPutAppsByAppIdEnvByEnvIdBody = zEnvVariableSetPayload
|
||||
|
||||
export const zPutAppsByAppIdEnvByEnvIdPath = z.object({
|
||||
app_id: z.string(),
|
||||
env_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Variable set
|
||||
*/
|
||||
export const zPutAppsByAppIdEnvByEnvIdResponse = zSimpleResultResponse
|
||||
|
||||
export const zPostAppsByAppIdFilesBody = zFileUploadPayload
|
||||
|
||||
export const zPostAppsByAppIdFilesPath = z.object({
|
||||
@@ -1019,6 +1313,42 @@ export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitPath = z.object({
|
||||
*/
|
||||
export const zPostAppsByAppIdHumanInputFormsByFormTokenSubmitResponse = zFormSubmitResponse
|
||||
|
||||
export const zGetAppsByAppIdRunsPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetAppsByAppIdRunsQuery = z.object({
|
||||
last_id: z.uuid().optional(),
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
status: z.enum(['failed', 'partial-succeeded', 'running', 'stopped', 'succeeded']).optional(),
|
||||
triggered_from: z.enum(['app-run', 'debugging']).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Run list
|
||||
*/
|
||||
export const zGetAppsByAppIdRunsResponse = zRunListResponse
|
||||
|
||||
export const zGetAppsByAppIdRunsByRunIdPath = z.object({
|
||||
app_id: z.string(),
|
||||
run_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Run detail
|
||||
*/
|
||||
export const zGetAppsByAppIdRunsByRunIdResponse = zWorkflowRunDetailResponse
|
||||
|
||||
export const zGetAppsByAppIdRunsByRunIdNodesPath = z.object({
|
||||
app_id: z.string(),
|
||||
run_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Node steps
|
||||
*/
|
||||
export const zGetAppsByAppIdRunsByRunIdNodesResponse = zWorkflowRunNodeExecutionListResponse
|
||||
|
||||
export const zGetAppsByAppIdTasksByTaskIdEventsPath = z.object({
|
||||
app_id: z.string(),
|
||||
task_id: z.string(),
|
||||
@@ -1044,6 +1374,31 @@ export const zPostAppsByAppIdTasksByTaskIdStopPath = z.object({
|
||||
*/
|
||||
export const zPostAppsByAppIdTasksByTaskIdStopResponse = zTaskStopResponse
|
||||
|
||||
export const zGetAppsByAppIdVersionsPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
export const zGetAppsByAppIdVersionsQuery = z.object({
|
||||
limit: z.int().gte(1).lte(100).optional().default(20),
|
||||
named_only: z.boolean().optional().default(false),
|
||||
page: z.int().gte(1).optional().default(1),
|
||||
})
|
||||
|
||||
/**
|
||||
* Version list
|
||||
*/
|
||||
export const zGetAppsByAppIdVersionsResponse = zVersionListResponse
|
||||
|
||||
export const zPostAppsByAppIdVersionsByVersionIdRestorePath = z.object({
|
||||
app_id: z.string(),
|
||||
version_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Restored into the draft
|
||||
*/
|
||||
export const zPostAppsByAppIdVersionsByVersionIdRestoreResponse = zRestoreResponse
|
||||
|
||||
export const zPostAppsByAppIdWorkflowRunBody = zWorkflowRunPayload
|
||||
|
||||
export const zPostAppsByAppIdWorkflowRunPath = z.object({
|
||||
@@ -1055,6 +1410,17 @@ export const zPostAppsByAppIdWorkflowRunPath = z.object({
|
||||
*/
|
||||
export const zPostAppsByAppIdWorkflowRunResponse = zEventStreamResponse
|
||||
|
||||
export const zPostAppsByAppIdPublishBody = zPublishPayload
|
||||
|
||||
export const zPostAppsByAppIdPublishPath = z.object({
|
||||
app_id: z.string(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Published
|
||||
*/
|
||||
export const zPostAppsByAppIdPublishResponse = zPublishResponse
|
||||
|
||||
export const zPostOauthDeviceApproveBody = zDeviceMutateRequest
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user