mirror of
https://github.com/langgenius/dify.git
synced 2026-06-22 15:36:23 +08:00
Compare commits
21 Commits
dependabot
...
deploy/ent
| Author | SHA1 | Date | |
|---|---|---|---|
| c3f56fcc9a | |||
| 00995545e7 | |||
| 475dafb8b7 | |||
| a0bdb16cb9 | |||
| 4111751bdf | |||
| c62276d7de | |||
| 8cc690268b | |||
| 8cc6b16661 | |||
| f06127aaa4 | |||
| 8c484411ea | |||
| 4c083e76e2 | |||
| 24080010c9 | |||
| c7ceaa5fe2 | |||
| 547340ecca | |||
| 0ca14cd8ad | |||
| 3c8d03d24f | |||
| 39bf04e7fe | |||
| c38cba1f8c | |||
| a811522d5f | |||
| f533e992d4 | |||
| d82b6fe48e |
6
.github/workflows/api-tests.yml
vendored
6
.github/workflows/api-tests.yml
vendored
@ -29,7 +29,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@ -91,7 +91,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@ -142,7 +142,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/autofix.yml
vendored
2
.github/workflows/autofix.yml
vendored
@ -20,7 +20,7 @@ jobs:
|
||||
run: echo "autofix.ci updates pull request branches, not merge group refs."
|
||||
|
||||
- if: github.event_name != 'merge_group'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Check Docker Compose inputs
|
||||
if: github.event_name != 'merge_group'
|
||||
|
||||
12
.github/workflows/cli-e2e.yml
vendored
12
.github/workflows/cli-e2e.yml
vendored
@ -79,7 +79,7 @@ jobs:
|
||||
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -123,7 +123,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -170,7 +170,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -233,7 +233,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -295,7 +295,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
@ -351,7 +351,7 @@ jobs:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v4
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/cli-edge.yml
vendored
2
.github/workflows/cli-edge.yml
vendored
@ -23,7 +23,7 @@ jobs:
|
||||
working-directory: ./cli
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
4
.github/workflows/cli-release.yml
vendored
4
.github/workflows/cli-release.yml
vendored
@ -35,7 +35,7 @@ jobs:
|
||||
dify_tag: ${{ steps.resolve.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -98,7 +98,7 @@ jobs:
|
||||
DIFY_TAG: ${{ needs.validate.outputs.dify_tag }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
2
.github/workflows/cli-smoke.yml
vendored
2
.github/workflows/cli-smoke.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
shell: bash
|
||||
steps:
|
||||
- name: Checkout cli ref
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/cli-tests.yml
vendored
2
.github/workflows/cli-tests.yml
vendored
@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
4
.github/workflows/db-migration-test.yml
vendored
4
.github/workflows/db-migration-test.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
2
.github/workflows/hotfix-cherry-pick.yml
vendored
2
.github/workflows/hotfix-cherry-pick.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
name: Require cherry-pick provenance
|
||||
runs-on: depot-ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/main-ci.yml
vendored
2
.github/workflows/main-ci.yml
vendored
@ -48,7 +48,7 @@ jobs:
|
||||
vdb-changed: ${{ steps.changes.outputs.vdb }}
|
||||
migration-changed: ${{ steps.changes.outputs.migration }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
id: changes
|
||||
with:
|
||||
|
||||
2
.github/workflows/pyrefly-diff.yml
vendored
2
.github/workflows/pyrefly-diff.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@ -21,7 +21,7 @@ jobs:
|
||||
if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.pull_requests[0].head.repo.full_name != github.repository }}
|
||||
steps:
|
||||
- name: Checkout default branch (trusted code)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
|
||||
- name: Setup Python & UV
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||
|
||||
2
.github/workflows/pyrefly-type-coverage.yml
vendored
2
.github/workflows/pyrefly-type-coverage.yml
vendored
@ -17,7 +17,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout PR branch
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
10
.github/workflows/style.yml
vendored
10
.github/workflows/style.yml
vendored
@ -19,7 +19,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -71,7 +71,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -114,7 +114,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -171,7 +171,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
@ -189,7 +189,7 @@ jobs:
|
||||
.editorconfig
|
||||
|
||||
- name: Super-linter
|
||||
uses: super-linter/super-linter/slim@4ce20838b8ab83717e78138c5b3a1407148e0918 # v8.7.0
|
||||
uses: super-linter/super-linter/slim@9e863354e3ff62e0727d37183162c4a88873df41 # v8.6.0
|
||||
if: steps.changed-files.outputs.any_changed == 'true'
|
||||
env:
|
||||
BASH_SEVERITY: warning
|
||||
|
||||
2
.github/workflows/tool-test-sdks.yaml
vendored
2
.github/workflows/tool-test-sdks.yaml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
working-directory: sdks/nodejs-client
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
4
.github/workflows/translate-i18n-claude.yml
vendored
4
.github/workflows/translate-i18n-claude.yml
vendored
@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
@ -158,7 +158,7 @@ jobs:
|
||||
|
||||
- name: Run Claude Code for Translation Sync
|
||||
if: steps.context.outputs.CHANGED_FILES != ''
|
||||
uses: anthropics/claude-code-action@2fee15510437d71399d9139ed60433470484a8fb # v1.0.153
|
||||
uses: anthropics/claude-code-action@806af32823ef69c8ef357086c573a902af641307 # v1.0.151
|
||||
with:
|
||||
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
2
.github/workflows/trigger-i18n-sync.yml
vendored
2
.github/workflows/trigger-i18n-sync.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
2
.github/workflows/vdb-tests-full.yml
vendored
2
.github/workflows/vdb-tests-full.yml
vendored
@ -24,7 +24,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/vdb-tests.yml
vendored
2
.github/workflows/vdb-tests.yml
vendored
@ -21,7 +21,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
2
.github/workflows/web-e2e.yml
vendored
2
.github/workflows/web-e2e.yml
vendored
@ -20,7 +20,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
8
.github/workflows/web-tests.yml
vendored
8
.github/workflows/web-tests.yml
vendored
@ -31,7 +31,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -102,7 +102,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
@ -134,7 +134,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@ -20,6 +20,7 @@ Private helpers
|
||||
|
||||
from collections.abc import Callable
|
||||
from functools import wraps
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import select
|
||||
from werkzeug.exceptions import Forbidden, NotFound
|
||||
@ -32,9 +33,39 @@ from models.dataset import Dataset
|
||||
from models.model import App
|
||||
from services.enterprise.rbac_service import RBACService
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
|
||||
__all__ = ["RBACPermission", "RBACResourceScope", "rbac_permission_required"]
|
||||
|
||||
|
||||
|
||||
def openapi_rbac_permission_required[**P, R](
|
||||
resource_type: RBACResourceScope,
|
||||
scene: RBACPermission,
|
||||
*,
|
||||
resource_required: bool = True,
|
||||
) -> Callable[[Callable[P, R]], Callable[P, R]]:
|
||||
"""RBAC guard for OpenAPI endpoints that may be called by either an Account or an EndUser."""
|
||||
inner = rbac_permission_required(resource_type, scene, resource_required=resource_required)
|
||||
|
||||
def decorator(view: Callable[P, R]) -> Callable[P, R]:
|
||||
guarded = inner(view)
|
||||
|
||||
@wraps(view)
|
||||
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
|
||||
auth_data: "AuthData | None" = kwargs.get("auth_data")
|
||||
if not auth_data:
|
||||
raise Forbidden() # openapi auth pipeline is required
|
||||
if auth_data.caller_kind == "end_user":
|
||||
# end_user is handled by openapi scope control
|
||||
return view(*args, **kwargs)
|
||||
return guarded(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
return decorator
|
||||
|
||||
def rbac_permission_required[**P, R](
|
||||
resource_type: RBACResourceScope,
|
||||
scene: RBACPermission,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)."
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -5,6 +5,7 @@ from typing import cast
|
||||
from flask_restx import Resource
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope, rbac_permission_required
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._models import AppDslExportQuery, AppDslExportResponse, AppDslImportPayload
|
||||
@ -38,6 +39,7 @@ class AppDslImportApi(Resource):
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
|
||||
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
|
||||
)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL, resource_required=False)
|
||||
@returns(200, Import, "Import completed")
|
||||
@returns(202, Import, "Import pending confirmation")
|
||||
@returns(400, Import, "Import failed")
|
||||
@ -126,6 +128,7 @@ class AppDslExportApi(Resource):
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
|
||||
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
|
||||
)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL)
|
||||
@accepts(query=AppDslExportQuery)
|
||||
@returns(200, AppDslExportResponse, "Export successful")
|
||||
def get(self, app_id: str, *, auth_data: AuthData, query: AppDslExportQuery):
|
||||
@ -156,6 +159,7 @@ class AppDslCheckDependenciesApi(Resource):
|
||||
allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
|
||||
allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
|
||||
)
|
||||
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_IMPORT_EXPORT_DSL)
|
||||
@returns(200, CheckDependenciesResult, "Dependencies checked")
|
||||
def get(self, app_id: str, *, auth_data: AuthData):
|
||||
app = cast(App, auth_data.app)
|
||||
|
||||
@ -19,6 +19,7 @@ from werkzeug.exceptions import (
|
||||
|
||||
import services
|
||||
from controllers.common.fields import EventStreamResponse
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope, openapi_rbac_permission_required
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._audit import emit_app_run
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
@ -137,6 +138,7 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest], Any]] = {
|
||||
@openapi_ns.route("/apps/<string:app_id>/run")
|
||||
class AppRunApi(Resource):
|
||||
@auth_router.guard(scope=Scope.APPS_RUN)
|
||||
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
@openapi_ns.response(200, "Run result (SSE stream)", openapi_ns.models[EventStreamResponse.__name__])
|
||||
@accepts(body=AppRunRequest)
|
||||
def post(self, app_id: str, *, auth_data: AuthData, body: AppRunRequest):
|
||||
@ -168,6 +170,7 @@ class AppRunApi(Resource):
|
||||
@openapi_ns.route("/apps/<string:app_id>/tasks/<string:task_id>/stop")
|
||||
class AppRunTaskStopApi(Resource):
|
||||
@auth_router.guard(scope=Scope.APPS_RUN)
|
||||
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
@returns(200, TaskStopResponse, description="Task stopped")
|
||||
def post(self, app_id: str, task_id: str, *, auth_data: AuthData):
|
||||
app_model, caller, caller_kind = auth_data.require_app_context()
|
||||
|
||||
@ -9,6 +9,7 @@ from flask_restx import Resource
|
||||
from werkzeug.exceptions import Conflict, NotFound, UnprocessableEntity
|
||||
|
||||
from controllers.common.fields import Parameters
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope, openapi_rbac_permission_required
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi._contract import accepts, returns
|
||||
from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA, build_input_schema, resolve_app_config
|
||||
@ -19,7 +20,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 +28,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,59 +84,58 @@ 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}))
|
||||
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
|
||||
@returns(200, AppDescribeResponse, description="App description")
|
||||
@accepts(query=AppDescribeQuery)
|
||||
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")
|
||||
class AppListApi(Resource):
|
||||
@auth_router.guard_workspace(scope=Scope.APPS_READ, allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}))
|
||||
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT, resource_required=False)
|
||||
@returns(200, AppListResponse, description="App list")
|
||||
@accepts(query=AppListQuery)
|
||||
def get(self, *, auth_data: AuthData, query: AppListQuery):
|
||||
@ -163,28 +162,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 +194,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,16 +12,21 @@ 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.common.wraps import RBACPermission, RBACResourceScope, openapi_rbac_permission_required
|
||||
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,24 +52,25 @@ 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>")
|
||||
class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
@openapi_ns.response(200, "Form definition", openapi_ns.models[HumanInputFormDefinitionResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.APPS_RUN)
|
||||
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_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)
|
||||
@ -72,6 +78,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
return _jsonify_form_definition(form)
|
||||
|
||||
@auth_router.guard(scope=Scope.APPS_RUN)
|
||||
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
@returns(200, FormSubmitResponse, description="Form submitted")
|
||||
@accepts(body=HumanInputFormSubmitPayload)
|
||||
def post(self, app_id: str, form_token: str, *, auth_data: AuthData, body: HumanInputFormSubmitPayload):
|
||||
@ -80,7 +87,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 +113,6 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
|
||||
submission_end_user_id=submission_end_user_id,
|
||||
)
|
||||
except FormNotFoundError:
|
||||
raise NotFound("Form not found")
|
||||
raise HumanInputFormNotFound()
|
||||
|
||||
return FormSubmitResponse()
|
||||
|
||||
@ -19,6 +19,7 @@ from werkzeug.exceptions import NotFound, UnprocessableEntity
|
||||
|
||||
from controllers.common.fields import EventStreamResponse
|
||||
from controllers.common.schema import query_params_from_model
|
||||
from controllers.common.wraps import RBACPermission, RBACResourceScope, openapi_rbac_permission_required
|
||||
from controllers.openapi import openapi_ns
|
||||
from controllers.openapi.auth.composition import auth_router
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
@ -47,6 +48,7 @@ class OpenApiWorkflowEventsApi(Resource):
|
||||
@openapi_ns.doc(params=query_params_from_model(WorkflowEventsQuery))
|
||||
@openapi_ns.response(200, "SSE event stream", openapi_ns.models[EventStreamResponse.__name__])
|
||||
@auth_router.guard(scope=Scope.APPS_RUN)
|
||||
@openapi_rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
|
||||
def get(self, app_id: str, task_id: str, *, auth_data: AuthData):
|
||||
app_model, caller, caller_kind = auth_data.require_app_context()
|
||||
app_mode = AppMode.value_of(app_model.mode)
|
||||
|
||||
@ -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()),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ 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
|
||||
@ -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,37 @@ 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[str]
|
||||
|
||||
|
||||
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]
|
||||
blocked_channels = {
|
||||
recipient_type.approval_channel_label for recipient_type, _ in recipients if recipient_type not in allowed
|
||||
}
|
||||
return FormDisposition(form_token=get_preferred_form_token(actionable), approval_channels=sorted(blocked_channels))
|
||||
|
||||
|
||||
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 +98,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
|
||||
|
||||
@ -135,19 +135,32 @@ class HumanInputDelivery(DefaultFieldsMixin, Base):
|
||||
|
||||
|
||||
class RecipientType(StrEnum):
|
||||
# Second value = approval-channel label (surfaced in `approval_channels`).
|
||||
# EMAIL_MEMBER member means that the
|
||||
EMAIL_MEMBER = "email_member"
|
||||
EMAIL_EXTERNAL = "email_external"
|
||||
EMAIL_MEMBER = "email_member", "email"
|
||||
EMAIL_EXTERNAL = "email_external", "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", "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", "console"
|
||||
# BACKSTAGE is used for backstage input inside console.
|
||||
BACKSTAGE = "backstage"
|
||||
BACKSTAGE = "backstage", "console"
|
||||
|
||||
_approval_channel_label: str
|
||||
|
||||
def __new__(cls, value: str, approval_channel_label: str) -> "RecipientType":
|
||||
member = str.__new__(cls, value)
|
||||
member._value_ = value
|
||||
member._approval_channel_label = approval_channel_label
|
||||
return member
|
||||
|
||||
@property
|
||||
def approval_channel_label(self) -> str:
|
||||
return self._approval_channel_label
|
||||
|
||||
|
||||
@final
|
||||
|
||||
@ -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
|
||||
|
||||
@ -475,7 +475,7 @@ class TriggerProviderService:
|
||||
tenant_id=tenant_id, provider_id=provider_id
|
||||
)
|
||||
# Create encrypter
|
||||
encrypter, cache = create_provider_encrypter(
|
||||
encrypter, _ = create_provider_encrypter(
|
||||
tenant_id=tenant_id,
|
||||
config=[x.to_basic_provider_config() for x in provider_controller.get_oauth_client_schema()],
|
||||
cache=NoOpProviderCredentialCache(),
|
||||
@ -506,14 +506,20 @@ class TriggerProviderService:
|
||||
subscription.credentials = dict(encrypter.encrypt(dict(refreshed_credentials.credentials)))
|
||||
subscription.credential_expires_at = refreshed_credentials.expires_at
|
||||
|
||||
# Clear cache
|
||||
cache.delete()
|
||||
|
||||
return {
|
||||
provider_id_value = subscription.provider_id
|
||||
result = {
|
||||
"result": "success",
|
||||
"expires_at": refreshed_credentials.expires_at,
|
||||
}
|
||||
|
||||
# Clear the trigger runtime credential cache after the DB commit so dispatch uses the refreshed token.
|
||||
delete_cache_for_subscription(
|
||||
tenant_id=tenant_id,
|
||||
provider_id=provider_id_value,
|
||||
subscription_id=subscription_id,
|
||||
)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def refresh_subscription(
|
||||
cls,
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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"
|
||||
19
api/tests/unit_tests/models/test_recipient_type_label.py
Normal file
19
api/tests/unit_tests/models/test_recipient_type_label.py
Normal file
@ -0,0 +1,19 @@
|
||||
import pytest
|
||||
|
||||
from models.human_input import RecipientType
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("recipient_type", "expected_label"),
|
||||
[
|
||||
(RecipientType.EMAIL_MEMBER, "email"),
|
||||
(RecipientType.EMAIL_EXTERNAL, "email"),
|
||||
(RecipientType.CONSOLE, "console"),
|
||||
(RecipientType.BACKSTAGE, "console"),
|
||||
(RecipientType.STANDALONE_WEB_APP, "web_app"),
|
||||
],
|
||||
)
|
||||
def test_approval_channel_label_collapses_delivery_types(recipient_type: RecipientType, expected_label: str) -> 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_label == expected_label
|
||||
@ -560,6 +560,7 @@ def test_refresh_oauth_token_should_refresh_and_persist_new_credentials(
|
||||
return_value=(cred_enc, cache),
|
||||
)
|
||||
mocker.patch.object(TriggerProviderService, "get_oauth_client", return_value={"client_id": "id"})
|
||||
mock_delete_cache = mocker.patch("services.trigger.trigger_provider_service.delete_cache_for_subscription")
|
||||
refreshed = SimpleNamespace(credentials={"access_token": "new"}, expires_at=12345)
|
||||
oauth_handler = MagicMock()
|
||||
oauth_handler.refresh_credentials.return_value = refreshed
|
||||
@ -573,7 +574,12 @@ def test_refresh_oauth_token_should_refresh_and_persist_new_credentials(
|
||||
assert subscription.credentials == {"access_token": "new"}
|
||||
assert subscription.credential_expires_at == 12345
|
||||
|
||||
cache.delete.assert_called_once()
|
||||
cache.delete.assert_not_called()
|
||||
mock_delete_cache.assert_called_once_with(
|
||||
tenant_id="tenant-1",
|
||||
provider_id=str(provider_id),
|
||||
subscription_id="sub-1",
|
||||
)
|
||||
|
||||
|
||||
def test_refresh_subscription_should_raise_error_when_subscription_missing(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -137,6 +137,17 @@ export const commandTree: CommandTree = {
|
||||
const verIdx = out.indexOf('Version')
|
||||
expect(authIdx).toBeLessThan(verIdx)
|
||||
})
|
||||
|
||||
it('quotes hyphenated keys and leaves plain identifier keys unquoted', () => {
|
||||
const entries: CommandEntry[] = [
|
||||
{ tokens: ['export', 'app'], identifier: 'ExportApp', importPath: '@/commands/export/app/index' },
|
||||
{ tokens: ['export', 'studio-app'], identifier: 'ExportStudioApp', importPath: '@/commands/export/studio-app/index' },
|
||||
]
|
||||
const out = formatModule(entries, buildTree(entries))
|
||||
expect(out).toContain(`'studio-app': { command: ExportStudioApp, subcommands: {} },`)
|
||||
expect(out).toContain(`app: { command: ExportApp, subcommands: {} },`)
|
||||
expect(out).not.toContain(`'app':`)
|
||||
})
|
||||
})
|
||||
|
||||
function makeFixture(): string {
|
||||
|
||||
@ -141,13 +141,24 @@ function emitNode(node: TreeNode, indent: string): string {
|
||||
return parts.join('\n')
|
||||
}
|
||||
|
||||
function needsQuoting(key: string): boolean {
|
||||
// A bare object key must be a valid JS identifier: the start class excludes digits
|
||||
// (letter/_/$ only), so a leading digit fails the match and the key gets quoted.
|
||||
return !/^[A-Z_$][\w$]*$/i.test(key)
|
||||
}
|
||||
|
||||
function emitKey(key: string): string {
|
||||
return needsQuoting(key) ? `'${key}'` : key
|
||||
}
|
||||
|
||||
function emitEntry(key: string, node: TreeNode, indent: string): string {
|
||||
const k = emitKey(key)
|
||||
const isLeaf = node.subcommands.size === 0 && node.command !== undefined
|
||||
if (isLeaf)
|
||||
return `${indent}${key}: { command: ${node.command}, subcommands: {} },`
|
||||
return `${indent}${k}: { command: ${node.command}, subcommands: {} },`
|
||||
|
||||
return [
|
||||
`${indent}${key}: {`,
|
||||
`${indent}${k}: {`,
|
||||
emitNode(node, indent),
|
||||
`${indent}},`,
|
||||
].join('\n')
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
import type { AppsClient } from './apps'
|
||||
import type { AppReader } from './app-reader'
|
||||
import type { AppInfoCache } from '@/cache/app-info'
|
||||
import type { AppMeta, AppMetaFieldKey } from '@/types/app-meta'
|
||||
import { covers, fromDescribe, mergeMeta } from '@/types/app-meta'
|
||||
|
||||
export type AppMetaClientOptions = {
|
||||
readonly apps: AppsClient
|
||||
readonly apps: AppReader
|
||||
readonly host: string
|
||||
readonly cache?: AppInfoCache
|
||||
readonly now?: () => Date
|
||||
}
|
||||
|
||||
export class AppMetaClient {
|
||||
private readonly apps: AppsClient
|
||||
private readonly apps: AppReader
|
||||
private readonly host: string
|
||||
private readonly cache: AppInfoCache | undefined
|
||||
private readonly now: () => Date
|
||||
|
||||
30
cli/src/api/app-reader.test.ts
Normal file
30
cli/src/api/app-reader.test.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { selectAppReader, SubjectKind, subjectOf } from './app-reader'
|
||||
import { AppsClient } from './apps'
|
||||
import { PermittedExternalAppsClient } from './permitted-external-apps'
|
||||
|
||||
const http = { baseURL: 'https://x', request: async () => new Response() } as unknown as HttpClient
|
||||
|
||||
function ctx(external: boolean): ActiveContext {
|
||||
return {
|
||||
host: 'h',
|
||||
email: 'e',
|
||||
ctx: {
|
||||
account: { id: 'a', email: 'e', name: 'n' },
|
||||
external_subject: external ? { email: 'e', issuer: 'i' } : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('selectAppReader', () => {
|
||||
it('account login → AppsClient', () => {
|
||||
expect(selectAppReader(ctx(false), http)).toBeInstanceOf(AppsClient)
|
||||
expect(subjectOf(ctx(false))).toBe(SubjectKind.Account)
|
||||
})
|
||||
it('external_subject present → PermittedExternalAppsClient', () => {
|
||||
expect(selectAppReader(ctx(true), http)).toBeInstanceOf(PermittedExternalAppsClient)
|
||||
expect(subjectOf(ctx(true))).toBe(SubjectKind.External)
|
||||
})
|
||||
})
|
||||
35
cli/src/api/app-reader.ts
Normal file
35
cli/src/api/app-reader.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { ListQuery } from './apps'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { AppsClient } from './apps'
|
||||
import { PermittedExternalAppsClient } from './permitted-external-apps'
|
||||
|
||||
export type AppReader = {
|
||||
list: (q: ListQuery) => Promise<AppListResponse>
|
||||
describe: (appId: string, fields?: readonly string[]) => Promise<AppDescribeResponse>
|
||||
}
|
||||
|
||||
// The auth subject behind an openapi bearer token. Each kind reads apps from its own surface.
|
||||
export const SubjectKind = {
|
||||
Account: 'account',
|
||||
External: 'external',
|
||||
} as const
|
||||
|
||||
export type SubjectKindValue = (typeof SubjectKind)[keyof typeof SubjectKind]
|
||||
|
||||
export function subjectOf(active: ActiveContext): SubjectKindValue {
|
||||
return active.ctx.external_subject !== undefined ? SubjectKind.External : SubjectKind.Account
|
||||
}
|
||||
|
||||
type AppReaderFactory = (http: HttpClient) => AppReader
|
||||
|
||||
// Maps each auth subject to the app reader for its surface.
|
||||
const APP_READER_BY_SUBJECT: Readonly<Record<SubjectKindValue, AppReaderFactory>> = {
|
||||
[SubjectKind.Account]: http => new AppsClient(http),
|
||||
[SubjectKind.External]: http => new PermittedExternalAppsClient(http),
|
||||
}
|
||||
|
||||
export function selectAppReader(active: ActiveContext, http: HttpClient): AppReader {
|
||||
return APP_READER_BY_SUBJECT[subjectOf(active)](http)
|
||||
}
|
||||
@ -36,7 +36,6 @@ describe('AppsClient.list', () => {
|
||||
// Optional filters are omitted entirely when not supplied.
|
||||
expect(q.has('mode')).toBe(false)
|
||||
expect(q.has('name')).toBe(false)
|
||||
expect(q.has('tag')).toBe(false)
|
||||
})
|
||||
|
||||
it('forwards explicit pagination and filters', async () => {
|
||||
@ -48,7 +47,6 @@ describe('AppsClient.list', () => {
|
||||
limit: 50,
|
||||
mode: 'chat',
|
||||
name: 'support bot',
|
||||
tag: 'prod',
|
||||
})
|
||||
|
||||
const q = queryOf(stub.captured.url)
|
||||
@ -56,18 +54,16 @@ describe('AppsClient.list', () => {
|
||||
expect(q.get('limit')).toBe('50')
|
||||
expect(q.get('mode')).toBe('chat')
|
||||
expect(q.get('name')).toBe('support bot')
|
||||
expect(q.get('tag')).toBe('prod')
|
||||
})
|
||||
|
||||
it('treats empty-string filters as absent (not blank query params)', async () => {
|
||||
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
|
||||
|
||||
await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '', tag: '' })
|
||||
await makeClient(stub.url).list({ workspaceId: 'ws-1', mode: '', name: '' })
|
||||
|
||||
const q = queryOf(stub.captured.url)
|
||||
expect(q.has('mode')).toBe(false)
|
||||
expect(q.has('name')).toBe(false)
|
||||
expect(q.has('tag')).toBe(false)
|
||||
})
|
||||
|
||||
it('propagates server 403 as a classified BaseError', async () => {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { AppReader } from './app-reader'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
@ -9,10 +10,14 @@ export type ListQuery = {
|
||||
readonly limit?: number
|
||||
readonly mode?: AppMode | ''
|
||||
readonly name?: string
|
||||
readonly tag?: string
|
||||
}
|
||||
|
||||
export class AppsClient {
|
||||
// An absent or empty mode filter means "any mode" — collapse both to undefined for the query.
|
||||
export function normalizeMode(mode: AppMode | '' | undefined): AppMode | undefined {
|
||||
return mode !== undefined && mode !== '' ? mode : undefined
|
||||
}
|
||||
|
||||
export class AppsClient implements AppReader {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
@ -25,9 +30,8 @@ export class AppsClient {
|
||||
workspace_id: q.workspaceId,
|
||||
page: q.page ?? 1,
|
||||
limit: q.limit ?? 20,
|
||||
mode: q.mode !== undefined && q.mode !== '' ? q.mode : undefined,
|
||||
mode: normalizeMode(q.mode),
|
||||
name: q.name !== undefined && q.name !== '' ? q.name : undefined,
|
||||
tag: q.tag !== undefined && q.tag !== '' ? q.tag : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
27
cli/src/api/permitted-external-apps.test.ts
Normal file
27
cli/src/api/permitted-external-apps.test.ts
Normal file
@ -0,0 +1,27 @@
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { PermittedExternalAppsClient } from './permitted-external-apps'
|
||||
|
||||
function fakeHttp() {
|
||||
return { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
|
||||
}
|
||||
|
||||
type WithOrpc = { orpc: unknown }
|
||||
|
||||
describe('PermittedExternalAppsClient', () => {
|
||||
it('list calls permittedExternalApps.get with paging/filter query', async () => {
|
||||
const c = new PermittedExternalAppsClient(fakeHttp())
|
||||
const get = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 0, has_more: false, data: [] })
|
||||
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get, byAppId: { describe: { get: vi.fn() } } } }
|
||||
await c.list({ workspaceId: '', page: 2, limit: 5, mode: undefined, name: 'a' })
|
||||
expect(get).toHaveBeenCalledWith({ query: { page: 2, limit: 5, mode: undefined, name: 'a' } })
|
||||
})
|
||||
|
||||
it('describe calls permittedExternalApps.byAppId.describe.get with app_id + fields', async () => {
|
||||
const c = new PermittedExternalAppsClient(fakeHttp())
|
||||
const dget = vi.fn().mockResolvedValue({ info: null, parameters: null, input_schema: null })
|
||||
;(c as unknown as WithOrpc).orpc = { permittedExternalApps: { get: vi.fn(), byAppId: { describe: { get: dget } } } }
|
||||
await c.describe('app-1', ['info'])
|
||||
expect(dget).toHaveBeenCalledWith({ params: { app_id: 'app-1' }, query: { fields: 'info' } })
|
||||
})
|
||||
})
|
||||
34
cli/src/api/permitted-external-apps.ts
Normal file
34
cli/src/api/permitted-external-apps.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import type { AppDescribeResponse, AppListResponse } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { AppReader } from './app-reader'
|
||||
import type { ListQuery } from './apps'
|
||||
import type { OpenApiClient } from '@/http/orpc'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { createOpenApiClient } from '@/http/orpc'
|
||||
import { normalizeMode } from './apps'
|
||||
|
||||
export class PermittedExternalAppsClient implements AppReader {
|
||||
private readonly orpc: OpenApiClient
|
||||
|
||||
constructor(http: HttpClient) {
|
||||
this.orpc = createOpenApiClient(http)
|
||||
}
|
||||
|
||||
// workspaceId/tag are ignored: the external grant is not workspace-scoped.
|
||||
async list(q: ListQuery): Promise<AppListResponse> {
|
||||
return this.orpc.permittedExternalApps.get({
|
||||
query: {
|
||||
page: q.page ?? 1,
|
||||
limit: q.limit ?? 20,
|
||||
mode: normalizeMode(q.mode),
|
||||
name: q.name !== undefined && q.name !== '' ? q.name : undefined,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async describe(appId: string, fields?: readonly string[]): Promise<AppDescribeResponse> {
|
||||
return this.orpc.permittedExternalApps.byAppId.describe.get({
|
||||
params: { app_id: appId },
|
||||
query: { fields: fields !== undefined && fields.length > 0 ? fields.join(',') : undefined },
|
||||
})
|
||||
}
|
||||
}
|
||||
2
cli/src/cache/app-info.test.ts
vendored
2
cli/src/cache/app-info.test.ts
vendored
@ -21,8 +21,6 @@ function metaInfoOnly(): AppMeta {
|
||||
name: 'Greeter',
|
||||
description: '',
|
||||
mode: 'chat',
|
||||
author: 'tester',
|
||||
tags: [],
|
||||
updated_at: undefined,
|
||||
service_api_enabled: false,
|
||||
is_agent: false,
|
||||
|
||||
@ -2,7 +2,9 @@ import type { CommandConstructor } from '@/framework/command'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import Login from '@/commands/auth/login/index'
|
||||
import DescribeApp from '@/commands/describe/app/index'
|
||||
import ExportStudioApp from '@/commands/export/studio-app/index'
|
||||
import GetApp from '@/commands/get/app/index'
|
||||
import ImportStudioApp from '@/commands/import/studio-app/index'
|
||||
import ResumeApp from '@/commands/resume/app/index'
|
||||
import RunApp from '@/commands/run/app/index'
|
||||
|
||||
@ -13,6 +15,8 @@ const GUIDED_COMMANDS: ReadonlyArray<readonly [string, CommandConstructor]> = [
|
||||
['resume app', ResumeApp],
|
||||
['describe app', DescribeApp],
|
||||
['get app', GetApp],
|
||||
['export studio-app', ExportStudioApp],
|
||||
['import studio-app', ImportStudioApp],
|
||||
['auth login', Login],
|
||||
]
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { AppDescribeInfo, TagItem } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { AppDescribeInfo } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { AppMeta } from '@/types/app-meta'
|
||||
|
||||
export const APP_DESCRIBE_MODE_KEY = 'app-describe'
|
||||
@ -28,10 +28,8 @@ export class AppDescribeOutput {
|
||||
['Name', info.name],
|
||||
['ID', info.id],
|
||||
['Mode', info.mode],
|
||||
['Author', info.author ?? ''],
|
||||
['Updated', info.updated_at ?? ''],
|
||||
['Service API', info.service_api_enabled ? 'true' : 'false'],
|
||||
['Tags', joinTags(info.tags ?? [])],
|
||||
]
|
||||
if (info.description !== '' && info.description !== undefined)
|
||||
rows.push(['Description', info.description ?? ''])
|
||||
@ -55,12 +53,6 @@ export class AppDescribeOutput {
|
||||
}
|
||||
}
|
||||
|
||||
function joinTags(tags: readonly TagItem[]): string {
|
||||
if (tags.length === 0)
|
||||
return '<none>'
|
||||
return tags.map(t => t.name).join(',')
|
||||
}
|
||||
|
||||
function alignedRows(rows: readonly [string, string][]): string[] {
|
||||
const widest = rows.reduce((m, [k]) => Math.max(m, k.length), 0)
|
||||
return rows.map(([k, v]) => `${`${k}:`.padEnd(widest + 2)}${v}`)
|
||||
|
||||
@ -5,7 +5,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadAppInfoCache } from '@/cache/app-info'
|
||||
import { formatted, stringifyOutput } from '@/framework/output'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
@ -34,6 +34,7 @@ describe('runDescribeApp', () => {
|
||||
process.env[ENV_CACHE_DIR] = dir
|
||||
})
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
if (prevCacheDir === undefined)
|
||||
delete process.env[ENV_CACHE_DIR]
|
||||
else
|
||||
@ -60,8 +61,6 @@ describe('runDescribeApp', () => {
|
||||
expect(out).toContain('Mode:')
|
||||
expect(out).toContain('chat')
|
||||
expect(out).toContain('Service API:')
|
||||
expect(out).toContain('Tags:')
|
||||
expect(out).toContain('demo')
|
||||
expect(out).toContain('Description:')
|
||||
expect(out).toContain('Parameters:')
|
||||
})
|
||||
@ -115,4 +114,13 @@ describe('runDescribeApp', () => {
|
||||
},
|
||||
)).rejects.toThrow()
|
||||
})
|
||||
|
||||
it('external login resolves describe via the permitted-external route', async () => {
|
||||
const activeExt: ActiveContext = { host: mock.url, email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
|
||||
const out = await runDescribeApp(
|
||||
{ appId: 'app-1' },
|
||||
{ active: activeExt, http: testHttpClient(mock.url, 'dfoe_test'), host: mock.url },
|
||||
)
|
||||
expect(out.payload.info?.id).toBe('app-1')
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,7 +3,7 @@ import type { AppInfoCache } from '@/cache/app-info'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AppMetaClient } from '@/api/app-meta'
|
||||
import { AppsClient } from '@/api/apps'
|
||||
import { selectAppReader } from '@/api/app-reader'
|
||||
import { runWithSpinner } from '@/sys/io/spinner'
|
||||
import { nullStreams } from '@/sys/io/streams'
|
||||
import { FieldInfo, FieldInputSchema, FieldParameters } from '@/types/app-meta'
|
||||
@ -26,7 +26,7 @@ export type DescribeAppDeps = {
|
||||
}
|
||||
|
||||
export async function runDescribeApp(opts: DescribeAppOptions, deps: DescribeAppDeps): Promise<AppDescribeOutput> {
|
||||
const apps = new AppsClient(deps.http)
|
||||
const apps = selectAppReader(deps.active, deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
const io = deps.io ?? nullStreams()
|
||||
const result = await runWithSpinner(
|
||||
|
||||
12
cli/src/commands/export/studio-app/guide.ts
Normal file
12
cli/src/commands/export/studio-app/guide.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export const agentGuide = `
|
||||
WHEN TO USE
|
||||
A studio app is what you build and edit in Studio on the web console,
|
||||
inside a workspace — the app's source definition, not the published app
|
||||
that 'run app' invokes. Export pulls that definition as YAML to back it
|
||||
up, diff it, or recreate the app elsewhere with 'import studio-app'. To
|
||||
run or inspect an app instead, use the 'app' noun.
|
||||
|
||||
ERROR RECOVERY
|
||||
app not found (404) difyctl get app
|
||||
not logged in (exit 4) difyctl auth login
|
||||
`
|
||||
@ -1,16 +1,17 @@
|
||||
import { DifyCommand } from '@/commands/_shared/dify-command'
|
||||
import { httpRetryFlag } from '@/commands/_shared/global-flags'
|
||||
import { Args, Flags } from '@/framework/flags'
|
||||
import { agentGuide } from './guide'
|
||||
import { runExportApp } from './run'
|
||||
|
||||
export default class ExportApp extends DifyCommand {
|
||||
static override description = 'Export an app\'s DSL configuration as YAML'
|
||||
export default class ExportStudioApp extends DifyCommand {
|
||||
static override description = 'Export a studio app\'s DSL configuration as YAML'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> export app <app-id>',
|
||||
'<%= config.bin %> export app <app-id> --output ./my-app.yaml',
|
||||
'<%= config.bin %> export app <app-id> --include-secret',
|
||||
'<%= config.bin %> export app <app-id> --workflow-id <workflow-id>',
|
||||
'<%= config.bin %> export studio-app <app-id>',
|
||||
'<%= config.bin %> export studio-app <app-id> --output ./my-app.yaml',
|
||||
'<%= config.bin %> export studio-app <app-id> --include-secret',
|
||||
'<%= config.bin %> export studio-app <app-id> --workflow-id <workflow-id>',
|
||||
]
|
||||
|
||||
static override args = {
|
||||
@ -26,7 +27,7 @@ export default class ExportApp extends DifyCommand {
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
const { args, flags } = this.parse(ExportApp, argv)
|
||||
const { args, flags } = this.parse(ExportStudioApp, argv)
|
||||
const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
|
||||
const result = await runExportApp({
|
||||
appId: args.id,
|
||||
@ -42,4 +43,8 @@ export default class ExportApp extends DifyCommand {
|
||||
ctx.io.out.write('\n')
|
||||
}
|
||||
}
|
||||
|
||||
override agentGuide(): string {
|
||||
return agentGuide
|
||||
}
|
||||
}
|
||||
@ -35,9 +35,8 @@ export async function runExportApp(opts: ExportAppOptions, deps: ExportAppDeps):
|
||||
const io = deps.io ?? nullStreams()
|
||||
const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h))
|
||||
|
||||
// workspace is needed to satisfy the auth pipeline; resolving it here
|
||||
// mirrors what other commands do even though the export endpoint does not
|
||||
// take workspace_id as a query parameter (it loads tenant from app).
|
||||
// workspace is resolved to satisfy the auth pipeline; the export endpoint itself
|
||||
// takes no workspace_id query parameter (it loads tenant from the app).
|
||||
resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
|
||||
const client = dslFactory(deps.http)
|
||||
@ -1,4 +1,4 @@
|
||||
import type { AppListResponse, AppListRow, TagItem } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { AppListResponse, AppListRow } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { TableCell, TableColumn } from '@/framework/output'
|
||||
|
||||
export const APP_MODE_KEY = 'app'
|
||||
@ -7,9 +7,7 @@ export const APP_COLUMNS: readonly TableColumn[] = [
|
||||
{ name: 'NAME', priority: 0 },
|
||||
{ name: 'ID', priority: 0 },
|
||||
{ name: 'MODE', priority: 0 },
|
||||
{ name: 'TAGS', priority: 0 },
|
||||
{ name: 'UPDATED', priority: 0 },
|
||||
{ name: 'AUTHOR', priority: 1 },
|
||||
{ name: 'WORKSPACE', priority: 1 },
|
||||
]
|
||||
|
||||
@ -25,9 +23,7 @@ export class AppRow {
|
||||
this.data.name,
|
||||
this.data.id,
|
||||
this.data.mode,
|
||||
joinTags(this.data.tags ?? []),
|
||||
this.data.updated_at ?? '',
|
||||
this.data.created_by_name ?? '',
|
||||
this.data.workspace_name ?? '',
|
||||
]
|
||||
}
|
||||
@ -70,7 +66,3 @@ export class AppListOutput {
|
||||
return this.envelope
|
||||
}
|
||||
}
|
||||
|
||||
function joinTags(tags: readonly TagItem[]): string {
|
||||
return tags.map(t => t.name).join(',')
|
||||
}
|
||||
|
||||
@ -42,7 +42,6 @@ export default class GetApp extends DifyCommand {
|
||||
'limit': Flags.string({ description: 'page size [1..200]' }),
|
||||
'mode': Flags.string({ description: 'filter by app mode', options: APP_MODE_VALUES }),
|
||||
'name': Flags.string({ description: 'filter by app name (server-side substring)' }),
|
||||
'tag': Flags.string({ description: 'filter by tag name (server-side exact match)' }),
|
||||
'http-retry': httpRetryFlag,
|
||||
'output': Flags.outputFormat({ options: [OutputFormat.JSON, OutputFormat.YAML, OutputFormat.NAME, OutputFormat.WIDE], default: '' }),
|
||||
}
|
||||
@ -59,7 +58,6 @@ export default class GetApp extends DifyCommand {
|
||||
limitRaw: flags.limit,
|
||||
mode: flags.mode as AppMode | undefined,
|
||||
name: flags.name,
|
||||
tag: flags.tag,
|
||||
format,
|
||||
}, { active: ctx.active, http: ctx.http, io: ctx.io })
|
||||
return table({
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { stringifyOutput, table } from '@/framework/output'
|
||||
import { AppListOutput } from './handlers.js'
|
||||
import { runGetApp } from './run.js'
|
||||
@ -25,6 +26,7 @@ describe('runGetApp', () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
await mock.stop()
|
||||
})
|
||||
|
||||
@ -40,13 +42,12 @@ describe('runGetApp', () => {
|
||||
}))
|
||||
}
|
||||
|
||||
it('list (no id, default format) renders table with NAME ID MODE TAGS UPDATED', async () => {
|
||||
it('list (no id, default format) renders table with NAME ID MODE UPDATED', async () => {
|
||||
const out = await render()
|
||||
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+TAGS\s+UPDATED/)
|
||||
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+UPDATED/)
|
||||
expect(out).toContain('Greeter')
|
||||
expect(out).toContain('app-1')
|
||||
expect(out).toContain('chat')
|
||||
expect(out).toContain('demo')
|
||||
expect(out).toContain('Workflow')
|
||||
expect(out).not.toContain('app-3')
|
||||
})
|
||||
@ -56,9 +57,7 @@ describe('runGetApp', () => {
|
||||
'NAME',
|
||||
'ID',
|
||||
'MODE',
|
||||
'TAGS',
|
||||
'UPDATED',
|
||||
'AUTHOR',
|
||||
'WORKSPACE',
|
||||
])
|
||||
})
|
||||
@ -76,12 +75,6 @@ describe('runGetApp', () => {
|
||||
expect(out).not.toContain('Greeter')
|
||||
})
|
||||
|
||||
it('--tag filters server-side', async () => {
|
||||
const out = await render({ tag: 'demo' })
|
||||
expect(out).toContain('Greeter')
|
||||
expect(out).not.toContain('Workflow')
|
||||
})
|
||||
|
||||
it('-A all-workspaces aggregates across workspaces sorted by id', async () => {
|
||||
const out = await render({ allWorkspaces: true })
|
||||
expect(out).toContain('app-1')
|
||||
@ -110,10 +103,9 @@ describe('runGetApp', () => {
|
||||
expect(out.trim().split('\n').sort()).toEqual(['app-1', 'app-2'])
|
||||
})
|
||||
|
||||
it('-o wide includes AUTHOR and WORKSPACE columns', async () => {
|
||||
it('-o wide includes the WORKSPACE column', async () => {
|
||||
const out = await render({ format: 'wide' })
|
||||
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+TAGS\s+UPDATED\s+AUTHOR\s+WORKSPACE/)
|
||||
expect(out).toContain('tester')
|
||||
expect(out).toMatch(/^NAME\s+ID\s+MODE\s+UPDATED\s+WORKSPACE/)
|
||||
expect(out).toContain('Default')
|
||||
})
|
||||
|
||||
@ -138,4 +130,25 @@ describe('runGetApp', () => {
|
||||
}
|
||||
await expect(runGetApp({}, { active: minimal, http: http() })).rejects.toThrow(/no workspace/)
|
||||
})
|
||||
|
||||
it('external login lists via permitted-external client without workspace', async () => {
|
||||
const list = vi.fn().mockResolvedValue({ page: 1, limit: 20, total: 1, has_more: false, data: [{ id: 'x', name: 'X', description: null, mode: 'chat', tags: [], updated_at: null, created_by_name: null, workspace_id: 'w', workspace_name: 'W' }] })
|
||||
const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps')
|
||||
vi.spyOn(PermittedExternalAppsClient.prototype, 'list').mockImplementation(list)
|
||||
const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
|
||||
const http = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
|
||||
const res = await runGetApp({}, { active, http })
|
||||
expect(list).toHaveBeenCalled()
|
||||
const firstCallArg = list.mock.calls[0]![0] as { workspaceId: string }
|
||||
expect(firstCallArg.workspaceId).toBe('')
|
||||
expect(res.data).toBeDefined()
|
||||
})
|
||||
|
||||
it('--all-workspaces throws UsageInvalidFlag for external logins', async () => {
|
||||
const active: ActiveContext = { host: 'h', email: 'e', ctx: { account: { id: 'a', email: 'e', name: 'n' }, external_subject: { email: 'e', issuer: 'i' } } }
|
||||
const httpClient = { baseURL: 'https://x', request: vi.fn() } as unknown as HttpClient
|
||||
await expect(runGetApp({ allWorkspaces: true }, { active, http: httpClient }))
|
||||
.rejects
|
||||
.toThrow(/--all-workspaces is not available for external logins/)
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
import type { AppDescribeResponse, AppListResponse, AppMode } from '@dify/contracts/api/openapi/types.gen'
|
||||
import type { AppReader } from '@/api/app-reader'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AppsClient } from '@/api/apps'
|
||||
import { selectAppReader, SubjectKind, subjectOf } from '@/api/app-reader'
|
||||
import { WorkspacesClient } from '@/api/workspaces'
|
||||
import { newError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
import { LIMIT_DEFAULT, parseLimit } from '@/limit/limit'
|
||||
import { getEnv } from '@/sys/index'
|
||||
import { runWithSpinner } from '@/sys/io/spinner'
|
||||
@ -19,7 +22,6 @@ export type GetAppOptions = {
|
||||
readonly limitRaw?: string
|
||||
readonly mode?: AppMode
|
||||
readonly name?: string
|
||||
readonly tag?: string
|
||||
readonly format?: string
|
||||
}
|
||||
|
||||
@ -28,7 +30,6 @@ export type GetAppDeps = {
|
||||
readonly http: HttpClient
|
||||
readonly io?: IOStreams
|
||||
readonly envLookup?: (k: string) => string | undefined
|
||||
readonly appsFactory?: (http: HttpClient) => AppsClient
|
||||
readonly workspacesFactory?: (http: HttpClient) => WorkspacesClient
|
||||
}
|
||||
|
||||
@ -40,10 +41,10 @@ export type GetAppResult = {
|
||||
|
||||
export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<GetAppResult> {
|
||||
const env = deps.envLookup ?? getEnv
|
||||
const appsFactory = deps.appsFactory ?? ((h: HttpClient) => new AppsClient(h))
|
||||
const wsFactory = deps.workspacesFactory ?? ((h: HttpClient) => new WorkspacesClient(h))
|
||||
|
||||
const apps = appsFactory(deps.http)
|
||||
const external = subjectOf(deps.active) === SubjectKind.External
|
||||
const apps = selectAppReader(deps.active, deps.http)
|
||||
const pageSize = resolveLimit(opts.limitRaw, env)
|
||||
const page = opts.page === undefined || opts.page <= 0 ? 1 : opts.page
|
||||
const label = opts.appId !== undefined && opts.appId !== '' ? 'Fetching app' : 'Fetching apps'
|
||||
@ -53,15 +54,20 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
|
||||
{ io, label },
|
||||
async (): Promise<AppListResponse> => {
|
||||
if (opts.allWorkspaces === true) {
|
||||
if (external)
|
||||
throw newError(ErrorCode.UsageInvalidFlag, '--all-workspaces is not available for external logins')
|
||||
const ws = wsFactory(deps.http)
|
||||
return runAllWorkspaces(apps, ws, opts, page, pageSize)
|
||||
}
|
||||
if (opts.appId !== undefined && opts.appId !== '') {
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsName = workspaceNameForId(deps.active, wsId)
|
||||
const wsId = external ? '' : resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
const wsName = external ? '' : workspaceNameForId(deps.active, wsId)
|
||||
const desc = await apps.describe(opts.appId, ['info'])
|
||||
return describeToEnvelope(desc, wsId, wsName)
|
||||
}
|
||||
if (external) {
|
||||
return apps.list({ workspaceId: '', page, limit: pageSize, mode: opts.mode, name: opts.name })
|
||||
}
|
||||
const wsId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
|
||||
return apps.list({
|
||||
workspaceId: wsId,
|
||||
@ -69,7 +75,6 @@ export async function runGetApp(opts: GetAppOptions, deps: GetAppDeps): Promise<
|
||||
limit: pageSize,
|
||||
mode: opts.mode,
|
||||
name: opts.name,
|
||||
tag: opts.tag,
|
||||
})
|
||||
},
|
||||
)
|
||||
@ -102,9 +107,7 @@ function describeToEnvelope(desc: AppDescribeResponse, wsId: string, wsName: str
|
||||
name: desc.info.name,
|
||||
description: desc.info.description,
|
||||
mode: desc.info.mode as AppMode,
|
||||
tags: desc.info.tags,
|
||||
updated_at: desc.info.updated_at,
|
||||
created_by_name: desc.info.author === '' ? undefined : desc.info.author,
|
||||
workspace_id: wsId,
|
||||
workspace_name: wsName === '' ? undefined : wsName,
|
||||
}],
|
||||
@ -118,7 +121,7 @@ function workspaceNameForId(active: ActiveContext, id: string): string {
|
||||
}
|
||||
|
||||
async function runAllWorkspaces(
|
||||
apps: AppsClient,
|
||||
apps: AppReader,
|
||||
ws: WorkspacesClient,
|
||||
opts: GetAppOptions,
|
||||
page: number,
|
||||
@ -139,7 +142,6 @@ async function runAllWorkspaces(
|
||||
limit,
|
||||
mode: opts.mode,
|
||||
name: opts.name,
|
||||
tag: opts.tag,
|
||||
})
|
||||
merged.total += env.total
|
||||
merged.data = [...merged.data, ...env.data]
|
||||
|
||||
17
cli/src/commands/import/studio-app/guide.ts
Normal file
17
cli/src/commands/import/studio-app/guide.ts
Normal file
@ -0,0 +1,17 @@
|
||||
export const agentGuide = `
|
||||
WHEN TO USE
|
||||
A studio app is what you build and edit in Studio on the web console,
|
||||
inside a workspace — the app's source definition. Import materialises a
|
||||
DSL YAML into a new (or existing) studio app; pair it with
|
||||
'export studio-app' to move an app between workspaces or instances. To
|
||||
run or inspect the result, switch to the 'app' noun.
|
||||
|
||||
BEHAVIOUR
|
||||
A DSL version mismatch is auto-confirmed; no second command needed.
|
||||
Missing plugin dependencies are listed on stderr — install them before
|
||||
running the app.
|
||||
|
||||
ERROR RECOVERY
|
||||
workspace required difyctl get workspace
|
||||
not logged in (exit 4) difyctl auth login
|
||||
`
|
||||
@ -1,16 +1,17 @@
|
||||
import { DifyCommand } from '@/commands/_shared/dify-command'
|
||||
import { httpRetryFlag } from '@/commands/_shared/global-flags'
|
||||
import { Flags } from '@/framework/flags'
|
||||
import { agentGuide } from './guide'
|
||||
import { pluginDependencyLabel, runImportApp } from './run'
|
||||
|
||||
export default class ImportApp extends DifyCommand {
|
||||
static override description = 'Import an app from a DSL YAML file or URL'
|
||||
export default class ImportStudioApp extends DifyCommand {
|
||||
static override description = 'Import a studio app from a DSL YAML file or URL'
|
||||
|
||||
static override examples = [
|
||||
'<%= config.bin %> import app --from-file ./app.yaml',
|
||||
'<%= config.bin %> import app --from-file /path/to/app.yaml --name "My App"',
|
||||
'<%= config.bin %> import app --from-url https://example.com/my-app.yaml',
|
||||
'<%= config.bin %> import app --from-file ./app.yaml --app-id <existing-app-id>',
|
||||
'<%= config.bin %> import studio-app --from-file ./app.yaml',
|
||||
'<%= config.bin %> import studio-app --from-file /path/to/app.yaml --name "My App"',
|
||||
'<%= config.bin %> import studio-app --from-url https://example.com/my-app.yaml',
|
||||
'<%= config.bin %> import studio-app --from-file ./app.yaml --app-id <existing-app-id>',
|
||||
]
|
||||
|
||||
static override flags = {
|
||||
@ -27,7 +28,7 @@ export default class ImportApp extends DifyCommand {
|
||||
}
|
||||
|
||||
async run(argv: string[]) {
|
||||
const { flags } = this.parse(ImportApp, argv)
|
||||
const { flags } = this.parse(ImportStudioApp, argv)
|
||||
if (flags['from-file'] === undefined && flags['from-url'] === undefined)
|
||||
this.error('one of --from-file or --from-url is required', { exit: 1 })
|
||||
if (flags['from-file'] !== undefined && flags['from-url'] !== undefined)
|
||||
@ -57,4 +58,8 @@ export default class ImportApp extends DifyCommand {
|
||||
ctx.io.err.write(` - ${pluginDependencyLabel(dep)}\n`)
|
||||
}
|
||||
}
|
||||
|
||||
override agentGuide(): string {
|
||||
return agentGuide
|
||||
}
|
||||
}
|
||||
66
cli/src/commands/resume/app/run.test.ts
Normal file
66
cli/src/commands/resume/app/run.test.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { AppRunClient } from '@/api/app-run'
|
||||
import { AppsClient } from '@/api/apps'
|
||||
import { PermittedExternalAppsClient } from '@/api/permitted-external-apps'
|
||||
import { bufferStreams } from '@/sys/io/streams'
|
||||
import { resumeApp } from './run.js'
|
||||
|
||||
const DESCRIBE_RESULT = {
|
||||
info: { id: 'app-2', name: 'X', mode: 'workflow', description: '', tags: [], author: '', updated_at: null, service_api_enabled: true, is_agent: false },
|
||||
parameters: null,
|
||||
input_schema: null,
|
||||
}
|
||||
|
||||
const FORM_RESP = { user_actions: [{ id: 'submit' }] }
|
||||
|
||||
function makeExternalActive(): ActiveContext {
|
||||
return {
|
||||
host: 'http://localhost',
|
||||
email: 'sso@x.io',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' },
|
||||
external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' },
|
||||
},
|
||||
} as unknown as ActiveContext
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('resumeApp pre-flight subject strategy', () => {
|
||||
it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => {
|
||||
const externalDescribe = vi.fn().mockResolvedValue(DESCRIBE_RESULT)
|
||||
const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe)
|
||||
const accountSpy = vi.spyOn(AppsClient.prototype, 'describe')
|
||||
|
||||
vi.spyOn(AppRunClient.prototype, 'submitHumanInput').mockResolvedValue(undefined as never)
|
||||
|
||||
const io = bufferStreams()
|
||||
const http = {
|
||||
baseURL: 'http://localhost',
|
||||
request: vi.fn().mockImplementation((opts: { path: string }) => {
|
||||
if (typeof opts.path === 'string' && opts.path.includes('form/human_input')) {
|
||||
return Promise.resolve(FORM_RESP)
|
||||
}
|
||||
// reconnect stream — return an async iterable that ends immediately
|
||||
const iter: AsyncIterable<never> = { [Symbol.asyncIterator]: () => ({ next: () => Promise.resolve({ done: true, value: undefined as never }) }) }
|
||||
return Promise.resolve(iter)
|
||||
}),
|
||||
} as unknown as import('@/http/types').HttpClient
|
||||
|
||||
try {
|
||||
await resumeApp(
|
||||
{ appId: 'app-2', formToken: 'ft-1', workflowRunId: 'wf-run-1', action: 'submit', inputs: {} },
|
||||
{ active: makeExternalActive(), http, host: 'http://localhost', io },
|
||||
)
|
||||
}
|
||||
catch {
|
||||
// run may fail after pre-flight due to stream mock; we only check which describe was called
|
||||
}
|
||||
|
||||
expect(externalSpy).toHaveBeenCalled()
|
||||
expect(accountSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@ -4,10 +4,11 @@ import type { RunContext } from '@/commands/run/app/_strategies/index'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AppMetaClient } from '@/api/app-meta'
|
||||
import { selectAppReader } from '@/api/app-reader'
|
||||
import { AppRunClient } from '@/api/app-run'
|
||||
import { AppsClient } from '@/api/apps'
|
||||
import { pickStrategy } from '@/commands/run/app/_strategies/index'
|
||||
import { RUN_MODES } from '@/commands/run/app/handlers'
|
||||
import { resolveInputs, TEXT_FORMATS } from '@/commands/run/app/input-flags'
|
||||
import { processExit } from '@/sys/index'
|
||||
import { colorEnabled, colorScheme } from '@/sys/io/color'
|
||||
import { FieldInfo } from '@/types/app-meta'
|
||||
@ -37,45 +38,8 @@ export type ResumeAppDeps = {
|
||||
readonly exit?: (code: number) => never
|
||||
}
|
||||
|
||||
const TEXT_FORMATS = new Set(['', 'text'])
|
||||
|
||||
async function resolveInputs(
|
||||
inputsJson: string | undefined,
|
||||
inputsFile: string | undefined,
|
||||
directInputs: Readonly<Record<string, unknown>> | undefined,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (inputsJson !== undefined && inputsFile !== undefined)
|
||||
throw new Error('--inputs and --inputs-file are mutually exclusive')
|
||||
if (inputsJson !== undefined) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(inputsJson)
|
||||
}
|
||||
catch {
|
||||
throw new Error('--inputs must be valid JSON')
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new Error('--inputs must be a JSON object')
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
if (inputsFile !== undefined) {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(inputsFile, 'utf8'))
|
||||
}
|
||||
catch {
|
||||
throw new Error('--inputs-file must contain valid JSON')
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new Error('--inputs-file must be a JSON object')
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
return { ...(directInputs ?? {}) }
|
||||
}
|
||||
|
||||
export async function resumeApp(opts: ResumeAppOptions, deps: ResumeAppDeps): Promise<void> {
|
||||
const apps = new AppsClient(deps.http)
|
||||
const apps = selectAppReader(deps.active, deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
const m = await meta.get(opts.appId, [FieldInfo])
|
||||
const mode = m.info?.mode ?? RUN_MODES.Workflow
|
||||
|
||||
42
cli/src/commands/run/app/input-flags.ts
Normal file
42
cli/src/commands/run/app/input-flags.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { BaseError } from '@/errors/base'
|
||||
import { ErrorCode } from '@/errors/codes'
|
||||
|
||||
// Output formats that render the run/resume result as plain text rather than JSON/YAML.
|
||||
export const TEXT_FORMATS = new Set(['', 'text'])
|
||||
|
||||
// Shared by `run app` and `resume app`: --inputs (inline JSON) / --inputs-file (JSON file) /
|
||||
// direct inputs are mutually exclusive ways to supply the run's variable map.
|
||||
export async function resolveInputs(
|
||||
inputsJson: string | undefined,
|
||||
inputsFile: string | undefined,
|
||||
directInputs: Readonly<Record<string, unknown>> | undefined,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (inputsJson !== undefined && inputsFile !== undefined)
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs and --inputs-file are mutually exclusive' })
|
||||
if (inputsJson !== undefined) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(inputsJson)
|
||||
}
|
||||
catch {
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be valid JSON' })
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be a JSON object' })
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
if (inputsFile !== undefined) {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(inputsFile, 'utf8'))
|
||||
}
|
||||
catch {
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must contain valid JSON' })
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must be a JSON object' })
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
return { ...(directInputs ?? {}) }
|
||||
}
|
||||
@ -1,11 +1,12 @@
|
||||
import type { DifyMock } from '@test/fixtures/dify-mock/server'
|
||||
import type { ActiveContext } from '@/auth/hosts'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { startMock } from '@test/fixtures/dify-mock/server'
|
||||
import { testHttpClient } from '@test/fixtures/http-client'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { loadAppInfoCache } from '@/cache/app-info'
|
||||
import { resumeApp } from '@/commands/resume/app/run'
|
||||
import { ENV_CACHE_DIR } from '@/store/dir'
|
||||
@ -381,4 +382,35 @@ describe('runApp', () => {
|
||||
expect(docInput.transfer_method).toBe('remote_url')
|
||||
expect(docInput.url).toBe('https://example.com/override.pdf')
|
||||
})
|
||||
|
||||
it('external login: mode pre-flight calls PermittedExternalAppsClient.describe, not AppsClient.describe', async () => {
|
||||
const describeResult = { info: { id: 'app-1', name: 'X', mode: 'chat', description: '', tags: [], author: '', updated_at: null, service_api_enabled: true, is_agent: false }, parameters: null, input_schema: null }
|
||||
const externalDescribe = vi.fn().mockResolvedValue(describeResult)
|
||||
const { PermittedExternalAppsClient } = await import('@/api/permitted-external-apps')
|
||||
const { AppsClient } = await import('@/api/apps')
|
||||
const externalSpy = vi.spyOn(PermittedExternalAppsClient.prototype, 'describe').mockImplementation(externalDescribe)
|
||||
const accountSpy = vi.spyOn(AppsClient.prototype, 'describe')
|
||||
const io = bufferStreams()
|
||||
const http = { baseURL: mock.url, request: vi.fn().mockResolvedValue({ answer: 'echo: hi', conversation_id: 'conv-1', message_id: 'msg-1', mode: 'chat', metadata: {} }) } as unknown as HttpClient
|
||||
const activeExt: ActiveContext = {
|
||||
host: mock.url,
|
||||
email: 'sso@x.io',
|
||||
ctx: {
|
||||
account: { id: 'acct-1', email: 'sso@x.io', name: 'SSO User' },
|
||||
external_subject: { email: 'sso@x.io', issuer: 'https://issuer.example.com' },
|
||||
},
|
||||
}
|
||||
try {
|
||||
await runApp(
|
||||
{ appId: 'app-1', message: 'hi' },
|
||||
{ active: activeExt, http, host: mock.url, io },
|
||||
)
|
||||
}
|
||||
catch {
|
||||
// run may fail due to mocked http; we only care about which describe was called
|
||||
}
|
||||
expect(externalSpy).toHaveBeenCalled()
|
||||
expect(accountSpy).not.toHaveBeenCalled()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
})
|
||||
|
||||
@ -3,8 +3,8 @@ import type { AppInfoCache } from '@/cache/app-info'
|
||||
import type { HttpClient } from '@/http/types'
|
||||
import type { IOStreams } from '@/sys/io/streams'
|
||||
import { AppMetaClient } from '@/api/app-meta'
|
||||
import { selectAppReader } from '@/api/app-reader'
|
||||
import { AppRunClient } from '@/api/app-run'
|
||||
import { AppsClient } from '@/api/apps'
|
||||
import { FileUploadClient } from '@/api/file-upload'
|
||||
import { pickStrategy } from '@/commands/run/app/_strategies/index'
|
||||
import { BaseError, HttpClientError } from '@/errors/base'
|
||||
@ -13,6 +13,7 @@ import { processExit } from '@/sys/index'
|
||||
import { FieldInfo } from '@/types/app-meta'
|
||||
import { resolveFileInputs } from './file-flags.js'
|
||||
import { RUN_MODES } from './handlers.js'
|
||||
import { resolveInputs, TEXT_FORMATS } from './input-flags.js'
|
||||
|
||||
export type RunAppOptions = {
|
||||
readonly appId: string
|
||||
@ -40,45 +41,8 @@ export type RunAppDeps = {
|
||||
readonly exit?: (code: number) => never
|
||||
}
|
||||
|
||||
const TEXT_FORMATS = new Set(['', 'text'])
|
||||
|
||||
async function resolveInputs(
|
||||
inputsJson: string | undefined,
|
||||
inputsFile: string | undefined,
|
||||
directInputs: Readonly<Record<string, unknown>> | undefined,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (inputsJson !== undefined && inputsFile !== undefined)
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs and --inputs-file are mutually exclusive' })
|
||||
if (inputsJson !== undefined) {
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(inputsJson)
|
||||
}
|
||||
catch {
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be valid JSON' })
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs must be a JSON object' })
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
if (inputsFile !== undefined) {
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
let parsed: unknown
|
||||
try {
|
||||
parsed = JSON.parse(await readFile(inputsFile, 'utf8'))
|
||||
}
|
||||
catch {
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must contain valid JSON' })
|
||||
}
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
||||
throw new BaseError({ code: ErrorCode.UsageInvalidFlag, message: '--inputs-file must be a JSON object' })
|
||||
return parsed as Record<string, unknown>
|
||||
}
|
||||
return { ...(directInputs ?? {}) }
|
||||
}
|
||||
|
||||
export async function runApp(opts: RunAppOptions, deps: RunAppDeps): Promise<void> {
|
||||
const apps = new AppsClient(deps.http)
|
||||
const apps = selectAppReader(deps.active, deps.http)
|
||||
const meta = new AppMetaClient({ apps, host: deps.host, cache: deps.cache })
|
||||
|
||||
try {
|
||||
|
||||
@ -17,11 +17,11 @@ import CreateMember from '@/commands/create/member/index'
|
||||
import DeleteMember from '@/commands/delete/member/index'
|
||||
import DescribeApp from '@/commands/describe/app/index'
|
||||
import EnvList from '@/commands/env/list/index'
|
||||
import ExportApp from '@/commands/export/app/index'
|
||||
import ExportStudioApp from '@/commands/export/studio-app/index'
|
||||
import GetApp from '@/commands/get/app/index'
|
||||
import GetMember from '@/commands/get/member/index'
|
||||
import GetWorkspace from '@/commands/get/workspace/index'
|
||||
import ImportApp from '@/commands/import/app/index'
|
||||
import ImportStudioApp from '@/commands/import/studio-app/index'
|
||||
import ResumeApp from '@/commands/resume/app/index'
|
||||
import RunApp from '@/commands/run/app/index'
|
||||
import SetMember from '@/commands/set/member/index'
|
||||
@ -77,7 +77,7 @@ export const commandTree: CommandTree = {
|
||||
},
|
||||
export: {
|
||||
subcommands: {
|
||||
app: { command: ExportApp, subcommands: {} },
|
||||
'studio-app': { command: ExportStudioApp, subcommands: {} },
|
||||
},
|
||||
},
|
||||
get: {
|
||||
@ -89,7 +89,7 @@ export const commandTree: CommandTree = {
|
||||
},
|
||||
import: {
|
||||
subcommands: {
|
||||
app: { command: ImportApp, subcommands: {} },
|
||||
'studio-app': { command: ImportStudioApp, subcommands: {} },
|
||||
},
|
||||
},
|
||||
resume: {
|
||||
|
||||
@ -22,6 +22,9 @@ const ACCOUNT_HELP_TEXT = `difyctl: account-bearer onboarding
|
||||
difyctl run app <id> "hello" -o json
|
||||
|
||||
Tips:
|
||||
* Two app nouns: 'studio-app' is what you build and edit in Studio on the
|
||||
web console inside a workspace (its source definition — export or move it);
|
||||
'app' is a published app you run and inspect.
|
||||
* 'difyctl auth list' shows your authenticated contexts; 'difyctl use host'
|
||||
and 'difyctl use account' switch between them.
|
||||
* Pass --workspace <id> to target a non-default workspace.
|
||||
@ -74,6 +77,16 @@ OUTPUT
|
||||
Pass -o json (or -o yaml) on every command — the JSON shape is stable and
|
||||
documented. Without it you get human tables meant for a terminal.
|
||||
|
||||
APP vs STUDIO-APP
|
||||
Two nouns, two faces of the same app:
|
||||
studio-app what you build and edit in Studio on the web console,
|
||||
inside a workspace — the app's source definition.
|
||||
app a published app, live and runnable.
|
||||
Use 'studio-app' to work with the definition you manage on the website
|
||||
(export it, move it between workspaces or instances); use 'app' to run
|
||||
and inspect a published one. The COMMANDS list shows the verbs each
|
||||
noun supports.
|
||||
|
||||
DISCOVERY
|
||||
difyctl help -o json full command tree + this contract, machine-readable
|
||||
difyctl get app -o json list apps (ids + modes)
|
||||
|
||||
@ -72,6 +72,25 @@ describe('classifyResponse — canonical ErrorBody', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyResponse 403', () => {
|
||||
it('maps 403 to AccessDenied (exit 4 bucket)', async () => {
|
||||
const req403 = new Request('https://x/openapi/v1/apps/abc/export')
|
||||
const res403 = new Response(
|
||||
JSON.stringify({ code: 'unsupported_token_type', message: 'unsupported_token_type', status: 403 }),
|
||||
{ status: 403, headers: { 'content-type': 'application/json' } },
|
||||
)
|
||||
const err = await classifyResponse(req403, res403)
|
||||
expect(err.code).toBe(ErrorCode.AccessDenied)
|
||||
expect(err.message).toBe('unsupported_token_type')
|
||||
})
|
||||
|
||||
it('403 with no parseable ErrorBody falls back to generic denied message', async () => {
|
||||
const err = await classified(403, 'not json')
|
||||
expect(err.code).toBe(ErrorCode.AccessDenied)
|
||||
expect(err.message).toBe('not permitted')
|
||||
})
|
||||
})
|
||||
|
||||
describe('classifyResponse — non-conforming bodies (no fallback by design)', () => {
|
||||
it('non-JSON body yields no serverError, classification by status', async () => {
|
||||
const err = await classified(502, '<html>bad gateway</html>')
|
||||
|
||||
@ -44,9 +44,17 @@ const RATE_LIMITED_CLASS: StatusClass = {
|
||||
includeRaw: false,
|
||||
}
|
||||
|
||||
const ACCESS_DENIED_CLASS: StatusClass = {
|
||||
code: ErrorCode.AccessDenied,
|
||||
fallbackMessage: () => 'not permitted',
|
||||
includeRaw: false,
|
||||
}
|
||||
|
||||
function statusClass(status: number): StatusClass {
|
||||
if (status === 401)
|
||||
return AUTH_EXPIRED_CLASS
|
||||
if (status === 403)
|
||||
return ACCESS_DENIED_CLASS
|
||||
if (status === 429)
|
||||
return RATE_LIMITED_CLASS
|
||||
if (status >= 500)
|
||||
|
||||
@ -44,10 +44,10 @@ describe('createOpenApiClient error mapping', () => {
|
||||
}
|
||||
|
||||
it('recovers Dify message from a canonical ErrorBody 4xx response', async () => {
|
||||
const caught = await classifiedError(403, { code: 'access_denied', message: 'no access', status: 403 })
|
||||
const caught = await classifiedError(422, { code: 'invalid_param', message: 'no access', status: 422 })
|
||||
|
||||
expect(caught.code).toBe(ErrorCode.Server4xxOther)
|
||||
expect(caught.httpStatus).toBe(403)
|
||||
expect(caught.httpStatus).toBe(422)
|
||||
expect(caught.message).toBe('no access')
|
||||
// Parity with the transport path: the migrated endpoint's error keeps the request
|
||||
// method/url and the raw body, so formatted errors still print the `request:` line
|
||||
|
||||
@ -9,8 +9,6 @@ function describeResp(): AppDescribeResponse {
|
||||
name: 'Greeter',
|
||||
description: '',
|
||||
mode: 'chat',
|
||||
author: 'tester',
|
||||
tags: [],
|
||||
updated_at: undefined,
|
||||
service_api_enabled: false,
|
||||
is_agent: false,
|
||||
|
||||
@ -519,14 +519,14 @@ async function provisionApps(
|
||||
|
||||
async function importAppCli(filePath: string, wsId: string): Promise<string> {
|
||||
const result = await run(
|
||||
['import', 'app', '--from-file', filePath, '--workspace', wsId],
|
||||
['import', 'studio-app', '--from-file', filePath, '--workspace', wsId],
|
||||
{ configDir, timeout: 60_000 },
|
||||
)
|
||||
if (result.exitCode !== 0)
|
||||
throw new Error(`import app failed (exit ${result.exitCode}): ${result.stderr}`)
|
||||
throw new Error(`import studio-app failed (exit ${result.exitCode}): ${result.stderr}`)
|
||||
const match = result.stderr.match(/app ([0-9a-f-]{36})/)
|
||||
if (!match?.[1])
|
||||
throw new Error(`import app: could not parse app_id: ${result.stderr}`)
|
||||
throw new Error(`import studio-app: could not parse app_id: ${result.stderr}`)
|
||||
return match[1]
|
||||
}
|
||||
|
||||
|
||||
@ -288,7 +288,7 @@ describe('E2E / agent skill — get app -o json (auth required)', () => {
|
||||
expect(line.trim()).not.toMatch(/\s/)
|
||||
})
|
||||
|
||||
itWithSso('[P0] [SSO] dfoe_ get app → JSON error envelope (insufficient_scope)', async () => {
|
||||
itWithSso('[P0] [SSO] dfoe_ get app -o json → permitted-apps list envelope', async () => {
|
||||
const tc = await withTempConfig()
|
||||
try {
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
@ -296,12 +296,21 @@ describe('E2E / agent skill — get app -o json (auth required)', () => {
|
||||
await mkdir(tc.configDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(tc.configDir, 'hosts.yml'),
|
||||
`${[`current_host: ${E.host}`, 'token_storage: file', 'tokens:', ` bearer: ${E.ssoToken}`].join('\n')}\n`,
|
||||
`${[
|
||||
`current_host: ${E.host}`,
|
||||
'token_storage: file',
|
||||
'tokens:',
|
||||
` bearer: ${E.ssoToken}`,
|
||||
'external_subject:',
|
||||
' email: sso@example.com',
|
||||
' issuer: https://issuer.example.com',
|
||||
].join('\n')}\n`,
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
const r = await run(['get', 'app', '-o', 'json'], { configDir: tc.configDir })
|
||||
expect(r.exitCode).not.toBe(0)
|
||||
assertErrorEnvelope(r)
|
||||
assertExitCode(r, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(r)
|
||||
expect(Array.isArray(parsed.data), 'permitted-apps envelope has a data array').toBe(true)
|
||||
}
|
||||
finally { await tc.cleanup() }
|
||||
})
|
||||
|
||||
@ -57,6 +57,8 @@ describe('E2E / difyctl auth whoami + SSO session', () => {
|
||||
})
|
||||
}
|
||||
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
|
||||
// ── auth whoami — internal user ──────────────────────────────────────────────
|
||||
|
||||
it('[P0] internal user auth whoami outputs email', async () => {
|
||||
@ -123,12 +125,12 @@ describe('E2E / difyctl auth whoami + SSO session', () => {
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
})
|
||||
|
||||
it('[P0] external user get app returns insufficient_scope error', async () => {
|
||||
// Spec: external user get app returns insufficient_scope
|
||||
itWithSso('[P0] external user can list permitted apps via SSO token', async () => {
|
||||
// External users read apps via the permitted-external surface (no workspace scope).
|
||||
await withSSOAuth()
|
||||
const result = await r(['get', 'app'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/insufficient|scope|workspace|SSO/i)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i)
|
||||
})
|
||||
|
||||
it('[P0] external user whoami outputs SSO email', async () => {
|
||||
@ -138,8 +140,6 @@ describe('E2E / difyctl auth whoami + SSO session', () => {
|
||||
expect(result.stdout).toContain('sso-user@example.com')
|
||||
})
|
||||
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
|
||||
itWithSso('[P0] external user can execute run app using SSO token', async () => {
|
||||
await injectSsoAuth(configDir, {
|
||||
host: E.host,
|
||||
|
||||
@ -67,12 +67,6 @@ describe('E2E / difyctl describe app', () => {
|
||||
expect(result.stdout).toMatch(/Name:/i)
|
||||
})
|
||||
|
||||
it('[P1] describe output contains Tags field', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/Tags:/i)
|
||||
})
|
||||
|
||||
// ── Input schema ──────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] describe output contains Parameters section', async () => {
|
||||
@ -172,8 +166,9 @@ describe('E2E / difyctl describe app', () => {
|
||||
|
||||
// ── External SSO ──────────────────────────────────────────────────────────
|
||||
|
||||
itWithSso('[P0] external SSO user describe app returns insufficient_scope (3.86)', async () => {
|
||||
// Spec 3.86: dfoe_ token → insufficient_scope, exit non-0.
|
||||
itWithSso('[P0] external SSO user can describe a permitted app', async () => {
|
||||
// A dfoe_ token resolves `describe app` via the permitted-external surface
|
||||
// (not the account /apps surface), so a permitted app describes successfully.
|
||||
// Uses DIFY_E2E_SSO_TOKEN; skipped when not configured.
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
@ -191,8 +186,10 @@ describe('E2E / difyctl describe app', () => {
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['describe', 'app', E.chatAppId], { configDir: ssoTmp.configDir })
|
||||
expect(result.exitCode, 'SSO user describe app should exit non-zero').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/ID:/i)
|
||||
expect(result.stdout).toContain(E.chatAppId)
|
||||
expect(result.stdout).toMatch(/Mode:/i)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
@ -225,16 +222,6 @@ describe('E2E / difyctl describe app', () => {
|
||||
expect(result.stdout).toContain('e2e-test')
|
||||
})
|
||||
|
||||
it('[P1] describe output contains Author field (3.67)', async () => {
|
||||
// Spec 3.67: output includes Author field when app has an author.
|
||||
const result = await withRetry(
|
||||
() => fx.r(['describe', 'app', E.chatAppId]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/Author:/i)
|
||||
})
|
||||
|
||||
it('[P0] Inputs section shows parameter names (3.70)', async () => {
|
||||
// Spec 3.70: Parameters/Inputs section displays variable names.
|
||||
// workflow app has x, num, enum_var, paragraph.
|
||||
|
||||
@ -151,15 +151,15 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => {
|
||||
|
||||
// ── External SSO ──────────────────────────────────────────────────────────
|
||||
|
||||
itWithSso('[P0] external SSO user get app -A returns insufficient_scope error (3.103)', async () => {
|
||||
// Spec 3.103: dfoe_ token on -A → insufficient_scope, exit non-0.
|
||||
// Merged from two duplicate fake-token cases; now uses real DIFY_E2E_SSO_TOKEN.
|
||||
itWithSso('[P0] external SSO user get app -A is rejected as an invalid flag', async () => {
|
||||
// --all-workspaces is meaningless for external SSO users (no workspace
|
||||
// scope), so the CLI rejects it client-side with usage_invalid_flag (exit 2).
|
||||
// Uses real DIFY_E2E_SSO_TOKEN; skipped when not configured.
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(ssoTmp.configDir, { recursive: true })
|
||||
// Use minimal SSO hosts.yml (no workspace) so CLI hits the scope/auth error path.
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
@ -171,8 +171,8 @@ describe('E2E / difyctl get app -A (all-workspaces)', () => {
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['get', 'app', '-A'], { configDir: ssoTmp.configDir })
|
||||
expect(result.exitCode, 'SSO user -A should exit non-zero').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth|missing/i)
|
||||
assertExitCode(result, 2)
|
||||
expect(result.stderr).toMatch(/--all-workspaces is not available for external logins/)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
|
||||
@ -206,17 +206,15 @@ describe('E2E / difyctl get app (list)', () => {
|
||||
|
||||
// ── External SSO ──────────────────────────────────────────────────────────
|
||||
|
||||
itWithSso('[P0] external SSO user get app returns insufficient_scope error (3.24 / 3.25)', async () => {
|
||||
// Spec 3.24: dfoe_ token → insufficient_scope; Spec 3.25: exit code is 1.
|
||||
itWithSso('[P0] external SSO user can list permitted apps', async () => {
|
||||
// A dfoe_ token lists apps via the permitted-external surface
|
||||
// (apps:read:permitted-external scope), with no workspace scoping.
|
||||
// Uses DIFY_E2E_SSO_TOKEN (itWithSso skips when not configured).
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(ssoTmp.configDir, { recursive: true })
|
||||
// SSO (dfoe_) users have apps:run scope only, not apps:list.
|
||||
// Inject a minimal hosts.yml without workspace so the CLI reaches the
|
||||
// scope-check path rather than resolving the workspace successfully.
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
@ -228,8 +226,8 @@ describe('E2E / difyctl get app (list)', () => {
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['get', 'app'], { configDir: ssoTmp.configDir })
|
||||
expect(result.exitCode, 'SSO user get app should exit non-zero').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/NAME\s+ID\s+MODE/i)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
|
||||
@ -68,8 +68,9 @@ describe('E2E / difyctl get app <id> (single)', () => {
|
||||
|
||||
// ── External SSO ──────────────────────────────────────────────────────────
|
||||
|
||||
itWithSso('[P0] external SSO user get app <id> returns insufficient_scope error (3.55)', async () => {
|
||||
// Spec 3.55: dfoe_ token on get app <id> → insufficient_scope, exit 1.
|
||||
itWithSso('[P0] external SSO user can get a permitted app by id', async () => {
|
||||
// A dfoe_ token resolves get app <id> via the permitted-external describe
|
||||
// surface (apps:read:permitted-external scope), so a permitted app is returned.
|
||||
// Uses DIFY_E2E_SSO_TOKEN; skipped when not configured.
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
@ -87,8 +88,8 @@ describe('E2E / difyctl get app <id> (single)', () => {
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['get', 'app', E.chatAppId], { configDir: ssoTmp.configDir })
|
||||
expect(result.exitCode, 'SSO user get app <id> should exit non-zero').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/insufficient_scope|scope|not_logged_in|auth/i)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toContain(E.chatAppId)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
/**
|
||||
* E2E: difyctl export app — DSL export
|
||||
* E2E: difyctl export studio-app — DSL export
|
||||
*
|
||||
* Prerequisites (DIFY_E2E_* env vars):
|
||||
* DIFY_E2E_WORKFLOW_APP_ID — echo-workflow app (no model provider dependency)
|
||||
@ -21,7 +21,7 @@ import { resolveEnv } from '../../setup/env.js'
|
||||
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
|
||||
const E = resolveEnv(caps)
|
||||
|
||||
describe('E2E / difyctl export app', () => {
|
||||
describe('E2E / difyctl export studio-app', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
@ -34,37 +34,37 @@ describe('E2E / difyctl export app', () => {
|
||||
// ── Basic export ──────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] exported DSL is non-empty YAML printed to stdout', async () => {
|
||||
const result = await fx.r(['export', 'app', E.workflowAppId])
|
||||
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim().length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P0] exported YAML contains kind: app', async () => {
|
||||
const result = await fx.r(['export', 'app', E.workflowAppId])
|
||||
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/^kind:\s*app/m)
|
||||
})
|
||||
|
||||
it('[P0] exported YAML contains version field', async () => {
|
||||
const result = await fx.r(['export', 'app', E.workflowAppId])
|
||||
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/^version:/m)
|
||||
})
|
||||
|
||||
it('[P0] exported YAML contains app section with mode', async () => {
|
||||
const result = await fx.r(['export', 'app', E.workflowAppId])
|
||||
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/^\s+mode:/m)
|
||||
})
|
||||
|
||||
it('[P1] exported YAML ends with a newline (POSIX pipe convention)', async () => {
|
||||
const result = await fx.r(['export', 'app', E.workflowAppId])
|
||||
const result = await fx.r(['export', 'studio-app', E.workflowAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.endsWith('\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('[P1] chat app export also succeeds and includes mode', async () => {
|
||||
const result = await fx.r(['export', 'app', E.chatAppId])
|
||||
const result = await fx.r(['export', 'studio-app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/^kind:\s*app/m)
|
||||
expect(result.stdout).toMatch(/^\s+mode:/m)
|
||||
@ -76,7 +76,7 @@ describe('E2E / difyctl export app', () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-'))
|
||||
const outPath = join(dir, 'exported.yaml')
|
||||
try {
|
||||
const result = await fx.r(['export', 'app', E.workflowAppId, '--output', outPath])
|
||||
const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath])
|
||||
assertExitCode(result, 0)
|
||||
const content = await readFile(outPath, 'utf8')
|
||||
expect(content).toMatch(/^kind:\s*app/m)
|
||||
@ -92,8 +92,8 @@ describe('E2E / difyctl export app', () => {
|
||||
const outPath = join(dir, 'exported.yaml')
|
||||
try {
|
||||
const [stdoutResult, fileResult] = await Promise.all([
|
||||
fx.r(['export', 'app', E.workflowAppId]),
|
||||
fx.r(['export', 'app', E.workflowAppId, '--output', outPath]).then(async (r) => {
|
||||
fx.r(['export', 'studio-app', E.workflowAppId]),
|
||||
fx.r(['export', 'studio-app', E.workflowAppId, '--output', outPath]).then(async (r) => {
|
||||
const content = await readFile(outPath, 'utf8')
|
||||
return { exitCode: r.exitCode, content }
|
||||
}),
|
||||
@ -113,12 +113,12 @@ describe('E2E / difyctl export app', () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-roundtrip-'))
|
||||
const dslPath = join(dir, 'roundtrip.yaml')
|
||||
try {
|
||||
const exportResult = await fx.r(['export', 'app', E.workflowAppId, '--output', dslPath])
|
||||
const exportResult = await fx.r(['export', 'studio-app', E.workflowAppId, '--output', dslPath])
|
||||
assertExitCode(exportResult, 0)
|
||||
|
||||
const importResult = await fx.r([
|
||||
'import',
|
||||
'app',
|
||||
'studio-app',
|
||||
'--from-file',
|
||||
dslPath,
|
||||
'--name',
|
||||
@ -137,7 +137,7 @@ describe('E2E / difyctl export app', () => {
|
||||
// ── Error scenarios ───────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] non-existent app returns exit code 1 with error in stderr', async () => {
|
||||
const result = await fx.r(['export', 'app', 'nonexistent-app-id-export-e2e'])
|
||||
const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-export-e2e'])
|
||||
expect(result.exitCode).toBe(1)
|
||||
expect(result.stderr.length).toBeGreaterThan(0)
|
||||
})
|
||||
@ -145,7 +145,7 @@ describe('E2E / difyctl export app', () => {
|
||||
it('[P0] unauthenticated export returns auth error (exit code 4)', async () => {
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['export', 'app', E.workflowAppId], {
|
||||
const result = await run(['export', 'studio-app', E.workflowAppId], {
|
||||
configDir: unauthTmp.configDir,
|
||||
})
|
||||
assertExitCode(result, 4)
|
||||
@ -156,13 +156,13 @@ describe('E2E / difyctl export app', () => {
|
||||
})
|
||||
|
||||
it('[P1] export with missing app id argument exits non-zero', async () => {
|
||||
const result = await fx.r(['export', 'app'])
|
||||
const result = await fx.r(['export', 'studio-app'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/missing required argument|required|app id/i)
|
||||
})
|
||||
|
||||
it('[P1] malformed --workflow-id returns a 4xx, not a 5xx', async () => {
|
||||
const result = await fx.r(['export', 'app', E.workflowAppId, '--workflow-id', 'not-a-uuid'])
|
||||
const result = await fx.r(['export', 'studio-app', E.workflowAppId, '--workflow-id', 'not-a-uuid'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/http_status:\s*4\d\d/)
|
||||
expect(result.stderr).not.toMatch(/http_status:\s*5\d\d/)
|
||||
@ -171,7 +171,7 @@ describe('E2E / difyctl export app', () => {
|
||||
it('[P1] non-existent --workflow-id returns 404, not a 5xx', async () => {
|
||||
const result = await fx.r([
|
||||
'export',
|
||||
'app',
|
||||
'studio-app',
|
||||
E.workflowAppId,
|
||||
'--workflow-id',
|
||||
'00000000-0000-0000-0000-000000000000',
|
||||
@ -184,7 +184,7 @@ describe('E2E / difyctl export app', () => {
|
||||
const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-nofile-'))
|
||||
const outPath = join(dir, 'should-not-exist.yaml')
|
||||
try {
|
||||
const result = await fx.r(['export', 'app', 'nonexistent-app-id-nofile-e2e', '--output', outPath])
|
||||
const result = await fx.r(['export', 'studio-app', 'nonexistent-app-id-nofile-e2e', '--output', outPath])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
const exists = await readFile(outPath, 'utf8').then(() => true).catch(() => false)
|
||||
expect(exists, 'output file must not be created on export failure').toBe(false)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user