Merge remote-tracking branch 'origin/main' into feat/agent-v2

# Conflicts:
#	api/controllers/console/app/agent_drive_inspector.py
#	api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py
#	api/openapi/markdown/console-openapi.md
#	api/services/agent/skill_standardize_service.py
#	api/services/agent_drive_service.py
#	api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py
#	api/tests/unit_tests/services/agent/test_skill_standardize_service.py
#	packages/contracts/generated/api/console/agent/orpc.gen.ts
#	packages/contracts/generated/api/console/agent/types.gen.ts
#	packages/contracts/generated/api/console/agent/zod.gen.ts
#	packages/contracts/generated/api/console/apps/orpc.gen.ts
#	packages/contracts/generated/api/console/apps/types.gen.ts
#	packages/contracts/generated/api/console/apps/zod.gen.ts
This commit is contained in:
Yanli 盐粒
2026-06-22 16:43:54 +08:00
215 changed files with 6267 additions and 3468 deletions
+23
View File
@@ -36,6 +36,7 @@ from extensions.ext_database import db
from fields.agent_fields import (
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentConfigSnapshotRestoreResponse,
AgentInviteOptionsResponse,
AgentLogListResponse,
AgentLogMessageListResponse,
@@ -223,6 +224,7 @@ register_response_schema_models(
AgentAppPartial,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentConfigSnapshotRestoreResponse,
AgentInviteOptionsResponse,
AgentLogListResponse,
AgentLogMessageListResponse,
@@ -649,3 +651,24 @@ class AgentRosterVersionDetailApi(Resource):
version_id=str(version_id),
),
)
@console_ns.route("/agent/<uuid:agent_id>/versions/<uuid:version_id>/restore")
class AgentRosterVersionRestoreApi(Resource):
@console_ns.response(200, "Agent version restored", console_ns.models[AgentConfigSnapshotRestoreResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID, version_id: UUID):
return dump_response(
AgentConfigSnapshotRestoreResponse,
_agent_roster_service().restore_agent_version(
tenant_id=tenant_id,
agent_id=str(agent_id),
version_id=str(version_id),
account_id=current_user.id,
),
)
@@ -10,8 +10,12 @@ backend — drive data lives in the API's own DB/storage, served straight from
from __future__ import annotations
import json
from collections.abc import Mapping
from typing import Any
from uuid import UUID
from flask import Response
from flask_restx import Resource
from pydantic import BaseModel, Field
@@ -49,6 +53,10 @@ class AgentDriveFileByAgentQuery(BaseModel):
key: str = Field(min_length=1, description="Drive key, e.g. tender-analyzer/SKILL.md")
class AgentDriveSkillInspectQuery(BaseModel):
node_id: str | None = Field(default=None, description="Workflow node ID (workflow composer variant)")
class AgentDriveItemResponse(ResponseModel):
key: str
size: int | None = None
@@ -80,6 +88,39 @@ class AgentDriveSkillListResponse(ResponseModel):
items: list[AgentDriveSkillItemResponse] = Field(default_factory=list)
class AgentDriveSkillFileResponse(ResponseModel):
path: str
name: str
type: str
drive_key: str | None = None
available_in_drive: bool
class AgentDriveSkillMarkdownResponse(ResponseModel):
key: str
size: int | None = None
truncated: bool
binary: bool
text: str | None = None
class AgentDriveSkillInspectResponse(ResponseModel):
path: str
skill_md_key: str
archive_key: str | None = None
name: str
description: str
size: int | None = None
mime_type: str | None = None
hash: str | None = None
created_at: int | None = None
source: str
files: list[AgentDriveSkillFileResponse] = Field(default_factory=list)
file_tree: list[dict[str, Any]] = Field(default_factory=list)
skill_md: AgentDriveSkillMarkdownResponse
warnings: list[str] = Field(default_factory=list)
class AgentDrivePreviewResponse(ResponseModel):
key: str
size: int | None = None
@@ -97,6 +138,7 @@ register_response_schema_models(
AgentDriveDownloadResponse,
AgentDriveListResponse,
AgentDrivePreviewResponse,
AgentDriveSkillInspectResponse,
AgentDriveSkillListResponse,
)
@@ -118,6 +160,13 @@ def _handle(exc: AgentDriveError) -> tuple[dict[str, object], int]:
return {"code": exc.code, "message": exc.message}, exc.status_code
def _json_response(data: Mapping[str, Any]):
return Response(
response=json.dumps(data, ensure_ascii=False, separators=(",", ":")),
content_type="application/json; charset=utf-8",
)
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
@@ -160,6 +209,30 @@ class AgentDriveSkillListByAgentApi(Resource):
return {"items": items}
@console_ns.route("/agent/<uuid:agent_id>/drive/skills/<path:skill_path>/inspect")
class AgentDriveSkillInspectByAgentApi(Resource):
@console_ns.doc("inspect_agent_drive_skill_by_agent")
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
@console_ns.doc(params={"agent_id": "Agent ID", "skill_path": "Skill path/slug, e.g. tender-analyzer"})
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID, skill_path: str):
resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
try:
return _json_response(
AgentDriveService().inspect_skill(
tenant_id=tenant_id,
agent_id=str(agent_id),
skill_path=skill_path,
)
)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/agent/<uuid:agent_id>/drive/files/preview")
class AgentDrivePreviewByAgentApi(Resource):
@console_ns.doc("preview_agent_drive_file_by_agent")
@@ -245,6 +318,39 @@ class AgentDriveSkillListApi(Resource):
return {"items": items}
@console_ns.route("/apps/<uuid:app_id>/agent/drive/skills/<path:skill_path>/inspect")
class AgentDriveSkillInspectApi(Resource):
@console_ns.doc("inspect_agent_drive_skill")
@console_ns.doc(description="Inspect one drive-backed skill for slash-menu hover/detail UI")
@console_ns.doc(
params={
"app_id": "Application ID",
"skill_path": "Skill path/slug, e.g. tender-analyzer",
**query_params_from_model(AgentDriveSkillInspectQuery),
}
)
@console_ns.response(200, "Drive skill inspect view", console_ns.models[AgentDriveSkillInspectResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=_WORKFLOW_APP_MODES)
def get(self, app_model: App, skill_path: str):
query = query_params_from_request(AgentDriveSkillInspectQuery)
agent_id = _resolve_agent_id(app_model, query.node_id)
if not agent_id:
return _agent_not_bound()
try:
return _json_response(
AgentDriveService().inspect_skill(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
skill_path=skill_path,
)
)
except AgentDriveError as exc:
return _handle(exc)
@console_ns.route("/apps/<uuid:app_id>/agent/drive/files/preview")
class AgentDrivePreviewApi(Resource):
@console_ns.doc("preview_agent_drive_file")
@@ -295,6 +401,8 @@ __all__ = [
"AgentDriveListByAgentApi",
"AgentDrivePreviewApi",
"AgentDrivePreviewByAgentApi",
"AgentDriveSkillInspectApi",
"AgentDriveSkillInspectByAgentApi",
"AgentDriveSkillListApi",
"AgentDriveSkillListByAgentApi",
]
+1 -35
View File
@@ -211,7 +211,7 @@ def _legacy_workspace_roles(
name=role_name,
description="",
is_builtin=True,
permission_keys=list(_LEGACY_ROLE_PERMISSION_KEYS[role_name]),
permission_keys=list(dict.fromkeys(_LEGACY_ROLE_PERMISSION_KEYS[role_name])),
role_tag="owner" if role_name == "owner" else "",
)
for role_name in ("owner", "admin", "editor", "normal", "dataset_operator")
@@ -244,11 +244,6 @@ def _legacy_workspace_roles(
)
# ---------------------------------------------------------------------------
# Permission catalogs.
# ---------------------------------------------------------------------------
@console_ns.route("/workspaces/current/rbac/role-permissions/catalog")
class RBACWorkspaceCatalogApi(Resource):
@login_required
@@ -375,30 +370,6 @@ class RBACRoleCopyApi(Resource):
return _dump(role), 201
@console_ns.route("/workspaces/current/rbac/roles/<uuid:role_id>/members")
class RBACRoleMembersApi(Resource):
@login_required
@rbac_permission_required(
RBACResourceScope.WORKSPACE, RBACPermission.WORKSPACE_ROLE_MANAGE, resource_required=False
)
@console_ns.response(200, "Success", console_ns.models[_RBACRoleAccountList.__name__])
def get(self, role_id):
tenant_id, account_id = _current_ids()
return _dump(
svc.RBACService.Roles.members(
tenant_id,
account_id,
str(role_id),
options=_pagination_options(),
)
)
# ---------------------------------------------------------------------------
# Access policies (tenant-level permission sets).
# ---------------------------------------------------------------------------
class _AccessPolicyCreateRequest(BaseModel):
name: str
resource_type: svc.RBACResourceType
@@ -788,11 +759,6 @@ class RBACDatasetMemberBindingsApi(Resource):
return {"result": "success"}
# ---------------------------------------------------------------------------
# Workspace-level access (Settings > Access Rules).
# ---------------------------------------------------------------------------
@console_ns.route("/workspaces/current/rbac/workspace/apps/access-policy")
class RBACWorkspaceAppMatrixApi(Resource):
@login_required
+2 -4
View File
@@ -31,7 +31,7 @@ from controllers.openapi._models import (
AppDslExportQuery,
AppDslExportResponse,
AppDslImportPayload,
AppInfoResponse,
AppInfo,
AppListQuery,
AppListResponse,
AppListRow,
@@ -62,7 +62,6 @@ from controllers.openapi._models import (
SessionListQuery,
SessionListResponse,
SessionRow,
TagItem,
TaskStopResponse,
UsageInfo,
WorkflowRunData,
@@ -96,12 +95,11 @@ register_response_schema_models(
openapi_ns,
ErrorBody,
EventStreamResponse,
TagItem,
UsageInfo,
MessageMetadata,
AppListRow,
AppListResponse,
AppInfoResponse,
AppInfo,
AppDescribeInfo,
AppDescribeResponse,
AppDslExportResponse,
+15
View File
@@ -63,6 +63,8 @@ class OpenApiErrorCode(StrEnum):
FILE_EXTENSION_BLOCKED = "file_extension_blocked"
MEMBER_LIMIT_EXCEEDED = "member_limit_exceeded"
MEMBER_LICENSE_EXCEEDED = "member_license_exceeded"
HUMAN_INPUT_FORM_NOT_FOUND = "form_not_found"
RECIPIENT_SURFACE_MISMATCH = "recipient_surface_mismatch"
class ErrorDetail(BaseModel):
@@ -239,3 +241,16 @@ class MemberLicenseExceeded(OpenApiError): # noqa: N818
error_code = OpenApiErrorCode.MEMBER_LICENSE_EXCEEDED
description = "Workspace member license capacity reached."
hint = "Contact your workspace administrator to expand the license seat count."
class HumanInputFormNotFound(OpenApiError): # noqa: N818
code = 404
error_code = OpenApiErrorCode.HUMAN_INPUT_FORM_NOT_FOUND
description = "No human-input form matches this token. It may be wrong, expired, or already submitted."
class RecipientSurfaceMismatch(OpenApiError): # noqa: N818
code = 403
error_code = OpenApiErrorCode.RECIPIENT_SURFACE_MISMATCH
description = "This form's recipient can't be submitted via the OpenAPI surface."
hint = "Action it through its channel (web app or console)."
+2 -11
View File
@@ -38,18 +38,12 @@ class PaginationEnvelope[T](BaseModel):
return cls(page=page, limit=limit, total=total, has_more=page * limit < total, data=items)
class TagItem(BaseModel):
name: str
class AppListRow(BaseModel):
id: str
name: str
description: str | None = None
mode: AppMode
tags: list[TagItem] = []
updated_at: str | None = None
created_by_name: str | None = None
workspace_id: str | None = None
workspace_name: str | None = None
@@ -70,16 +64,14 @@ class PermittedExternalAppsListResponse(BaseModel):
data: list[AppListRow]
class AppInfoResponse(BaseModel):
class AppInfo(BaseModel):
id: str
name: str
description: str | None = None
mode: str
author: str | None = None
tags: list[TagItem] = []
class AppDescribeInfo(AppInfoResponse):
class AppDescribeInfo(AppInfo):
updated_at: str | None = None
service_api_enabled: bool
is_agent: bool = False
@@ -294,7 +286,6 @@ class AppListQuery(BaseModel):
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
mode: AppMode | None = None
name: str | None = Field(None, max_length=200)
tag: str | None = Field(None, max_length=100)
class AppRunRequest(BaseModel):
+38 -54
View File
@@ -19,7 +19,6 @@ from controllers.openapi._models import (
AppListQuery,
AppListResponse,
AppListRow,
TagItem,
)
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData
@@ -28,9 +27,9 @@ from core.app.app_config.common.parameters_mapping import get_parameters_from_fe
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from models import App
from models.model import AppMode
from services.account_service import TenantService
from services.app_service import AppListParams, AppService
from services.tag_service import TagService
_ALLOWED_DESCRIBE_FIELDS: frozenset[str] = frozenset({"info", "parameters", "input_schema"})
@@ -84,6 +83,42 @@ def parameters_payload(app: App) -> dict:
return Parameters.model_validate(parameters).model_dump(mode="json")
def build_app_describe_response(app: App, fields: set[str] | None) -> AppDescribeResponse:
"""Public projection of an app (name / params / input schema) — never internal config."""
want_info = fields is None or "info" in fields
want_params = fields is None or "parameters" in fields
want_schema = fields is None or "input_schema" in fields
info = (
AppDescribeInfo(
id=str(app.id),
name=app.name,
mode=app.mode,
description=app.description,
updated_at=app.updated_at.isoformat() if app.updated_at else None,
service_api_enabled=bool(app.enable_api),
is_agent=app.mode in (AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT),
)
if want_info
else None
)
parameters: dict[str, Any] | None = None
input_schema: dict[str, Any] | None = None
if want_params:
try:
parameters = parameters_payload(app)
except AppUnavailableError:
parameters = dict(_EMPTY_PARAMETERS)
if want_schema:
try:
input_schema = build_input_schema(app)
except AppUnavailableError:
input_schema = dict(EMPTY_INPUT_SCHEMA)
return AppDescribeResponse(info=info, parameters=parameters, input_schema=input_schema)
@openapi_ns.route("/apps/<string:app_id>/describe")
class AppDescribeApi(AppReadResource):
@auth_router.guard(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
@@ -92,46 +127,7 @@ class AppDescribeApi(AppReadResource):
def get(self, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery):
# describe is UUID-only (workspace_id query param dropped in #37212).
app = self._load(app_id)
requested = query.fields
want_info = requested is None or "info" in requested
want_params = requested is None or "parameters" in requested
want_schema = requested is None or "input_schema" in requested
info = (
AppDescribeInfo(
id=str(app.id),
name=app.name,
mode=app.mode,
description=app.description,
tags=[TagItem(name=t.name) for t in app.tags],
author=app.author_name,
updated_at=app.updated_at.isoformat() if app.updated_at else None,
service_api_enabled=bool(app.enable_api),
is_agent=app.mode in ("agent-chat", "advanced-chat"),
)
if want_info
else None
)
parameters: dict[str, Any] | None = None
input_schema: dict[str, Any] | None = None
if want_params:
try:
parameters = parameters_payload(app)
except AppUnavailableError:
parameters = dict(_EMPTY_PARAMETERS)
if want_schema:
try:
input_schema = build_input_schema(app)
except AppUnavailableError:
input_schema = dict(EMPTY_INPUT_SCHEMA)
return AppDescribeResponse(
info=info,
parameters=parameters,
input_schema=input_schema,
)
return build_app_describe_response(app, query.fields)
@openapi_ns.route("/apps")
@@ -163,28 +159,18 @@ class AppListApi(Resource):
name=app.name,
description=app.description,
mode=app.mode,
tags=[TagItem(name=t.name) for t in app.tags],
updated_at=app.updated_at.isoformat() if app.updated_at else None,
created_by_name=getattr(app, "author_name", None),
workspace_id=str(workspace_id),
workspace_name=tenant_name,
)
env = AppListResponse(page=1, limit=1, total=1, has_more=False, data=[item])
return env
tag_ids: list[str] | None = None
if query.tag:
tags = TagService.get_tag_by_tag_name("app", workspace_id, query.tag, db.session)
if not tags:
return empty
tag_ids = [tag.id for tag in tags]
params = AppListParams(
page=query.page,
limit=query.limit,
mode=query.mode.value if query.mode else "all", # type:ignore
name=query.name,
tag_ids=tag_ids,
status="normal",
# Visibility gate pushed into the query — pagination.total stays
# consistent across pages because invisible rows never count.
@@ -205,9 +191,7 @@ class AppListApi(Resource):
name=r.name,
description=r.description,
mode=r.mode,
tags=[TagItem(name=t.name) for t in r.tags],
updated_at=r.updated_at.isoformat() if r.updated_at else None,
created_by_name=getattr(r, "author_name", None),
workspace_id=str(workspace_id),
workspace_name=tenant_name,
)
@@ -8,14 +8,18 @@ EE blueprint chain so this module is unreachable there.
from __future__ import annotations
from flask_restx import Resource
from werkzeug.exceptions import NotFound
from controllers.openapi import openapi_ns
from controllers.openapi._contract import accepts, returns
from controllers.openapi._models import (
AppDescribeQuery,
AppDescribeResponse,
AppListRow,
PermittedExternalAppsListQuery,
PermittedExternalAppsListResponse,
)
from controllers.openapi.apps import build_app_describe_response
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, Edition
from extensions.ext_database import db
@@ -67,9 +71,7 @@ class PermittedExternalAppsListApi(Resource):
name=app.name,
description=app.description,
mode=app.mode,
tags=[], # tenant-scoped; not surfaced cross-tenant
updated_at=app.updated_at.isoformat() if app.updated_at else None,
created_by_name=None, # cross-tenant author leak prevention
workspace_id=str(app.tenant_id),
workspace_name=tenant.name if tenant else None,
)
@@ -82,3 +84,20 @@ class PermittedExternalAppsListApi(Resource):
data=items,
)
return env
@openapi_ns.route("/permitted-external-apps/<string:app_id>/describe")
class PermittedExternalAppDescribeApi(Resource):
@auth_router.guard(
scope=Scope.APPS_READ_PERMITTED_EXTERNAL,
allowed_token_types=frozenset({TokenType.OAUTH_EXTERNAL_SSO}),
edition=frozenset({Edition.EE}),
)
@returns(200, AppDescribeResponse, description="Permitted external app description")
@accepts(query=AppDescribeQuery)
def get(self, app_id: str, *, auth_data: AuthData, query: AppDescribeQuery):
# App already loaded and ACL-checked by the external_sso pipeline; project it.
app = auth_data.app
if app is None:
raise NotFound("app not found")
return build_app_describe_response(app, query.fields)
+12 -8
View File
@@ -12,16 +12,20 @@ import logging
from flask import Response
from flask_restx import Resource
from werkzeug.exceptions import BadRequest, NotFound
from werkzeug.exceptions import BadRequest
from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values
from controllers.common.schema import register_schema_models
from controllers.openapi import openapi_ns
from controllers.openapi._contract import accepts, returns
from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch
from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData
from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface
from core.workflow.human_input_policy import (
HumanInputSurface,
is_recipient_type_allowed_for_surface,
)
from extensions.ext_database import db
from libs.helper import to_timestamp
from libs.oauth_bearer import Scope
@@ -47,12 +51,12 @@ def _jsonify_form_definition(form) -> Response:
def _ensure_form_belongs_to_app(form, app_model: App) -> None:
if form.app_id != app_model.id or form.tenant_id != app_model.tenant_id:
raise NotFound("Form not found")
raise HumanInputFormNotFound()
def _ensure_form_is_allowed_for_openapi(form) -> None:
if not is_recipient_type_allowed_for_surface(form.recipient_type, HumanInputSurface.OPENAPI):
raise NotFound("Form not found")
raise RecipientSurfaceMismatch()
@openapi_ns.route("/apps/<string:app_id>/form/human_input/<string:form_token>")
@@ -60,11 +64,11 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
@openapi_ns.response(200, "Form definition", openapi_ns.models[HumanInputFormDefinitionResponse.__name__])
@auth_router.guard(scope=Scope.APPS_RUN)
def get(self, app_id: str, form_token: str, *, auth_data: AuthData):
app_model, caller, caller_kind = auth_data.require_app_context()
app_model, _caller, _caller_kind = auth_data.require_app_context()
service = HumanInputService(db.engine)
form = service.get_form_by_token(form_token)
if form is None:
raise NotFound("Form not found")
raise HumanInputFormNotFound()
_ensure_form_belongs_to_app(form, app_model)
_ensure_form_is_allowed_for_openapi(form)
@@ -80,7 +84,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
service = HumanInputService(db.engine)
form = service.get_form_by_token(form_token)
if form is None:
raise NotFound("Form not found")
raise HumanInputFormNotFound()
_ensure_form_belongs_to_app(form, app_model)
_ensure_form_is_allowed_for_openapi(form)
@@ -106,6 +110,6 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
submission_end_user_id=submission_end_user_id,
)
except FormNotFoundError:
raise NotFound("Form not found")
raise HumanInputFormNotFound()
return FormSubmitResponse()
@@ -51,8 +51,11 @@ from core.tools.entities.tool_entities import ToolProviderType
from core.tools.tool_manager import ToolManager
from core.trigger.constants import TRIGGER_PLUGIN_NODE_TYPE
from core.trigger.trigger_manager import TriggerManager
from core.workflow.human_input_forms import load_form_tokens_by_form_id
from core.workflow.human_input_forms import (
load_form_dispositions_by_form_id,
)
from core.workflow.human_input_policy import (
FormDisposition,
HumanInputSurface,
enrich_human_input_pause_reasons,
resolve_human_input_pause_reason_inputs,
@@ -340,13 +343,14 @@ class WorkflowResponseConverter:
human_input_form_ids = [reason.form_id for reason in resolved_reasons if isinstance(reason, HumanInputRequired)]
expiration_times_by_form_id: dict[str, datetime] = {}
display_in_ui_by_form_id: dict[str, bool] = {}
form_token_by_form_id: dict[str, str] = {}
dispositions_by_form_id: dict[str, FormDisposition] = {}
if human_input_form_ids:
stmt = select(
HumanInputForm.id,
HumanInputForm.expiration_time,
HumanInputForm.form_definition,
).where(HumanInputForm.id.in_(human_input_form_ids))
hitl_surface = _INVOKE_FROM_TO_HITL_SURFACE.get(self._application_generate_entity.invoke_from)
with Session(bind=db.engine) as session:
for form_id, expiration_time, form_definition in session.execute(stmt):
expiration_times_by_form_id[str(form_id)] = expiration_time
@@ -355,17 +359,17 @@ class WorkflowResponseConverter:
except (TypeError, json.JSONDecodeError):
definition_payload = {}
display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui"))
form_token_by_form_id = load_form_tokens_by_form_id(
dispositions_by_form_id = load_form_dispositions_by_form_id(
human_input_form_ids,
session=session,
surface=_INVOKE_FROM_TO_HITL_SURFACE.get(self._application_generate_entity.invoke_from),
surface=hitl_surface,
)
# Reconnect paths must preserve the same pause-reason contract as live streams;
# otherwise clients see schema drift after resume.
pause_reasons = enrich_human_input_pause_reasons(
pause_reasons,
form_tokens_by_form_id=form_token_by_form_id,
dispositions_by_form_id=dispositions_by_form_id,
expiration_times_by_form_id={
form_id: int(expiration_time.timestamp())
for form_id, expiration_time in expiration_times_by_form_id.items()
@@ -379,6 +383,7 @@ class WorkflowResponseConverter:
expiration_time = expiration_times_by_form_id.get(reason.form_id)
if expiration_time is None:
raise ValueError(f"HumanInputForm not found for pause reason, form_id={reason.form_id}")
disposition = dispositions_by_form_id.get(reason.form_id)
responses.append(
HumanInputRequiredResponse(
task_id=task_id,
@@ -391,7 +396,8 @@ class WorkflowResponseConverter:
inputs=reason.inputs,
actions=reason.actions,
display_in_ui=display_in_ui_by_form_id.get(reason.form_id, False),
form_token=form_token_by_form_id.get(reason.form_id),
form_token=disposition.form_token if disposition else None,
approval_channels=list(disposition.approval_channels) if disposition else [],
resolved_default_values=reason.resolved_default_values,
expiration_time=int(expiration_time.timestamp()),
),
+3
View File
@@ -288,6 +288,7 @@ class HumanInputRequiredResponse(StreamResponse):
actions: Sequence[UserActionConfig] = Field(default_factory=list)
display_in_ui: bool = False
form_token: str | None = None
approval_channels: list[str] = Field(default_factory=list)
resolved_default_values: Mapping[str, Any] = Field(default_factory=dict)
expiration_time: int = Field(..., description="Unix timestamp in seconds")
@@ -311,6 +312,7 @@ class HumanInputRequiredPauseReasonPayload(BaseModel):
actions: Sequence[UserActionConfig] = Field(default_factory=list)
display_in_ui: bool = False
form_token: str | None = None
approval_channels: list[str] = Field(default_factory=list)
resolved_default_values: Mapping[str, Any] = Field(default_factory=dict)
expiration_time: int
@@ -325,6 +327,7 @@ class HumanInputRequiredPauseReasonPayload(BaseModel):
actions=data.actions,
display_in_ui=data.display_in_ui,
form_token=data.form_token,
approval_channels=data.approval_channels,
resolved_default_values=data.resolved_default_values,
expiration_time=data.expiration_time,
)
+48 -47
View File
@@ -12,60 +12,61 @@ from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.workflow.human_input_policy import HumanInputSurface, get_preferred_form_token
from core.workflow.human_input_policy import (
FormDisposition,
HumanInputSurface,
disposition_for_surface,
)
from extensions.ext_database import db
from models.human_input import HumanInputFormRecipient, RecipientType
def load_form_dispositions_by_form_id(
form_ids: Sequence[str],
*,
session: Session | None = None,
surface: HumanInputSurface | None = None,
) -> dict[str, FormDisposition]:
"""Resolve each paused form's resume token and approval channels for `surface`."""
unique_form_ids = list(dict.fromkeys(form_ids))
if not unique_form_ids:
return {}
if session is not None:
return _load_form_dispositions_by_form_id(session, unique_form_ids, surface=surface)
with Session(bind=db.engine, expire_on_commit=False) as new_session:
return _load_form_dispositions_by_form_id(new_session, unique_form_ids, surface=surface)
def _load_form_dispositions_by_form_id(
session: Session,
form_ids: Sequence[str],
*,
surface: HumanInputSurface | None,
) -> dict[str, FormDisposition]:
recipients_by_form_id: dict[str, list[tuple[RecipientType, str]]] = {}
stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids))
for recipient in session.scalars(stmt):
recipients_by_form_id.setdefault(recipient.form_id, []).append(
(recipient.recipient_type, recipient.access_token or "")
)
return {
form_id: disposition_for_surface(recipients, surface=surface)
for form_id, recipients in recipients_by_form_id.items()
}
def load_form_tokens_by_form_id(
form_ids: Sequence[str],
*,
session: Session | None = None,
surface: HumanInputSurface | None = None,
) -> dict[str, str]:
"""Load the preferred access token for each human input form."""
unique_form_ids = list(dict.fromkeys(form_ids))
if not unique_form_ids:
return {}
if session is not None:
return _load_form_tokens_by_form_id(session, unique_form_ids, surface=surface)
with Session(bind=db.engine, expire_on_commit=False) as new_session:
return _load_form_tokens_by_form_id(new_session, unique_form_ids, surface=surface)
def _load_form_tokens_by_form_id(
session: Session,
form_ids: Sequence[str],
*,
surface: HumanInputSurface | None = None,
) -> dict[str, str]:
recipients_by_form_id: dict[str, list[tuple[RecipientType, str]]] = {}
stmt = select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id.in_(form_ids))
for recipient in session.scalars(stmt):
if not recipient.access_token:
continue
recipients_by_form_id.setdefault(recipient.form_id, []).append(
(recipient.recipient_type, recipient.access_token)
)
tokens_by_form_id: dict[str, str] = {}
for form_id, recipients in recipients_by_form_id.items():
token = _get_surface_form_token(recipients, surface=surface)
if token is not None:
tokens_by_form_id[form_id] = token
return tokens_by_form_id
def _get_surface_form_token(
recipients: Sequence[tuple[RecipientType, str]],
*,
surface: HumanInputSurface | None,
) -> str | None:
if surface in {HumanInputSurface.SERVICE_API, HumanInputSurface.OPENAPI}:
for recipient_type, token in recipients:
if recipient_type == RecipientType.STANDALONE_WEB_APP and token:
return token
return get_preferred_form_token(recipients)
"""Resume tokens only, for callers that don't surface approval channels."""
dispositions = load_form_dispositions_by_form_id(form_ids, session=session, surface=surface)
return {
form_id: disposition.form_token
for form_id, disposition in dispositions.items()
if disposition.form_token is not None
}
+37 -6
View File
@@ -2,14 +2,14 @@ from __future__ import annotations
from collections.abc import Mapping, Sequence
from enum import StrEnum
from typing import Any
from typing import Any, NamedTuple
from graphon.entities.pause_reason import HumanInputRequired, PauseReason, PauseReasonType
from graphon.nodes.human_input.entities import FormInputConfig, SelectInputConfig
from graphon.nodes.human_input.enums import ValueSourceType
from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool
from graphon.variables import ArrayStringSegment
from models.human_input import RecipientType
from models.human_input import ApprovalChannel, RecipientType
class HumanInputSurface(StrEnum):
@@ -20,7 +20,7 @@ class HumanInputSurface(StrEnum):
# SERVICE_API and OPENAPI are intentionally narrower than CONSOLE: token callers
# should only be able to act on end-user web forms, not internal console flows.
_ALLOWED_RECIPIENT_TYPES_BY_SURFACE: dict[HumanInputSurface, frozenset[RecipientType]] = {
ALLOWED_RECIPIENT_TYPES_BY_SURFACE: dict[HumanInputSurface, frozenset[RecipientType]] = {
HumanInputSurface.SERVICE_API: frozenset({RecipientType.STANDALONE_WEB_APP}),
HumanInputSurface.CONSOLE: frozenset({RecipientType.CONSOLE, RecipientType.BACKSTAGE}),
HumanInputSurface.OPENAPI: frozenset({RecipientType.STANDALONE_WEB_APP}),
@@ -41,7 +41,7 @@ def is_recipient_type_allowed_for_surface(
) -> bool:
if recipient_type is None:
return False
return recipient_type in _ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface]
return recipient_type in ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface]
def get_preferred_form_token(
@@ -59,10 +59,39 @@ def get_preferred_form_token(
return chosen_token
class FormDisposition(NamedTuple):
"""How a paused form resolves for one API surface.
A form's recipients split into those the surface may act on (yielding a resume
`form_token`) and those it may not (their channels named in `approval_channels`
so the caller is told where approval actually happens instead).
"""
form_token: str | None
approval_channels: list[ApprovalChannel]
def disposition_for_surface(
recipients: Sequence[tuple[RecipientType, str]],
*,
surface: HumanInputSurface | None,
) -> FormDisposition:
if surface is None:
return FormDisposition(form_token=get_preferred_form_token(recipients), approval_channels=[])
allowed = ALLOWED_RECIPIENT_TYPES_BY_SURFACE[surface]
actionable = [(recipient_type, token) for recipient_type, token in recipients if recipient_type in allowed]
return FormDisposition(
form_token=get_preferred_form_token(actionable),
approval_channels=sorted(
{recipient_type.approval_channel for recipient_type, _ in recipients if recipient_type not in allowed}
),
)
def enrich_human_input_pause_reasons(
reasons: Sequence[Mapping[str, Any]],
*,
form_tokens_by_form_id: Mapping[str, str],
dispositions_by_form_id: Mapping[str, FormDisposition],
expiration_times_by_form_id: Mapping[str, int],
) -> list[dict[str, Any]]:
enriched: list[dict[str, Any]] = []
@@ -71,7 +100,9 @@ def enrich_human_input_pause_reasons(
if updated.get("TYPE") == PauseReasonType.HUMAN_INPUT_REQUIRED:
form_id = updated.get("form_id")
if isinstance(form_id, str):
updated["form_token"] = form_tokens_by_form_id.get(form_id)
disposition = dispositions_by_form_id.get(form_id)
updated["form_token"] = disposition.form_token if disposition else None
updated["approval_channels"] = list(disposition.approval_channels) if disposition else []
expiration_time = expiration_times_by_form_id.get(form_id)
if expiration_time is not None:
updated["expiration_time"] = expiration_time
+5
View File
@@ -289,6 +289,11 @@ class AgentConfigSnapshotListResponse(ResponseModel):
data: list[AgentConfigSnapshotSummaryResponse]
class AgentConfigSnapshotRestoreResponse(ResponseModel):
result: Literal["success"]
active_config_snapshot_id: str
class AgentComposerAgentResponse(ResponseModel):
id: str
name: str
+2
View File
@@ -83,6 +83,8 @@ class AgentConfigRevisionOperation(StrEnum):
SAVE_NEW_AGENT = "save_new_agent"
# Promotes a workflow-only Agent into the reusable Agent Roster.
SAVE_TO_ROSTER = "save_to_roster"
# Switches the Agent's current published config back to an existing version.
RESTORE_VERSION = "restore_version"
class WorkflowAgentBindingType(StrEnum):
+26 -6
View File
@@ -134,20 +134,40 @@ class HumanInputDelivery(DefaultFieldsMixin, Base):
)
class ApprovalChannel(StrEnum):
"""Where a paused human input form can be approved, surfaced to API callers."""
EMAIL = "email"
WEB_APP = "web_app"
CONSOLE = "console"
class RecipientType(StrEnum):
# EMAIL_MEMBER member means that the
EMAIL_MEMBER = "email_member"
EMAIL_EXTERNAL = "email_external"
# Second value = the approval channel this recipient maps to (surfaced in `approval_channels`).
EMAIL_MEMBER = "email_member", ApprovalChannel.EMAIL
EMAIL_EXTERNAL = "email_external", ApprovalChannel.EMAIL
# STANDALONE_WEB_APP is used by the standalone web app.
#
# It's not used while running workflows / chatflows containing HumanInput
# node inside console.
STANDALONE_WEB_APP = "standalone_web_app"
STANDALONE_WEB_APP = "standalone_web_app", ApprovalChannel.WEB_APP
# CONSOLE is used while running workflows / chatflows containing HumanInput
# node inside console. (E.G. running installed apps or debugging workflows / chatflows)
CONSOLE = "console"
CONSOLE = "console", ApprovalChannel.CONSOLE
# BACKSTAGE is used for backstage input inside console.
BACKSTAGE = "backstage"
BACKSTAGE = "backstage", ApprovalChannel.CONSOLE
_approval_channel: ApprovalChannel
def __new__(cls, value: str, approval_channel: ApprovalChannel) -> "RecipientType":
member = str.__new__(cls, value)
member._value_ = value
member._approval_channel = approval_channel
return member
@property
def approval_channel(self) -> ApprovalChannel:
return self._approval_channel
@final
+93
View File
@@ -591,6 +591,22 @@ List drive-backed skills for an Agent App
| ---- | ----------- | ------ |
| 200 | Drive skills | **application/json**: [AgentDriveSkillListResponse](#agentdriveskilllistresponse)<br> |
### [GET] /agent/{agent_id}/drive/skills/{skill_path}/inspect
Inspect one drive-backed skill for slash-menu hover/detail UI
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | Agent ID | Yes | string (uuid) |
| skill_path | path | Skill path/slug, e.g. tender-analyzer | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)<br> |
### [POST] /agent/{agent_id}/features
Update an Agent App's presentation features (opener, follow-up, citations, ...)
@@ -920,6 +936,20 @@ Infer CLI tool + ENV suggestions from a standardized Agent App skill
| ---- | ----------- | ------ |
| 200 | Agent version detail | **application/json**: [AgentConfigSnapshotDetailResponse](#agentconfigsnapshotdetailresponse)<br> |
### [POST] /agent/{agent_id}/versions/{version_id}/restore
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
| version_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Agent version restored | **application/json**: [AgentConfigSnapshotRestoreResponse](#agentconfigsnapshotrestoreresponse)<br> |
### [GET] /all-workspaces
#### Parameters
@@ -1486,6 +1516,23 @@ List drive-backed skills for the bound agent
| ---- | ----------- | ------ |
| 200 | Drive skills | **application/json**: [AgentDriveSkillListResponse](#agentdriveskilllistresponse)<br> |
### [GET] /apps/{app_id}/agent/drive/skills/{skill_path}/inspect
Inspect one drive-backed skill for slash-menu hover/detail UI
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | Application ID | Yes | string (uuid) |
| skill_path | path | Skill path/slug, e.g. tender-analyzer | Yes | string |
| node_id | query | Workflow node ID (workflow composer variant) | No | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Drive skill inspect view | **application/json**: [AgentDriveSkillInspectResponse](#agentdriveskillinspectresponse)<br> |
### [DELETE] /apps/{app_id}/agent/files
Delete one drive file by key via drive commit-null semantics
@@ -12338,6 +12385,13 @@ Audit operation recorded for Agent Soul version/revision changes.
| ---- | ---- | ----------- | -------- |
| data | [ [AgentConfigSnapshotSummaryResponse](#agentconfigsnapshotsummaryresponse) ] | | Yes |
#### AgentConfigSnapshotRestoreResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| active_config_snapshot_id | string | | Yes |
| result | string | | Yes |
#### AgentConfigSnapshotSummaryResponse
| Name | Type | Description | Required |
@@ -12443,6 +12497,35 @@ Audit operation recorded for Agent Soul version/revision changes.
| text | string | | No |
| truncated | boolean | | Yes |
#### AgentDriveSkillFileResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| available_in_drive | boolean | | Yes |
| drive_key | string | | No |
| name | string | | Yes |
| path | string | | Yes |
| type | string | | Yes |
#### AgentDriveSkillInspectResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| archive_key | string | | No |
| created_at | integer | | No |
| description | string | | Yes |
| file_tree | [ object ] | | No |
| files | [ [AgentDriveSkillFileResponse](#agentdriveskillfileresponse) ] | | No |
| hash | string | | No |
| mime_type | string | | No |
| name | string | | Yes |
| path | string | | Yes |
| size | integer | | No |
| skill_md | [AgentDriveSkillMarkdownResponse](#agentdriveskillmarkdownresponse) | | Yes |
| skill_md_key | string | | Yes |
| source | string | | Yes |
| warnings | [ string ] | | No |
#### AgentDriveSkillItemResponse
| Name | Type | Description | Required |
@@ -12463,6 +12546,16 @@ Audit operation recorded for Agent Soul version/revision changes.
| ---- | ---- | ----------- | -------- |
| items | [ [AgentDriveSkillItemResponse](#agentdriveskillitemresponse) ] | | No |
#### AgentDriveSkillMarkdownResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| binary | boolean | | Yes |
| key | string | | Yes |
| size | integer | | No |
| text | string | | No |
| truncated | boolean | | Yes |
#### AgentEnvVariableConfig
| Name | Type | Description | Required |
+17 -15
View File
@@ -83,7 +83,6 @@ User-scoped operations
| mode | query | | No | string, <br>**Available values:** "advanced-chat", "agent", "agent-chat", "channel", "chat", "completion", "rag-pipeline", "workflow" |
| name | query | | No | string |
| page | query | | No | integer, <br>**Default:** 1 |
| tag | query | | No | string |
| workspace_id | query | | Yes | string |
#### Responses
@@ -331,6 +330,22 @@ 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] /permitted-external-apps/{app_id}/describe
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| fields | query | | No | string |
| app_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Permitted external app description | **application/json**: [AppDescribeResponse](#appdescriberesponse)<br> |
| 422 | Validation error | **application/json**: [ErrorBody](#errorbody)<br> |
| default | Error | **application/json**: [ErrorBody](#errorbody)<br> |
### [GET] /workspaces
#### Responses
@@ -507,14 +522,12 @@ Upload a file to use as an input variable when running the app
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| author | string | | No |
| description | string | | No |
| id | string | | Yes |
| is_agent | boolean | | No |
| mode | string | | Yes |
| name | string | | Yes |
| service_api_enabled | boolean | | Yes |
| tags | [ [TagItem](#tagitem) ], <br>**Default:** | | No |
| updated_at | string | | No |
#### AppDescribeQuery
@@ -568,16 +581,14 @@ Request body for POST /workspaces/<workspace_id>/apps/imports.
| yaml_content | string | Inline YAML DSL string (required when mode is yaml-content) | No |
| yaml_url | string | Remote URL to fetch YAML from (required when mode is yaml-url) | No |
#### AppInfoResponse
#### AppInfo
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| author | string | | No |
| description | string | | No |
| id | string | | Yes |
| mode | string | | Yes |
| name | string | | Yes |
| tags | [ [TagItem](#tagitem) ], <br>**Default:** | | No |
#### AppListQuery
@@ -589,7 +600,6 @@ mode is a closed enum.
| mode | [AppMode](#appmode) | | No |
| name | string | | No |
| page | integer, <br>**Default:** 1 | | No |
| tag | string | | No |
| workspace_id | string | | Yes |
#### AppListResponse
@@ -606,12 +616,10 @@ mode is a closed enum.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| created_by_name | string | | No |
| description | string | | No |
| id | string | | Yes |
| mode | [AppMode](#appmode) | | Yes |
| name | string | | Yes |
| tags | [ [TagItem](#tagitem) ], <br>**Default:** | | No |
| updated_at | string | | No |
| workspace_id | string | | No |
| workspace_name | string | | No |
@@ -982,12 +990,6 @@ Pagination for GET /account/sessions. Strict (extra='forbid').
| last_used_at | string | | No |
| prefix | string | | Yes |
#### TagItem
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| name | string | | Yes |
#### TaskStopResponse
200 body for POST /apps/<id>/tasks/<task_id>/stop. The handler always returns
+56 -1
View File
@@ -666,12 +666,16 @@ class AgentRosterService:
@staticmethod
def _visible_version_operations(agent: Agent) -> set[AgentConfigRevisionOperation]:
if agent.source == AgentSource.AGENT_APP:
return {AgentConfigRevisionOperation.SAVE_NEW_VERSION}
return {
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
AgentConfigRevisionOperation.RESTORE_VERSION,
}
return {
AgentConfigRevisionOperation.CREATE_VERSION,
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
AgentConfigRevisionOperation.SAVE_NEW_AGENT,
AgentConfigRevisionOperation.SAVE_TO_ROSTER,
AgentConfigRevisionOperation.RESTORE_VERSION,
}
def active_config_is_published(self, *, tenant_id: str, agent: Agent) -> bool:
@@ -764,6 +768,46 @@ class AgentRosterService:
]
return result
def restore_agent_version(
self, *, tenant_id: str, agent_id: str, version_id: str, account_id: str
) -> dict[str, Any]:
agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True)
visible_version_ids = self._visible_version_ids_stmt(tenant_id=tenant_id, agent_id=agent_id, agent=agent)
visible_version_id = self._session.scalar(
select(AgentConfigSnapshot.id)
.where(
AgentConfigSnapshot.tenant_id == tenant_id,
AgentConfigSnapshot.agent_id == agent_id,
AgentConfigSnapshot.id == version_id,
AgentConfigSnapshot.id.in_(select(visible_version_ids.c.current_snapshot_id)),
)
.limit(1)
)
if not visible_version_id:
raise AgentVersionNotFoundError()
version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
if agent.active_config_snapshot_id == version.id:
return {"result": "success", "active_config_snapshot_id": version.id}
previous_snapshot_id = agent.active_config_snapshot_id
agent.active_config_snapshot_id = version.id
agent.active_config_has_model = agent_soul_has_model(version.config_snapshot)
agent.updated_by = account_id
self._session.add(
AgentConfigRevision(
tenant_id=tenant_id,
agent_id=agent_id,
previous_snapshot_id=previous_snapshot_id,
current_snapshot_id=version.id,
revision=self._next_revision(tenant_id=tenant_id, agent_id=agent_id),
operation=AgentConfigRevisionOperation.RESTORE_VERSION,
created_by=account_id,
)
)
self._session.commit()
return {"result": "success", "active_config_snapshot_id": version.id}
def _get_agent(self, *, tenant_id: str, agent_id: str, roster_only: bool = False) -> Agent:
stmt = select(Agent).where(Agent.tenant_id == tenant_id, Agent.id == agent_id)
if roster_only:
@@ -789,6 +833,17 @@ class AgentRosterService:
raise AgentVersionNotFoundError()
return version
def _next_revision(self, *, tenant_id: str, agent_id: str) -> int:
return (
self._session.scalar(
select(func.max(AgentConfigRevision.revision)).where(
AgentConfigRevision.tenant_id == tenant_id,
AgentConfigRevision.agent_id == agent_id,
)
)
or 0
) + 1
def _load_published_active_snapshot_agent_ids(self, *, tenant_id: str, agents: list[Agent]) -> set[str]:
predicates = [
and_(
@@ -92,7 +92,11 @@ class SkillStandardizeService:
file_ref=DriveFileRef(kind="tool_file", id=md_tool_file.id),
value_owned_by_drive=True,
is_skill=True,
skill_metadata=DriveSkillMetadata(name=manifest.name, description=manifest.description),
skill_metadata=DriveSkillMetadata(
name=manifest.name,
description=manifest.description,
manifest_files=manifest.files,
),
),
DriveCommitItem(
key=archive_key,
+235 -6
View File
@@ -76,6 +76,10 @@ class DriveSkillMetadata(BaseModel):
name: str
description: str = ""
# Safe archive member paths captured during skill standardization. The drive
# stores only canonical SKILL.md + full archive, so the UI uses this manifest
# to show the original uploaded package contents.
manifest_files: list[str] | None = None
@field_validator("name")
@classmethod
@@ -110,6 +114,31 @@ class AgentDriveSkillInfo(TypedDict):
created_at: int | None
class AgentDriveSkillFileInfo(TypedDict):
path: str
name: str
type: str
drive_key: str | None
available_in_drive: bool
class AgentDriveSkillInspectInfo(TypedDict):
path: str
skill_md_key: str
archive_key: str | None
name: str
description: str
size: int | None
mime_type: str | None
hash: str | None
created_at: int | None
source: str
files: list[AgentDriveSkillFileInfo]
file_tree: list[dict[str, Any]]
skill_md: dict[str, Any]
warnings: list[str]
def decode_drive_mention_ref(ref_id: str) -> str:
"""Decode the prompt token's URL-encoded drive-key field."""
@@ -219,6 +248,52 @@ class AgentDriveService:
self._delete_storage(storage_key)
return committed
def delete(
self,
*,
tenant_id: str,
agent_id: str,
prefix: str | None = None,
key: str | None = None,
) -> list[str]:
"""Delete drive entries by exact ``key`` or by ``prefix`` (ENG-625 D5).
Drive-owned values get their backing record + storage object cleaned via
the same ``_cleanup_value`` path commit-overwrite uses; shared values only
lose the KV row. Idempotent: deleting nothing returns ``[]``.
"""
if (prefix is None) == (key is None):
raise AgentDriveError("invalid_delete_scope", "delete requires exactly one of prefix or key")
removed_keys: list[str] = []
pending_storage_deletes: list[str] = []
with session_factory.create_session() as session:
self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id)
stmt = select(AgentDriveFile).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
)
if key is not None:
stmt = stmt.where(AgentDriveFile.key == normalize_drive_key(key))
else:
stmt = stmt.where(AgentDriveFile.key.startswith(normalize_drive_key(prefix or "")))
rows = list(session.scalars(stmt))
for row in rows:
if row.value_owned_by_drive:
self._cleanup_value(
session,
tenant_id=tenant_id,
file_kind=row.file_kind,
file_id=row.file_id,
exclude_row_id=row.id,
pending_storage_deletes=pending_storage_deletes,
)
removed_keys.append(row.key)
session.delete(row)
session.commit()
for storage_key in pending_storage_deletes:
self._delete_storage(storage_key)
return removed_keys
def list_skills(self, *, tenant_id: str, agent_id: str) -> list[AgentDriveSkillInfo]:
"""Return the drive-backed skill catalog derived from canonical ``SKILL.md`` rows."""
@@ -248,13 +323,12 @@ class AgentDriveService:
skills: list[AgentDriveSkillInfo] = []
for row in skill_rows:
metadata = self._parse_skill_metadata(row.key, row.skill_metadata)
archive_key = self._skill_archive_key(row.key)
skills.append(
{
"path": self._skill_path_from_key(row.key),
"skill_md_key": row.key,
"archive_key": (
self._skill_archive_key(row.key) if self._skill_archive_key(row.key) in archive_keys else None
),
"archive_key": archive_key if archive_key in archive_keys else None,
"name": metadata.name,
"description": metadata.description,
"size": row.size,
@@ -265,6 +339,42 @@ class AgentDriveService:
)
return skills
def inspect_skill(self, *, tenant_id: str, agent_id: str, skill_path: str) -> AgentDriveSkillInspectInfo:
"""Return the UI-facing skill inspect view for slash-menu hover/detail."""
skill_path = normalize_drive_key(skill_path)
skill_md_key = skill_path if skill_path.endswith(_SKILL_MD_SUFFIX) else f"{skill_path}{_SKILL_MD_SUFFIX}"
skill_path = self._skill_path_from_key(skill_md_key)
catalog = next(
(item for item in self.list_skills(tenant_id=tenant_id, agent_id=agent_id) if item["path"] == skill_path),
None,
)
if catalog is None:
raise AgentDriveError("skill_not_found", "no drive-backed skill for this path", status_code=404)
manifest_files = self._manifest_files_from_skill_metadata(
tenant_id=tenant_id,
agent_id=agent_id,
skill_md_key=skill_md_key,
)
drive_items = self.manifest(tenant_id=tenant_id, agent_id=agent_id, prefix=f"{skill_path}/")
drive_keys = {item["key"] for item in drive_items}
preview = self.preview(tenant_id=tenant_id, agent_id=agent_id, key=skill_md_key)
files, warnings = self._skill_file_entries(
skill_path=skill_path,
skill_md_key=skill_md_key,
manifest_files=manifest_files,
drive_keys=drive_keys,
)
return {
**catalog,
"source": "skill_md",
"files": files,
"file_tree": self._build_file_tree(files),
"skill_md": preview,
"warnings": warnings,
}
def _commit_one(
self,
session: Session,
@@ -325,8 +435,8 @@ class AgentDriveService:
existing.is_skill = item.is_skill
existing.skill_metadata = skill_metadata
existing.size = size
existing.mime_type = mime_type
existing.hash = file_hash
existing.mime_type = mime_type
return self._row_dict(existing)
row = AgentDriveFile(
@@ -437,7 +547,11 @@ class AgentDriveService:
"skill metadata is required for canonical skill rows",
status_code=400,
)
return json.dumps(item.skill_metadata.model_dump(mode="json"), separators=(",", ":"), sort_keys=True)
return json.dumps(
item.skill_metadata.model_dump(mode="json", exclude_none=True),
separators=(",", ":"),
sort_keys=True,
)
@staticmethod
def _parse_skill_metadata(key: str, raw_metadata: str | None) -> DriveSkillMetadata:
@@ -456,6 +570,122 @@ class AgentDriveService:
status_code=500,
) from exc
@staticmethod
def _manifest_files_from_skill_metadata(*, tenant_id: str, agent_id: str, skill_md_key: str) -> list[str] | None:
with session_factory.create_session() as session:
row = session.scalar(
select(AgentDriveFile).where(
AgentDriveFile.tenant_id == tenant_id,
AgentDriveFile.agent_id == agent_id,
AgentDriveFile.key == skill_md_key,
AgentDriveFile.is_skill.is_(True),
)
)
if row is None:
return None
try:
metadata = AgentDriveService._parse_skill_metadata(row.key, row.skill_metadata)
except Exception:
logger.warning("drive skill inspect: malformed skill metadata for %s", skill_md_key, exc_info=True)
return None
return [str(item) for item in (metadata.manifest_files or []) if str(item).strip()] or None
@classmethod
def _skill_file_entries(
cls,
*,
skill_path: str,
skill_md_key: str,
manifest_files: list[str] | None,
drive_keys: set[str],
) -> tuple[list[AgentDriveSkillFileInfo], list[str]]:
warnings: list[str] = []
if manifest_files:
paths = sorted({normalize_drive_key(path) for path in manifest_files})
else:
paths = sorted(
{
key.removeprefix(f"{skill_path}/")
for key in drive_keys
if not key.endswith(f"/{_SKILL_ARCHIVE_NAME}")
}
)
warnings.append("manifest_files_unavailable")
files: list[AgentDriveSkillFileInfo] = []
for path in paths:
if path == _SKILL_ARCHIVE_NAME:
continue
drive_key = f"{skill_path}/{path}"
files.append(
{
"path": path,
"name": path.rsplit("/", 1)[-1],
"type": "file",
"drive_key": drive_key if drive_key in drive_keys else None,
"available_in_drive": drive_key in drive_keys,
}
)
if "SKILL.md" not in {file["path"] for file in files}:
files.insert(
0,
{
"path": "SKILL.md",
"name": "SKILL.md",
"type": "file",
"drive_key": skill_md_key,
"available_in_drive": skill_md_key in drive_keys,
},
)
return files, warnings
@staticmethod
def _build_file_tree(files: list[AgentDriveSkillFileInfo]) -> list[dict[str, Any]]:
root: dict[str, Any] = {}
for file in files:
cursor = root
parts = [part for part in file["path"].split("/") if part]
path_parts: list[str] = []
for part in parts[:-1]:
path_parts.append(part)
directory = cursor.setdefault(
part,
{
"name": part,
"path": "/".join(path_parts),
"type": "directory",
"children": {},
},
)
cursor = directory["children"]
leaf_name = parts[-1] if parts else file["name"]
cursor[leaf_name] = {
"name": leaf_name,
"path": file["path"],
"type": file["type"],
"drive_key": file["drive_key"],
"available_in_drive": file["available_in_drive"],
}
def serialize(node: dict[str, Any]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in sorted(node.values(), key=lambda value: (value["type"] != "directory", value["name"])):
if item["type"] == "directory":
children = serialize(item["children"])
result.append(
{
"name": item["name"],
"path": item["path"],
"type": "directory",
"children": children,
}
)
else:
result.append(item)
return result
return serialize(root)
@staticmethod
def _assert_agent_belongs_to_tenant(session: Session, *, tenant_id: str, agent_id: str) -> None:
try:
@@ -679,7 +909,6 @@ class AgentDriveService:
__all__ = [
"AgentDriveError",
"AgentDriveService",
"AgentDriveSkillInfo",
"DriveCommitItem",
"DriveFileRef",
"DriveSkillMetadata",
+75 -12
View File
@@ -313,14 +313,24 @@ _LEGACY_WORKSPACE_OWNER_KEYS: list[str] = [
"plugin.debug",
"credential.use",
"credential.manage",
"billing.view",
"billing.subscription.manage",
"billing.manage",
"app.acl.preview",
"app_library.access",
"app.create_and_management",
"app.tag.manage",
"dataset.acl.preview",
"dataset.create_and_management",
"dataset.tag.manage",
"dataset.external.connect",
"dataset.api_key.manage",
"snippets.create_and_modify",
"snippets.management",
"tool.manage",
"mcp.manage",
"snippets.create_and_modify",
"snippets.management",
]
_LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
@@ -335,14 +345,22 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
"plugin.debug",
"credential.use",
"credential.manage",
"billing.view",
"billing.subscription.manage",
"billing.manage",
"app_library.access",
"app.create_and_management",
"app.tag.manage",
"dataset.create_and_management",
"dataset.tag.manage",
"dataset.external.connect",
"dataset.api_key.manage",
"snippets.create_and_modify",
"snippets.management",
"tool.manage",
"mcp.manage",
"snippets.create_and_modify",
"snippets.management",
]
_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
@@ -356,7 +374,9 @@ _LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
"dataset.create_and_management",
"dataset.tag.manage",
"dataset.external.connect",
"snippets.create_and_modify",
"tool.manage",
"snippets.create_and_modify",
]
_LEGACY_WORKSPACE_NORMAL_KEYS: list[str] = [
@@ -373,6 +393,7 @@ _LEGACY_WORKSPACE_DATASET_OPERATOR_KEYS: list[str] = [
]
_LEGACY_APP_OWNER_KEYS: list[str] = [
"app.acl.preview",
"app.acl.view_layout",
"app.acl.test_and_run",
"app.acl.edit",
@@ -384,6 +405,7 @@ _LEGACY_APP_OWNER_KEYS: list[str] = [
]
_LEGACY_APP_ADMIN_KEYS: list[str] = [
"app.acl.preview",
"app.acl.view_layout",
"app.acl.test_and_run",
"app.acl.edit",
@@ -395,6 +417,7 @@ _LEGACY_APP_ADMIN_KEYS: list[str] = [
]
_LEGACY_APP_EDITOR_KEYS: list[str] = [
"app.acl.preview",
"app.acl.view_layout",
"app.acl.test_and_run",
"app.acl.edit",
@@ -406,12 +429,14 @@ _LEGACY_APP_EDITOR_KEYS: list[str] = [
]
_LEGACY_APP_NORMAL_KEYS: list[str] = [
"app.acl.preview",
"app.acl.view_layout",
"app.acl.test_and_run",
"app.acl.monitor",
]
_LEGACY_DATASET_OWNER_KEYS: list[str] = [
"dataset.acl.preview",
"dataset.acl.readonly",
"dataset.acl.edit",
"dataset.acl.import_export_dsl",
@@ -427,6 +452,7 @@ _LEGACY_DATASET_OWNER_KEYS: list[str] = [
]
_LEGACY_DATASET_ADMIN_KEYS: list[str] = [
"dataset.acl.preview",
"dataset.acl.readonly",
"dataset.acl.edit",
"dataset.acl.import_export_dsl",
@@ -442,6 +468,7 @@ _LEGACY_DATASET_ADMIN_KEYS: list[str] = [
]
_LEGACY_DATASET_EDITOR_KEYS: list[str] = [
"dataset.acl.preview",
"dataset.acl.readonly",
"dataset.acl.edit",
"dataset.acl.import_export_dsl",
@@ -492,6 +519,19 @@ _LEGACY_MY_PERMISSIONS: dict[TenantAccountRole, dict[str, list[str]]] = {
}
def _legacy_role_permission_keys(role: TenantAccountRole) -> list[str]:
permissions = _LEGACY_MY_PERMISSIONS.get(role, {})
return list(
dict.fromkeys(
[
*permissions.get("workspace", []),
*permissions.get("app", []),
*permissions.get("dataset", []),
]
)
)
def _legacy_my_permissions(tenant_id: str, account_id: str | None) -> MyPermissionsResponse:
if not account_id:
return MyPermissionsResponse()
@@ -1518,21 +1558,44 @@ class RBACService:
)
return AccessMatrixItem.model_validate(data or {})
# ------------------------------------------------------------------
# Member ↔ role bindings (screenshot 3: Settings > Members > Assign roles).
# ------------------------------------------------------------------
class MemberRoles:
@staticmethod
def get(tenant_id: str, account_id: str | None, member_account_id: str) -> MemberRolesResponse:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/members/rbac-roles",
tenant_id=tenant_id,
account_id=account_id,
params={"account_id": member_account_id},
)
rst = MemberRolesResponse.model_validate(data or {})
return rst
if dify_config.RBAC_ENABLED:
data = _inner_call(
"GET",
f"{_INNER_PREFIX}/members/rbac-roles",
tenant_id=tenant_id,
account_id=account_id,
params={"account_id": member_account_id},
)
rst = MemberRolesResponse.model_validate(data or {})
return rst
else:
with session_factory.create_session() as session:
role = session.scalar(
select(TenantAccountJoin.role).where(
TenantAccountJoin.tenant_id == tenant_id,
TenantAccountJoin.account_id == member_account_id,
)
)
return MemberRolesResponse(
account_id=member_account_id,
roles=[
RBACRole(
id="",
name=role,
description="",
is_builtin=True,
type="",
permission_keys=_legacy_role_permission_keys(role),
role_tag="owner" if role == "owner" else role,
tenant_id=tenant_id,
)
]
if role
else [],
)
@staticmethod
def batch_get(
+5 -1
View File
@@ -3,6 +3,8 @@ from typing import TypedDict
import httpx
OPERATION_REQUEST_TIMEOUT = httpx.Timeout(10.0, connect=3.0)
class UtmInfo(TypedDict, total=False):
"""Expected shape of the utm_info dict passed to record_utm.
@@ -26,7 +28,9 @@ class OperationService:
headers = {"Content-Type": "application/json", "Billing-Api-Secret-Key": cls.secret_key}
url = f"{cls.base_url}{endpoint}"
response = httpx.request(method, url, json=json, params=params, headers=headers)
response = httpx.request(
method, url, json=json, params=params, headers=headers, timeout=OPERATION_REQUEST_TIMEOUT
)
return response.json()
@@ -23,8 +23,11 @@ from core.app.entities.task_entities import (
WorkflowStartStreamResponse,
)
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
from core.workflow.human_input_forms import load_form_tokens_by_form_id
from core.workflow.human_input_forms import (
load_form_dispositions_by_form_id,
)
from core.workflow.human_input_policy import (
FormDisposition,
HumanInputSurface,
enrich_human_input_pause_reasons,
resolve_human_input_pause_reason_inputs,
@@ -359,7 +362,7 @@ def _build_human_input_required_events(
expiration_times_by_form_id: dict[str, int] = {}
display_in_ui_by_form_id: dict[str, bool] = {}
form_tokens_by_form_id: dict[str, str] = {}
dispositions_by_form_id: dict[str, FormDisposition] = {}
if human_input_form_ids and session_maker is not None:
stmt = select(HumanInputForm.id, HumanInputForm.expiration_time, HumanInputForm.form_definition).where(
HumanInputForm.id.in_(human_input_form_ids)
@@ -372,7 +375,7 @@ def _build_human_input_required_events(
except (TypeError, json.JSONDecodeError):
definition_payload = {}
display_in_ui_by_form_id[str(form_id)] = bool(definition_payload.get("display_in_ui"))
form_tokens_by_form_id = load_form_tokens_by_form_id(
dispositions_by_form_id = load_form_dispositions_by_form_id(
human_input_form_ids,
session=session,
surface=human_input_surface,
@@ -393,6 +396,7 @@ def _build_human_input_required_events(
reason.inputs,
variable_pool=variable_pool,
)
disposition = dispositions_by_form_id.get(form_id)
response = HumanInputRequiredResponse(
task_id=task_id,
@@ -405,7 +409,8 @@ def _build_human_input_required_events(
inputs=resolved_inputs,
actions=reason.actions,
display_in_ui=display_in_ui_by_form_id.get(form_id, False),
form_token=form_tokens_by_form_id.get(form_id),
form_token=disposition.form_token if disposition else None,
approval_channels=list(disposition.approval_channels) if disposition else [],
resolved_default_values=reason.resolved_default_values,
expiration_time=expiration_time,
),
@@ -493,11 +498,11 @@ def _build_pause_event(
for form_id in [reason.get("form_id")]
if isinstance(form_id, str)
]
form_tokens_by_form_id: dict[str, str] = {}
dispositions_by_form_id: dict[str, FormDisposition] = {}
expiration_times_by_form_id: dict[str, int] = {}
if human_input_form_ids and session_maker is not None:
with session_maker() as session:
form_tokens_by_form_id = load_form_tokens_by_form_id(
dispositions_by_form_id = load_form_dispositions_by_form_id(
human_input_form_ids,
session=session,
surface=human_input_surface,
@@ -512,7 +517,7 @@ def _build_pause_event(
# otherwise clients see schema drift after resume.
reasons = enrich_human_input_pause_reasons(
reasons,
form_tokens_by_form_id=form_tokens_by_form_id,
dispositions_by_form_id=dispositions_by_form_id,
expiration_times_by_form_id=expiration_times_by_form_id,
)
@@ -28,6 +28,7 @@ from controllers.console.agent.roster import (
AgentLogsApi,
AgentLogSourcesApi,
AgentRosterVersionDetailApi,
AgentRosterVersionRestoreApi,
AgentRosterVersionsApi,
AgentStatisticsSummaryApi,
)
@@ -158,6 +159,9 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
"/agent/<uuid:agent_id>/logs/<uuid:conversation_id>/messages",
"/agent/<uuid:agent_id>/log-sources",
"/agent/<uuid:agent_id>/statistics/summary",
"/agent/<uuid:agent_id>/versions",
"/agent/<uuid:agent_id>/versions/<uuid:version_id>",
"/agent/<uuid:agent_id>/versions/<uuid:version_id>/restore",
"/agent/invite-options",
):
assert route in paths
@@ -513,6 +517,13 @@ def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatc
],
},
)
captured_restore: dict[str, object] = {}
def restore_agent_version(_self, **kwargs):
captured_restore.update(kwargs)
return {"result": "success", "active_config_snapshot_id": kwargs["version_id"]}
monkeypatch.setattr(roster_controller.AgentRosterService, "restore_agent_version", restore_agent_version)
assert (
unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), "tenant-1", agent_id)["data"][0]["id"]
@@ -523,6 +534,16 @@ def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatc
)
assert version_detail["id"] == version_id
assert version_detail["agent_id"] == agent_id
restored = unwrap(AgentRosterVersionRestoreApi.post)(
AgentRosterVersionRestoreApi(), "tenant-1", SimpleNamespace(id="account-1"), agent_id, version_id
)
assert restored == {"result": "success", "active_config_snapshot_id": version_id}
assert captured_restore == {
"tenant_id": "tenant-1",
"agent_id": agent_id,
"version_id": version_id,
"account_id": "account-1",
}
def test_agent_observability_routes_resolve_app_from_agent_id(
@@ -20,6 +20,8 @@ from controllers.console.app.agent_drive_inspector import (
AgentDriveListByAgentApi,
AgentDrivePreviewApi,
AgentDrivePreviewByAgentApi,
AgentDriveSkillInspectApi,
AgentDriveSkillInspectByAgentApi,
AgentDriveSkillListApi,
AgentDriveSkillListByAgentApi,
)
@@ -84,39 +86,6 @@ def test_list_by_agent_filters_value_pointers_out_of_console_payload():
assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "agent-1"
def test_skill_list_by_agent_calls_list_skills_and_returns_catalog_shape():
raw = _raw(AgentDriveSkillListByAgentApi.get)
with app.test_request_context("/"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.list_skills.return_value = [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
}
]
body = raw(AgentDriveSkillListByAgentApi(), "tenant-1", "agent-1")
assert body == {
"items": [
{
"path": "tender-analyzer",
"skill_md_key": "tender-analyzer/SKILL.md",
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
"name": "Tender Analyzer",
"description": "Parses RFPs.",
}
]
}
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.list_skills.call_args.kwargs == {"tenant_id": "tenant-1", "agent_id": "agent-1"}
def test_list_resolves_workflow_node_binding_agent():
raw = _raw(AgentDriveListApi.get)
with app.test_request_context("/?node_id=agent-node-1"):
@@ -132,6 +101,33 @@ def test_list_resolves_workflow_node_binding_agent():
assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1"
def test_skill_list_by_agent_calls_service():
raw = _raw(AgentDriveSkillListByAgentApi.get)
with app.test_request_context("/"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.list_skills.return_value = [
{
"path": "pdf-toolkit",
"skill_md_key": "pdf-toolkit/SKILL.md",
"archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip",
"name": "PDF Toolkit",
"description": "Work with PDFs.",
"size": 5,
"mime_type": "text/markdown",
"hash": None,
"created_at": 1718000000,
}
]
body = raw(AgentDriveSkillListByAgentApi(), "tenant-1", "agent-1")
assert body["items"][0]["path"] == "pdf-toolkit"
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "agent-1"
def test_skill_list_resolves_workflow_node_binding_agent():
raw = _raw(AgentDriveSkillListApi.get)
with app.test_request_context("/?node_id=agent-node-1"):
@@ -141,10 +137,86 @@ def test_skill_list_resolves_workflow_node_binding_agent():
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
drive.return_value.list_skills.return_value = []
raw(AgentDriveSkillListApi(), _APP)
body = raw(AgentDriveSkillListApi(), _APP)
assert body == {"items": []}
assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "wf-agent-9"
assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1"
def test_skill_inspect_by_agent_returns_strict_json_response():
raw = _raw(AgentDriveSkillInspectByAgentApi.get)
payload = {
"path": "pdf-toolkit",
"skill_md_key": "pdf-toolkit/SKILL.md",
"archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip",
"name": "PDF Toolkit",
"description": "Work with PDFs.",
"size": 5,
"mime_type": "text/markdown",
"hash": None,
"created_at": 1718000000,
"source": "skill_md",
"files": [
{
"path": "SKILL.md",
"name": "SKILL.md",
"type": "file",
"drive_key": "pdf-toolkit/SKILL.md",
"available_in_drive": True,
}
],
"file_tree": [],
"skill_md": {
"key": "pdf-toolkit/SKILL.md",
"size": 5,
"truncated": False,
"binary": False,
"text": "# PDF Toolkit\nUse it.\n",
},
"warnings": [],
}
with app.test_request_context("/"):
with (
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP),
patch(f"{_MOD}.AgentDriveService") as drive,
):
drive.return_value.inspect_skill.return_value = payload
response = raw(AgentDriveSkillInspectByAgentApi(), "tenant-1", "agent-1", "pdf-toolkit")
assert response.status_code == 200
assert response.get_json()["skill_md"]["text"] == "# PDF Toolkit\nUse it.\n"
assert b"# PDF Toolkit\\nUse it.\\n" in response.get_data()
def test_skill_inspect_resolves_workflow_node_binding_agent():
raw = _raw(AgentDriveSkillInspectApi.get)
payload = {
"path": "pdf-toolkit",
"skill_md_key": "pdf-toolkit/SKILL.md",
"archive_key": None,
"name": "PDF Toolkit",
"description": "",
"size": 5,
"mime_type": "text/markdown",
"hash": None,
"created_at": None,
"source": "skill_md",
"files": [],
"file_tree": [],
"skill_md": {"key": "pdf-toolkit/SKILL.md", "size": 5, "truncated": False, "binary": False, "text": "# hi"},
"warnings": [],
}
with app.test_request_context("/?node_id=agent-node-1"):
with (
patch(f"{_MOD}.AgentComposerService") as composer,
patch(f"{_MOD}.AgentDriveService") as drive,
):
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
drive.return_value.inspect_skill.return_value = payload
response = raw(AgentDriveSkillInspectApi(), _APP, "pdf-toolkit")
assert response.get_json()["path"] == "pdf-toolkit"
assert drive.return_value.inspect_skill.call_args.kwargs["agent_id"] == "wf-agent-9"
def test_list_400_when_no_agent_bound():
@@ -185,7 +185,7 @@ class TestPaginationMapping:
"name": "owner",
"description": "",
"is_builtin": True,
"permission_keys": list(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["owner"]),
"permission_keys": list(dict.fromkeys(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["owner"])),
"role_tag": "owner",
},
{
@@ -196,7 +196,7 @@ class TestPaginationMapping:
"name": "admin",
"description": "",
"is_builtin": True,
"permission_keys": list(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["admin"]),
"permission_keys": list(dict.fromkeys(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["admin"])),
"role_tag": "",
},
]
@@ -336,23 +336,6 @@ class TestResourceAccessScopeBindings:
class TestPaginationForwarding:
def test_role_members_get_forwards_outer_pagination_params(self, app):
with (
app.test_request_context("/workspaces/current/rbac/roles/role-1/members?page=2&limit=50&reverse=true"),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
patch("controllers.console.workspace.rbac.svc.RBACService.Roles.members") as mock_members,
patch("controllers.console.workspace.rbac._dump", return_value={}),
):
inspect.unwrap(rbac_mod.RBACRoleMembersApi.get)(rbac_mod.RBACRoleMembersApi(), "role-1")
_, _, role_id = mock_members.call_args.args
_, kwargs = mock_members.call_args
assert role_id == "role-1"
options = kwargs["options"]
assert options.page_number == 2
assert options.results_per_page == 50
assert options.reverse is True
def test_access_policies_get_forwards_outer_pagination_params(self, app):
with (
app.test_request_context(
@@ -0,0 +1,73 @@
from types import SimpleNamespace
from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA
from controllers.openapi.apps import _EMPTY_PARAMETERS, build_app_describe_response
from controllers.service_api.app.error import AppUnavailableError
class _FakeApp(SimpleNamespace):
pass
def _app() -> _FakeApp:
from datetime import datetime
return _FakeApp(
id="11111111-1111-1111-1111-111111111111",
name="Demo",
mode="chat",
description="d",
tags=[],
author_name="me",
updated_at=datetime(2026, 1, 1),
enable_api=True,
)
def test_fields_none_returns_all_blocks(monkeypatch):
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"})
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1})
resp = build_app_describe_response(_app(), None)
assert resp.info is not None
assert resp.info.name == "Demo"
assert resp.parameters == {"k": "v"}
assert resp.input_schema == {"s": 1}
def test_fields_subset_limits_blocks(monkeypatch):
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"})
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1})
resp = build_app_describe_response(_app(), ["info"])
assert resp.info is not None
assert resp.parameters is None
assert resp.input_schema is None
def test_info_omits_author_and_tags(monkeypatch):
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {})
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {})
resp = build_app_describe_response(_app(), ["info"])
assert resp.info is not None
# Usage-face describe must not expose creator identity or tags (cross-tenant leak).
assert not hasattr(resp.info, "author")
assert not hasattr(resp.info, "tags")
def test_parameters_fallback_on_app_unavailable(monkeypatch):
def _raise(app):
raise AppUnavailableError()
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", _raise)
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1})
resp = build_app_describe_response(_app(), ["parameters"])
assert resp.parameters == dict(_EMPTY_PARAMETERS)
def test_input_schema_fallback_on_app_unavailable(monkeypatch):
def _raise(app):
raise AppUnavailableError()
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"})
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", _raise)
resp = build_app_describe_response(_app(), ["input_schema"])
assert resp.input_schema == dict(EMPTY_INPUT_SCHEMA)
@@ -5,7 +5,7 @@ Runs against the model directly, not the HTTP layer. Pins:
- workspace_id is required.
- numeric bounds enforced (page >= 1, limit in [1, MAX_PAGE_LIMIT]).
- mode validates against the AppMode enum.
- name and tag have length caps.
- name has a length cap.
"""
from __future__ import annotations
@@ -24,7 +24,6 @@ def test_defaults():
assert q.limit == 20
assert q.mode is None
assert q.name is None
assert q.tag is None
def test_workspace_id_required():
@@ -80,12 +79,6 @@ def test_name_length_capped():
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "name": "x" * 201})
def test_tag_length_capped():
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "tag": "x" * 100})
with pytest.raises(ValidationError):
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "tag": "x" * 101})
def test_all_fields_accept_valid_values():
"""Pin the happy-path acceptance for every field in one place."""
q = AppListQuery.model_validate(
@@ -95,7 +88,6 @@ def test_all_fields_accept_valid_values():
"limit": 50,
"mode": "workflow",
"name": "search",
"tag": "prod",
}
)
assert q.workspace_id == "00000000-0000-0000-0000-000000000001"
@@ -104,4 +96,3 @@ def test_all_fields_accept_valid_values():
assert q.mode is not None
assert q.mode.value == "workflow"
assert q.name == "search"
assert q.tag == "prod"
@@ -26,11 +26,13 @@ from controllers.openapi._errors import (
ErrorBody,
ErrorDetail,
FilenameNotExists,
HumanInputFormNotFound,
MemberLicenseExceeded,
MemberLimitExceeded,
OpenApiError,
OpenApiErrorCode,
OpenApiErrorFormatter,
RecipientSurfaceMismatch,
)
from controllers.service_api.app.error import (
AppUnavailableError,
@@ -319,6 +321,8 @@ ERROR_MATRIX = [
(BlockedFileExtensionError(), 400, "file_extension_blocked"),
(MemberLimitExceeded(), 403, "member_limit_exceeded"),
(MemberLicenseExceeded(), 403, "member_license_exceeded"),
(HumanInputFormNotFound(), 404, "form_not_found"),
(RecipientSurfaceMismatch(), 403, "recipient_surface_mismatch"),
]
@@ -11,8 +11,9 @@ from unittest.mock import Mock
import pytest
from flask import Flask
from werkzeug.exceptions import NotFound, UnprocessableEntity
from werkzeug.exceptions import UnprocessableEntity
from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch
from controllers.openapi.auth.data import AuthData
from libs.oauth_bearer import Scope, TokenType
from models.human_input import RecipientType
@@ -89,7 +90,7 @@ class TestOpenApiHumanInputFormGet:
caller = SimpleNamespace(id="acct-1")
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/bad"):
with pytest.raises(NotFound):
with pytest.raises(HumanInputFormNotFound):
api.get.__wrapped__(
api,
app_id="app-1",
@@ -101,7 +102,10 @@ class TestOpenApiHumanInputFormGet:
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
form = SimpleNamespace(
app_id="other-app", tenant_id="tenant-1", expiration_time=datetime(2099, 1, 1, tzinfo=UTC)
app_id="other-app",
tenant_id="tenant-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
expiration_time=datetime(2099, 1, 1, tzinfo=UTC),
)
service_mock = Mock()
service_mock.get_form_by_token.return_value = form
@@ -114,7 +118,7 @@ class TestOpenApiHumanInputFormGet:
caller = SimpleNamespace(id="acct-1")
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
with pytest.raises(NotFound):
with pytest.raises(HumanInputFormNotFound):
api.get.__wrapped__(
api,
app_id="app-1",
@@ -142,7 +146,7 @@ class TestOpenApiHumanInputFormGet:
caller = SimpleNamespace(id="acct-1")
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
with pytest.raises(NotFound):
with pytest.raises(RecipientSurfaceMismatch):
api.get.__wrapped__(
api,
app_id="app-1",
@@ -234,6 +238,38 @@ class TestOpenApiHumanInputFormPost:
)
assert result == ({}, 200)
def test_post_standalone_web_app_recipient_submits(
self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch
):
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
form = self._make_form(recipient_type=RecipientType.STANDALONE_WEB_APP)
service_mock = Mock()
service_mock.get_form_by_token.return_value = form
module = sys.modules["controllers.openapi.human_input_form"]
monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock)
monkeypatch.setattr(module, "db", SimpleNamespace(engine=object()))
api = OpenApiWorkflowHumanInputFormApi()
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
caller = SimpleNamespace(id="anyone")
with app.test_request_context(
"/openapi/v1/apps/app-1/form/human_input/tok-1",
method="POST",
json={"action": "approve", "inputs": {}},
):
result = api.post.__wrapped__(
api,
app_id="app-1",
form_token="tok-1",
auth_data=_make_auth_data(app_model, caller, "end_user"),
)
service_mock.submit_form_by_token.assert_called_once()
assert result == ({}, 200)
def test_post_rejects_invalid_body_with_422(self, app: Flask, bypass_pipeline):
"""Malformed body → 422 via @accepts (was an unmapped pydantic error → 500)."""
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
@@ -63,23 +63,19 @@ def test_envelope_uses_pep695_generics():
def test_app_info_response_dump_matches_spec():
from controllers.openapi._models import AppInfoResponse
from controllers.openapi._models import AppInfo
obj = AppInfoResponse(
obj = AppInfo(
id="app1",
name="X",
description="d",
mode="chat",
author="alice",
tags=[{"name": "prod"}],
)
assert obj.model_dump(mode="json") == {
"id": "app1",
"name": "X",
"description": "d",
"mode": "chat",
"author": "alice",
"tags": [{"name": "prod"}],
}
@@ -91,8 +87,6 @@ def test_app_describe_response_nests_info_and_parameters():
name="X",
mode="chat",
description=None,
tags=[],
author=None,
updated_at="2026-05-05T00:00:00+00:00",
service_api_enabled=True,
)
@@ -29,7 +29,7 @@ from core.app.entities.task_entities import (
WorkflowPauseStreamResponse,
)
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
from core.workflow.human_input_policy import HumanInputSurface
from core.workflow.human_input_policy import FormDisposition, HumanInputSurface
from core.workflow.system_variables import build_system_variables
from graphon.entities import WorkflowStartReason
from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType
@@ -592,8 +592,10 @@ class TestHitlServiceApi:
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
monkeypatch.setattr(
workflow_response_converter,
"load_form_tokens_by_form_id",
lambda form_ids, session=None, surface=None: {"form-1": "token"},
"load_form_dispositions_by_form_id",
lambda form_ids, session=None, surface=None: {
"form-1": FormDisposition(form_token="token", approval_channels=[])
},
)
reason = HumanInputRequired(
@@ -652,8 +654,10 @@ class TestHitlServiceApi:
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
resumption_context = _build_resumption_context("task-ctx")
monkeypatch.setattr(
"services.workflow_event_snapshot_service.load_form_tokens_by_form_id",
lambda form_ids, session=None, surface=None: {"form-1": "wtok"},
"services.workflow_event_snapshot_service.load_form_dispositions_by_form_id",
lambda form_ids, session=None, surface=None: {
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
},
)
class _SessionContext:
@@ -175,6 +175,7 @@ class TestAdvancedChatGenerateTaskPipeline:
"actions": [{"id": "approve", "title": "Approve", "button_style": "default"}],
"display_in_ui": True,
"form_token": "token-1",
"approval_channels": [],
"resolved_default_values": {},
"expiration_time": 123,
}
@@ -26,6 +26,26 @@ from models.account import Account
from models.human_input import RecipientType
class _FakeSession:
"""Stub session: `execute` feeds the form-expiration query, `scalars` the recipients."""
def __init__(self, *, execute_rows=(), scalars_rows=()):
self._execute_rows = execute_rows
self._scalars_rows = scalars_rows
def execute(self, _stmt):
return list(self._execute_rows)
def scalars(self, _stmt):
return list(self._scalars_rows)
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
class _RecordingWorkflowAppRunner(WorkflowAppRunner):
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -97,11 +117,11 @@ def test_graph_run_paused_event_emits_queue_pause_event():
assert queue_event.paused_nodes == ["node-pause-1"]
def _build_converter():
def _build_converter(*, invoke_from: InvokeFrom = InvokeFrom.SERVICE_API):
application_generate_entity = SimpleNamespace(
inputs={},
files=[],
invoke_from=InvokeFrom.SERVICE_API,
invoke_from=invoke_from,
app_config=SimpleNamespace(app_id="app-id", tenant_id="tenant-id"),
)
system_variables = build_system_variables(
@@ -131,32 +151,15 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon
)
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
session = _FakeSession(
execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')],
scalars_rows=[
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.CONSOLE, access_token="console-token"),
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
],
)
class _FakeSession:
def execute(self, _stmt):
return [("form-1", expiration_time, '{"display_in_ui": true}')]
def scalars(self, _stmt):
return [
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.CONSOLE,
access_token="console-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.BACKSTAGE,
access_token="backstage-token",
),
]
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession())
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
reason = HumanInputRequired(
@@ -195,10 +198,92 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon
assert hi_resp.data.inputs[0].output_variable_name == "field"
assert hi_resp.data.actions[0].id == "approve"
assert hi_resp.data.display_in_ui is True
assert hi_resp.data.form_token == "backstage-token"
assert hi_resp.data.form_token is None
assert hi_resp.data.approval_channels == ["console"]
assert hi_resp.data.expiration_time == int(expiration_time.timestamp())
def _build_paused_human_input_response(monkeypatch, recipients):
"""Drive the live OPENAPI pause path with the given recipients via a fake session."""
converter = _build_converter(invoke_from=InvokeFrom.OPENAPI)
converter.workflow_start_to_stream_response(
task_id="task",
workflow_run_id="run-id",
workflow_id="workflow-id",
reason=WorkflowStartReason.INITIAL,
)
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
session = _FakeSession(
execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')],
scalars_rows=list(recipients),
)
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
reason = HumanInputRequired(
form_id="form-1",
form_content="Rendered",
inputs=[ParagraphInputConfig(output_variable_name="field")],
actions=[UserActionConfig(id="approve", title="Approve")],
node_id="node-id",
node_title="Human Step",
)
queue_event = QueueWorkflowPausedEvent(
reasons=[reason],
outputs={},
paused_nodes=["node-id"],
)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
responses = converter.workflow_pause_to_stream_response(
event=queue_event,
task_id="task",
graph_runtime_state=runtime_state,
)
assert isinstance(responses[0], HumanInputRequiredResponse)
return responses
def test_openapi_pause_without_web_app_recipient_emits_approval_channels(monkeypatch: pytest.MonkeyPatch):
responses = _build_paused_human_input_response(
monkeypatch,
recipients=[
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.EMAIL_MEMBER, access_token="email-token"),
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
],
)
hi_resp = responses[0]
assert hi_resp.data.form_token is None
assert hi_resp.data.approval_channels == ["console", "email"]
pause_resp = responses[-1]
assert pause_resp.data.reasons[0]["approval_channels"] == ["console", "email"]
def test_openapi_pause_with_web_app_recipient_sets_token_and_channels(monkeypatch: pytest.MonkeyPatch):
responses = _build_paused_human_input_response(
monkeypatch,
recipients=[
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
access_token="web-app-token",
),
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
],
)
hi_resp = responses[0]
assert hi_resp.data.form_token == "web-app-token"
assert hi_resp.data.approval_channels == ["console"]
pause_resp = responses[-1]
assert pause_resp.data.reasons[0]["approval_channels"] == ["console"]
def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatch: pytest.MonkeyPatch):
converter = _build_converter()
converter.workflow_start_to_stream_response(
@@ -209,21 +294,9 @@ def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatc
)
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
session = _FakeSession(execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')])
class _FakeSession:
def execute(self, _stmt):
return [("form-1", expiration_time, '{"display_in_ui": true}')]
def scalars(self, _stmt):
return []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession())
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
reason = HumanInputRequired(
@@ -134,6 +134,7 @@ class TestWorkflowGenerateTaskPipeline:
"actions": [],
"display_in_ui": False,
"form_token": None,
"approval_channels": [],
"resolved_default_values": {},
"expiration_time": 1,
}
@@ -32,6 +32,7 @@ from models.human_input import (
EmailMemberRecipientPayload,
HumanInputFormRecipient,
RecipientType,
StandaloneWebAppRecipientPayload,
)
@@ -307,6 +308,9 @@ class _DummyRecipient:
recipient_type: RecipientType
access_token: str
form: _DummyForm | None = None
recipient_payload: str = dataclasses.field(
default_factory=lambda: StandaloneWebAppRecipientPayload().model_dump_json()
)
class _FakeScalarResult:
@@ -0,0 +1,63 @@
import pytest
from core.workflow.human_input_policy import FormDisposition, enrich_human_input_pause_reasons
from graphon.entities.pause_reason import PauseReasonType
_HUMAN_INPUT_REASON = {"TYPE": PauseReasonType.HUMAN_INPUT_REQUIRED, "form_id": "f1"}
@pytest.mark.parametrize(
("dispositions", "expected_token", "expected_channels"),
[
({"f1": FormDisposition(form_token=None, approval_channels=["console", "email"])}, None, ["console", "email"]),
({"f1": FormDisposition(form_token="tok", approval_channels=[])}, "tok", []),
# form_id absent from the map (no recipient rows) falls back to no token, no channels.
({}, None, []),
],
)
def test_enrich_projects_disposition_onto_reason(dispositions, expected_token, expected_channels):
out = enrich_human_input_pause_reasons(
[dict(_HUMAN_INPUT_REASON)],
dispositions_by_form_id=dispositions,
expiration_times_by_form_id={},
)
assert out[0]["form_token"] == expected_token
assert out[0]["approval_channels"] == expected_channels
def test_enrich_leaves_non_human_input_reasons_untouched():
reason = {"TYPE": "something_else", "form_id": "f1"}
out = enrich_human_input_pause_reasons(
[reason],
dispositions_by_form_id={"f1": FormDisposition(form_token="tok", approval_channels=["email"])},
expiration_times_by_form_id={},
)
assert out[0] == reason
assert "form_token" not in out[0]
assert "approval_channels" not in out[0]
def test_pause_reason_payload_carries_approval_channels_through_factory():
# from_response_data maps fields by hand; this guards approval_channels/form_token
# (the fields this feature added) against being dropped in that mapping.
from core.app.entities.task_entities import (
HumanInputRequiredPauseReasonPayload,
HumanInputRequiredResponse,
)
data = HumanInputRequiredResponse.Data(
form_id="f",
node_id="n",
node_title="t",
form_content="c",
expiration_time=123,
form_token=None,
approval_channels=["console"],
)
payload = HumanInputRequiredPauseReasonPayload.from_response_data(data)
assert payload.approval_channels == ["console"]
assert payload.form_token is None
@@ -1,7 +1,16 @@
from types import SimpleNamespace
from core.workflow.human_input_forms import _load_form_tokens_by_form_id, load_form_tokens_by_form_id
from core.workflow.human_input_policy import HumanInputSurface
import pytest
from core.workflow.human_input_forms import (
load_form_dispositions_by_form_id,
load_form_tokens_by_form_id,
)
from core.workflow.human_input_policy import (
FormDisposition,
HumanInputSurface,
disposition_for_surface,
)
from models.human_input import RecipientType
@@ -13,91 +22,100 @@ class _FakeSession:
return self._recipients
def test_load_form_tokens_by_form_id_prefers_backstage_token() -> None:
def _recipient(form_id: str, recipient_type: RecipientType, access_token: str | None) -> SimpleNamespace:
return SimpleNamespace(form_id=form_id, recipient_type=recipient_type, access_token=access_token)
@pytest.mark.parametrize(
("surface", "expected_token"),
[
# Unfiltered (no surface) picks the highest-priority recipient: backstage.
(None, "backstage-token"),
# SERVICE_API may only act on the web-app recipient.
(HumanInputSurface.SERVICE_API, "web-token"),
],
)
def test_load_form_tokens_picks_token_for_surface(surface, expected_token) -> None:
session = _FakeSession(
recipients=[
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
access_token="web-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.CONSOLE,
access_token="console-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.BACKSTAGE,
access_token="backstage-token",
),
[
_recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"),
_recipient("form-1", RecipientType.CONSOLE, "console-token"),
_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"),
]
)
assert load_form_tokens_by_form_id(["form-1"], session=session) == {"form-1": "backstage-token"}
assert load_form_tokens_by_form_id(["form-1"], session=session, surface=surface) == {"form-1": expected_token}
def test_load_form_tokens_by_form_id_ignores_unsupported_recipients() -> None:
def test_load_form_tokens_drops_forms_without_actionable_token() -> None:
session = _FakeSession(
recipients=[
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.EMAIL_MEMBER,
access_token="email-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.CONSOLE,
access_token=None,
),
[
_recipient("form-1", RecipientType.EMAIL_MEMBER, "email-token"),
_recipient("form-1", RecipientType.CONSOLE, None),
]
)
assert load_form_tokens_by_form_id(["form-1"], session=session) == {}
def test_load_form_tokens_by_form_id_uses_shared_priority() -> None:
def test_load_form_tokens_service_api_surface_uses_web_token() -> None:
session = _FakeSession(
recipients=[
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
access_token="web-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.CONSOLE,
access_token="console-token",
),
[
_recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"),
_recipient("form-1", RecipientType.CONSOLE, "console-token"),
_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"),
]
)
assert _load_form_tokens_by_form_id(session, ["form-1"]) == {"form-1": "console-token"}
assert load_form_tokens_by_form_id(["form-1"], session=session, surface=HumanInputSurface.SERVICE_API) == {
"form-1": "web-token"
}
def test_load_form_tokens_by_form_id_uses_web_token_for_service_api_surface() -> None:
def test_load_dispositions_openapi_webapp_form_is_resumable() -> None:
session = _FakeSession(
recipients=[
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
access_token="web-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.CONSOLE,
access_token="console-token",
),
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.BACKSTAGE,
access_token="backstage-token",
),
[
_recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"),
_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"),
]
)
assert load_form_tokens_by_form_id(
["form-1"],
session=session,
surface=HumanInputSurface.SERVICE_API,
) == {"form-1": "web-token"}
assert load_form_dispositions_by_form_id(["form-1"], session=session, surface=HumanInputSurface.OPENAPI) == {
"form-1": FormDisposition(form_token="web-token", approval_channels=["console"])
}
def test_load_dispositions_openapi_backstage_only_form_yields_channels_not_token() -> None:
session = _FakeSession([_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token")])
assert load_form_dispositions_by_form_id(["form-1"], session=session, surface=HumanInputSurface.OPENAPI) == {
"form-1": FormDisposition(form_token=None, approval_channels=["console"])
}
# disposition_for_surface partitions recipients into a surface-actionable resume
# token plus the approval channels of the recipients the surface may NOT act on.
_WEB = (RecipientType.STANDALONE_WEB_APP, "tok_web")
_BACKSTAGE = (RecipientType.BACKSTAGE, "tok_b")
_CONSOLE = (RecipientType.CONSOLE, "tok_c")
_EMAIL_MEMBER = (RecipientType.EMAIL_MEMBER, "t1")
_EMAIL_EXTERNAL = (RecipientType.EMAIL_EXTERNAL, "t2")
@pytest.mark.parametrize(
("recipients", "surface", "expected"),
[
# Token surface acts on the web-app recipient; blocked recipients become channels.
([_BACKSTAGE, _WEB], HumanInputSurface.OPENAPI, FormDisposition("tok_web", ["console"])),
([_EMAIL_MEMBER, _EMAIL_EXTERNAL], HumanInputSurface.OPENAPI, FormDisposition(None, ["email"])),
([_EMAIL_MEMBER, _BACKSTAGE], HumanInputSurface.OPENAPI, FormDisposition(None, ["console", "email"])),
# CONSOLE acts on console/backstage; a web-app recipient is blocked → web_app channel.
([_CONSOLE, _WEB], HumanInputSurface.CONSOLE, FormDisposition("tok_c", ["web_app"])),
([_WEB], HumanInputSurface.CONSOLE, FormDisposition(None, ["web_app"])),
# No surface: unfiltered priority token, channels never populated.
([_BACKSTAGE], None, FormDisposition("tok_b", [])),
([_WEB, _EMAIL_MEMBER], None, FormDisposition("tok_web", [])),
],
)
def test_disposition_for_surface_partitions_token_and_channels(recipients, surface, expected) -> None:
assert disposition_for_surface(recipients, surface=surface) == expected
@@ -12,38 +12,28 @@ from graphon.runtime import VariablePool
from models.human_input import RecipientType
def test_service_api_only_allows_public_webapp_forms() -> None:
assert is_recipient_type_allowed_for_surface(
RecipientType.STANDALONE_WEB_APP,
HumanInputSurface.SERVICE_API,
)
assert not is_recipient_type_allowed_for_surface(
RecipientType.CONSOLE,
HumanInputSurface.SERVICE_API,
)
assert not is_recipient_type_allowed_for_surface(
RecipientType.BACKSTAGE,
HumanInputSurface.SERVICE_API,
)
assert not is_recipient_type_allowed_for_surface(
RecipientType.EMAIL_MEMBER,
HumanInputSurface.SERVICE_API,
)
def test_console_only_allows_internal_console_surfaces() -> None:
assert is_recipient_type_allowed_for_surface(
RecipientType.CONSOLE,
HumanInputSurface.CONSOLE,
)
assert is_recipient_type_allowed_for_surface(
RecipientType.BACKSTAGE,
HumanInputSurface.CONSOLE,
)
assert not is_recipient_type_allowed_for_surface(
RecipientType.STANDALONE_WEB_APP,
HumanInputSurface.CONSOLE,
)
# Token surfaces (SERVICE_API, OPENAPI) may act only on public web-app forms;
# CONSOLE may act on internal console/backstage forms. OPENAPI mirrors SERVICE_API
# today but is pinned independently because the two are expected to diverge.
@pytest.mark.parametrize(
("recipient_type", "surface", "allowed"),
[
(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.SERVICE_API, True),
(RecipientType.CONSOLE, HumanInputSurface.SERVICE_API, False),
(RecipientType.BACKSTAGE, HumanInputSurface.SERVICE_API, False),
(RecipientType.EMAIL_MEMBER, HumanInputSurface.SERVICE_API, False),
(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.OPENAPI, True),
(RecipientType.CONSOLE, HumanInputSurface.OPENAPI, False),
(RecipientType.BACKSTAGE, HumanInputSurface.OPENAPI, False),
(RecipientType.CONSOLE, HumanInputSurface.CONSOLE, True),
(RecipientType.BACKSTAGE, HumanInputSurface.CONSOLE, True),
(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.CONSOLE, False),
],
)
def test_recipient_type_allowed_per_surface(
recipient_type: RecipientType, surface: HumanInputSurface, allowed: bool
) -> None:
assert is_recipient_type_allowed_for_surface(recipient_type, surface) is allowed
def test_preferred_form_token_uses_shared_priority_order() -> None:
@@ -56,6 +46,17 @@ def test_preferred_form_token_uses_shared_priority_order() -> None:
assert get_preferred_form_token(recipients) == "backstage-token"
def test_preferred_form_token_skips_prioritized_type_with_empty_token() -> None:
# An empty token is not actionable: the highest-priority recipient that
# actually carries a token wins, not the highest-priority type.
recipients = [
(RecipientType.BACKSTAGE, ""),
(RecipientType.CONSOLE, "console-token"),
]
assert get_preferred_form_token(recipients) == "console-token"
def test_resolve_variable_select_input_options_uses_runtime_values() -> None:
variable_pool = VariablePool()
variable_pool.add(("start", "options"), ["approve", "reject"])
@@ -1,34 +0,0 @@
"""Tests for OPENAPI surface in HumanInputPolicy and human_input_forms."""
from __future__ import annotations
from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface
from models.human_input import RecipientType
def test_openapi_surface_exists():
assert HumanInputSurface.OPENAPI == "openapi"
def test_openapi_allows_standalone_web_app():
assert is_recipient_type_allowed_for_surface(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.OPENAPI)
def test_openapi_rejects_console_recipient():
assert not is_recipient_type_allowed_for_surface(RecipientType.CONSOLE, HumanInputSurface.OPENAPI)
def test_openapi_rejects_backstage_recipient():
assert not is_recipient_type_allowed_for_surface(RecipientType.BACKSTAGE, HumanInputSurface.OPENAPI)
def test_get_surface_form_token_openapi_picks_standalone_web_app():
"""OPENAPI surface should pick STANDALONE_WEB_APP token, same as SERVICE_API."""
from core.workflow.human_input_forms import _get_surface_form_token
recipients = [
(RecipientType.BACKSTAGE, "backstage-token"),
(RecipientType.STANDALONE_WEB_APP, "web-token"),
]
token = _get_surface_form_token(recipients, surface=HumanInputSurface.OPENAPI)
assert token == "web-token"
@@ -33,6 +33,7 @@ def test_agent_enums_match_prd_boundaries():
assert AgentStatus.ACTIVE.value == "active"
assert AgentStatus.ARCHIVED.value == "archived"
assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION.value == "save_current_version"
assert AgentConfigRevisionOperation.RESTORE_VERSION.value == "restore_version"
assert WorkflowAgentBindingType.ROSTER_AGENT.value == "roster_agent"
assert WorkflowAgentBindingType.INLINE_AGENT.value == "inline_agent"
@@ -0,0 +1,21 @@
import pytest
from models.human_input import ApprovalChannel, RecipientType
@pytest.mark.parametrize(
("recipient_type", "expected_channel"),
[
(RecipientType.EMAIL_MEMBER, ApprovalChannel.EMAIL),
(RecipientType.EMAIL_EXTERNAL, ApprovalChannel.EMAIL),
(RecipientType.CONSOLE, ApprovalChannel.CONSOLE),
(RecipientType.BACKSTAGE, ApprovalChannel.CONSOLE),
(RecipientType.STANDALONE_WEB_APP, ApprovalChannel.WEB_APP),
],
)
def test_approval_channel_collapses_delivery_types(
recipient_type: RecipientType, expected_channel: ApprovalChannel
) -> None:
# Both email types collapse to EMAIL and console/backstage to CONSOLE:
# the user-facing approval channel, not the internal recipient type.
assert recipient_type.approval_channel == expected_channel
@@ -1045,12 +1045,93 @@ def test_agent_app_visible_versions_exclude_draft_saves():
agent_app_operations = AgentRosterService._visible_version_operations(agent_app)
roster_operations = AgentRosterService._visible_version_operations(roster_agent)
assert agent_app_operations == {AgentConfigRevisionOperation.SAVE_NEW_VERSION}
assert agent_app_operations == {
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
AgentConfigRevisionOperation.RESTORE_VERSION,
}
assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in agent_app_operations
assert AgentConfigRevisionOperation.CREATE_VERSION in roster_operations
assert AgentConfigRevisionOperation.RESTORE_VERSION in roster_operations
assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in roster_operations
def test_restore_roster_agent_version_switches_active_snapshot(monkeypatch: pytest.MonkeyPatch):
fake_session = FakeSession(scalar=["version-2", 6])
service = AgentRosterService(fake_session)
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
name="Analyst",
description="old",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="version-4",
)
version = AgentConfigSnapshot(
id="version-2",
tenant_id="tenant-1",
agent_id="agent-1",
version=2,
config_snapshot=_agent_soul_with_model(),
)
monkeypatch.setattr(service, "_get_agent", lambda **kwargs: agent)
monkeypatch.setattr(service, "_get_version", lambda **kwargs: version)
restored = service.restore_agent_version(
tenant_id="tenant-1",
agent_id="agent-1",
version_id="version-2",
account_id="account-1",
)
assert restored == {"result": "success", "active_config_snapshot_id": "version-2"}
assert agent.active_config_snapshot_id == "version-2"
assert agent.active_config_has_model is True
assert agent.updated_by == "account-1"
assert fake_session.commits == 1
revision = fake_session.added[0]
assert revision.tenant_id == "tenant-1"
assert revision.agent_id == "agent-1"
assert revision.previous_snapshot_id == "version-4"
assert revision.current_snapshot_id == "version-2"
assert revision.revision == 7
assert revision.operation == AgentConfigRevisionOperation.RESTORE_VERSION
assert revision.created_by == "account-1"
def test_restore_roster_agent_version_rejects_invisible_versions(monkeypatch: pytest.MonkeyPatch):
fake_session = FakeSession(scalar=[None])
service = AgentRosterService(fake_session)
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
name="Analyst",
description="old",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
active_config_snapshot_id="version-4",
)
monkeypatch.setattr(service, "_get_agent", lambda **kwargs: agent)
with pytest.raises(roster_service.AgentVersionNotFoundError):
service.restore_agent_version(
tenant_id="tenant-1",
agent_id="agent-1",
version_id="version-2",
account_id="account-1",
)
assert agent.active_config_snapshot_id == "version-4"
assert fake_session.added == []
assert fake_session.commits == 0
def test_app_list_all_excludes_agent_apps_by_default():
filters = AppService._build_app_list_filters(
"account-1", "tenant-1", AppListParams(mode="all"), FakeSession(scalar=None, scalars=None)
@@ -83,6 +83,7 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits():
assert items[0].is_skill is True
assert items[0].skill_metadata is not None
assert items[0].skill_metadata.name == "PDF Toolkit"
assert items[0].skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"]
assert items[1].is_skill is False
# The returned upload response carries only the drive-derived fields the UI needs.
@@ -620,6 +620,45 @@ class TestMyPermissions:
assert out.dataset.default_permission_keys == dataset_keys
assert out.app.overrides == []
assert out.dataset.overrides == []
if role == "owner":
assert "billing.view" in out.workspace.permission_keys
assert "snippets.management" in out.workspace.permission_keys
assert "app.acl.preview" in out.workspace.permission_keys
assert "dataset.acl.preview" in out.workspace.permission_keys
assert "app.acl.preview" in out.app.default_permission_keys
assert "dataset.acl.preview" in out.dataset.default_permission_keys
@pytest.mark.parametrize(
("role", "expected_snippet_keys"),
[
("owner", {"snippets.create_and_modify", "snippets.management"}),
("admin", {"snippets.create_and_modify", "snippets.management"}),
("editor", {"snippets.create_and_modify"}),
("normal", set()),
("dataset_operator", set()),
],
)
def test_get_uses_legacy_snippet_permissions_when_rbac_disabled(
self,
mock_send: MagicMock,
role: str,
expected_snippet_keys: set[str],
):
mock_session = MagicMock()
mock_session.__enter__.return_value = mock_session
mock_session.scalar.return_value = role
with (
patch(f"{MODULE}.dify_config.RBAC_ENABLED", False),
patch(f"{MODULE}.session_factory.create_session", return_value=mock_session),
):
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1")
actual_snippet_keys = {
permission_key for permission_key in out.workspace.permission_keys if permission_key.startswith("snippets.")
}
mock_send.assert_not_called()
assert actual_snippet_keys == expected_snippet_keys
def test_get_returns_empty_when_role_missing_and_rbac_disabled(self, mock_send: MagicMock):
mock_session = MagicMock()
@@ -668,7 +707,8 @@ class TestMemberRoles:
}
],
}
out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2")
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True):
out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2")
call = _call_args(mock_send)
assert call.method == "GET"
assert call.endpoint == "/rbac/members/rbac-roles"
@@ -676,6 +716,33 @@ class TestMemberRoles:
assert out.account_id == "acct-2"
assert out.roles[0].name == "Member"
def test_get_legacy_role_includes_permission_keys(self, mock_send: MagicMock):
session = MagicMock()
session.scalar.return_value = svc.TenantAccountRole.EDITOR
with (
patch(f"{MODULE}.dify_config.RBAC_ENABLED", False),
patch(f"{MODULE}.session_factory.create_session") as create_session,
):
create_session.return_value.__enter__.return_value = session
out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2")
mock_send.assert_not_called()
assert out.account_id == "acct-2"
assert out.roles[0].name == "editor"
assert out.roles[0].permission_keys == list(
dict.fromkeys(
[
*svc._LEGACY_WORKSPACE_EDITOR_KEYS,
*svc._LEGACY_APP_EDITOR_KEYS,
*svc._LEGACY_DATASET_EDITOR_KEYS,
]
)
)
assert "snippets.create_and_modify" in out.roles[0].permission_keys
assert "app.acl.preview" in out.roles[0].permission_keys
assert "dataset.acl.preview" in out.roles[0].permission_keys
def test_replace(self, mock_send: MagicMock):
mock_send.return_value = {"account_id": "acct-2", "roles": []}
svc.RBACService.MemberRoles.replace(
@@ -704,3 +704,104 @@ def test_manifest_items_carry_created_at_for_inspector():
_commit("files/x.txt", tf)
items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT)
assert items[0]["created_at"] is None or isinstance(items[0]["created_at"], int)
# ── DIFY-2517: skill catalog / inspect ───────────────────────────────────────
def _commit_skill(*, manifest_files: list[str] | None = None) -> None:
md = _seed_tool_file(name="SKILL.md")
zf = _seed_tool_file(name="full.zip")
AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key="pdf-toolkit/SKILL.md",
file_ref={"kind": "tool_file", "id": md},
value_owned_by_drive=True,
is_skill=True,
skill_metadata=DriveSkillMetadata(
name="PDF Toolkit",
description="Work with PDFs.",
manifest_files=manifest_files,
),
),
DriveCommitItem(
key="pdf-toolkit/.DIFY-SKILL-FULL.zip",
file_ref={"kind": "tool_file", "id": zf},
value_owned_by_drive=True,
),
],
)
def test_list_skills_uses_canonical_skill_rows():
_commit_skill(manifest_files=["SKILL.md", "scripts/run.py"])
skills = AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT)
created_at = skills[0].pop("created_at")
assert skills == [
{
"path": "pdf-toolkit",
"skill_md_key": "pdf-toolkit/SKILL.md",
"archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip",
"name": "PDF Toolkit",
"description": "Work with PDFs.",
"size": 5,
"mime_type": "text/plain",
"hash": None,
}
]
assert created_at is None or isinstance(created_at, int)
def test_inspect_skill_returns_manifest_files_and_file_tree():
_commit_skill(manifest_files=["SKILL.md", "references/guide.md", "scripts/run.py"])
with patch("services.agent_drive_service.storage") as storage_mock:
storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"])
result = AgentDriveService().inspect_skill(tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit")
assert result["source"] == "skill_md"
assert result["warnings"] == []
assert [file["path"] for file in result["files"]] == ["SKILL.md", "references/guide.md", "scripts/run.py"]
assert result["files"][0]["available_in_drive"] is True
assert result["files"][1]["available_in_drive"] is False
assert result["file_tree"][0]["name"] == "references"
assert result["file_tree"][1]["name"] == "scripts"
assert result["file_tree"][2]["name"] == "SKILL.md"
assert result["skill_md"]["text"] == "# PDF Toolkit\n"
def test_inspect_skill_falls_back_to_drive_keys_when_manifest_missing():
_commit_skill(manifest_files=None)
with patch("services.agent_drive_service.storage") as storage_mock:
storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"])
result = AgentDriveService().inspect_skill(tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit")
assert result["warnings"] == ["manifest_files_unavailable"]
assert [file["path"] for file in result["files"]] == ["SKILL.md"]
def test_skill_metadata_rejects_non_canonical_rows():
tf = _seed_tool_file(name="not-skill.md")
with pytest.raises(AgentDriveError) as exc_info:
AgentDriveService().commit(
tenant_id=TENANT,
user_id=USER,
agent_id=AGENT,
items=[
DriveCommitItem(
key="files/not-skill.md",
file_ref={"kind": "tool_file", "id": tf},
value_owned_by_drive=True,
is_skill=True,
skill_metadata=DriveSkillMetadata(name="Bad"),
)
],
)
assert exc_info.value.code == "invalid_skill_key"
@@ -1,4 +1,5 @@
import dataclasses
import logging
from datetime import datetime, timedelta
from unittest.mock import MagicMock
@@ -672,7 +673,7 @@ def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, mock_session_f
assert "WorkflowRun not found" in str(excinfo.value)
def test_enqueue_resume_app_not_found(mocker: MockerFixture, mock_session_factory):
def test_enqueue_resume_app_not_found(mocker, mock_session_factory, caplog):
session_factory, session = mock_session_factory
service = HumanInputService(session_factory)
@@ -687,10 +688,10 @@ def test_enqueue_resume_app_not_found(mocker: MockerFixture, mock_session_factor
)
session.execute.return_value.scalar_one_or_none.return_value = None
logger_spy = mocker.patch("services.human_input_service.logger")
service.enqueue_resume("workflow-run-id")
logger_spy.error.assert_called_once()
with caplog.at_level(logging.ERROR, logger="services.human_input_service"):
service.enqueue_resume("workflow-run-id")
assert any(r.levelno >= logging.ERROR for r in caplog.records)
def test_is_globally_expired_zero_timeout(
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
import httpx
import pytest
from services.operation_service import OperationService
from services.operation_service import OPERATION_REQUEST_TIMEOUT, OperationService
class TestOperationService:
@@ -44,6 +44,7 @@ class TestOperationService:
assert kwargs["json"] == json_data
assert kwargs["headers"]["Billing-Api-Secret-Key"] == "s3cr3t"
assert kwargs["headers"]["Content-Type"] == "application/json"
assert kwargs["timeout"] == OPERATION_REQUEST_TIMEOUT
@patch("httpx.request")
def test_should_propagate_httpx_error_when__send_request_raises(
@@ -2,6 +2,7 @@
from __future__ import annotations
import logging
import sys
from dataclasses import dataclass
from datetime import UTC, datetime
@@ -532,7 +533,10 @@ def test_vectorize_summary_error_handler_tries_chunk_id_lookup_and_can_warn_not_
error_session.commit.assert_not_called()
def test_update_summary_record_error_warns_when_missing(monkeypatch: pytest.MonkeyPatch) -> None:
def test_update_summary_record_error_warns_when_missing(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
dataset = _dataset()
segment = _segment()
@@ -544,14 +548,15 @@ def test_update_summary_record_error_warns_when_missing(monkeypatch: pytest.Monk
SimpleNamespace(create_session=MagicMock(return_value=_SessionContext(session))),
)
logger_mock = MagicMock()
monkeypatch.setattr(summary_module, "logger", logger_mock)
SummaryIndexService.update_summary_record_error(segment, dataset, "err")
logger_mock.warning.assert_called_once()
with caplog.at_level(logging.WARNING, logger="services.summary_index_service"):
SummaryIndexService.update_summary_record_error(segment, dataset, "err")
assert any(r.levelno >= logging.WARNING for r in caplog.records)
def test_generate_and_vectorize_summary_creates_missing_record_and_logs_usage(monkeypatch: pytest.MonkeyPatch) -> None:
def test_generate_and_vectorize_summary_creates_missing_record_and_logs_usage(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
dataset = _dataset()
segment = _segment()
@@ -567,12 +572,10 @@ def test_generate_and_vectorize_summary_creates_missing_record_and_logs_usage(mo
monkeypatch.setattr(SummaryIndexService, "generate_summary_for_segment", MagicMock(return_value=("sum", usage)))
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", MagicMock(return_value=None))
logger_mock = MagicMock()
monkeypatch.setattr(summary_module, "logger", logger_mock)
result = SummaryIndexService.generate_and_vectorize_summary(segment, dataset, {"enable": True})
assert result.status in {SummaryStatus.GENERATING, SummaryStatus.COMPLETED}
logger_mock.info.assert_called()
with caplog.at_level(logging.INFO, logger="services.summary_index_service"):
result = SummaryIndexService.generate_and_vectorize_summary(segment, dataset, {"enable": True})
assert result.status in {SummaryStatus.GENERATING, SummaryStatus.COMPLETED}
assert any(r.levelno >= logging.INFO for r in caplog.records)
def test_generate_summaries_for_document_skip_conditions(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -759,6 +762,7 @@ def test_enable_summaries_for_segments_no_summaries_noop(monkeypatch: pytest.Mon
def test_enable_summaries_for_segments_skips_segment_or_content_and_handles_vectorize_error(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
dataset = _dataset()
summary1 = _summary_record(summary_content="sum", node_id="n1")
@@ -786,12 +790,11 @@ def test_enable_summaries_for_segments_skips_segment_or_content_and_handles_vect
SimpleNamespace(create_session=MagicMock(return_value=_SessionContext(session))),
)
logger_mock = MagicMock()
monkeypatch.setattr(summary_module, "logger", logger_mock)
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", MagicMock(side_effect=RuntimeError("boom")))
SummaryIndexService.enable_summaries_for_segments(dataset)
logger_mock.exception.assert_called_once()
with caplog.at_level(logging.ERROR, logger="services.summary_index_service"):
SummaryIndexService.enable_summaries_for_segments(dataset)
assert any(r.levelno >= logging.ERROR for r in caplog.records)
session.commit.assert_called_once()
@@ -859,7 +862,10 @@ def test_update_summary_for_segment_empty_content_deletes_existing(monkeypatch:
session.commit.assert_called_once()
def test_update_summary_for_segment_empty_content_delete_vector_warns(monkeypatch: pytest.MonkeyPatch) -> None:
def test_update_summary_for_segment_empty_content_delete_vector_warns(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
dataset = _dataset()
segment = _segment()
record = _summary_record(summary_content="old", node_id="n1")
@@ -875,11 +881,10 @@ def test_update_summary_for_segment_empty_content_delete_vector_warns(monkeypatc
vector_instance = MagicMock()
vector_instance.delete_by_ids.side_effect = RuntimeError("boom")
monkeypatch.setattr(summary_module, "Vector", MagicMock(return_value=vector_instance))
logger_mock = MagicMock()
monkeypatch.setattr(summary_module, "logger", logger_mock)
assert SummaryIndexService.update_summary_for_segment(segment, dataset, "") is None
logger_mock.warning.assert_called()
with caplog.at_level(logging.WARNING, logger="services.summary_index_service"):
assert SummaryIndexService.update_summary_for_segment(segment, dataset, "") is None
assert any(r.levelno >= logging.WARNING for r in caplog.records)
def test_update_summary_for_segment_empty_content_no_record_noop(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -923,7 +928,10 @@ def test_update_summary_for_segment_updates_existing_and_vectorizes(monkeypatch:
session.commit.assert_called()
def test_update_summary_for_segment_existing_vector_delete_warns(monkeypatch: pytest.MonkeyPatch) -> None:
def test_update_summary_for_segment_existing_vector_delete_warns(
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
dataset = _dataset()
segment = _segment()
record = _summary_record(summary_content="old", node_id="n1")
@@ -940,11 +948,10 @@ def test_update_summary_for_segment_existing_vector_delete_warns(monkeypatch: py
vector_instance.delete_by_ids.side_effect = RuntimeError("boom")
monkeypatch.setattr(summary_module, "Vector", MagicMock(return_value=vector_instance))
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", MagicMock(return_value=None))
logger_mock = MagicMock()
monkeypatch.setattr(summary_module, "logger", logger_mock)
SummaryIndexService.update_summary_for_segment(segment, dataset, "new")
logger_mock.warning.assert_called()
with caplog.at_level(logging.WARNING, logger="services.summary_index_service"):
SummaryIndexService.update_summary_for_segment(segment, dataset, "new")
assert any(r.levelno >= logging.WARNING for r in caplog.records)
def test_update_summary_for_segment_existing_vectorize_failure_returns_error_record(
@@ -253,7 +253,7 @@ def test_add_trigger_subscription_should_raise_error_when_provider_limit_reached
mock_session: MagicMock,
provider_id: TriggerProviderID,
provider_controller: MagicMock,
caplog,
caplog: pytest.LogCaptureFixture,
) -> None:
# Arrange
_patch_redis_lock(mocker)
@@ -274,7 +274,7 @@ def test_add_trigger_subscription_should_raise_error_when_provider_limit_reached
properties={},
credentials={},
)
assert sum(1 for r in caplog.records if r.levelno >= logging.ERROR) == 1
assert any(r.levelno >= logging.ERROR for r in caplog.records)
def test_add_trigger_subscription_should_raise_error_when_name_exists(
@@ -269,7 +269,8 @@ def test_create_segments_vector_parent_child_uses_default_embedding_model_when_p
def test_create_segments_vector_parent_child_missing_document_logs_warning_and_continues(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
dataset = _make_dataset(doc_form=vector_service_module.IndexStructureType.PARENT_CHILD_INDEX)
segment = _make_segment()
@@ -290,7 +291,7 @@ def test_create_segments_vector_parent_child_missing_document_logs_warning_and_c
VectorService.create_segments_vector(
None, [segment], dataset, vector_service_module.IndexStructureType.PARENT_CHILD_INDEX
)
assert "Expected DatasetDocument record to exist, but none was found" in caplog.text
assert any(r.levelno >= logging.WARNING for r in caplog.records)
index_processor.load.assert_not_called()
@@ -614,7 +615,8 @@ def test_update_multimodel_vector_commits_when_no_upload_files_found(monkeypatch
def test_update_multimodel_vector_adds_bindings_and_vectors_and_skips_missing_upload_files(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=True)
segment = _make_segment(segment_id="seg-1", tenant_id="tenant-1", attachments=[{"id": "old-1"}])
@@ -631,8 +633,7 @@ def test_update_multimodel_vector_adds_bindings_and_vectors_and_skips_missing_up
with caplog.at_level(logging.WARNING, logger="services.vector_service"):
VectorService.update_multimodel_vector(segment=segment, attachment_ids=["file-1", "missing"], dataset=dataset)
assert "Upload file not found for attachment_id" in caplog.text
assert any(r.levelno >= logging.WARNING for r in caplog.records)
db_mock.session.add_all.assert_called_once()
bindings = db_mock.session.add_all.call_args.args[0]
assert len(bindings) == 1
@@ -671,7 +672,8 @@ def test_update_multimodel_vector_updates_bindings_without_multimodal_vector_ops
def test_update_multimodel_vector_rolls_back_and_reraises_on_error(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=True)
segment = _make_segment(segment_id="seg-1", tenant_id="tenant-1", attachments=[{"id": "old-1"}])
@@ -691,7 +693,5 @@ def test_update_multimodel_vector_rolls_back_and_reraises_on_error(
with pytest.raises(RuntimeError, match="boom"):
VectorService.update_multimodel_vector(segment=segment, attachment_ids=["file-1"], dataset=dataset)
exception_records = [r for r in caplog.records if r.levelname == "ERROR"]
assert len(exception_records) == 1
assert "Failed to update multimodal vector for segment" in exception_records[0].getMessage()
assert any(r.levelno >= logging.ERROR for r in caplog.records)
db_mock.session.rollback.assert_called_once()
@@ -1,3 +1,4 @@
import logging
from types import SimpleNamespace
from typing import Any
from unittest.mock import MagicMock
@@ -31,21 +32,21 @@ class TestWebhookServiceExtractionFallbacks:
self,
flask_app: Flask,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
warning_mock = MagicMock()
monkeypatch.setattr(service_module.logger, "warning", warning_mock)
webhook_trigger = MagicMock()
with flask_app.test_request_context(
"/webhook",
method="POST",
headers={"Content-Type": "application/vnd.custom"},
data="plain content",
):
result = WebhookService.extract_webhook_data(webhook_trigger)
with caplog.at_level(logging.WARNING, logger="services.trigger.webhook_service"):
with flask_app.test_request_context(
"/webhook",
method="POST",
headers={"Content-Type": "application/vnd.custom"},
data="plain content",
):
result = WebhookService.extract_webhook_data(webhook_trigger)
assert result["body"] == {"raw": "plain content"}
warning_mock.assert_called_once()
assert result["body"] == {"raw": "plain content"}
assert any(r.levelno >= logging.WARNING for r in caplog.records)
def test_extract_webhook_data_should_raise_for_request_too_large(
self,
@@ -171,14 +172,13 @@ class TestWebhookServiceValidationAndConversion:
def test_validate_json_value_should_return_original_for_unmapped_supported_segment_type(
self,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
) -> None:
warning_mock = MagicMock()
monkeypatch.setattr(service_module.logger, "warning", warning_mock)
with caplog.at_level(logging.WARNING, logger="services.trigger.webhook_service"):
result = WebhookService._validate_json_value("param", {"x": 1}, "unsupported-type")
result = WebhookService._validate_json_value("param", {"x": 1}, "unsupported-type")
assert result == {"x": 1}
warning_mock.assert_called_once()
assert result == {"x": 1}
assert any(r.levelno >= logging.WARNING for r in caplog.records)
def test_validate_and_convert_value_should_wrap_conversion_errors(self) -> None:
with pytest.raises(ValueError, match="validation failed"):
@@ -16,12 +16,14 @@ from core.app.app_config.entities import WorkflowUIBasedAppConfig
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
from core.app.entities.task_entities import StreamEvent
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
from core.workflow.human_input_policy import FormDisposition, HumanInputSurface
from graphon.entities.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource
from graphon.nodes.human_input.enums import ValueSourceType
from graphon.runtime import GraphRuntimeState, VariablePool
from models.enums import CreatorUserRole
from models.human_input import RecipientType
from models.model import AppMode
from models.workflow import WorkflowRun
from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot
@@ -763,7 +765,11 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
resumption_context = _build_resumption_context("task-ctx")
monkeypatch.setattr(
service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"}
service_module,
"load_form_dispositions_by_form_id",
lambda form_ids, session=None, surface=None: {
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
},
)
session_maker = _SessionMaker(
SimpleNamespace(
@@ -803,12 +809,99 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M
assert pause_data["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp())
def _build_recipient_snapshot_events(recipients: Sequence[Any]) -> list[Mapping[str, Any]]:
"""Drive the reconnect snapshot pause path for the OPENAPI surface.
Lets the real disposition loader run against a fake session whose ``scalars``
yields the given recipients, so the reconnect path derives the same token and
approval channels as the live path for the same recipient set.
"""
workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED)
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
resumption_context = _build_resumption_context("task-ctx")
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
session_maker = _SessionMaker(
SimpleNamespace(
execute=lambda _stmt: [("form-1", expiration_time, '{"display_in_ui": true}')],
scalars=lambda _stmt: list(recipients),
)
)
pause_entity = _FakePauseEntity(
pause_id="pause-1",
workflow_run_id="run-1",
paused_at_value=expiration_time,
pause_reasons=[
HumanInputRequired(
form_id="form-1",
form_content="content",
node_id="node-1",
node_title="Human Input",
)
],
)
return _build_snapshot_events(
workflow_run=workflow_run,
node_snapshots=[snapshot],
task_id="task-ctx",
message_context=None,
pause_entity=pause_entity,
resumption_context=resumption_context,
session_maker=cast(sessionmaker[Session], session_maker),
human_input_surface=HumanInputSurface.OPENAPI,
)
def test_reconnect_pause_without_web_app_recipient_emits_approval_channels() -> None:
events = _build_recipient_snapshot_events(
recipients=[
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.EMAIL_MEMBER, access_token="email-token"),
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
],
)
human_input_event = events[-2]
assert human_input_event["event"] == StreamEvent.HUMAN_INPUT_REQUIRED
assert human_input_event["data"]["form_token"] is None
assert human_input_event["data"]["approval_channels"] == ["console", "email"]
pause_data = events[-1]["data"]
assert pause_data["reasons"][0]["form_token"] is None
assert pause_data["reasons"][0]["approval_channels"] == ["console", "email"]
def test_reconnect_pause_with_web_app_recipient_sets_token_and_channels() -> None:
events = _build_recipient_snapshot_events(
recipients=[
SimpleNamespace(
form_id="form-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
access_token="web-app-token",
),
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
],
)
human_input_event = events[-2]
assert human_input_event["event"] == StreamEvent.HUMAN_INPUT_REQUIRED
assert human_input_event["data"]["form_token"] == "web-app-token"
assert human_input_event["data"]["approval_channels"] == ["console"]
pause_data = events[-1]["data"]
assert pause_data["reasons"][0]["form_token"] == "web-app-token"
assert pause_data["reasons"][0]["approval_channels"] == ["console"]
def test_build_snapshot_events_resolves_pause_reason_select_options(monkeypatch: pytest.MonkeyPatch) -> None:
workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED)
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
resumption_context = _build_resumption_context("task-ctx", select_options=["approve", "reject"])
monkeypatch.setattr(
service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"}
service_module,
"load_form_dispositions_by_form_id",
lambda form_ids, session=None, surface=None: {
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
},
)
session_maker = _SessionMaker(
SimpleNamespace(
@@ -886,7 +979,11 @@ def test_build_workflow_event_stream_loads_pause_tokens_without_flask_app_contex
service_module, "_load_resumption_context", MagicMock(return_value=_build_resumption_context("task-1"))
)
monkeypatch.setattr(
service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"}
service_module,
"load_form_dispositions_by_form_id",
lambda form_ids, session=None, surface=None: {
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
},
)
session = SimpleNamespace(