mirror of
https://github.com/langgenius/dify.git
synced 2026-10-16 15:35:32 +08:00
467 lines
19 KiB
Python
467 lines
19 KiB
Python
"""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 Any, Final
|
|
from uuid import UUID
|
|
|
|
from flask_restx import Resource
|
|
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, op_of
|
|
from controllers.openapi._errors import (
|
|
DraftNotFound,
|
|
EnvVariableNotFound,
|
|
NodeNotFound,
|
|
OpenApiError,
|
|
RunNotFound,
|
|
SecretMaskNotSecret,
|
|
SecretMaskUnknownId,
|
|
VersionNotFound,
|
|
VersionNotRestorable,
|
|
)
|
|
from controllers.openapi._files import end_read_transaction, merge_files
|
|
from controllers.openapi._hints import cursor_after, next_page_hint, page_after
|
|
from controllers.openapi._models import (
|
|
AdvancedChatNodeRunPayload,
|
|
EnvVariableListResponse,
|
|
EnvVariableRow,
|
|
EnvVariableSetPayload,
|
|
EnvVariableValueType,
|
|
NodeRunPayload,
|
|
PublishPayload,
|
|
PublishResponse,
|
|
RestoreResponse,
|
|
RunListQuery,
|
|
RunListResponse,
|
|
VersionListQuery,
|
|
VersionListResponse,
|
|
VersionRow,
|
|
)
|
|
from controllers.openapi._upload import UploadParts
|
|
from controllers.openapi.auth.context import Context
|
|
from controllers.openapi.auth.requirements import EDITOR_ROLES, Requirement, account_app_guards
|
|
from core.helper import encrypter
|
|
from core.workflow.llm_environment_variable import environment_variable_value_type
|
|
from extensions.ext_application_services import application_services
|
|
from factories import variable_factory
|
|
from fields.workflow_run_fields import (
|
|
WorkflowRunDetailResponse,
|
|
WorkflowRunNodeExecutionListResponse,
|
|
WorkflowRunNodeExecutionResponse,
|
|
node_execution_response_source,
|
|
workflow_run_pagination_response_source,
|
|
workflow_run_response_source,
|
|
)
|
|
from graphon.variables import SecretVariable, VariableBase
|
|
from graphon.variables.exc import VariableError
|
|
from libs.datetime_utils import naive_utc_now
|
|
from libs.helper import to_timestamp
|
|
from models import App, AppMode
|
|
from models.workflow import Workflow, WorkflowDataError, WorkflowRun
|
|
from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError
|
|
from services.workflow.dsl_import import stored_secret_ids
|
|
from services.workflow.graph_check import CONTAINER_NODE_TYPES, GRAPH_MODES
|
|
from services.workflow.graph_diff import draft_token
|
|
from services.workflow_run_service import WorkflowRunListArgs
|
|
from services.workflow_service import WorkflowService
|
|
from services.workflow_variable_reference_validator import advisory_variable_reference_warning
|
|
|
|
_RUN_READ_GUARDS: Final = account_app_guards(
|
|
RBACPermission.APP_CREATE_AND_MANAGEMENT, scope=Scope.APPS_READ, roles=None, modes=GRAPH_MODES
|
|
)
|
|
VERSION_READ_GUARDS: Final = account_app_guards(
|
|
RBACPermission.APP_VIEW_LAYOUT, scope=Scope.APPS_READ, roles=EDITOR_ROLES, modes=GRAPH_MODES
|
|
)
|
|
_RELEASE_GUARDS: Final = account_app_guards(
|
|
RBACPermission.APP_RELEASE_AND_VERSION, scope=Scope.WORKSPACE_WRITE, roles=EDITOR_ROLES, modes=GRAPH_MODES
|
|
)
|
|
_ENV_WRITE_GUARDS: Final = account_app_guards(
|
|
RBACPermission.APP_EDIT, scope=Scope.WORKSPACE_WRITE, roles=EDITOR_ROLES, modes=GRAPH_MODES
|
|
)
|
|
|
|
|
|
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(
|
|
ctx.request_context, app_id=ctx.app.id, run_id=_require_uuid(run_id, not_found=RunNotFound)
|
|
)
|
|
if run is None:
|
|
raise RunNotFound()
|
|
return run
|
|
|
|
|
|
@openapi_ns.route("/apps/<string:app_id>/runs")
|
|
class AppRunListApi(Resource):
|
|
@endpoint(
|
|
op="get.run",
|
|
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="The latest draft test run of this app, by anyone",
|
|
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):
|
|
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(
|
|
ctx.request_context,
|
|
app_id=ctx.app.id,
|
|
args=args,
|
|
triggered_from=query.triggered_from,
|
|
)
|
|
page = RunListResponse.model_validate(workflow_run_pagination_response_source(pagination, session=ctx.session))
|
|
page.hints = next_page_hint(
|
|
op=op_of(AppRunListApi.get),
|
|
path_args={"app_id": ctx.app.id},
|
|
query=query,
|
|
page=cursor_after(last_id=page.data[-1].id if page.data else None, has_more=page.has_more),
|
|
)
|
|
return page
|
|
|
|
|
|
@openapi_ns.route("/apps/<string:app_id>/runs/<string:run_id>")
|
|
class AppRunDescribeApi(Resource):
|
|
@endpoint(
|
|
op="describe.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):
|
|
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.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):
|
|
run = _require_run(ctx, run_id)
|
|
steps = application_services().workflow_runs.get_workflow_run_node_executions(
|
|
ctx.request_context, 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):
|
|
try:
|
|
workflow = WorkflowService().publish_workflow(
|
|
session=ctx.session,
|
|
app_model=ctx.app,
|
|
account=ctx.account,
|
|
marked_name=body.marked_name,
|
|
marked_comment=body.marked_comment,
|
|
)
|
|
except ValueError as exc:
|
|
raise BadRequest(str(exc)) from exc
|
|
app_in_session = ctx.session.get(App, ctx.app.id)
|
|
if app_in_session:
|
|
app_in_session.workflow_id = workflow.id
|
|
app_in_session.updated_by = ctx.account.id
|
|
app_in_session.updated_at = naive_utc_now()
|
|
return PublishResponse(
|
|
version_id=workflow.id,
|
|
created_at=to_timestamp(workflow.created_at),
|
|
warning=advisory_variable_reference_warning(workflow.graph),
|
|
)
|
|
|
|
|
|
@openapi_ns.route("/apps/<string:app_id>/versions")
|
|
class AppVersionListApi(Resource):
|
|
@endpoint(
|
|
op="get.console_app.version",
|
|
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):
|
|
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).model_copy(update={"current": workflow.id == ctx.app.workflow_id})
|
|
for workflow in workflows
|
|
],
|
|
)
|
|
page.hints = next_page_hint(
|
|
op=op_of(AppVersionListApi.get),
|
|
path_args={"app_id": ctx.app.id},
|
|
query=query,
|
|
page=page_after(page=query.page, limit=query.limit, has_more=has_more),
|
|
)
|
|
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):
|
|
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=ctx.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_token(draft))
|
|
|
|
|
|
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 require_draft(ctx: Context) -> Workflow:
|
|
draft = WorkflowService().get_draft_workflow(app_model=ctx.app, session=ctx.session)
|
|
if draft is None:
|
|
raise DraftNotFound()
|
|
return draft
|
|
|
|
|
|
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=ctx.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=VERSION_READ_GUARDS,
|
|
returns=(HTTPStatus.OK, EnvVariableListResponse, "Environment variables"),
|
|
)
|
|
def get(self, ctx: Context, app_id: str):
|
|
return EnvVariableListResponse(data=env_variable_rows(require_draft(ctx).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):
|
|
draft = require_draft(ctx)
|
|
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 and 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):
|
|
if all(variable.id != env_id for variable in require_draft(ctx).environment_variables):
|
|
raise EnvVariableNotFound()
|
|
_patch_env(ctx, upserts=[], deletions=[env_id])
|
|
return SimpleResultResponse(result="success")
|
|
|
|
|
|
def _node_run_guards(mode: AppMode) -> tuple[Requirement, ...]:
|
|
return account_app_guards(RBACPermission.APP_TEST_AND_RUN, scope=Scope.APPS_RUN, roles=EDITOR_ROLES, modes=(mode,))
|
|
|
|
|
|
def run_draft_node(
|
|
ctx: Context, node_id: str, *, inputs: dict[str, Any], query: str, files: UploadParts | None = None
|
|
) -> WorkflowRunNodeExecutionResponse:
|
|
workflow_service = WorkflowService()
|
|
draft = require_draft(ctx)
|
|
try:
|
|
node = draft.get_node_config_by_id(node_id)
|
|
except WorkflowDataError as exc:
|
|
raise NodeNotFound() from exc
|
|
except ValueError as exc:
|
|
raise BadRequest(str(exc)) from exc
|
|
if Workflow.get_node_type_from_node_config(node) in CONTAINER_NODE_TYPES:
|
|
raise BadRequest("Loop and iteration nodes can't run alone; test them with a full draft run")
|
|
if files:
|
|
end_read_transaction(ctx.session)
|
|
user_inputs = merge_files(inputs, files, ctx.caller)
|
|
try:
|
|
execution = workflow_service.run_draft_workflow_node(
|
|
app_model=ctx.app,
|
|
draft_workflow=draft,
|
|
node_id=node_id,
|
|
user_inputs=user_inputs,
|
|
account=ctx.account,
|
|
query=query,
|
|
)
|
|
except ValueError as exc:
|
|
raise BadRequest(str(exc)) from exc
|
|
return WorkflowRunNodeExecutionResponse.model_validate(
|
|
node_execution_response_source(execution, session=ctx.session), from_attributes=True
|
|
)
|
|
|
|
|
|
@openapi_ns.route("/apps/<string:app_id>/draft/workflow/nodes/<string:node_id>:run")
|
|
class WorkflowDraftNodeRunApi(Resource):
|
|
@endpoint(
|
|
op="test.node.workflow",
|
|
kind=Kind.OBJECT,
|
|
summary="Test one node of a workflow draft, reusing what the last draft run saved",
|
|
examples=(
|
|
Example(title="Test a node", input={"app_id": "<app_id>", "node_id": "<node_id>"}),
|
|
Example(
|
|
title="Test a node with an upstream value overridden",
|
|
input={"app_id": "<app_id>", "node_id": "<node_id>", "inputs": {"#llm.text#": "Hello"}},
|
|
),
|
|
),
|
|
requirements=_node_run_guards(AppMode.WORKFLOW),
|
|
body=NodeRunPayload,
|
|
returns=(HTTPStatus.OK, WorkflowRunNodeExecutionResponse, "Node execution"),
|
|
)
|
|
def post(self, ctx: Context, app_id: str, node_id: str, *, body: NodeRunPayload):
|
|
return run_draft_node(ctx, node_id, inputs=body.inputs, query="", files=body.files)
|
|
|
|
|
|
@openapi_ns.route("/apps/<string:app_id>/draft/advanced-chat/nodes/<string:node_id>:run")
|
|
class AdvancedChatDraftNodeRunApi(Resource):
|
|
@endpoint(
|
|
op="test.node.advanced_chat",
|
|
kind=Kind.OBJECT,
|
|
summary="Test one node of an advanced-chat draft, reusing what the last draft run saved",
|
|
examples=(Example(title="Test a node", input={"app_id": "<app_id>", "node_id": "<node_id>", "query": "Hi"}),),
|
|
requirements=_node_run_guards(AppMode.ADVANCED_CHAT),
|
|
body=AdvancedChatNodeRunPayload,
|
|
returns=(HTTPStatus.OK, WorkflowRunNodeExecutionResponse, "Node execution"),
|
|
)
|
|
def post(self, ctx: Context, app_id: str, node_id: str, *, body: AdvancedChatNodeRunPayload):
|
|
return run_draft_node(ctx, node_id, inputs=body.inputs, query=body.query, files=body.files)
|