Compare commits

...

2 Commits

Author SHA1 Message Date
autofix-ci[bot]
fa876cc95f [autofix.ci] apply automated fixes 2026-09-30 02:08:55 +00:00
GareArc
94d9134719 feat(api): let agents edit, test and publish workflow apps over openapi
DSL export returns a draft_hash covering the graph, features, environment
and conversation variables; an import that sends an older hash fails
instead of wiping newer edits. A blank secret imported over an app keeps
the stored value. The incompatible-node error names the nodes.

New openapi ops: draft test runs, run history with node steps, publish,
version list and restore, and environment variables (secrets masked on
read). Publish shares one service method with the console.
2026-09-30 02:06:02 +00:00
30 changed files with 2940 additions and 177 deletions

View File

@@ -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,

View File

@@ -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",

View File

@@ -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."

View File

@@ -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"
)

View File

@@ -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):

View File

@@ -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")

View 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")

View File

@@ -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

View File

@@ -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, "

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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):

View File

@@ -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:

View File

@@ -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)

View File

@@ -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.

View File

@@ -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)

View File

@@ -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",

View File

@@ -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

View File

@@ -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

View 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()

View File

@@ -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.

View File

@@ -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:

View File

@@ -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

View File

@@ -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()

View File

@@ -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], [])

View File

@@ -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

View File

@@ -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(),

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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
/**