mirror of
https://github.com/langgenius/dify.git
synced 2026-05-31 22:26:19 +08:00
Compare commits
4 Commits
feat/cli-e
...
feat(agent
| Author | SHA1 | Date | |
|---|---|---|---|
| 16b499cd16 | |||
| c447a1c622 | |||
| 6cf351d12f | |||
| 578de4a51b |
91
.github/workflows/cli-e2e.yml
vendored
91
.github/workflows/cli-e2e.yml
vendored
@ -1,91 +0,0 @@
|
||||
name: CLI E2E Tests
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cli_ref:
|
||||
description: "Git ref to build the CLI from (default: current branch)"
|
||||
type: string
|
||||
required: false
|
||||
test_scope:
|
||||
description: "Test scope to run"
|
||||
type: choice
|
||||
required: false
|
||||
default: smoke
|
||||
options:
|
||||
- smoke # [P0] cases only — fast
|
||||
- full # all cases
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: E2E — difyctl
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
steps:
|
||||
# ── Checkout ───────────────────────────────────────────────────────────
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v4
|
||||
with:
|
||||
ref: ${{ inputs.cli_ref || github.ref }}
|
||||
persist-credentials: false
|
||||
|
||||
# ── Runtime setup ──────────────────────────────────────────────────────
|
||||
- name: Setup web environment
|
||||
uses: ./.github/actions/setup-web
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
with:
|
||||
bun-version: latest
|
||||
|
||||
# Re-initialise pnpm to match the CLI's packageManager (pnpm@11.x).
|
||||
# setup-web installs pnpm@9 via setup-vp; this step overrides it so
|
||||
# the CLI workspace uses the correct version declared in cli/package.json.
|
||||
- uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
|
||||
with:
|
||||
package_json_field: packageManager
|
||||
run_install: false
|
||||
|
||||
- name: Install CLI dependencies
|
||||
working-directory: cli
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate command tree
|
||||
working-directory: cli
|
||||
run: pnpm tree:gen
|
||||
|
||||
# ── Run E2E tests ──────────────────────────────────────────────────────
|
||||
- name: Run E2E tests (${{ inputs.test_scope || 'smoke' }})
|
||||
working-directory: cli
|
||||
env:
|
||||
DIFY_E2E_HOST: ${{ secrets.DIFY_E2E_HOST }}
|
||||
DIFY_E2E_TOKEN: ${{ secrets.DIFY_E2E_TOKEN }}
|
||||
DIFY_E2E_WORKSPACE_ID: ${{ secrets.DIFY_E2E_WORKSPACE_ID }}
|
||||
DIFY_E2E_WORKSPACE_NAME: ${{ secrets.DIFY_E2E_WORKSPACE_NAME }}
|
||||
DIFY_E2E_CHAT_APP_ID: ${{ secrets.DIFY_E2E_CHAT_APP_ID }}
|
||||
DIFY_E2E_WORKFLOW_APP_ID: ${{ secrets.DIFY_E2E_WORKFLOW_APP_ID }}
|
||||
DIFY_E2E_EMAIL: ${{ secrets.DIFY_E2E_EMAIL }}
|
||||
DIFY_E2E_PASSWORD: ${{ secrets.DIFY_E2E_PASSWORD }}
|
||||
DIFY_E2E_HITL_APP_ID: ${{ secrets.DIFY_E2E_HITL_APP_ID }}
|
||||
DIFY_E2E_FILE_APP_ID: ${{ secrets.DIFY_E2E_FILE_APP_ID }}
|
||||
run: |
|
||||
if [ "${{ inputs.test_scope }}" = "full" ]; then
|
||||
pnpm test:e2e
|
||||
else
|
||||
pnpm test:e2e:smoke
|
||||
fi
|
||||
|
||||
# ── Upload results ─────────────────────────────────────────────────────
|
||||
- name: Upload test results on failure
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-results-${{ github.run_id }}
|
||||
path: cli/test-results/
|
||||
retention-days: 3
|
||||
@ -5,7 +5,7 @@ from controllers.console import console_ns
|
||||
from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, edit_permission_required, setup_required
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.model import App, AppMode
|
||||
from models.model import AppMode
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.entities.agent_entities import ComposerSavePayload
|
||||
@ -19,7 +19,7 @@ class WorkflowAgentComposerApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
def get(self, app_model: App, node_id: str):
|
||||
def get(self, app_model, node_id: str):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
return AgentComposerService.load_workflow_composer(
|
||||
tenant_id=tenant_id,
|
||||
@ -33,7 +33,7 @@ class WorkflowAgentComposerApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
def put(self, app_model: App, node_id: str):
|
||||
def put(self, app_model, node_id: str):
|
||||
account, tenant_id = current_account_with_tenant()
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.save_workflow_composer(
|
||||
@ -52,7 +52,7 @@ class WorkflowAgentComposerValidateApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
def post(self, app_model: App, node_id: str):
|
||||
def post(self, app_model, node_id: str):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
return {"result": "success", "errors": []}
|
||||
@ -64,7 +64,7 @@ class WorkflowAgentComposerCandidatesApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
def get(self, app_model: App, node_id: str):
|
||||
def get(self, app_model, node_id: str):
|
||||
return AgentComposerService.get_workflow_candidates(app_id=app_model.id)
|
||||
|
||||
|
||||
@ -74,7 +74,7 @@ class WorkflowAgentComposerImpactApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
def post(self, app_model: App, node_id: str):
|
||||
def post(self, app_model, node_id: str):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
current_snapshot_id = payload.binding.current_snapshot_id if payload.binding else None
|
||||
@ -91,7 +91,7 @@ class WorkflowAgentComposerSaveToRosterApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
|
||||
def post(self, app_model: App, node_id: str):
|
||||
def post(self, app_model, node_id: str):
|
||||
account, tenant_id = current_account_with_tenant()
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.save_workflow_composer(
|
||||
@ -109,7 +109,7 @@ class AgentAppComposerApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
_, tenant_id = current_account_with_tenant()
|
||||
return AgentComposerService.load_agent_app_composer(tenant_id=tenant_id, app_id=app_model.id)
|
||||
|
||||
@ -119,7 +119,7 @@ class AgentAppComposerApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@get_app_model()
|
||||
def put(self, app_model: App):
|
||||
def put(self, app_model):
|
||||
account, tenant_id = current_account_with_tenant()
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
return AgentComposerService.save_agent_app_composer(
|
||||
@ -137,7 +137,7 @@ class AgentAppComposerValidateApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
|
||||
ComposerConfigValidator.validate_save_payload(payload)
|
||||
return {"result": "success", "errors": []}
|
||||
@ -149,5 +149,5 @@ class AgentAppComposerCandidatesApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model()
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
return AgentComposerService.get_agent_app_candidates(app_id=app_model.id)
|
||||
|
||||
@ -8,7 +8,7 @@ from controllers.console.app.wraps import get_app_model
|
||||
from controllers.console.wraps import account_initialization_required, setup_required
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import login_required
|
||||
from models.model import App, AppMode
|
||||
from models.model import AppMode
|
||||
from services.agent_service import AgentService
|
||||
|
||||
|
||||
@ -39,7 +39,7 @@ class AgentLogApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT_CHAT])
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
"""Get agent logs"""
|
||||
args = AgentLogQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
|
||||
@ -573,7 +573,7 @@ class AppApi(Resource):
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@get_app_model(mode=None)
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
"""Get app detail"""
|
||||
app_service = AppService()
|
||||
|
||||
@ -581,7 +581,7 @@ class AppApi(Resource):
|
||||
|
||||
if FeatureService.get_system_features().webapp_auth.enabled:
|
||||
app_setting = EnterpriseService.WebAppAuth.get_app_access_mode_by_id(app_id=str(app_model.id))
|
||||
app_model.access_mode = app_setting.access_mode # type: ignore[attr-defined]
|
||||
app_model.access_mode = app_setting.access_mode
|
||||
|
||||
response_model = AppDetailWithSite.model_validate(app_model, from_attributes=True)
|
||||
return response_model.model_dump(mode="json")
|
||||
@ -598,7 +598,7 @@ class AppApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=None)
|
||||
@edit_permission_required
|
||||
def put(self, app_model: App):
|
||||
def put(self, app_model):
|
||||
"""Update app"""
|
||||
args = UpdateAppPayload.model_validate(console_ns.payload)
|
||||
|
||||
@ -627,7 +627,7 @@ class AppApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def delete(self, app_model: App):
|
||||
def delete(self, app_model):
|
||||
"""Delete app"""
|
||||
app_service = AppService()
|
||||
app_service.delete_app(app_model)
|
||||
@ -648,7 +648,7 @@ class AppCopyApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=None)
|
||||
@edit_permission_required
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
"""Copy app"""
|
||||
# The role of the current user in the ta table must be admin, owner, or editor
|
||||
current_user, _ = current_account_with_tenant()
|
||||
@ -709,7 +709,7 @@ class AppExportApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
"""Export app"""
|
||||
args = AppExportQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
@ -731,7 +731,7 @@ class AppPublishToCreatorsPlatformApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=None)
|
||||
@edit_permission_required
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
"""Publish app to Creators Platform"""
|
||||
from configs import dify_config
|
||||
from core.helper.creators import get_redirect_url, upload_dsl
|
||||
@ -762,7 +762,7 @@ class AppNameApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=None)
|
||||
@edit_permission_required
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
args = AppNamePayload.model_validate(console_ns.payload)
|
||||
|
||||
app_service = AppService()
|
||||
@ -784,7 +784,7 @@ class AppIconApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=None)
|
||||
@edit_permission_required
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
args = AppIconPayload.model_validate(console_ns.payload or {})
|
||||
|
||||
app_service = AppService()
|
||||
@ -811,7 +811,7 @@ class AppSiteStatus(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=None)
|
||||
@edit_permission_required
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
args = AppSiteStatusPayload.model_validate(console_ns.payload)
|
||||
|
||||
app_service = AppService()
|
||||
@ -833,7 +833,7 @@ class AppApiStatus(Resource):
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=None)
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
args = AppApiStatusPayload.model_validate(console_ns.payload)
|
||||
|
||||
app_service = AppService()
|
||||
@ -874,7 +874,7 @@ class AppTraceApi(Resource):
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
@get_app_model
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
# add app trace
|
||||
args = AppTracePayload.model_validate(console_ns.payload)
|
||||
|
||||
|
||||
@ -70,7 +70,7 @@ class ChatMessageAudioApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
file = request.files["file"]
|
||||
|
||||
try:
|
||||
@ -171,7 +171,7 @@ class TextModesApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
try:
|
||||
args = TextToSpeechVoiceQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ from libs import helper
|
||||
from libs.helper import uuid_value
|
||||
from libs.login import current_user, login_required
|
||||
from models import Account
|
||||
from models.model import App, AppMode
|
||||
from models.model import AppMode
|
||||
from services.app_generate_service import AppGenerateService
|
||||
from services.app_task_service import AppTaskService
|
||||
from services.errors.llm import InvokeRateLimitError
|
||||
@ -84,7 +84,7 @@ class CompletionMessageApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=AppMode.COMPLETION)
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
args_model = CompletionMessagePayload.model_validate(console_ns.payload)
|
||||
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
||||
|
||||
@ -131,7 +131,7 @@ class CompletionMessageStopApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=AppMode.COMPLETION)
|
||||
def post(self, app_model: App, task_id: str):
|
||||
def post(self, app_model, task_id: str):
|
||||
if not isinstance(current_user, Account):
|
||||
raise ValueError("current_user must be an Account instance")
|
||||
|
||||
@ -159,7 +159,7 @@ class ChatMessageApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT])
|
||||
@edit_permission_required
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
args_model = ChatMessagePayload.model_validate(console_ns.payload)
|
||||
args = args_model.model_dump(exclude_none=True, by_alias=True)
|
||||
|
||||
@ -212,7 +212,7 @@ class ChatMessageStopApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
def post(self, app_model: App, task_id: str):
|
||||
def post(self, app_model, task_id: str):
|
||||
if not isinstance(current_user, Account):
|
||||
raise ValueError("current_user must be an Account instance")
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ from fields.conversation_fields import (
|
||||
from libs.datetime_utils import naive_utc_now, parse_time_range
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import Conversation, EndUser, Message, MessageAnnotation
|
||||
from models.model import App, AppMode
|
||||
from models.model import AppMode
|
||||
from services.conversation_service import ConversationService
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
|
||||
@ -93,7 +93,7 @@ class CompletionConversationApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=AppMode.COMPLETION)
|
||||
@edit_permission_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = CompletionConversationQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
@ -165,7 +165,7 @@ class CompletionConversationDetailApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=AppMode.COMPLETION)
|
||||
@edit_permission_required
|
||||
def get(self, app_model: App, conversation_id: UUID):
|
||||
def get(self, app_model, conversation_id: UUID):
|
||||
conversation_id_str = str(conversation_id)
|
||||
return ConversationMessageDetailResponse.model_validate(
|
||||
_get_conversation(app_model, conversation_id_str), from_attributes=True
|
||||
@ -182,7 +182,7 @@ class CompletionConversationDetailApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=AppMode.COMPLETION)
|
||||
@edit_permission_required
|
||||
def delete(self, app_model: App, conversation_id: UUID):
|
||||
def delete(self, app_model, conversation_id: UUID):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
conversation_id_str = str(conversation_id)
|
||||
|
||||
@ -207,7 +207,7 @@ class ChatConversationApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@edit_permission_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
args = ChatConversationQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
@ -318,7 +318,7 @@ class ChatConversationDetailApi(Resource):
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@edit_permission_required
|
||||
def get(self, app_model: App, conversation_id: UUID):
|
||||
def get(self, app_model, conversation_id: UUID):
|
||||
conversation_id_str = str(conversation_id)
|
||||
return ConversationDetailResponse.model_validate(
|
||||
_get_conversation(app_model, conversation_id_str), from_attributes=True
|
||||
@ -335,7 +335,7 @@ class ChatConversationDetailApi(Resource):
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def delete(self, app_model: App, conversation_id: UUID):
|
||||
def delete(self, app_model, conversation_id: UUID):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
conversation_id_str = str(conversation_id)
|
||||
|
||||
|
||||
@ -19,7 +19,7 @@ from fields.base import ResponseModel
|
||||
from libs.helper import to_timestamp
|
||||
from libs.login import login_required
|
||||
from models import ConversationVariable
|
||||
from models.model import App, AppMode
|
||||
from models.model import AppMode
|
||||
|
||||
|
||||
class ConversationVariablesQuery(BaseModel):
|
||||
@ -94,7 +94,7 @@ class ConversationVariablesApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=AppMode.ADVANCED_CHAT)
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
args = ConversationVariablesQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
stmt = (
|
||||
|
||||
@ -17,7 +17,7 @@ from fields.base import ResponseModel
|
||||
from libs.helper import to_timestamp
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.enums import AppMCPServerStatus
|
||||
from models.model import App, AppMCPServer
|
||||
from models.model import AppMCPServer
|
||||
|
||||
|
||||
class MCPServerCreatePayload(BaseModel):
|
||||
@ -73,7 +73,7 @@ class AppMCPServerController(Resource):
|
||||
@account_initialization_required
|
||||
@setup_required
|
||||
@get_app_model
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
|
||||
if server is None:
|
||||
return {}
|
||||
@ -92,7 +92,7 @@ class AppMCPServerController(Resource):
|
||||
@login_required
|
||||
@setup_required
|
||||
@edit_permission_required
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
_, current_tenant_id = current_account_with_tenant()
|
||||
payload = MCPServerCreatePayload.model_validate(console_ns.payload or {})
|
||||
|
||||
@ -127,7 +127,7 @@ class AppMCPServerController(Resource):
|
||||
@setup_required
|
||||
@account_initialization_required
|
||||
@edit_permission_required
|
||||
def put(self, app_model: App):
|
||||
def put(self, app_model):
|
||||
payload = MCPServerUpdatePayload.model_validate(console_ns.payload or {})
|
||||
server = db.session.get(AppMCPServer, payload.id)
|
||||
if not server:
|
||||
|
||||
@ -45,7 +45,7 @@ from libs.helper import to_timestamp, uuid_value
|
||||
from libs.infinite_scroll_pagination import InfiniteScrollPagination
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.enums import FeedbackFromSource, FeedbackRating
|
||||
from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
|
||||
from models.model import AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
|
||||
from services.errors.conversation import ConversationNotExistsError
|
||||
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
|
||||
from services.message_service import MessageService, attach_message_extra_contents
|
||||
@ -180,7 +180,7 @@ class ChatMessageListApi(Resource):
|
||||
@setup_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
@edit_permission_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
args = ChatMessagesQuery.model_validate(request.args.to_dict())
|
||||
|
||||
conversation = db.session.scalar(
|
||||
@ -257,7 +257,7 @@ class MessageFeedbackApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
|
||||
args = MessageFeedbackPayload.model_validate(console_ns.payload)
|
||||
@ -314,7 +314,7 @@ class MessageAnnotationCountApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
count = db.session.scalar(
|
||||
select(func.count(MessageAnnotation.id)).where(MessageAnnotation.app_id == app_model.id)
|
||||
)
|
||||
@ -337,7 +337,7 @@ class MessageSuggestedQuestionApi(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
def get(self, app_model: App, message_id: UUID):
|
||||
def get(self, app_model, message_id: UUID):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
message_id_str = str(message_id)
|
||||
|
||||
@ -379,7 +379,7 @@ class MessageFeedbackExportApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
args = FeedbackExportQuery.model_validate(request.args.to_dict())
|
||||
|
||||
# Import the service function
|
||||
@ -417,7 +417,7 @@ class MessageApi(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App, message_id: UUID):
|
||||
def get(self, app_model, message_id: UUID):
|
||||
message_id_str = str(message_id)
|
||||
|
||||
message = db.session.scalar(
|
||||
|
||||
@ -16,7 +16,7 @@ from events.app_event import app_model_config_was_updated
|
||||
from extensions.ext_database import db
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.model import App, AppMode, AppModelConfig
|
||||
from models.model import AppMode, AppModelConfig
|
||||
from services.app_model_config_service import AppModelConfigService
|
||||
|
||||
|
||||
@ -52,7 +52,7 @@ class ModelConfigResource(Resource):
|
||||
@edit_permission_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.AGENT_CHAT, AppMode.CHAT, AppMode.COMPLETION])
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
"""Modify app model config"""
|
||||
current_user, current_tenant_id = current_account_with_tenant()
|
||||
# validate config
|
||||
|
||||
@ -20,7 +20,6 @@ from fields.base import ResponseModel
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import Site
|
||||
from models.model import App
|
||||
|
||||
|
||||
class AppSiteUpdatePayload(BaseModel):
|
||||
@ -85,7 +84,7 @@ class AppSite(Resource):
|
||||
@edit_permission_required
|
||||
@account_initialization_required
|
||||
@get_app_model
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
args = AppSiteUpdatePayload.model_validate(console_ns.payload or {})
|
||||
current_user, _ = current_account_with_tenant()
|
||||
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
||||
@ -134,7 +133,7 @@ class AppSiteAccessTokenReset(Resource):
|
||||
@is_admin_or_owner_required
|
||||
@account_initialization_required
|
||||
@get_app_model
|
||||
def post(self, app_model: App):
|
||||
def post(self, app_model):
|
||||
current_user, _ = current_account_with_tenant()
|
||||
site = db.session.scalar(select(Site).where(Site.app_id == app_model.id).limit(1))
|
||||
|
||||
|
||||
@ -15,7 +15,6 @@ from libs.datetime_utils import parse_time_range
|
||||
from libs.helper import convert_datetime_to_date
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models import AppMode
|
||||
from models.model import App
|
||||
|
||||
|
||||
class StatisticTimeRangeQuery(BaseModel):
|
||||
@ -48,7 +47,7 @@ class DailyMessageStatistic(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
account, _ = current_account_with_tenant()
|
||||
|
||||
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@ -62,12 +61,8 @@ FROM
|
||||
WHERE
|
||||
app_id = :app_id
|
||||
AND invoke_from != :invoke_from"""
|
||||
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
|
||||
assert account.timezone is not None
|
||||
arg_dict: dict[str, object] = {
|
||||
"tz": account.timezone,
|
||||
"app_id": app_model.id,
|
||||
"invoke_from": InvokeFrom.DEBUGGER,
|
||||
}
|
||||
|
||||
try:
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
|
||||
@ -109,7 +104,7 @@ class DailyConversationStatistic(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
account, _ = current_account_with_tenant()
|
||||
|
||||
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@ -123,12 +118,8 @@ FROM
|
||||
WHERE
|
||||
app_id = :app_id
|
||||
AND invoke_from != :invoke_from"""
|
||||
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
|
||||
assert account.timezone is not None
|
||||
arg_dict: dict[str, object] = {
|
||||
"tz": account.timezone,
|
||||
"app_id": app_model.id,
|
||||
"invoke_from": InvokeFrom.DEBUGGER,
|
||||
}
|
||||
|
||||
try:
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
|
||||
@ -169,7 +160,7 @@ class DailyTerminalsStatistic(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
account, _ = current_account_with_tenant()
|
||||
|
||||
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@ -183,12 +174,8 @@ FROM
|
||||
WHERE
|
||||
app_id = :app_id
|
||||
AND invoke_from != :invoke_from"""
|
||||
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
|
||||
assert account.timezone is not None
|
||||
arg_dict: dict[str, object] = {
|
||||
"tz": account.timezone,
|
||||
"app_id": app_model.id,
|
||||
"invoke_from": InvokeFrom.DEBUGGER,
|
||||
}
|
||||
|
||||
try:
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
|
||||
@ -230,7 +217,7 @@ class DailyTokenCostStatistic(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
account, _ = current_account_with_tenant()
|
||||
|
||||
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@ -245,12 +232,8 @@ FROM
|
||||
WHERE
|
||||
app_id = :app_id
|
||||
AND invoke_from != :invoke_from"""
|
||||
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
|
||||
assert account.timezone is not None
|
||||
arg_dict: dict[str, object] = {
|
||||
"tz": account.timezone,
|
||||
"app_id": app_model.id,
|
||||
"invoke_from": InvokeFrom.DEBUGGER,
|
||||
}
|
||||
|
||||
try:
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
|
||||
@ -294,7 +277,7 @@ class AverageSessionInteractionStatistic(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
account, _ = current_account_with_tenant()
|
||||
|
||||
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@ -316,12 +299,8 @@ FROM
|
||||
WHERE
|
||||
c.app_id = :app_id
|
||||
AND m.invoke_from != :invoke_from"""
|
||||
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
|
||||
assert account.timezone is not None
|
||||
arg_dict: dict[str, object] = {
|
||||
"tz": account.timezone,
|
||||
"app_id": app_model.id,
|
||||
"invoke_from": InvokeFrom.DEBUGGER,
|
||||
}
|
||||
|
||||
try:
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
|
||||
@ -374,7 +353,7 @@ class UserSatisfactionRateStatistic(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
account, _ = current_account_with_tenant()
|
||||
|
||||
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@ -392,12 +371,8 @@ LEFT JOIN
|
||||
WHERE
|
||||
m.app_id = :app_id
|
||||
AND m.invoke_from != :invoke_from"""
|
||||
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
|
||||
assert account.timezone is not None
|
||||
arg_dict: dict[str, object] = {
|
||||
"tz": account.timezone,
|
||||
"app_id": app_model.id,
|
||||
"invoke_from": InvokeFrom.DEBUGGER,
|
||||
}
|
||||
|
||||
try:
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
|
||||
@ -444,7 +419,7 @@ class AverageResponseTimeStatistic(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=AppMode.COMPLETION)
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
account, _ = current_account_with_tenant()
|
||||
|
||||
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@ -458,12 +433,8 @@ FROM
|
||||
WHERE
|
||||
app_id = :app_id
|
||||
AND invoke_from != :invoke_from"""
|
||||
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
|
||||
assert account.timezone is not None
|
||||
arg_dict: dict[str, object] = {
|
||||
"tz": account.timezone,
|
||||
"app_id": app_model.id,
|
||||
"invoke_from": InvokeFrom.DEBUGGER,
|
||||
}
|
||||
|
||||
try:
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
|
||||
@ -505,7 +476,7 @@ class TokensPerSecondStatistic(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
account, _ = current_account_with_tenant()
|
||||
args = StatisticTimeRangeQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
@ -521,12 +492,8 @@ FROM
|
||||
WHERE
|
||||
app_id = :app_id
|
||||
AND invoke_from != :invoke_from"""
|
||||
arg_dict = {"tz": account.timezone, "app_id": app_model.id, "invoke_from": InvokeFrom.DEBUGGER}
|
||||
assert account.timezone is not None
|
||||
arg_dict: dict[str, object] = {
|
||||
"tz": account.timezone,
|
||||
"app_id": app_model.id,
|
||||
"invoke_from": InvokeFrom.DEBUGGER,
|
||||
}
|
||||
|
||||
try:
|
||||
start_datetime_utc, end_datetime_utc = parse_time_range(args.start, args.end, account.timezone)
|
||||
|
||||
@ -11,7 +11,7 @@ from extensions.ext_database import db
|
||||
from libs.datetime_utils import parse_time_range
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
from models.enums import WorkflowRunTriggeredFrom
|
||||
from models.model import App, AppMode
|
||||
from models.model import AppMode
|
||||
from repositories.factory import DifyAPIRepositoryFactory
|
||||
|
||||
|
||||
@ -46,7 +46,7 @@ class WorkflowDailyRunsStatistic(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
account, _ = current_account_with_tenant()
|
||||
|
||||
args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@ -86,7 +86,7 @@ class WorkflowDailyTerminalsStatistic(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
account, _ = current_account_with_tenant()
|
||||
|
||||
args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@ -126,7 +126,7 @@ class WorkflowDailyTokenCostStatistic(Resource):
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
account, _ = current_account_with_tenant()
|
||||
|
||||
args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True))
|
||||
@ -166,7 +166,7 @@ class WorkflowAverageAppInteractionStatistic(Resource):
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@get_app_model(mode=[AppMode.WORKFLOW])
|
||||
def get(self, app_model: App):
|
||||
def get(self, app_model):
|
||||
account, _ = current_account_with_tenant()
|
||||
|
||||
args = WorkflowStatisticQuery.model_validate(request.args.to_dict(flat=True))
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
# E2E test environment variables
|
||||
# Copy this file to .env.e2e and fill in real values before running tests.
|
||||
# See test/e2e/setup/env.ts for documentation on each variable.
|
||||
|
||||
# Required
|
||||
DIFY_E2E_HOST=https://your-staging-host.dify.ai
|
||||
DIFY_E2E_TOKEN=dfoa_your_token_here
|
||||
DIFY_E2E_WORKSPACE_ID=ws-your-workspace-id
|
||||
DIFY_E2E_CHAT_APP_ID=app-echo-chat-id
|
||||
DIFY_E2E_WORKFLOW_APP_ID=app-echo-workflow-id
|
||||
|
||||
# Optional (skip related tests when absent)
|
||||
DIFY_E2E_SSO_TOKEN=
|
||||
DIFY_E2E_HITL_APP_ID=
|
||||
DIFY_E2E_FILE_APP_ID=
|
||||
DIFY_E2E_WORKSPACE_NAME=
|
||||
|
||||
# For logout / devices revoke tests (mint disposable tokens via device flow API)
|
||||
DIFY_E2E_EMAIL=
|
||||
DIFY_E2E_PASSWORD=
|
||||
8
cli/.gitignore
vendored
8
cli/.gitignore
vendored
@ -4,10 +4,4 @@ node_modules/
|
||||
*.tsbuildinfo
|
||||
.vitest-cache/
|
||||
docs/specs/
|
||||
context/
|
||||
# E2E test env (contains tokens/credentials — use .env.e2e.example instead)
|
||||
.env.e2e
|
||||
# Generated / runtime artifacts
|
||||
oclif.manifest.json
|
||||
npm-shrinkwrap.json
|
||||
tmp/
|
||||
context/
|
||||
@ -30,9 +30,6 @@
|
||||
"dev": "bun bin/dev.js",
|
||||
"test": "vp test",
|
||||
"test:coverage": "vp test --coverage",
|
||||
"test:e2e": "vp test --config vitest.e2e.config.ts",
|
||||
"test:e2e:smoke": "vp test --config vitest.e2e.config.ts --testNamePattern \"\\[P0\\]\"",
|
||||
"test:e2e:local": "DIFY_E2E_MODE=local vp test --config vitest.e2e.config.ts",
|
||||
"lint": "eslint",
|
||||
"lint:fix": "eslint --fix",
|
||||
"type-check": "tsc",
|
||||
|
||||
@ -1,19 +0,0 @@
|
||||
# E2E test environment template — copy to .env.e2e and fill in the values.
|
||||
# .env.e2e is git-ignored; this file is safe to commit.
|
||||
#
|
||||
# Required:
|
||||
DIFY_E2E_HOST=
|
||||
DIFY_E2E_TOKEN=
|
||||
DIFY_E2E_WORKSPACE_ID=
|
||||
DIFY_E2E_WORKSPACE_NAME=
|
||||
DIFY_E2E_CHAT_APP_ID=
|
||||
DIFY_E2E_WORKFLOW_APP_ID=
|
||||
|
||||
# Optional — skip related tests when absent:
|
||||
DIFY_E2E_SSO_TOKEN=
|
||||
DIFY_E2E_FILE_APP_ID=
|
||||
DIFY_E2E_HITL_APP_ID=
|
||||
|
||||
# Used by global-setup to mint per-suite tokens (logout / devices tests):
|
||||
DIFY_E2E_EMAIL=
|
||||
DIFY_E2E_PASSWORD=
|
||||
@ -1,115 +0,0 @@
|
||||
# Dify CLI — E2E Test Suite
|
||||
|
||||
End-to-end tests that exercise the **real `difyctl` binary** against a live
|
||||
Dify server. Every test uses an isolated temporary config directory so no
|
||||
state leaks between test files.
|
||||
|
||||
## Directory layout
|
||||
|
||||
```
|
||||
test/e2e/
|
||||
├── setup/
|
||||
│ ├── env.ts — Load & validate DIFY_E2E_* env vars
|
||||
│ ├── global-setup.ts — Health-check server + mint disposable token
|
||||
│ └── global-teardown.ts — Delete conversations created during the run
|
||||
│
|
||||
├── helpers/
|
||||
│ ├── cli.ts — run(), withAuthFixture(), mintFreshToken(),
|
||||
│ │ injectAuth(), spawn_background()
|
||||
│ ├── assert.ts — assertExitCode, assertJson, assertErrorEnvelope,
|
||||
│ │ assertNoAnsi, assertPipeFriendlyJson, …
|
||||
│ ├── cleanup-registry.ts — registerConversation() / cleanupRegisteredConversations()
|
||||
│ ├── retry.ts — withRetry(fn, { attempts, delayMs })
|
||||
│ └── skip.ts — optionalIt(), optionalDescribe()
|
||||
│
|
||||
└── suites/
|
||||
├── auth/
|
||||
│ ├── status.e2e.ts — auth status (text + JSON + SSO)
|
||||
│ ├── use.e2e.ts — workspace switching
|
||||
│ ├── whoami.e2e.ts — whoami + external SSO session checks
|
||||
│ ├── devices.e2e.ts — devices list + revoke (runs near-last)
|
||||
│ └── logout.e2e.ts — logout + local credential cleanup (runs last)
|
||||
├── config/
|
||||
│ └── config.e2e.ts — config path/get/set/unset/view, env override
|
||||
└── run/
|
||||
├── run-app-basic.e2e.ts — basic run, -o json, --inputs, streaming,
|
||||
│ conversation, CI mode
|
||||
├── run-app-streaming.e2e.ts — Ctrl+C / error-event / chunk timing
|
||||
├── run-app-file.e2e.ts — --file upload (local + remote URL)
|
||||
└── run-app-hitl.e2e.ts — HITL pause + resume
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
Copy the credential template and fill in your values:
|
||||
|
||||
```bash
|
||||
cp cli/.env.e2e.example cli/.env.e2e
|
||||
# edit cli/.env.e2e with real credentials
|
||||
```
|
||||
|
||||
### Required env vars
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------------- | -------------------------------------------------------- |
|
||||
| `DIFY_E2E_HOST` | Staging server base URL (`http://localhost`) |
|
||||
| `DIFY_E2E_TOKEN` | Internal user bearer token (`dfoa_…`) |
|
||||
| `DIFY_E2E_WORKSPACE_ID` | Primary workspace ID |
|
||||
| `DIFY_E2E_CHAT_APP_ID` | Chat app — outputs `echo: {query}` |
|
||||
| `DIFY_E2E_WORKFLOW_APP_ID` | Workflow app — input `x` (required), outputs `echo: {x}` |
|
||||
|
||||
### Optional env vars
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------------- | ---------------------------------------------------- |
|
||||
| `DIFY_E2E_SSO_TOKEN` | External SSO bearer token (`dfoe_…`) |
|
||||
| `DIFY_E2E_HITL_APP_ID` | Workflow app with a Human-Input node |
|
||||
| `DIFY_E2E_FILE_APP_ID` | Workflow app with a file input variable (`doc`) |
|
||||
| `DIFY_E2E_WORKSPACE_NAME` | Display name for the primary workspace |
|
||||
| `DIFY_E2E_EMAIL` | Console account email (enables disposable tokens) |
|
||||
| `DIFY_E2E_PASSWORD` | Console account password (enables disposable tokens) |
|
||||
|
||||
> `DIFY_E2E_EMAIL` + `DIFY_E2E_PASSWORD` are used by `global-setup` and the
|
||||
> `devices`/`logout` suites to mint fresh single-use `dfoa_` tokens via the
|
||||
> device flow API, so those tests never revoke the shared `DIFY_E2E_TOKEN`.
|
||||
|
||||
## Running tests
|
||||
|
||||
```bash
|
||||
cd cli
|
||||
|
||||
# Run the full E2E suite
|
||||
bun run test:e2e
|
||||
|
||||
# Run only [P0] smoke cases
|
||||
bun run test:e2e:smoke
|
||||
|
||||
# Run offline-safe config tests only (no network required)
|
||||
bun run test:e2e:local
|
||||
|
||||
# Run a single file
|
||||
bun vitest --config vitest.e2e.config.ts test/e2e/suites/auth/status.e2e.ts
|
||||
```
|
||||
|
||||
## Test execution order
|
||||
|
||||
Files run sequentially (`fileParallelism: false`) in this order:
|
||||
|
||||
```
|
||||
status → use → whoami → config → run (basic / streaming / file / HITL)
|
||||
→ devices → logout
|
||||
```
|
||||
|
||||
`devices` and `logout` run last because they revoke real server sessions.
|
||||
|
||||
## Design decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **No mocking** | All HTTP traffic goes to the real server — this catches real integration regressions. |
|
||||
| **Isolated config dirs** | Each test creates a fresh `withTempConfig()` dir; session state never leaks between tests. |
|
||||
| **`withAuthFixture()`** | Combines `withTempConfig` + `injectAuth` into a single fixture; reduces beforeEach boilerplate. |
|
||||
| **`injectAuth()` bypasses Device Flow** | Non-auth tests skip the browser step; only `auth/` suites exercise the real flow. |
|
||||
| **`mintFreshToken()`** | `logout` and `devices-revoke` tests mint a disposable `dfoa_` token via the device flow API, so revoking it never kills the shared `DIFY_E2E_TOKEN`. |
|
||||
| **Global `retry: 0`** | Flaky network calls use `withRetry()` locally with `shouldRetry` filtering; global retry masks non-idempotent failures (e.g. logout). |
|
||||
| **Conversation cleanup** | `registerConversation()` + global-teardown delete staging conversations after the run. |
|
||||
@ -1,155 +0,0 @@
|
||||
/**
|
||||
* E2E assertion helpers.
|
||||
*
|
||||
* These wrap vitest's `expect` with richer failure messages that include the
|
||||
* full stdout / stderr of the failing process — essential for debugging CI.
|
||||
*/
|
||||
|
||||
import type { RunResult } from './cli.js'
|
||||
import { expect } from 'vitest'
|
||||
import './vitest-context.js'
|
||||
|
||||
// ── ANSI ──────────────────────────────────────────────────────────────────
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const ANSI_RE = /\x1B\[[0-9;]*[mGKHFA-DJsuhl]/g
|
||||
|
||||
function redact(text: string): string {
|
||||
return text
|
||||
.replace(/\bBearer\s+[\w.-]+\b/g, 'Bearer [REDACTED]')
|
||||
.replace(/\bdfo[ae]_[\w-]+\b/g, 'dfo*_REDACTED')
|
||||
}
|
||||
|
||||
// ── Exit code ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Assert the exit code matches `expected`.
|
||||
* On failure, prints the full stdout and stderr so the cause is visible in CI.
|
||||
*/
|
||||
export function assertExitCode(result: RunResult, expected: number): void {
|
||||
if (result.exitCode !== expected) {
|
||||
process.stderr.write(
|
||||
`\n[E2E assertExitCode] expected ${expected}, got ${result.exitCode}\n`
|
||||
+ `stdout:\n${redact(result.stdout) || '(empty)'}\n`
|
||||
+ `stderr:\n${redact(result.stderr) || '(empty)'}\n`,
|
||||
)
|
||||
}
|
||||
expect(result.exitCode, `exit code should be ${expected}`).toBe(expected)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the exit code is NOT 0 (i.e. some error occurred).
|
||||
*/
|
||||
export function assertNonZeroExit(result: RunResult): void {
|
||||
expect(result.exitCode, 'exit code should be non-zero').not.toBe(0)
|
||||
}
|
||||
|
||||
// ── Stdout / stderr content ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Assert stdout is valid JSON and return the parsed value.
|
||||
*/
|
||||
export function assertJson<T = unknown>(result: RunResult): T {
|
||||
let parsed: T
|
||||
try {
|
||||
parsed = JSON.parse(result.stdout) as T
|
||||
}
|
||||
catch {
|
||||
throw new Error(
|
||||
`stdout is not valid JSON.\nstdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}`,
|
||||
)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert stderr contains a valid JSON error envelope of the shape:
|
||||
* { error: { code: string, message: string, hint?: string } }
|
||||
*
|
||||
* @param result - The run result to inspect.
|
||||
* @param expectedCode - When provided, also asserts that error.code equals this value.
|
||||
* Use the stable error codes from the CLI contract, e.g.:
|
||||
* 'not_logged_in', 'app_not_found', 'insufficient_scope', 'auth_expired'
|
||||
*
|
||||
* @example
|
||||
* assertErrorEnvelope(result, 'not_logged_in')
|
||||
* assertErrorEnvelope(result, 'app_not_found')
|
||||
*/
|
||||
export function assertErrorEnvelope(
|
||||
result: RunResult,
|
||||
expectedCode?: string,
|
||||
): { error: { code: string, message: string, hint?: string } } {
|
||||
const raw = result.stderr.trim()
|
||||
let parsed: { error: { code: string, message: string, hint?: string } }
|
||||
try {
|
||||
parsed = JSON.parse(raw) as typeof parsed
|
||||
}
|
||||
catch {
|
||||
throw new Error(
|
||||
`stderr is not valid JSON.\nstdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}`,
|
||||
)
|
||||
}
|
||||
expect(parsed, 'stderr envelope missing "error" key').toHaveProperty('error')
|
||||
expect(parsed.error, 'error.code must be a non-empty string').toHaveProperty('code')
|
||||
expect(parsed.error, 'error.message must be a non-empty string').toHaveProperty('message')
|
||||
expect(typeof parsed.error.code, 'error.code must be a string').toBe('string')
|
||||
expect(parsed.error.code.length, 'error.code must be non-empty').toBeGreaterThan(0)
|
||||
if (expectedCode !== undefined) {
|
||||
expect(
|
||||
parsed.error.code,
|
||||
`error.code should be "${expectedCode}", got "${parsed.error.code}"\nstderr:\n${redact(result.stderr)}`,
|
||||
).toBe(expectedCode)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
// ── ANSI / formatting ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Assert the given text contains no ANSI escape sequences.
|
||||
* Pass `label` to identify which stream failed (e.g. 'stdout', 'stderr').
|
||||
*/
|
||||
export function assertNoAnsi(text: string, label = 'output'): void {
|
||||
const clean = text.replace(ANSI_RE, '')
|
||||
expect(text, `${label} must not contain ANSI control codes`).toBe(clean)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert stdout starts with `{` and ends with `\n` — the canonical format
|
||||
* for pipe-friendly JSON output.
|
||||
*/
|
||||
export function assertPipeFriendlyJson(result: RunResult): void {
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
expect(
|
||||
result.stdout.trimStart().startsWith('{') || result.stdout.trimStart().startsWith('['),
|
||||
'stdout should start with { or [ for pipe-friendly JSON',
|
||||
).toBe(true)
|
||||
expect(result.stdout.endsWith('\n'), 'stdout should end with newline').toBe(true)
|
||||
}
|
||||
|
||||
// ── stdout / stderr contains ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Assert stdout contains the given substring, printing full output on failure.
|
||||
*/
|
||||
export function assertStdoutContains(result: RunResult, expected: string): void {
|
||||
if (!result.stdout.includes(expected)) {
|
||||
process.stderr.write(
|
||||
`\n[E2E assertStdoutContains] "${expected}" not found in stdout.\n`
|
||||
+ `stdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}\n`,
|
||||
)
|
||||
}
|
||||
expect(result.stdout).toContain(expected)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert stderr contains the given substring, printing full output on failure.
|
||||
*/
|
||||
export function assertStderrContains(result: RunResult, expected: string): void {
|
||||
if (!result.stderr.includes(expected)) {
|
||||
process.stderr.write(
|
||||
`\n[E2E assertStderrContains] "${expected}" not found in stderr.\n`
|
||||
+ `stdout:\n${redact(result.stdout)}\nstderr:\n${redact(result.stderr)}\n`,
|
||||
)
|
||||
}
|
||||
expect(result.stderr).toContain(expected)
|
||||
}
|
||||
@ -1,93 +0,0 @@
|
||||
/**
|
||||
* E2E cleanup registry.
|
||||
*
|
||||
* Test suites call `registerConversation(host, token, appId, conversationId)`
|
||||
* whenever a real conversation is created on staging. The global teardown
|
||||
* iterates the registry and deletes all collected conversations so staging
|
||||
* data stays clean between CI runs.
|
||||
*
|
||||
* Design notes:
|
||||
* - Uses a module-level array (shared within the same worker process).
|
||||
* - vitest runs E2E suites in a single fork (fileParallelism: false), so one
|
||||
* process owns the full registry.
|
||||
* - Deletion is best-effort: individual failures are logged but do not throw.
|
||||
*/
|
||||
|
||||
export type ConversationEntry = {
|
||||
host: string
|
||||
token: string
|
||||
appId: string
|
||||
conversationId: string
|
||||
}
|
||||
|
||||
const _conversations: ConversationEntry[] = []
|
||||
|
||||
/**
|
||||
* Register a conversation for cleanup in teardown.
|
||||
* Call this whenever `run app` returns a `conversation_id`.
|
||||
*/
|
||||
export function registerConversation(
|
||||
host: string,
|
||||
token: string,
|
||||
appId: string,
|
||||
conversationId: string,
|
||||
): void {
|
||||
if (!conversationId || !appId)
|
||||
return
|
||||
_conversations.push({ host, token, appId, conversationId })
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all registered conversations (for use in teardown).
|
||||
*/
|
||||
export function getRegisteredConversations(): readonly ConversationEntry[] {
|
||||
return _conversations
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all registered conversations from the staging server.
|
||||
* Called once from global-teardown.ts.
|
||||
*/
|
||||
export async function cleanupRegisteredConversations(): Promise<void> {
|
||||
if (_conversations.length === 0)
|
||||
return
|
||||
|
||||
console.log(`[E2E teardown] Cleaning up ${_conversations.length} staged conversation(s)…`)
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
_conversations.map(({ host, token, appId, conversationId }) =>
|
||||
deleteConversation(host, token, appId, conversationId),
|
||||
),
|
||||
)
|
||||
|
||||
const failed = results.filter(r => r.status === 'rejected')
|
||||
if (failed.length > 0) {
|
||||
console.warn(
|
||||
`[E2E teardown] ${failed.length} conversation deletion(s) failed (non-blocking):`,
|
||||
failed.map(r => (r as PromiseRejectedResult).reason).join(', '),
|
||||
)
|
||||
}
|
||||
else {
|
||||
console.log(`[E2E teardown] All conversations cleaned up.`)
|
||||
}
|
||||
|
||||
_conversations.length = 0
|
||||
}
|
||||
|
||||
async function deleteConversation(
|
||||
host: string,
|
||||
token: string,
|
||||
appId: string,
|
||||
conversationId: string,
|
||||
): Promise<void> {
|
||||
const url = `${host.replace(/\/$/, '')}/openapi/v1/apps/${appId}/conversations/${conversationId}`
|
||||
const res = await fetch(url, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
})
|
||||
// 404 is acceptable — conversation may have already been cleaned up
|
||||
if (!res.ok && res.status !== 404) {
|
||||
throw new Error(`DELETE ${url} → HTTP ${res.status}`)
|
||||
}
|
||||
}
|
||||
@ -1,373 +0,0 @@
|
||||
/**
|
||||
* E2E CLI runner helpers.
|
||||
*
|
||||
* Core primitive: run(argv, opts) → { stdout, stderr, exitCode }
|
||||
*
|
||||
* The binary is invoked via `bun bin/dev.js` so tests work without a prior
|
||||
* `pnpm build`. Each test should use its own isolated configDir (created via
|
||||
* withTempConfig) to prevent session state leaking between tests.
|
||||
*/
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { execSync, spawn } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
|
||||
/** Path to the dev entry point — no build required. */
|
||||
export const BIN = resolve(__dirname, '../../../bin/dev.js')
|
||||
|
||||
/**
|
||||
* Resolve the `bun` executable path.
|
||||
* Priority: PATH → ~/.bun/bin/bun → /usr/local/bin/bun
|
||||
*/
|
||||
function resolveBun(): string {
|
||||
const candidates = [
|
||||
// Respect PATH first
|
||||
'bun',
|
||||
// Common install locations
|
||||
`${process.env.HOME}/.bun/bin/bun`,
|
||||
'/usr/local/bin/bun',
|
||||
'/opt/homebrew/bin/bun',
|
||||
]
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
execSync(`${candidate} --version`, { stdio: 'ignore', timeout: 3000 })
|
||||
return candidate
|
||||
}
|
||||
catch { /* try next */ }
|
||||
}
|
||||
throw new Error(
|
||||
'bun not found. Install it with: curl -fsSL https://bun.sh/install | bash',
|
||||
)
|
||||
}
|
||||
|
||||
export const BUN = resolveBun()
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export type RunOptions = {
|
||||
/**
|
||||
* Override or extend the process environment.
|
||||
* Values are merged on top of `process.env`.
|
||||
*/
|
||||
env?: Record<string, string>
|
||||
/**
|
||||
* Path to an isolated config directory.
|
||||
* The CLI reads hosts.yml from this directory.
|
||||
* Passed as DIFY_CONFIG_DIR env var.
|
||||
*/
|
||||
configDir?: string
|
||||
/** Maximum time to wait for the process, in ms. Default: 30 000 */
|
||||
timeout?: number
|
||||
/** String to write to stdin, then close the pipe. */
|
||||
stdin?: string
|
||||
}
|
||||
|
||||
export type RunResult = {
|
||||
stdout: string
|
||||
stderr: string
|
||||
exitCode: number
|
||||
}
|
||||
|
||||
// ── Core runner ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Execute `difyctl <argv>` and return the captured stdout, stderr and exit code.
|
||||
*
|
||||
* Environment notes:
|
||||
* - CI=1 suppresses interactive prompts and spinners.
|
||||
* - NO_COLOR=1 strips ANSI escape codes from output.
|
||||
* - DIFY_CONFIG_DIR is set to opts.configDir when provided.
|
||||
*/
|
||||
export function run(argv: string[], opts: RunOptions = {}): Promise<RunResult> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
// Suppress interactive prompts in all E2E tests.
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
// Point the CLI at the isolated config directory.
|
||||
...(opts.configDir !== undefined ? { DIFY_CONFIG_DIR: opts.configDir } : {}),
|
||||
...opts.env,
|
||||
}
|
||||
|
||||
const proc = spawn(BUN, [BIN, ...argv], { env })
|
||||
const timeoutMs = opts.timeout ?? 30_000
|
||||
let timedOut = false
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true
|
||||
proc.kill('SIGINT')
|
||||
setTimeout(() => proc.kill('SIGKILL'), 2000).unref?.()
|
||||
}, timeoutMs)
|
||||
timeoutId.unref?.()
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
proc.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8')
|
||||
})
|
||||
proc.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
|
||||
if (opts.stdin !== undefined) {
|
||||
proc.stdin.write(opts.stdin)
|
||||
proc.stdin.end()
|
||||
}
|
||||
|
||||
proc.on('close', (code: number | null) => {
|
||||
clearTimeout(timeoutId)
|
||||
resolve({ stdout, stderr, exitCode: code ?? (timedOut ? 124 : 1) })
|
||||
})
|
||||
|
||||
proc.on('error', (err: Error) => {
|
||||
clearTimeout(timeoutId)
|
||||
reject(new Error(`Failed to spawn CLI process: ${err.message}`))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── Config directory helpers ───────────────────────────────────────────────
|
||||
|
||||
export type TempConfig = {
|
||||
/** Path to the isolated config directory. */
|
||||
configDir: string
|
||||
/** Remove the directory and all its contents. */
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a fresh temporary config directory for a single test.
|
||||
* Always call cleanup() in afterEach to avoid leaking temp directories.
|
||||
*/
|
||||
export async function withTempConfig(): Promise<TempConfig> {
|
||||
const configDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-'))
|
||||
return {
|
||||
configDir,
|
||||
cleanup: () => rm(configDir, { recursive: true, force: true }),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth injection ─────────────────────────────────────────────────────────
|
||||
|
||||
export type AuthInjectionOptions = {
|
||||
/** Staging server base URL (no trailing slash). */
|
||||
host: string
|
||||
/** Bearer token — dfoa_ for internal, dfoe_ for SSO. */
|
||||
bearer: string
|
||||
/** Primary workspace to write into the bundle. */
|
||||
workspaceId: string
|
||||
workspaceName: string
|
||||
workspaceRole?: string
|
||||
/**
|
||||
* Server-side session UUID (OAuthAccessToken.id).
|
||||
* When provided, written as `token_id` in hosts.yml so that
|
||||
* `devices revoke` can correctly detect selfHit and clear local credentials.
|
||||
*/
|
||||
tokenId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a pre-baked hosts.yml into configDir so tests can skip the real
|
||||
* Device-Flow login. Auth-specific E2E tests (login/logout/status) use the
|
||||
* real flow and should NOT call this function.
|
||||
*/
|
||||
export async function injectAuth(configDir: string, opts: AuthInjectionOptions): Promise<void> {
|
||||
await mkdir(configDir, { recursive: true, mode: 0o700 })
|
||||
|
||||
const role = opts.workspaceRole ?? 'owner'
|
||||
// Serialise to YAML manually to avoid a runtime dep on js-yaml in helpers.
|
||||
const hostsYml = `${[
|
||||
`current_host: ${opts.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: ${opts.bearer}`,
|
||||
...(opts.tokenId !== undefined ? [`token_id: ${opts.tokenId}`] : []),
|
||||
`workspace:`,
|
||||
` id: ${opts.workspaceId}`,
|
||||
` name: "${opts.workspaceName}"`,
|
||||
` role: ${role}`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${opts.workspaceId}`,
|
||||
` name: "${opts.workspaceName}"`,
|
||||
` role: ${role}`,
|
||||
].join('\n')}\n`
|
||||
|
||||
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
}
|
||||
|
||||
// ── Process signal helpers ─────────────────────────────────────────────────
|
||||
|
||||
export type SpawnedProcess = {
|
||||
/** Send SIGINT (Ctrl+C) to the process. */
|
||||
interrupt: () => void
|
||||
/** Wait for the process to exit and return the result. */
|
||||
wait: () => Promise<RunResult>
|
||||
}
|
||||
|
||||
/**
|
||||
* Start `difyctl <argv>` in the background without waiting for it to finish.
|
||||
* Useful for testing interrupt / timeout behaviour.
|
||||
*/
|
||||
export function spawn_background(argv: string[], opts: RunOptions = {}): SpawnedProcess {
|
||||
const env: Record<string, string> = {
|
||||
...(process.env as Record<string, string>),
|
||||
CI: '1',
|
||||
NO_COLOR: '1',
|
||||
...(opts.configDir !== undefined ? { DIFY_CONFIG_DIR: opts.configDir } : {}),
|
||||
...opts.env,
|
||||
}
|
||||
|
||||
const proc = spawn(BUN, [BIN, ...argv], { env })
|
||||
const timeoutMs = opts.timeout ?? 30_000
|
||||
let timedOut = false
|
||||
const timeoutId = setTimeout(() => {
|
||||
timedOut = true
|
||||
proc.kill('SIGINT')
|
||||
setTimeout(() => proc.kill('SIGKILL'), 2000).unref?.()
|
||||
}, timeoutMs)
|
||||
timeoutId.unref?.()
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
proc.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8')
|
||||
})
|
||||
proc.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8')
|
||||
})
|
||||
|
||||
return {
|
||||
interrupt: () => { proc.kill('SIGINT') },
|
||||
wait: () => new Promise((res) => {
|
||||
proc.on('close', (code: number | null) => {
|
||||
clearTimeout(timeoutId)
|
||||
res({ stdout, stderr, exitCode: code ?? (timedOut ? 124 : 1) })
|
||||
})
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auth fixture ───────────────────────────────────────────────────────────
|
||||
|
||||
export type AuthFixture = {
|
||||
/** Path to the isolated config directory, pre-loaded with a valid session. */
|
||||
configDir: string
|
||||
/**
|
||||
* Run `difyctl <argv>` using the fixture's config dir.
|
||||
* Shorthand for `run(argv, { configDir, env })`.
|
||||
*/
|
||||
r: (argv: string[], extraEnv?: Record<string, string>) => Promise<RunResult>
|
||||
/** Remove the temp config directory. Call in afterEach. */
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an isolated config directory pre-loaded with a valid internal-user
|
||||
* session. Designed for use with vitest's beforeEach / afterEach:
|
||||
*
|
||||
* @example
|
||||
* let fx: AuthFixture
|
||||
* beforeEach(async () => { fx = await withAuthFixture(E) })
|
||||
* afterEach(async () => { await fx.cleanup() })
|
||||
*
|
||||
* it('...', async () => {
|
||||
* const result = await fx.r(['get', 'app'])
|
||||
* assertExitCode(result, 0)
|
||||
* })
|
||||
*/
|
||||
export async function withAuthFixture(
|
||||
E: { host: string, token: string, workspaceId: string, workspaceName: string },
|
||||
): Promise<AuthFixture> {
|
||||
const { configDir, cleanup } = await withTempConfig()
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.token,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
return {
|
||||
configDir,
|
||||
r: (argv, extraEnv) => run(argv, { configDir, env: extraEnv }),
|
||||
cleanup,
|
||||
}
|
||||
}
|
||||
|
||||
// ── On-demand disposable token ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mint a fresh dfoa_ OAuth token on demand via the 3-step device flow API.
|
||||
* Use this inside tests that need to revoke a real session without consuming
|
||||
* the shared DIFY_E2E_TOKEN or the global-setup disposableToken.
|
||||
*
|
||||
* Requires DIFY_E2E_EMAIL and DIFY_E2E_PASSWORD to be set.
|
||||
* Returns empty string if credentials are missing.
|
||||
*
|
||||
* Steps:
|
||||
* 1. POST /console/api/login (Base64 password) → session cookie
|
||||
* 2. POST /openapi/v1/oauth/device/code → device_code + user_code
|
||||
* 3. POST /openapi/v1/oauth/device/approve → approved
|
||||
* 4. POST /openapi/v1/oauth/device/token → dfoa_ token
|
||||
*/
|
||||
export async function mintFreshToken(
|
||||
host: string,
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<string> {
|
||||
if (!email || !password)
|
||||
return ''
|
||||
|
||||
const base = host.replace(/\/$/, '')
|
||||
const sig = AbortSignal.timeout(15_000)
|
||||
|
||||
// Step 1 — console login
|
||||
const passwordB64 = Buffer.from(password, 'utf8').toString('base64')
|
||||
const loginRes = await fetch(`${base}/console/api/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password: passwordB64, remember_me: false }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!loginRes.ok)
|
||||
return ''
|
||||
|
||||
const setCookieHeaders = loginRes.headers.getSetCookie?.() ?? []
|
||||
const cookieString = setCookieHeaders.map(c => c.split(';')[0]).join('; ')
|
||||
const csrfMatch = cookieString.match(/csrf_token=([^;]+)/)
|
||||
const csrfToken = csrfMatch ? csrfMatch[1] : ''
|
||||
|
||||
// Step 2 — device code
|
||||
const codeRes = await fetch(`${base}/openapi/v1/oauth/device/code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ client_id: 'difyctl', device_label: 'e2e-fresh' }),
|
||||
signal: sig,
|
||||
})
|
||||
if (!codeRes.ok)
|
||||
return ''
|
||||
const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string }
|
||||
|
||||
// Step 3 — approve
|
||||
const approveRes = await fetch(`${base}/openapi/v1/oauth/device/approve`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRFToken': csrfToken },
|
||||
body: JSON.stringify({ user_code }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!approveRes.ok)
|
||||
return ''
|
||||
|
||||
// Step 4 — poll token
|
||||
const tokenRes = await fetch(`${base}/openapi/v1/oauth/device/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_code, client_id: 'difyctl' }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!tokenRes.ok)
|
||||
return ''
|
||||
const body = await tokenRes.json() as { token?: string }
|
||||
return body.token ?? ''
|
||||
}
|
||||
@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Retry helper for E2E tests running against a staging server.
|
||||
*
|
||||
* Staging environments can be flaky — occasional 5xx errors or slow cold
|
||||
* starts are expected. Use `withRetry` to wrap assertions that may fail
|
||||
* transiently without masking real failures.
|
||||
*/
|
||||
|
||||
const DEFAULT_ATTEMPTS = 3
|
||||
const DEFAULT_DELAY_MS = 1000
|
||||
|
||||
export type RetryOptions = {
|
||||
/** Total number of attempts (first try + retries). Default: 3 */
|
||||
attempts?: number
|
||||
/** Delay between retries in ms. Default: 1000 */
|
||||
delayMs?: number
|
||||
/** Optional predicate — only retry when this returns true for the error. */
|
||||
shouldRetry?: (err: unknown) => boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute `fn()` and retry on failure.
|
||||
*
|
||||
* @example
|
||||
* const result = await withRetry(() => run(['get', 'app', '-o', 'json']))
|
||||
*/
|
||||
export async function withRetry<T>(fn: () => Promise<T>, opts: RetryOptions = {}): Promise<T> {
|
||||
const total = opts.attempts ?? DEFAULT_ATTEMPTS
|
||||
const delay = opts.delayMs ?? DEFAULT_DELAY_MS
|
||||
const shouldRetry = opts.shouldRetry ?? (() => true)
|
||||
|
||||
let lastErr: unknown
|
||||
for (let attempt = 1; attempt <= total; attempt++) {
|
||||
try {
|
||||
return await fn()
|
||||
}
|
||||
catch (err) {
|
||||
lastErr = err
|
||||
if (attempt === total || !shouldRetry(err))
|
||||
break
|
||||
|
||||
console.warn(`[E2E retry] attempt ${attempt}/${total} failed — retrying in ${delay}ms`)
|
||||
await sleep(delay)
|
||||
}
|
||||
}
|
||||
throw lastErr
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import { describe, it } from 'vitest'
|
||||
|
||||
export function optionalDescribe(condition: boolean) {
|
||||
return condition ? describe : describe.skip
|
||||
}
|
||||
|
||||
export function optionalIt(condition: boolean) {
|
||||
return condition ? it : it.skip
|
||||
}
|
||||
@ -1,9 +0,0 @@
|
||||
import type { E2ECapabilities } from '../setup/env.js'
|
||||
|
||||
declare module 'vitest' {
|
||||
export type ProvidedContext = {
|
||||
e2eCapabilities: E2ECapabilities
|
||||
}
|
||||
}
|
||||
|
||||
export { }
|
||||
@ -1,109 +0,0 @@
|
||||
/**
|
||||
* E2E environment configuration.
|
||||
*
|
||||
* All DIFY_E2E_* variables must be set before running E2E tests.
|
||||
* In CI they are injected from GitHub Actions secrets.
|
||||
* Locally, export them in your shell or use a .env.e2e file.
|
||||
*
|
||||
* Required:
|
||||
* DIFY_E2E_HOST Staging server base URL (e.g. https://api.staging.dify.ai)
|
||||
* DIFY_E2E_TOKEN Internal user bearer token (dfoa_ prefix)
|
||||
* DIFY_E2E_WORKSPACE_ID Workspace ID for the test account
|
||||
* DIFY_E2E_CHAT_APP_ID Echo-chat app — outputs "echo: {query}"
|
||||
* DIFY_E2E_WORKFLOW_APP_ID Echo-workflow app — input x (required), outputs "echo: {x}"
|
||||
*
|
||||
* Optional (skip related tests when absent):
|
||||
* DIFY_E2E_SSO_TOKEN External SSO bearer token (dfoe_ prefix)
|
||||
* DIFY_E2E_HITL_APP_ID Workflow app with a Human-Input node
|
||||
* DIFY_E2E_FILE_APP_ID Workflow app with a file input variable (doc)
|
||||
*/
|
||||
|
||||
export type E2EEnv = {
|
||||
/** Staging server base URL */
|
||||
host: string
|
||||
/** Internal user bearer token (dfoa_…) */
|
||||
token: string
|
||||
/** External SSO bearer token (dfoe_…) — may be empty */
|
||||
ssoToken: string
|
||||
/** Primary workspace ID */
|
||||
workspaceId: string
|
||||
/** Workspace name (informational) */
|
||||
workspaceName: string
|
||||
/** Chat app that echoes the query */
|
||||
chatAppId: string
|
||||
/** Workflow app that echoes input x */
|
||||
workflowAppId: string
|
||||
/** Workflow app with HITL node — empty when not configured */
|
||||
hitlAppId: string
|
||||
/** Workflow app with file input (doc variable) — empty when not configured */
|
||||
fileAppId: string
|
||||
/**
|
||||
* Console account email — used by global-setup to mint a disposable token
|
||||
* for logout tests via the device flow API. Optional: if absent, logout
|
||||
* tests that need a real revoke are skipped.
|
||||
*/
|
||||
email: string
|
||||
/** Console account password (plain-text; Base64-encoded before sending) */
|
||||
password: string
|
||||
}
|
||||
|
||||
export type E2ECapabilities = {
|
||||
tokenValid: boolean
|
||||
tokenId?: string
|
||||
/**
|
||||
* Per-suite dedicated tokens minted by global-setup via the device flow.
|
||||
* Each destructive suite (logout, devices) gets its own fresh dfoa_ token so
|
||||
* that revoking it never invalidates DIFY_E2E_TOKEN used by other suites.
|
||||
* Empty string when DIFY_E2E_EMAIL/PASSWORD are not configured.
|
||||
*/
|
||||
logoutToken: string
|
||||
devicesToken: string
|
||||
}
|
||||
|
||||
let _cached: E2EEnv | undefined
|
||||
|
||||
/** Load and validate E2E environment variables. Throws if required vars are missing. */
|
||||
export function loadE2EEnv(): E2EEnv {
|
||||
if (_cached !== undefined)
|
||||
return _cached
|
||||
|
||||
const required: Array<[keyof NodeJS.ProcessEnv, string]> = [
|
||||
['DIFY_E2E_HOST', 'Staging server URL'],
|
||||
['DIFY_E2E_TOKEN', 'Internal user bearer token'],
|
||||
['DIFY_E2E_WORKSPACE_ID', 'Workspace ID'],
|
||||
['DIFY_E2E_CHAT_APP_ID', 'Echo-chat app ID'],
|
||||
['DIFY_E2E_WORKFLOW_APP_ID', 'Echo-workflow app ID'],
|
||||
]
|
||||
|
||||
const missing = required.filter(([k]) => !process.env[k])
|
||||
if (missing.length > 0) {
|
||||
const list = missing.map(([k, desc]) => ` ${k} (${desc})`).join('\n')
|
||||
throw new Error(
|
||||
`E2E tests require the following environment variables to be set:\n${list}\n\n`
|
||||
+ 'See test/e2e/setup/env.ts for documentation.',
|
||||
)
|
||||
}
|
||||
|
||||
_cached = {
|
||||
host: process.env.DIFY_E2E_HOST!,
|
||||
token: process.env.DIFY_E2E_TOKEN!,
|
||||
ssoToken: process.env.DIFY_E2E_SSO_TOKEN ?? '',
|
||||
workspaceId: process.env.DIFY_E2E_WORKSPACE_ID!,
|
||||
workspaceName: process.env.DIFY_E2E_WORKSPACE_NAME ?? 'E2E Workspace',
|
||||
chatAppId: process.env.DIFY_E2E_CHAT_APP_ID!,
|
||||
workflowAppId: process.env.DIFY_E2E_WORKFLOW_APP_ID!,
|
||||
hitlAppId: process.env.DIFY_E2E_HITL_APP_ID ?? '',
|
||||
fileAppId: process.env.DIFY_E2E_FILE_APP_ID ?? '',
|
||||
email: process.env.DIFY_E2E_EMAIL ?? '',
|
||||
password: process.env.DIFY_E2E_PASSWORD ?? '',
|
||||
}
|
||||
return _cached
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip a test when an optional app fixture is not configured.
|
||||
* Usage: skipUnless(E.hitlAppId, 'DIFY_E2E_HITL_APP_ID')
|
||||
*/
|
||||
export function isE2ELocalMode(): boolean {
|
||||
return process.env.DIFY_E2E_MODE === 'local'
|
||||
}
|
||||
@ -1,169 +0,0 @@
|
||||
/**
|
||||
* Vitest global setup — runs once before all E2E suites.
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Validate required environment variables are present.
|
||||
* 2. Confirm the staging server is reachable AND the shared token is valid —
|
||||
* GET /openapi/v1/account/sessions (HTTP 200 = valid, else abort).
|
||||
* 3. Resolve the current session's token_id via the prefix field.
|
||||
* 4. Mint per-suite dedicated tokens for suites that revoke sessions:
|
||||
* - logoutToken → auth/logout.e2e.ts
|
||||
* - devicesToken → auth/devices.e2e.ts
|
||||
* Each suite consumes only its own token, so DIFY_E2E_TOKEN remains
|
||||
* valid for all non-destructive suites throughout the run.
|
||||
*
|
||||
* If the health-check fails the entire test run is aborted early.
|
||||
*/
|
||||
|
||||
import type { TestProject } from 'vitest/node'
|
||||
import type { E2ECapabilities } from './env.js'
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { loadE2EEnv } from './env.js'
|
||||
|
||||
export async function setup(project: TestProject): Promise<void> {
|
||||
if (process.env.DIFY_E2E_MODE === 'local')
|
||||
return
|
||||
|
||||
const E = loadE2EEnv()
|
||||
const base = E.host.replace(/\/$/, '')
|
||||
|
||||
// ── 1. Validate main token ─────────────────────────────────────────────
|
||||
const sessionsUrl = `${base}/openapi/v1/account/sessions?page=1&limit=100`
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(sessionsUrl, {
|
||||
headers: { Authorization: `Bearer ${E.token}` },
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
throw new Error(
|
||||
`[E2E global-setup] Cannot reach staging server at ${sessionsUrl}.\n`
|
||||
+ `Check DIFY_E2E_HOST and network connectivity.\n${String(err)}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`[E2E global-setup] Token is invalid or expired (HTTP ${res.status}).\n`
|
||||
+ `Update DIFY_E2E_TOKEN and retry.\nURL: ${sessionsUrl}`,
|
||||
)
|
||||
}
|
||||
|
||||
console.log(`[E2E] Staging server is healthy and token is valid at ${E.host}`)
|
||||
|
||||
// ── 2. Resolve token_id ────────────────────────────────────────────────
|
||||
const body = await res.json() as { data: Array<{ id: string, prefix: string }> }
|
||||
const match = body.data.find(s => s.prefix !== '' && E.token.startsWith(s.prefix))
|
||||
|
||||
// ── 3. Mint per-suite dedicated tokens ────────────────────────────────
|
||||
let logoutToken = ''
|
||||
let devicesToken = ''
|
||||
|
||||
if (E.email && E.password) {
|
||||
const mint = (label: string) => mintToken(base, E.email, E.password, label)
|
||||
|
||||
const [lt, dt] = await Promise.allSettled([
|
||||
mint('e2e-logout-suite'),
|
||||
mint('e2e-devices-suite'),
|
||||
])
|
||||
|
||||
if (lt.status === 'fulfilled') {
|
||||
logoutToken = lt.value
|
||||
console.log(`[E2E] logoutToken minted: ${logoutToken.slice(0, 20)}…`)
|
||||
}
|
||||
else {
|
||||
console.warn(`[E2E global-setup] Failed to mint logoutToken: ${lt.reason}`)
|
||||
}
|
||||
|
||||
if (dt.status === 'fulfilled') {
|
||||
devicesToken = dt.value
|
||||
console.log(`[E2E] devicesToken minted: ${devicesToken.slice(0, 20)}…`)
|
||||
}
|
||||
else {
|
||||
console.warn(`[E2E global-setup] Failed to mint devicesToken: ${dt.reason}`)
|
||||
}
|
||||
}
|
||||
else {
|
||||
console.warn('[E2E global-setup] DIFY_E2E_EMAIL/PASSWORD not set — per-suite tokens not minted; destructive tests may skip')
|
||||
}
|
||||
|
||||
const capabilities: E2ECapabilities = {
|
||||
tokenValid: true,
|
||||
tokenId: match?.id,
|
||||
logoutToken,
|
||||
devicesToken,
|
||||
}
|
||||
|
||||
project.provide('e2eCapabilities', capabilities)
|
||||
}
|
||||
|
||||
export { teardown } from './global-teardown.js'
|
||||
|
||||
// ── Device flow token minting ──────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Mint a fresh dfoa_ OAuth token via the 3-step device flow:
|
||||
* 1. POST /openapi/v1/oauth/device/code → device_code + user_code
|
||||
* 2. POST /console/api/login → session cookie + CSRF
|
||||
* POST /openapi/v1/oauth/device/approve (with cookie)
|
||||
* 3. POST /openapi/v1/oauth/device/token → dfoa_ bearer token
|
||||
*/
|
||||
async function mintToken(base: string, email: string, password: string, label: string): Promise<string> {
|
||||
// Step 1 — request device code
|
||||
const codeRes = await fetch(`${base}/openapi/v1/oauth/device/code`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ client_id: 'difyctl', device_label: label }),
|
||||
signal: AbortSignal.timeout(15_000),
|
||||
})
|
||||
if (!codeRes.ok)
|
||||
throw new Error(`device/code failed: HTTP ${codeRes.status}`)
|
||||
const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string }
|
||||
|
||||
// Step 2a — console login → session cookie + CSRF token
|
||||
const passwordB64 = Buffer.from(password, 'utf8').toString('base64')
|
||||
const loginRes = await fetch(`${base}/console/api/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password: passwordB64, remember_me: false }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!loginRes.ok)
|
||||
throw new Error(`console/api/login failed: HTTP ${loginRes.status}`)
|
||||
|
||||
const setCookieHeaders = loginRes.headers.getSetCookie?.() ?? []
|
||||
const cookieString = setCookieHeaders.map(c => c.split(';')[0]).join('; ')
|
||||
const csrfMatch = cookieString.match(/csrf_token=([^;]+)/)
|
||||
const csrfToken = csrfMatch ? csrfMatch[1] : ''
|
||||
|
||||
// Step 2b — approve the device code
|
||||
const approveRes = await fetch(`${base}/openapi/v1/oauth/device/approve`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cookie': cookieString,
|
||||
'X-CSRFToken': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ user_code }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!approveRes.ok)
|
||||
throw new Error(`device/approve failed: HTTP ${approveRes.status}`)
|
||||
|
||||
// Step 3 — exchange device code for bearer token
|
||||
const tokenRes = await fetch(`${base}/openapi/v1/oauth/device/token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ device_code, client_id: 'difyctl' }),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
if (!tokenRes.ok)
|
||||
throw new Error(`device/token failed: HTTP ${tokenRes.status}`)
|
||||
|
||||
const tokenBody = await tokenRes.json() as { token?: string, error?: string }
|
||||
if (!tokenBody.token)
|
||||
throw new Error(`device/token response missing token: ${JSON.stringify(tokenBody)}`)
|
||||
|
||||
return tokenBody.token
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
/**
|
||||
* Vitest global teardown — runs once after all E2E suites complete.
|
||||
*
|
||||
* Responsibilities:
|
||||
* 1. Delete all conversations created on the staging server during the run
|
||||
* (collected via registerConversation() in test suites).
|
||||
*
|
||||
* Deletion is best-effort — failures are logged but do not fail the run.
|
||||
*/
|
||||
|
||||
import { cleanupRegisteredConversations } from '../helpers/cleanup-registry.js'
|
||||
|
||||
export async function teardown(): Promise<void> {
|
||||
await cleanupRegisteredConversations()
|
||||
}
|
||||
@ -1,135 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl auth devices — multi-device session management
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Multi-device Session Management (21 cases)
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import { assertExitCode, assertJson } from '../../helpers/assert.js'
|
||||
import { injectAuth, mintFreshToken, run, withTempConfig } from '../../helpers/cli.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
const caps = inject('e2eCapabilities')
|
||||
const tokenValid = caps.tokenValid
|
||||
const tokenId = caps.tokenId
|
||||
|
||||
describe('E2E / difyctl auth devices', () => {
|
||||
let configDir: string
|
||||
let cleanup: () => Promise<void>
|
||||
|
||||
beforeEach(async () => {
|
||||
const tmp = await withTempConfig()
|
||||
configDir = tmp.configDir
|
||||
cleanup = tmp.cleanup
|
||||
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.token,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
tokenId,
|
||||
})
|
||||
})
|
||||
afterEach(async () => {
|
||||
await cleanup()
|
||||
})
|
||||
|
||||
function r(argv: string[]) {
|
||||
return run(argv, { configDir })
|
||||
}
|
||||
|
||||
// ── devices list ─────────────────────────────────────────────────────────────
|
||||
|
||||
const itSessions = optionalIt(tokenValid)
|
||||
|
||||
itSessions('[P0] logged-in user can view the devices list', async () => {
|
||||
// Spec: logged-in user can view the devices list
|
||||
const result = await r(['auth', 'devices', 'list'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
itSessions('[P0] devices list displays device IDs', async () => {
|
||||
// Spec: devices list displays device IDs
|
||||
const result = await r(['auth', 'devices', 'list'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/tok-|id|device/i)
|
||||
})
|
||||
|
||||
itSessions('[P0] devices list supports JSON output and returns valid JSON', async () => {
|
||||
// Spec: devices list supports JSON output
|
||||
const result = await r(['auth', 'devices', 'list', '--json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[], total: number }>(result)
|
||||
expect(parsed).toHaveProperty('data')
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
})
|
||||
|
||||
itSessions('[P1] devices list JSON schema is stable (contains data and total fields)', async () => {
|
||||
// Spec: devices list JSON schema is stable
|
||||
const result = await r(['auth', 'devices', 'list', '--json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[], total: number, page: number, limit: number }>(result)
|
||||
expect(parsed).toHaveProperty('total')
|
||||
expect(parsed).toHaveProperty('page')
|
||||
expect(parsed).toHaveProperty('limit')
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated devices list returns auth error (exit code 4)', async () => {
|
||||
// Spec: unauthenticated devices list returns auth error + exit code 4
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['auth', 'devices', 'list'], { configDir: unauthTmp.configDir })
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i)
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── devices revoke ───────────────────────────────────────────────────────────
|
||||
|
||||
itSessions('[P0] revoking a specified device succeeds (exit code 0)', async () => {
|
||||
// Spec: revoking a specified device succeeds
|
||||
// Mint a fresh token on demand so this test only revokes its own session,
|
||||
// never the shared E.token or the global-setup disposableToken.
|
||||
const freshToken = await mintFreshToken(E.host, E.email, E.password)
|
||||
if (!freshToken) {
|
||||
// Credentials not configured — skip rather than risk revoking the main session.
|
||||
return
|
||||
}
|
||||
|
||||
// Inject the fresh token into a dedicated config dir
|
||||
const revokeTmp = await withTempConfig()
|
||||
try {
|
||||
await injectAuth(revokeTmp.configDir, {
|
||||
host: E.host,
|
||||
bearer: freshToken,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const revokeR = (argv: string[]) => run(argv, { configDir: revokeTmp.configDir })
|
||||
|
||||
// List sessions authenticated as the fresh token
|
||||
const listResult = await revokeR(['auth', 'devices', 'list', '--json'])
|
||||
assertExitCode(listResult, 0)
|
||||
const { data } = assertJson<{ data: Array<{ id: string, prefix: string }> }>(listResult)
|
||||
|
||||
// Find the entry whose prefix matches the fresh token
|
||||
const entry = data.find(d => d.prefix && freshToken.startsWith(d.prefix))
|
||||
if (!entry) {
|
||||
// Fresh session not found — may have been filtered; skip gracefully.
|
||||
return
|
||||
}
|
||||
|
||||
const revokeResult = await revokeR(['auth', 'devices', 'revoke', entry.id, '--yes'])
|
||||
assertExitCode(revokeResult, 0)
|
||||
}
|
||||
finally {
|
||||
await revokeTmp.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -1,183 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl auth logout — Logout
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Logout (18 cases)
|
||||
*/
|
||||
|
||||
import { access } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
|
||||
import { assertExitCode } from '../../helpers/assert.js'
|
||||
import { injectAuth, run, withTempConfig } from '../../helpers/cli.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
const caps = inject('e2eCapabilities')
|
||||
|
||||
describe('E2E / difyctl auth logout', () => {
|
||||
let configDir: string
|
||||
let cleanup: () => Promise<void>
|
||||
|
||||
beforeEach(async () => {
|
||||
const { configDir: dir, cleanup: cl } = await withTempConfig()
|
||||
configDir = dir
|
||||
cleanup = cl
|
||||
})
|
||||
afterEach(async () => {
|
||||
await cleanup()
|
||||
})
|
||||
|
||||
function r(argv: string[]) {
|
||||
return run(argv, { configDir })
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the dedicated per-suite logoutToken so that auth logout
|
||||
* calls DELETE /account/sessions/self on a disposable session and
|
||||
* never revokes the shared DIFY_E2E_TOKEN used by other suites.
|
||||
*/
|
||||
async function withAuth() {
|
||||
const token = caps.logoutToken || E.token
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: token,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
}
|
||||
|
||||
async function hostsFileExists(): Promise<boolean> {
|
||||
try {
|
||||
await access(join(configDir, 'hosts.yml'))
|
||||
return true
|
||||
}
|
||||
catch { return false }
|
||||
}
|
||||
|
||||
// ── Basic logout ────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] logged-in user can logout successfully — stdout contains success message', async () => {
|
||||
// Spec: logged-in user can logout successfully
|
||||
await withAuth()
|
||||
const result = await r(['auth', 'logout'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/logged out/i)
|
||||
})
|
||||
|
||||
it('[P0] local hosts.yml is deleted after logout', async () => {
|
||||
// Spec: local token deleted after logout
|
||||
await withAuth()
|
||||
expect(await hostsFileExists()).toBe(true)
|
||||
await r(['auth', 'logout'])
|
||||
expect(await hostsFileExists()).toBe(false)
|
||||
})
|
||||
|
||||
it('[P0] auth status returns "Not logged in" after logout', async () => {
|
||||
// Spec: auth status returns not-logged-in after logout
|
||||
await withAuth()
|
||||
await r(['auth', 'logout'])
|
||||
const statusResult = await r(['auth', 'status'])
|
||||
expect(statusResult.exitCode).toBe(4)
|
||||
expect(statusResult.stdout).toMatch(/not logged in/i)
|
||||
})
|
||||
|
||||
it('[P1] auth status exit code is 4 after logout', async () => {
|
||||
// Spec: auth status exit code is 4 after logout
|
||||
await withAuth()
|
||||
await r(['auth', 'logout'])
|
||||
const statusResult = await r(['auth', 'status'])
|
||||
expect(statusResult.exitCode).toBe(4)
|
||||
})
|
||||
|
||||
it('[P0] logout calls the revoke session endpoint (or best-effort local credential clear)', async () => {
|
||||
// Spec: logout calls the revoke session endpoint + logout returns success when revoke succeeds
|
||||
// Uses disposableToken so the shared DIFY_E2E_TOKEN is not revoked.
|
||||
await withAuth()
|
||||
const result = await r(['auth', 'logout'])
|
||||
// Local token must be cleared regardless of whether server revoke succeeds
|
||||
assertExitCode(result, 0)
|
||||
expect(await hostsFileExists()).toBe(false)
|
||||
})
|
||||
|
||||
it('[P0] local credentials are cleared even when server revoke fails (best-effort)', async () => {
|
||||
// Spec: local credentials cleared even when server revoke fails
|
||||
// Inject an invalid token → server rejects revoke, but local state must still be cleared
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: 'dfoa_invalid_will_fail_revoke',
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
const result = await r(['auth', 'logout'])
|
||||
// exit 0 (best-effort); local file is cleared
|
||||
assertExitCode(result, 0)
|
||||
expect(await hostsFileExists()).toBe(false)
|
||||
})
|
||||
|
||||
// ── Unauthenticated (idempotent) ─────────────────────────────────────────────
|
||||
|
||||
it('[P0] logout without a session returns not_logged_in error (exit code 4)', async () => {
|
||||
// Spec: logout without a session is idempotent
|
||||
// Actual behaviour: CLI returns not_logged_in (exit 4) when no token is present
|
||||
const result = await r(['auth', 'logout'])
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in/i)
|
||||
})
|
||||
|
||||
// ── External SSO logout ─────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] external SSO user logout works correctly — local token cleared', async () => {
|
||||
// Spec: external SSO user logout works correctly
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoe_sso_test_token`,
|
||||
`external_subject:`,
|
||||
` email: sso@example.com`,
|
||||
` issuer: https://issuer.example.com`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
|
||||
const result = await r(['auth', 'logout'])
|
||||
assertExitCode(result, 0)
|
||||
expect(await hostsFileExists()).toBe(false)
|
||||
})
|
||||
|
||||
// ── Network error scenario ───────────────────────────────────────────────────
|
||||
|
||||
it('[P0] local token is cleared even when logout encounters a network error', async () => {
|
||||
// Spec: local credentials cleared even when network is unavailable
|
||||
// Use an unreachable host to simulate network failure
|
||||
const { writeFile, mkdir } = await import('node:fs/promises')
|
||||
await mkdir(configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: http://unreachable-host-xyz.invalid`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoa_test_network_error`,
|
||||
`workspace:`,
|
||||
` id: ws-1`,
|
||||
` name: Test`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
|
||||
const result = await run(['auth', 'logout'], { configDir, timeout: 10_000 })
|
||||
// Local token is cleared even if network request fails
|
||||
assertExitCode(result, 0)
|
||||
expect(await hostsFileExists()).toBe(false)
|
||||
})
|
||||
|
||||
// ── Post-logout operations ───────────────────────────────────────────────────
|
||||
|
||||
it('[P1] run app returns auth error (exit code 4) after logout', async () => {
|
||||
// Spec: run app returns auth error after logout
|
||||
// Use disposableToken so the shared DIFY_E2E_TOKEN is not revoked.
|
||||
await withAuth()
|
||||
await r(['auth', 'logout'])
|
||||
const result = await r(['run', 'app', E.chatAppId, 'test'])
|
||||
expect(result.exitCode).toBe(4)
|
||||
})
|
||||
})
|
||||
@ -1,180 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl auth status — Auth Status
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Auth Status (12 cases)
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js'
|
||||
import { injectAuth, run, withTempConfig } from '../../helpers/cli.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
|
||||
describe('E2E / difyctl auth status', () => {
|
||||
let configDir: string
|
||||
let cleanup: () => Promise<void>
|
||||
|
||||
beforeEach(async () => {
|
||||
const { configDir: dir, cleanup: cl } = await withTempConfig()
|
||||
configDir = dir
|
||||
cleanup = cl
|
||||
})
|
||||
afterEach(async () => {
|
||||
await cleanup()
|
||||
})
|
||||
|
||||
function r(argv: string[], extraEnv?: Record<string, string>) {
|
||||
return run(argv, { configDir, env: extraEnv })
|
||||
}
|
||||
|
||||
async function withAuth() {
|
||||
// Write a complete bundle including account fields so --json output includes account
|
||||
const { writeFile, mkdir } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
await mkdir(configDir, { recursive: true, mode: 0o700 })
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: ${E.token}`,
|
||||
`account:`,
|
||||
` id: acct-e2e`,
|
||||
` email: e2e@example.com`,
|
||||
` name: E2E User`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "${E.workspaceName}"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "${E.workspaceName}"`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
}
|
||||
|
||||
async function withSSOAuth() {
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.ssoToken || 'dfoe_test',
|
||||
workspaceId: '',
|
||||
workspaceName: '',
|
||||
})
|
||||
// Overwrite to add external_subject field
|
||||
const { writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: ${E.ssoToken || 'dfoe_test'}`,
|
||||
`external_subject:`,
|
||||
` email: sso@example.com`,
|
||||
` issuer: https://issuer.example.com`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
}
|
||||
|
||||
// ── Basic status display ─────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] internal user auth status displays host, email, and workspace info', async () => {
|
||||
// Spec: internal user auth status displays host information
|
||||
await withAuth()
|
||||
const result = await r(['auth', 'status'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toContain(E.host.replace(/^https?:\/\//, ''))
|
||||
expect(result.stdout).toContain(E.workspaceName)
|
||||
})
|
||||
|
||||
it('[P0] auth status --json outputs a valid JSON schema', async () => {
|
||||
// Spec: auth status --json output is a parseable schema
|
||||
await withAuth()
|
||||
const result = await r(['auth', 'status', '--json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = JSON.parse(result.stdout) as Record<string, unknown>
|
||||
expect(parsed).toHaveProperty('logged_in', true)
|
||||
expect(parsed).toHaveProperty('host')
|
||||
expect(parsed).toHaveProperty('account')
|
||||
})
|
||||
|
||||
it('[P1] auth status -v displays workspace role and storage info', async () => {
|
||||
// Spec: auth status -v displays workspace role
|
||||
await withAuth()
|
||||
const result = await r(['auth', 'status', '-v'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toContain('owner')
|
||||
expect(result.stdout).toMatch(/file|keychain/)
|
||||
})
|
||||
|
||||
// ── Unauthenticated scenario ─────────────────────────────────────────────────
|
||||
|
||||
it('[P0] unauthenticated auth status returns "Not logged in" — exit code 4', async () => {
|
||||
// Spec: unauthenticated auth status returns error + exit code 4
|
||||
// configDir is empty (no hosts.yml)
|
||||
const result = await r(['auth', 'status'])
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stdout).toMatch(/not logged in/i)
|
||||
})
|
||||
|
||||
// ── External SSO user ────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] external SSO user auth status does not display workspace row', async () => {
|
||||
// Spec: external SSO user auth status does not show workspace
|
||||
await withSSOAuth()
|
||||
const result = await r(['auth', 'status'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).not.toMatch(/workspace/i)
|
||||
})
|
||||
|
||||
it('[P0] external SSO user auth status displays issuer URL', async () => {
|
||||
// Spec: external SSO user auth status displays issuer URL
|
||||
await withSSOAuth()
|
||||
const result = await r(['auth', 'status'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toContain('issuer.example.com')
|
||||
})
|
||||
|
||||
it('[P0] external SSO user auth status displays External SSO session info', async () => {
|
||||
// Spec: external SSO user auth status displays External SSO Session info
|
||||
await withSSOAuth()
|
||||
const result = await r(['auth', 'status'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/SSO|apps:run/i)
|
||||
})
|
||||
|
||||
// ── Error scenarios ──────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] auth status returns auth error when token is expired (401)', async () => {
|
||||
// Spec: auth status returns auth error after token expires
|
||||
// Inject a syntactically valid but actually expired token
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: 'dfoa_invalid_expired_token_xyz',
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
// auth status reads only the local hosts.yml (no network); status is shown as long as a token exists.
|
||||
// Real token-expiry detection happens when commands like get app / run app are executed.
|
||||
const result = await r(['auth', 'status'])
|
||||
// A token present → show status without a 401 (status makes no network request)
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
it('[P1] auth status outputs JSON error envelope in JSON mode', async () => {
|
||||
// Spec: auth status outputs JSON error in JSON mode
|
||||
const result = await r(['auth', 'status', '--json'])
|
||||
// When not logged in, --json mode should output JSON rather than plain text
|
||||
expect(result.exitCode).toBe(4)
|
||||
// stdout should contain JSON (not-logged-in state)
|
||||
const parsed = JSON.parse(result.stdout) as { logged_in: boolean }
|
||||
expect(parsed.logged_in).toBe(false)
|
||||
})
|
||||
|
||||
it('[P0] auth status output contains no ANSI colour (non-TTY)', async () => {
|
||||
await withAuth()
|
||||
const result = await r(['auth', 'status'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
})
|
||||
@ -1,186 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl auth use — Workspace switching
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Auth/Workspace Switching (22 cases)
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { assertExitCode } from '../../helpers/assert.js'
|
||||
import { run, withTempConfig } from '../../helpers/cli.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
|
||||
// Secondary workspace used in tests — injected into available_workspaces
|
||||
const WS2_ID = 'ws-e2e-secondary-0000-000000000002'
|
||||
const WS2_NAME = 'Secondary Workspace'
|
||||
|
||||
describe('E2E / difyctl auth use', () => {
|
||||
let configDir: string
|
||||
let cleanup: () => Promise<void>
|
||||
|
||||
beforeEach(async () => {
|
||||
const tmp = await withTempConfig()
|
||||
configDir = tmp.configDir
|
||||
cleanup = tmp.cleanup
|
||||
})
|
||||
afterEach(async () => {
|
||||
await cleanup()
|
||||
})
|
||||
|
||||
function r(argv: string[]) {
|
||||
return run(argv, { configDir })
|
||||
}
|
||||
|
||||
/** Inject a bundle with two workspaces. */
|
||||
async function withTwoWorkspaces() {
|
||||
await mkdir(configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: ${E.token}`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "${E.workspaceName}"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "${E.workspaceName}"`,
|
||||
` role: owner`,
|
||||
` - id: ${WS2_ID}`,
|
||||
` name: "${WS2_NAME}"`,
|
||||
` role: normal`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
}
|
||||
|
||||
async function withSSOAuth() {
|
||||
await mkdir(configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoe_sso_test`,
|
||||
`external_subject:`,
|
||||
` email: sso@example.com`,
|
||||
` issuer: https://issuer.example.com`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
}
|
||||
|
||||
// ── Normal workspace switch ──────────────────────────────────────────────────
|
||||
|
||||
it('[P0] internal user can switch to a specified workspace', async () => {
|
||||
// Spec: internal user can switch to a specified workspace
|
||||
await withTwoWorkspaces()
|
||||
const result = await r(['auth', 'use', WS2_ID])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/switched|workspace/i)
|
||||
expect(result.stdout).toContain(WS2_NAME)
|
||||
})
|
||||
|
||||
it('[P0] auth status shows the new workspace after auth use', async () => {
|
||||
// Spec: auth status shows new workspace after auth use
|
||||
await withTwoWorkspaces()
|
||||
await r(['auth', 'use', WS2_ID])
|
||||
const status = await r(['auth', 'status'])
|
||||
assertExitCode(status, 0)
|
||||
expect(status.stdout).toContain(WS2_NAME)
|
||||
})
|
||||
|
||||
it('[P0] auth use updates current_workspace_id (hosts.yml is updated)', async () => {
|
||||
// Spec: auth use updates current_workspace_id
|
||||
await withTwoWorkspaces()
|
||||
await r(['auth', 'use', WS2_ID])
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8')
|
||||
expect(hostsContent).toContain(WS2_ID)
|
||||
})
|
||||
|
||||
it('[P1] switching to the same workspace repeatedly is idempotent', async () => {
|
||||
// Spec: switching to the same workspace is idempotent
|
||||
await withTwoWorkspaces()
|
||||
const r1 = await r(['auth', 'use', E.workspaceId])
|
||||
assertExitCode(r1, 0)
|
||||
const r2 = await r(['auth', 'use', E.workspaceId])
|
||||
assertExitCode(r2, 0)
|
||||
})
|
||||
|
||||
it('[P1] current workspace is persisted after auth use', async () => {
|
||||
// Spec: current workspace is persisted after auth use
|
||||
await withTwoWorkspaces()
|
||||
await r(['auth', 'use', WS2_ID])
|
||||
// Read hosts.yml directly to verify the workspace id was written
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8')
|
||||
expect(hostsContent).toContain(WS2_ID)
|
||||
})
|
||||
|
||||
// ── Error scenarios ──────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] switching to a non-existent workspace returns an error', async () => {
|
||||
// Spec: switching to a non-existent workspace returns an error
|
||||
await withTwoWorkspaces()
|
||||
const result = await r(['auth', 'use', 'ws-does-not-exist-xyz'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/not found|workspace/i)
|
||||
})
|
||||
|
||||
it('[P0] current_workspace_id is unchanged when workspace switch fails', async () => {
|
||||
// Spec: current_workspace_id is unchanged when workspace switch fails
|
||||
await withTwoWorkspaces()
|
||||
await r(['auth', 'use', 'ws-does-not-exist-xyz'])
|
||||
// Read hosts.yml directly; the original workspace id should still be present
|
||||
const { readFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const hostsContent = await readFile(join(configDir, 'hosts.yml'), 'utf8')
|
||||
expect(hostsContent).toContain(E.workspaceId)
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated auth use returns auth error (exit code 4)', async () => {
|
||||
// Spec: unauthenticated auth use returns auth error + exit code 4
|
||||
const result = await r(['auth', 'use', E.workspaceId])
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i)
|
||||
})
|
||||
|
||||
it('[P0] missing workspace argument returns a usage error', async () => {
|
||||
// Spec: missing workspace argument returns a usage error
|
||||
await withTwoWorkspaces()
|
||||
const result = await r(['auth', 'use'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/missing required argument|workspace/i)
|
||||
})
|
||||
|
||||
// ── External SSO user ────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] external SSO user is rejected when executing auth use', async () => {
|
||||
// Spec: external SSO user is rejected when executing auth use
|
||||
await withSSOAuth()
|
||||
const result = await r(['auth', 'use', 'any-ws-id'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/external SSO|workspace/i)
|
||||
})
|
||||
|
||||
it('[P1] external SSO user auth use exit code is 1 or 2', async () => {
|
||||
// Spec: external SSO user auth use exit code is 1
|
||||
await withSSOAuth()
|
||||
const result = await r(['auth', 'use', 'any-ws-id'])
|
||||
expect([1, 2]).toContain(result.exitCode)
|
||||
})
|
||||
|
||||
// ── JSON mode ────────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] stderr contains an error description when workspace does not exist', async () => {
|
||||
// Spec: non-existent workspace returns an error
|
||||
// Note: auth use does not support the -o flag; errors are reported via stderr text
|
||||
await withTwoWorkspaces()
|
||||
const result = await r(['auth', 'use', 'ws-nonexistent-xyz'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/not.?found|workspace/i)
|
||||
})
|
||||
})
|
||||
@ -1,175 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl auth whoami + external SSO session behaviour
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec
|
||||
* - Dify CLI/Auth/External SSO Login (19 cases, testable subset)
|
||||
*
|
||||
* Note: interactive login (Device Flow browser) and Headless auth require a real browser;
|
||||
* E2E layer bypasses Device Flow via injectAuth, focusing on session state and CLI behaviour.
|
||||
*/
|
||||
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { assertExitCode } from '../../helpers/assert.js'
|
||||
import { run, withTempConfig } from '../../helpers/cli.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
|
||||
describe('E2E / difyctl auth whoami + SSO session', () => {
|
||||
let configDir: string
|
||||
let cleanup: () => Promise<void>
|
||||
|
||||
beforeEach(async () => {
|
||||
const tmp = await withTempConfig()
|
||||
configDir = tmp.configDir
|
||||
cleanup = tmp.cleanup
|
||||
})
|
||||
afterEach(async () => {
|
||||
await cleanup()
|
||||
})
|
||||
|
||||
function r(argv: string[]) {
|
||||
return run(argv, { configDir })
|
||||
}
|
||||
|
||||
async function withInternalAuth() {
|
||||
await mkdir(configDir, { recursive: true, mode: 0o700 })
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: ${E.token}`,
|
||||
`account:`,
|
||||
` id: acct-e2e`,
|
||||
` email: e2e-user@example.com`,
|
||||
` name: E2E User`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "${E.workspaceName}"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "${E.workspaceName}"`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
}
|
||||
|
||||
async function withSSOAuth(issuer = 'https://idp.example.com') {
|
||||
await mkdir(configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoe_sso_test_token`,
|
||||
`external_subject:`,
|
||||
` email: sso-user@example.com`,
|
||||
` issuer: ${issuer}`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
}
|
||||
|
||||
// ── auth whoami — internal user ──────────────────────────────────────────────
|
||||
|
||||
it('[P0] internal user auth whoami outputs email', async () => {
|
||||
await withInternalAuth()
|
||||
const result = await r(['auth', 'whoami'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/@/)
|
||||
})
|
||||
|
||||
it('[P0] auth whoami --json outputs valid JSON containing email', async () => {
|
||||
await withInternalAuth()
|
||||
const result = await r(['auth', 'whoami', '--json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = JSON.parse(result.stdout) as { email: string }
|
||||
expect(parsed).toHaveProperty('email')
|
||||
expect(parsed.email).toMatch(/@/)
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated auth whoami returns auth error (exit code 4)', async () => {
|
||||
const result = await r(['auth', 'whoami'])
|
||||
assertExitCode(result, 4)
|
||||
})
|
||||
|
||||
// ── External SSO user behaviour ──────────────────────────────────────────────
|
||||
|
||||
it('[P0] external SSO user auth status displays apps:run-only restriction', async () => {
|
||||
// Spec: auth status displays apps:run-only restriction
|
||||
await withSSOAuth()
|
||||
const result = await r(['auth', 'status'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/apps:run|SSO/i)
|
||||
})
|
||||
|
||||
it('[P0] external SSO user auth status does not display workspace info', async () => {
|
||||
// Spec: auth status does not display workspace information
|
||||
await withSSOAuth()
|
||||
const result = await r(['auth', 'status'])
|
||||
assertExitCode(result, 0)
|
||||
// SSO users have no workspace
|
||||
expect(result.stdout).not.toMatch(/^ {2}Workspace:/m)
|
||||
})
|
||||
|
||||
it('[P0] external SSO user auth status displays issuer URL', async () => {
|
||||
// Spec: auth status displays External SSO Session + issuer URL
|
||||
await withSSOAuth('https://idp.enterprise.com')
|
||||
const result = await r(['auth', 'status'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toContain('idp.enterprise.com')
|
||||
})
|
||||
|
||||
it('[P0] external user gets an error executing auth use (external SSO subjects have no workspaces)', async () => {
|
||||
// Spec: external user gets an error when executing auth use
|
||||
await withSSOAuth()
|
||||
const result = await r(['auth', 'use', 'any-ws-id'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/external SSO|workspace/i)
|
||||
})
|
||||
|
||||
it('[P0] external user get workspace returns empty list or insufficient_scope', async () => {
|
||||
// Spec: external user get workspace returns an empty list
|
||||
await withSSOAuth()
|
||||
const result = await r(['get', 'workspace'])
|
||||
// SSO token has no workspace scope
|
||||
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
|
||||
await withSSOAuth()
|
||||
const result = await r(['get', 'app'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/insufficient|scope|workspace|SSO/i)
|
||||
})
|
||||
|
||||
it('[P0] external user whoami outputs SSO email', async () => {
|
||||
await withSSOAuth()
|
||||
const result = await r(['auth', 'whoami'])
|
||||
assertExitCode(result, 0)
|
||||
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 mkdir(configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`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`
|
||||
await writeFile(join(configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
|
||||
const result = await r(['run', 'app', E.chatAppId, 'hello'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
@ -1,299 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl config — configuration management
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec
|
||||
* - Dify CLI/Config/Initialization & Default Paths (26 cases, testable subset)
|
||||
* - Dify CLI/Config/Environment Variable Override Priority (26 cases, testable subset)
|
||||
*
|
||||
* Covers sub-commands: config path / config get / config set / config unset / config view
|
||||
* All cases run purely locally — no real Dify server required.
|
||||
*/
|
||||
|
||||
import { access, mkdir, mkdtemp, rm, stat, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { assertExitCode, assertNoAnsi } from '../../helpers/assert.js'
|
||||
import { run, withTempConfig } from '../../helpers/cli.js'
|
||||
|
||||
describe('E2E / difyctl config', () => {
|
||||
let configDir: string
|
||||
let cleanup: () => Promise<void>
|
||||
|
||||
beforeEach(async () => {
|
||||
const tmp = await withTempConfig()
|
||||
configDir = tmp.configDir
|
||||
cleanup = tmp.cleanup
|
||||
})
|
||||
afterEach(async () => {
|
||||
await cleanup()
|
||||
})
|
||||
|
||||
function r(argv: string[], extraEnv?: Record<string, string>) {
|
||||
return run(argv, { configDir, env: extraEnv })
|
||||
}
|
||||
|
||||
// ── config path ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] config path returns the correct absolute path to config.yml', async () => {
|
||||
// Spec: default config path is correct
|
||||
const result = await r(['config', 'path'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim()).toBe(join(configDir, 'config.yml'))
|
||||
})
|
||||
|
||||
it('[P0] config path output ends with a newline', async () => {
|
||||
const result = await r(['config', 'path'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/config\.yml\n$/)
|
||||
})
|
||||
|
||||
// ── config set / get ─────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] config set defaults.format writes successfully — stdout contains key=value', async () => {
|
||||
const result = await r(['config', 'set', 'defaults.format', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/defaults\.format/)
|
||||
})
|
||||
|
||||
it('[P0] config get reads the previously written defaults.format', async () => {
|
||||
await r(['config', 'set', 'defaults.format', 'json'])
|
||||
const result = await r(['config', 'get', 'defaults.format'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim()).toBe('json')
|
||||
})
|
||||
|
||||
it('[P0] config set defaults.limit writes and reads back correctly', async () => {
|
||||
await r(['config', 'set', 'defaults.limit', '50'])
|
||||
const result = await r(['config', 'get', 'defaults.limit'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim()).toBe('50')
|
||||
})
|
||||
|
||||
it('[P0] config set state.current_app writes and reads back correctly', async () => {
|
||||
const appId = 'app-e2e-config-test'
|
||||
await r(['config', 'set', 'state.current_app', appId])
|
||||
const result = await r(['config', 'get', 'state.current_app'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim()).toBe(appId)
|
||||
})
|
||||
|
||||
it('[P0] config get returns empty string for an unset key (exit 0)', async () => {
|
||||
// Spec: missing config fields fall back to default values
|
||||
const result = await r(['config', 'get', 'defaults.format'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim()).toBe('')
|
||||
})
|
||||
|
||||
it('[P1] multiple config set calls for different keys each persist independently', async () => {
|
||||
// Spec: existing config is not overwritten when setting other keys
|
||||
await r(['config', 'set', 'defaults.format', 'yaml'])
|
||||
await r(['config', 'set', 'defaults.limit', '30'])
|
||||
const fmt = await r(['config', 'get', 'defaults.format'])
|
||||
const lim = await r(['config', 'get', 'defaults.limit'])
|
||||
expect(fmt.stdout.trim()).toBe('yaml')
|
||||
expect(lim.stdout.trim()).toBe('30')
|
||||
})
|
||||
|
||||
// ── config unset ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] config unset clears a set key — get returns empty string', async () => {
|
||||
await r(['config', 'set', 'defaults.format', 'json'])
|
||||
await r(['config', 'unset', 'defaults.format'])
|
||||
const result = await r(['config', 'get', 'defaults.format'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim()).toBe('')
|
||||
})
|
||||
|
||||
it('[P1] config unset of an unset key is idempotent (exit 0)', async () => {
|
||||
const result = await r(['config', 'unset', 'defaults.format'])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
it('[P1] other keys are unaffected after config unset', async () => {
|
||||
await r(['config', 'set', 'defaults.format', 'table'])
|
||||
await r(['config', 'set', 'defaults.limit', '10'])
|
||||
await r(['config', 'unset', 'defaults.format'])
|
||||
const lim = await r(['config', 'get', 'defaults.limit'])
|
||||
expect(lim.stdout.trim()).toBe('10')
|
||||
})
|
||||
|
||||
// ── config view ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] config view outputs nothing for an empty configuration', async () => {
|
||||
const result = await r(['config', 'view'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim()).toBe('')
|
||||
})
|
||||
|
||||
it('[P0] config view displays all set key = value pairs', async () => {
|
||||
await r(['config', 'set', 'defaults.format', 'yaml'])
|
||||
await r(['config', 'set', 'defaults.limit', '20'])
|
||||
const result = await r(['config', 'view'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toContain('defaults.format = yaml')
|
||||
expect(result.stdout).toContain('defaults.limit = 20')
|
||||
})
|
||||
|
||||
it('[P0] config view --json outputs valid JSON containing the set keys', async () => {
|
||||
await r(['config', 'set', 'defaults.format', 'json'])
|
||||
await r(['config', 'set', 'defaults.limit', '15'])
|
||||
const result = await r(['config', 'view', '--json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = JSON.parse(result.stdout) as Record<string, unknown>
|
||||
expect(parsed).toHaveProperty('defaults.format', 'json')
|
||||
expect(parsed).toHaveProperty('defaults.limit', 15)
|
||||
})
|
||||
|
||||
it('[P1] config view --json outputs a valid empty JSON object for an empty config', async () => {
|
||||
const result = await r(['config', 'view', '--json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = JSON.parse(result.stdout)
|
||||
expect(typeof parsed).toBe('object')
|
||||
})
|
||||
|
||||
// ── Initialization and default paths ─────────────────────────────────────────
|
||||
|
||||
it('[P0] the first config set auto-creates the config directory and config.yml file', async () => {
|
||||
// Spec: config directory and file are auto-created on first use
|
||||
await r(['config', 'set', 'defaults.format', 'json'])
|
||||
await expect(access(join(configDir, 'config.yml'))).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('[P0] config.yml file permissions are 0o600', async () => {
|
||||
// Spec: config file has correct default permissions
|
||||
await r(['config', 'set', 'defaults.format', 'json'])
|
||||
const info = await stat(join(configDir, 'config.yml'))
|
||||
expect(info.mode & 0o777).toBe(0o600)
|
||||
})
|
||||
|
||||
it('[P0] config.yml contains the correct schema_version field', async () => {
|
||||
// Spec: config file has the correct default schema
|
||||
await r(['config', 'set', 'defaults.format', 'json'])
|
||||
const raw = await import('node:fs/promises').then(fs =>
|
||||
fs.readFile(join(configDir, 'config.yml'), 'utf8'),
|
||||
)
|
||||
expect(raw).toMatch(/schema_version/)
|
||||
})
|
||||
|
||||
it('[P0] invalid YAML content in config returns a parse error', async () => {
|
||||
// Spec: invalid config content returns a parse error
|
||||
await mkdir(configDir, { recursive: true })
|
||||
await writeFile(join(configDir, 'config.yml'), ': broken: yaml: [[[', { mode: 0o600 })
|
||||
const result = await r(['config', 'get', 'defaults.format'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/parse|yaml|config/i)
|
||||
})
|
||||
|
||||
it('[P0] schema_version higher than supported returns config_schema_unsupported error', async () => {
|
||||
// Spec: config with schema_version higher than supported returns an error
|
||||
await mkdir(configDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(configDir, 'config.yml'),
|
||||
'schema_version: 999\ndefaults: {}\nstate: {}\n',
|
||||
{ mode: 0o600 },
|
||||
)
|
||||
const result = await r(['config', 'get', 'defaults.format'])
|
||||
expect(result.exitCode).toBe(6) // VersionCompat
|
||||
expect(result.stderr).toMatch(/schema_version|unsupported|upgrade/i)
|
||||
})
|
||||
|
||||
it('[P0] DIFY_CONFIG_DIR overrides the default path — config path returns the specified directory', async () => {
|
||||
// Spec: DIFY_CONFIG_DIR env var overrides the default path
|
||||
const altDir = await mkdtemp(join(tmpdir(), 'difyctl-alt-'))
|
||||
try {
|
||||
const result = await run(['config', 'path'], { configDir: altDir })
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trim()).toBe(join(altDir, 'config.yml'))
|
||||
}
|
||||
finally {
|
||||
await rm(altDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('[P0] a temporary DIFY_CONFIG_DIR does not modify the original config directory', async () => {
|
||||
// Spec: a temporary DIFY_CONFIG_DIR injection does not modify the original config
|
||||
await r(['config', 'set', 'defaults.format', 'yaml'])
|
||||
|
||||
const altDir = await mkdtemp(join(tmpdir(), 'difyctl-alt-'))
|
||||
try {
|
||||
await run(['config', 'set', 'defaults.format', 'json'], { configDir: altDir })
|
||||
// The original configDir content must be unchanged
|
||||
const original = await r(['config', 'get', 'defaults.format'])
|
||||
expect(original.stdout.trim()).toBe('yaml')
|
||||
}
|
||||
finally {
|
||||
await rm(altDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// ── Error scenarios ──────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] config get of an unknown key returns exit code 2', async () => {
|
||||
const result = await r(['config', 'get', 'unknown.key'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
expect(result.stderr).toMatch(/unknown config key/i)
|
||||
})
|
||||
|
||||
it('[P0] config set of an unknown key returns exit code 2', async () => {
|
||||
const result = await r(['config', 'set', 'unknown.key', 'val'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
expect(result.stderr).toMatch(/unknown config key/i)
|
||||
})
|
||||
|
||||
it('[P0] config unset of an unknown key returns exit code 2', async () => {
|
||||
const result = await r(['config', 'unset', 'unknown.key'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
expect(result.stderr).toMatch(/unknown config key/i)
|
||||
})
|
||||
|
||||
it('[P0] config set defaults.format with an invalid value returns exit code 2', async () => {
|
||||
// Spec: config_invalid_value → usage error
|
||||
const result = await r(['config', 'set', 'defaults.format', 'not_a_format'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
expect(result.stderr).toMatch(/defaults\.format|not one of/i)
|
||||
})
|
||||
|
||||
it('[P0] config set defaults.limit 0 (below minimum) returns exit code 2', async () => {
|
||||
const result = await r(['config', 'set', 'defaults.limit', '0'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
})
|
||||
|
||||
it('[P0] config set defaults.limit 201 (above maximum) returns exit code 2', async () => {
|
||||
const result = await r(['config', 'set', 'defaults.limit', '201'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
})
|
||||
|
||||
it('[P0] config set defaults.limit with a non-numeric string returns exit code 2', async () => {
|
||||
const result = await r(['config', 'set', 'defaults.limit', 'abc'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
})
|
||||
|
||||
it('[P1] config set with missing value argument returns an error', async () => {
|
||||
const result = await r(['config', 'set', 'defaults.format'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/missing required argument/i)
|
||||
})
|
||||
|
||||
it('[P1] config get with missing key argument returns an error', async () => {
|
||||
const result = await r(['config', 'get'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/missing required argument/i)
|
||||
})
|
||||
|
||||
// ── Output format ────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] config output contains no ANSI colour (non-TTY environment)', async () => {
|
||||
await r(['config', 'set', 'defaults.format', 'json'])
|
||||
const result = await r(['config', 'view'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
|
||||
it('[P0] config initialization/operations do not leak sensitive information (token/secret)', async () => {
|
||||
// Spec: config initialization logs do not leak sensitive information
|
||||
const result = await r(['config', 'view'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout + result.stderr).not.toMatch(/dfoa_|dfoe_|secret|password/i)
|
||||
})
|
||||
})
|
||||
@ -1,195 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl describe app — Describe App
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Describe App (29 cases)
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
} from '../../helpers/assert.js'
|
||||
import { withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
const NONEXISTENT_ID = 'app-does-not-exist-e2e-xyz'
|
||||
|
||||
describe('E2E / difyctl describe app', () => {
|
||||
let fx: Awaited<ReturnType<typeof withAuthFixture>>
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── Basic describe ────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] logged-in user can describe an app', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P0] default text output is labelled-section style', async () => {
|
||||
// Spec: default output is kubectl-describe-style labelled sections
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
// Labelled output contains key: value pairs
|
||||
expect(result.stdout).toMatch(/\w+:\s+\S/)
|
||||
})
|
||||
|
||||
it('[P1] describe output contains ID field', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/ID:/i)
|
||||
expect(result.stdout).toContain(E.chatAppId)
|
||||
})
|
||||
|
||||
it('[P1] describe output contains Mode field', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/Mode:/i)
|
||||
})
|
||||
|
||||
it('[P1] describe output contains Name field', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
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 () => {
|
||||
// Spec: Inputs/Parameters section present when app has an input schema
|
||||
const result = await fx.r(['describe', 'app', E.workflowAppId])
|
||||
assertExitCode(result, 0)
|
||||
// Workflow app has at least a 'x' required input
|
||||
expect(result.stdout).toMatch(/Parameters|Inputs/i)
|
||||
})
|
||||
|
||||
// ── JSON output ───────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] -o json outputs the raw server describe response', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ info: { id: string } }>(result)
|
||||
expect(parsed.info?.id).toBe(E.chatAppId)
|
||||
})
|
||||
|
||||
it('[P1] JSON output is valid indented JSON', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
// Indented JSON: multiple lines, starts with '{'
|
||||
expect(result.stdout.trim()).toMatch(/^\{/)
|
||||
expect(result.stdout.split('\n').length).toBeGreaterThan(2)
|
||||
})
|
||||
|
||||
it('[P1] JSON output can be piped', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.trimStart().startsWith('{')).toBe(true)
|
||||
expect(result.stdout.endsWith('\n')).toBe(true)
|
||||
})
|
||||
|
||||
// ── Unsupported formats ───────────────────────────────────────────────────
|
||||
|
||||
it('[P0] -o wide is not supported and returns an error', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'wide'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/NoCompatiblePrinter|invalid|unsupported|wide/i)
|
||||
})
|
||||
|
||||
it('[P0] -o name is not supported and returns an error', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId, '-o', 'name'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/NoCompatiblePrinter|invalid|unsupported|name/i)
|
||||
})
|
||||
|
||||
// ── Not found ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] describing a non-existent app returns an error', async () => {
|
||||
const result = await fx.r(['describe', 'app', NONEXISTENT_ID])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
})
|
||||
|
||||
it('[P0] non-existent app exit code is 1', async () => {
|
||||
const result = await fx.r(['describe', 'app', NONEXISTENT_ID])
|
||||
expect(result.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('[P1] non-existent app in JSON mode outputs JSON error envelope', async () => {
|
||||
const result = await fx.r(['describe', 'app', NONEXISTENT_ID, '-o', 'json'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
assertErrorEnvelope(result)
|
||||
})
|
||||
|
||||
// ── Missing argument ──────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] missing app id returns usage error', async () => {
|
||||
const result = await fx.r(['describe', 'app'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/missing required argument|required/i)
|
||||
})
|
||||
|
||||
// ── Unauthenticated ───────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] unauthenticated describe app returns auth error', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const { run } = await import('../../helpers/cli.js')
|
||||
const result = await run(['describe', 'app', E.chatAppId], { configDir: tmp.configDir })
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in|auth/i)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── External SSO ──────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] external SSO user describe app returns insufficient_scope', async () => {
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const { withTempConfig: wtc, run } = await import('../../helpers/cli.js')
|
||||
const ssoTmp = await wtc()
|
||||
try {
|
||||
await mkdir(ssoTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoe_sso_test_token`,
|
||||
`external_subject:`,
|
||||
` email: sso@example.com`,
|
||||
` issuer: https://issuer.example.com`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['describe', 'app', E.chatAppId], { configDir: ssoTmp.configDir })
|
||||
// SSO subjects have no workspace; CLI reports usage_missing_arg before reaching scope check
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Output quality ────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] describe output has no ANSI colour codes (non-TTY)', async () => {
|
||||
const result = await fx.r(['describe', 'app', E.chatAppId])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
})
|
||||
@ -1,219 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl get app -A — Cross-Workspace App Query
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Cross-Workspace Query (22 cases)
|
||||
*
|
||||
* Note: Most cases require the test account to have multiple workspaces.
|
||||
* Tests that depend on multiple workspaces are guarded by checking the
|
||||
* available_workspaces count from auth status.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
assertPipeFriendlyJson,
|
||||
} from '../../helpers/assert.js'
|
||||
import { withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
|
||||
describe('E2E / difyctl get app -A (all-workspaces)', () => {
|
||||
let fx: Awaited<ReturnType<typeof withAuthFixture>>
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── Basic fan-out ─────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] internal user can execute all-workspaces query', async () => {
|
||||
const result = await fx.r(['get', 'app', '-A', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
})
|
||||
|
||||
it('[P1] --all-workspaces and -A flags behave identically', async () => {
|
||||
const r1 = await fx.r(['get', 'app', '-A', '-o', 'json'])
|
||||
const r2 = await fx.r(['get', 'app', '--all-workspaces', '-o', 'json'])
|
||||
assertExitCode(r1, 0)
|
||||
assertExitCode(r2, 0)
|
||||
// Both return same structure
|
||||
const p1 = assertJson<{ data: unknown[] }>(r1)
|
||||
const p2 = assertJson<{ data: unknown[] }>(r2)
|
||||
expect(p1.data.length).toBe(p2.data.length)
|
||||
})
|
||||
|
||||
// ── Output format ─────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] table output contains WORKSPACE column (or workspace_id in JSON)', async () => {
|
||||
// WORKSPACE column appears in table only when apps span multiple workspaces.
|
||||
// Verify via JSON that workspace_id is populated instead.
|
||||
const result = await fx.r(['get', 'app', '-A', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ workspace_id?: string }> }>(result)
|
||||
if (parsed.data.length > 0) {
|
||||
const hasWorkspace = parsed.data.some(a => typeof a.workspace_id === 'string' && a.workspace_id.length > 0)
|
||||
expect(hasWorkspace).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('[P0] JSON output contains workspace_id per app', async () => {
|
||||
const result = await fx.r(['get', 'app', '-A', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ workspace_id?: string }> }>(result)
|
||||
if (parsed.data.length > 0) {
|
||||
// At least the known workspace should be represented
|
||||
const hasWorkspaceId = parsed.data.some(app => typeof app.workspace_id === 'string')
|
||||
expect(hasWorkspaceId).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] YAML output contains workspace_id', async () => {
|
||||
const result = await fx.r(['get', 'app', '-A', '-o', 'yaml'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/workspace_id/)
|
||||
})
|
||||
|
||||
it('[P1] all-workspaces output is pipe-friendly in JSON mode', async () => {
|
||||
const result = await fx.r(['get', 'app', '-A', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
assertPipeFriendlyJson(result)
|
||||
})
|
||||
|
||||
it('[P0] all-workspaces output has no ANSI colour codes (non-TTY)', async () => {
|
||||
const result = await fx.r(['get', 'app', '-A'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
|
||||
// ── Filters in all-workspaces mode ────────────────────────────────────────
|
||||
|
||||
it('[P1] --limit applies in all-workspaces mode', async () => {
|
||||
const result = await fx.r(['get', 'app', '-A', '--limit', '1', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
// limit applies per workspace; total may be > 1 across workspaces
|
||||
// but the call itself must succeed
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
})
|
||||
|
||||
it('[P1] --mode filter applies in all-workspaces mode', async () => {
|
||||
const result = await fx.r(['get', 'app', '-A', '--mode', 'workflow', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ mode: string }> }>(result)
|
||||
parsed.data.forEach(app => expect(app.mode).toBe('workflow'))
|
||||
})
|
||||
|
||||
// ── Unauthenticated ───────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] unauthenticated get app -A returns auth error', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const { run } = await import('../../helpers/cli.js')
|
||||
const result = await run(['get', 'app', '-A'], { configDir: tmp.configDir })
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in|auth/i)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated -A exit code is 4', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const { run } = await import('../../helpers/cli.js')
|
||||
const result = await run(['get', 'app', '-A'], { configDir: tmp.configDir })
|
||||
expect(result.exitCode).toBe(4)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── External SSO ──────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] external SSO user get app -A returns error', async () => {
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const { withTempConfig: wtc, run } = await import('../../helpers/cli.js')
|
||||
const ssoTmp = await wtc()
|
||||
try {
|
||||
await mkdir(ssoTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoe_sso_test_token`,
|
||||
`external_subject:`,
|
||||
` email: sso@example.com`,
|
||||
` issuer: https://issuer.example.com`,
|
||||
].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).not.toBe(0)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P0] external SSO user -A exit code is not 0', async () => {
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const { withTempConfig: wtc, run } = await import('../../helpers/cli.js')
|
||||
const ssoTmp = await wtc()
|
||||
try {
|
||||
await mkdir(ssoTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoe_sso_test_token`,
|
||||
`external_subject:`,
|
||||
` email: sso@example.com`,
|
||||
` issuer: https://issuer.example.com`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['get', 'app', '-A'], { configDir: ssoTmp.configDir })
|
||||
// SSO subject has no workspace, so all-workspaces returns error
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── JSON error envelope ───────────────────────────────────────────────────
|
||||
|
||||
it('[P1] JSON mode error outputs JSON error envelope to stderr', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const { run } = await import('../../helpers/cli.js')
|
||||
const result = await run(['get', 'app', '-A', '-o', 'json'], { configDir: tmp.configDir })
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
assertErrorEnvelope(result)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Stability ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] using -A with -w together returns a stable result or clear error', async () => {
|
||||
// Spec: behaviour when both flags are provided should be stable
|
||||
const result = await fx.r(['get', 'app', '-A', '-w', E.workspaceId, '-o', 'json'])
|
||||
// Either success (ignores -w) or a clear usage/logical error — must not panic
|
||||
const isValid = result.exitCode === 0 || result.exitCode === 1 || result.exitCode === 2
|
||||
expect(isValid).toBe(true)
|
||||
})
|
||||
})
|
||||
@ -1,247 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl get app (list mode) — App List
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/App List (31 cases)
|
||||
*
|
||||
* Prerequisites (DIFY_E2E_* env vars):
|
||||
* DIFY_E2E_CHAT_APP_ID — echo-chat app
|
||||
* DIFY_E2E_WORKFLOW_APP_ID — echo-workflow app
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
assertPipeFriendlyJson,
|
||||
} from '../../helpers/assert.js'
|
||||
import { withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
|
||||
describe('E2E / difyctl get app (list)', () => {
|
||||
let fx: Awaited<ReturnType<typeof withAuthFixture>>
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── Basic listing ─────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] logged-in user can retrieve app list', async () => {
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P0] default output format is table', async () => {
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
// table output: has column headers, no leading '{' (not JSON)
|
||||
expect(result.stdout.trimStart()).not.toMatch(/^\{/)
|
||||
})
|
||||
|
||||
it('[P1] table output contains app ID', async () => {
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/ID/i)
|
||||
})
|
||||
|
||||
it('[P1] table output contains app name', async () => {
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/NAME/i)
|
||||
})
|
||||
|
||||
it('[P1] table output contains mode column', async () => {
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/MODE/i)
|
||||
})
|
||||
|
||||
// ── Output formats ────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] -o json outputs valid JSON', async () => {
|
||||
const result = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
})
|
||||
|
||||
it('[P1] -o yaml outputs valid YAML (non-empty, no JSON braces)', async () => {
|
||||
const result = await fx.r(['get', 'app', '-o', 'yaml'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
// YAML lists start with '- ' not '{'
|
||||
expect(result.stdout.trimStart()).not.toMatch(/^\{/)
|
||||
})
|
||||
|
||||
it('[P1] -o name outputs only app IDs (one per line)', async () => {
|
||||
const result = await fx.r(['get', 'app', '-o', 'name'])
|
||||
assertExitCode(result, 0)
|
||||
const lines = result.stdout.trim().split('\n').filter(Boolean)
|
||||
expect(lines.length).toBeGreaterThan(0)
|
||||
// Each line should look like a UUID
|
||||
expect(lines[0]).toMatch(/^[0-9a-f-]{36}$/)
|
||||
})
|
||||
|
||||
it('[P1] -o wide outputs extended fields', async () => {
|
||||
const result = await fx.r(['get', 'app', '-o', 'wide'])
|
||||
assertExitCode(result, 0)
|
||||
// wide adds AUTHOR and WORKSPACE columns
|
||||
expect(result.stdout).toMatch(/AUTHOR|WORKSPACE/i)
|
||||
})
|
||||
|
||||
it('[P1] output is pipe-friendly in JSON mode', async () => {
|
||||
const result = await fx.r(['get', 'app', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
assertPipeFriendlyJson(result)
|
||||
})
|
||||
|
||||
it('[P0] output has no ANSI colour codes (non-TTY)', async () => {
|
||||
const result = await fx.r(['get', 'app'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
|
||||
// ── --limit ───────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] --limit restricts number of returned apps', async () => {
|
||||
const result = await fx.r(['get', 'app', '--limit', '1', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(parsed.data.length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('[P1] --limit 1 returns exactly one result', async () => {
|
||||
const result = await fx.r(['get', 'app', '--limit', '1', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(parsed.data.length).toBe(1)
|
||||
})
|
||||
|
||||
it('[P0] --limit 0 returns usage error (exit code 2)', async () => {
|
||||
const result = await fx.r(['get', 'app', '--limit', '0'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
})
|
||||
|
||||
it('[P0] --limit 201 returns usage error (exit code 2)', async () => {
|
||||
const result = await fx.r(['get', 'app', '--limit', '201'])
|
||||
expect(result.exitCode).toBe(2)
|
||||
})
|
||||
|
||||
// ── --mode filter ─────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] --mode chat filters to chat apps only', async () => {
|
||||
const result = await fx.r(['get', 'app', '--mode', 'chat', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ mode: string }> }>(result)
|
||||
parsed.data.forEach(app => expect(app.mode).toBe('chat'))
|
||||
})
|
||||
|
||||
it('[P0] --mode workflow filters to workflow apps only', async () => {
|
||||
const result = await fx.r(['get', 'app', '--mode', 'workflow', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: Array<{ mode: string }> }>(result)
|
||||
parsed.data.forEach(app => expect(app.mode).toBe('workflow'))
|
||||
})
|
||||
|
||||
it('[P0] --mode with a valid enum value succeeds', async () => {
|
||||
// Spec: valid enum filter returns successfully
|
||||
const result = await fx.r(['get', 'app', '--mode', 'workflow', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
it('[P1] --mode with unknown value returns empty list or usage error', async () => {
|
||||
// Spec: invalid mode — CLI intercepts (oclif validates enum options, returns non-zero)
|
||||
const result = await fx.r(['get', 'app', '--mode', 'chatbot', '-o', 'json'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
})
|
||||
|
||||
// ── workspace override ────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] -w overrides the default workspace', async () => {
|
||||
// Pass the known workspace id — should return apps for that workspace
|
||||
const result = await fx.r(['get', 'app', '--workspace', E.workspaceId, '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data: unknown[] }>(result)
|
||||
expect(Array.isArray(parsed.data)).toBe(true)
|
||||
})
|
||||
|
||||
// ── Unauthenticated ───────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] unauthenticated get app returns auth error', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const { run } = await import('../../helpers/cli.js')
|
||||
const result = await run(['get', 'app'], { configDir: tmp.configDir })
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in|auth/i)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated get app exit code is 4', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const { run } = await import('../../helpers/cli.js')
|
||||
const result = await run(['get', 'app'], { configDir: tmp.configDir })
|
||||
expect(result.exitCode).toBe(4)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── External SSO ──────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] external SSO user get app returns insufficient_scope error', async () => {
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const { withTempConfig: wtc } = await import('../../helpers/cli.js')
|
||||
const ssoTmp = await wtc()
|
||||
try {
|
||||
await mkdir(ssoTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoe_sso_test_token`,
|
||||
`external_subject:`,
|
||||
` email: sso@example.com`,
|
||||
` issuer: https://issuer.example.com`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const { run } = await import('../../helpers/cli.js')
|
||||
const result = await run(['get', 'app'], { configDir: ssoTmp.configDir })
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
// SSO subjects have no workspace; CLI reports usage_missing_arg before reaching scope check
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── JSON error envelope ───────────────────────────────────────────────────
|
||||
|
||||
it('[P1] JSON mode error outputs JSON error envelope to stderr', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const { run } = await import('../../helpers/cli.js')
|
||||
const result = await run(['get', 'app', '-o', 'json'], { configDir: tmp.configDir })
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
assertErrorEnvelope(result)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -1,104 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl get app <id> — Single App Query
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Discovery/Single App Query (22 cases)
|
||||
*
|
||||
* Note: difyctl get app <id> calls GET /apps/<id>/describe?fields=info internally.
|
||||
* Server 1.14.1 returns HTTP 500 for all app IDs on this endpoint, so tests that
|
||||
* require a successful single-app lookup are deferred to a compatible server version.
|
||||
* The non-network tests (unauthenticated, not-found, error format) are covered here.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
} from '../../helpers/assert.js'
|
||||
import { withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
const NONEXISTENT_ID = 'app-does-not-exist-e2e-xyz'
|
||||
|
||||
describe('E2E / difyctl get app <id> (single)', () => {
|
||||
let fx: Awaited<ReturnType<typeof withAuthFixture>>
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── Not found ─────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] querying a non-existent app returns a non-zero exit code', async () => {
|
||||
const result = await fx.r(['get', 'app', NONEXISTENT_ID])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
})
|
||||
|
||||
it('[P0] non-existent app exit code is 1', async () => {
|
||||
const result = await fx.r(['get', 'app', NONEXISTENT_ID])
|
||||
expect(result.exitCode).toBe(1)
|
||||
})
|
||||
|
||||
it('[P1] JSON mode error for non-existent app outputs JSON error envelope', async () => {
|
||||
const result = await fx.r(['get', 'app', NONEXISTENT_ID, '-o', 'json'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
assertErrorEnvelope(result)
|
||||
})
|
||||
|
||||
// ── Unauthenticated ───────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] unauthenticated get app <id> returns auth error (exit code 4)', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const { run } = await import('../../helpers/cli.js')
|
||||
const result = await run(['get', 'app', E.workflowAppId], { configDir: tmp.configDir })
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in|auth/i)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated exit code is 4', async () => {
|
||||
const tmp = await withTempConfig()
|
||||
try {
|
||||
const { run } = await import('../../helpers/cli.js')
|
||||
const result = await run(['get', 'app', E.workflowAppId], { configDir: tmp.configDir })
|
||||
expect(result.exitCode).toBe(4)
|
||||
}
|
||||
finally {
|
||||
await tmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── External SSO ──────────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] external SSO user get app <id> returns a non-zero exit code', async () => {
|
||||
const { mkdir, writeFile } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const { withTempConfig: wtc, run } = await import('../../helpers/cli.js')
|
||||
const ssoTmp = await wtc()
|
||||
try {
|
||||
await mkdir(ssoTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoe_sso_test_token`,
|
||||
`external_subject:`,
|
||||
` email: sso@example.com`,
|
||||
` issuer: https://issuer.example.com`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(['get', 'app', E.workflowAppId], { configDir: ssoTmp.configDir })
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -1,446 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl run app — basic app execution
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/Basic App Execution (4.1)
|
||||
*
|
||||
* Streaming output cases → run-app-streaming.e2e.ts
|
||||
* Conversation mode cases → run-app-conversation.e2e.ts
|
||||
*
|
||||
* Staging app prerequisites (specified via DIFY_E2E_* env vars):
|
||||
* echo-chat — mode=chat, query variable, outputs "echo: {query}"
|
||||
* echo-workflow — mode=workflow, x variable (required), outputs "echo: {x}"
|
||||
*/
|
||||
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
assertPipeFriendlyJson,
|
||||
assertStdoutContains,
|
||||
} from '../../helpers/assert.js'
|
||||
import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
|
||||
// ── Suite ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('E2E / difyctl run app', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Basic execution
|
||||
// =========================================================================
|
||||
|
||||
describe('Basic execution', () => {
|
||||
it('[P0] logged-in internal user can run app — stdout contains the app result', async () => {
|
||||
// Spec: logged-in internal user can run app / default output shows execution result
|
||||
// withRetry: staging LLM inference may have transient 5xx on cold start
|
||||
const result = await withRetry(() => fx.r(['run', 'app', E.chatAppId, 'hello']), {
|
||||
attempts: 3,
|
||||
delayMs: 2000,
|
||||
shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message),
|
||||
})
|
||||
assertExitCode(result, 0)
|
||||
assertStdoutContains(result, 'echo:hello')
|
||||
// Spec 4.1.4: default output has no ANSI colour codes (non-TTY; run() sets NO_COLOR=1)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
|
||||
it('[P0] run app invokes the execute endpoint (stdout has actual content)', async () => {
|
||||
// Spec: run app invokes the execute endpoint
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'e2e-smoke'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P1] text output preserves newlines (stdout ends with \\n)', async () => {
|
||||
// Spec: text output preserves newlines
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'newline'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/\n$/)
|
||||
})
|
||||
|
||||
it('[P1] repeated run app calls each complete independently (3 iterations)', async () => {
|
||||
// Spec: repeated run app calls do not affect historical state
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, `repeat-${i}`])
|
||||
assertExitCode(result, 0)
|
||||
assertStdoutContains(result, `echo:repeat-${i}`)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Output format
|
||||
// =========================================================================
|
||||
|
||||
describe('Output format (-o)', () => {
|
||||
it('[P0] -o json outputs valid JSON', async () => {
|
||||
// Spec: -o json produces valid JSON
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'json-test', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ answer: string, mode: string }>(result)
|
||||
expect(parsed).toHaveProperty('answer')
|
||||
expect(parsed.mode).toMatch(/chat/)
|
||||
})
|
||||
|
||||
it('[P1] JSON output includes execution metadata (message_id / conversation_id)', async () => {
|
||||
// Spec: JSON output includes execution metadata
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'meta', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<Record<string, unknown>>(result)
|
||||
expect(parsed).toHaveProperty('message_id')
|
||||
expect(parsed).toHaveProperty('conversation_id')
|
||||
})
|
||||
|
||||
it('[P1] JSON output supports piping (no ANSI, starts with {, ends with \\n)', async () => {
|
||||
// Spec: JSON output supports piping
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'pipe', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
assertPipeFriendlyJson(result)
|
||||
})
|
||||
|
||||
it('[P1] JSON mode outputs a JSON error envelope to stderr', async () => {
|
||||
// Spec: JSON mode outputs a JSON error envelope
|
||||
const result = await fx.r(['run', 'app', 'app-nonexistent-xyz-e2e', 'hello', '-o', 'json'])
|
||||
assertNonZeroExit(result)
|
||||
assertErrorEnvelope(result, 'server_4xx_other')
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// --inputs flag
|
||||
// =========================================================================
|
||||
|
||||
describe('--inputs flag', () => {
|
||||
it('[P0] run app supports --inputs (workflow app)', async () => {
|
||||
// Spec: run app supports --inputs
|
||||
// withRetry: staging workflow execution may have transient 5xx
|
||||
const result = await withRetry(
|
||||
() => fx.r(['run', 'app', E.workflowAppId, '--inputs', JSON.stringify({ x: 'workflow-val', num: 42, enum_var: 'A', paragraph: 'short text' })]),
|
||||
{ attempts: 3, delayMs: 2000, shouldRetry: err => err instanceof Error && /5\d{2}|ECONNRESET|timeout/i.test(err.message) },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
assertStdoutContains(result, 'workflow-val')
|
||||
})
|
||||
|
||||
it('[P0] multiple inputs take effect simultaneously', async () => {
|
||||
// Spec: multiple --inputs entries take effect simultaneously
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'multi-test', num: 42, enum_var: 'A', paragraph: 'short text' }),
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
it('[P0] invalid JSON for --inputs returns usage error (exit code 2)', async () => {
|
||||
// Spec: missing required parameter / invalid input
|
||||
const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs', 'not-json'])
|
||||
assertExitCode(result, 2)
|
||||
expect(result.stderr).toMatch(/valid JSON/i)
|
||||
})
|
||||
|
||||
it('[P0] JSON array for --inputs returns usage error', async () => {
|
||||
const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs', '[1,2,3]'])
|
||||
assertExitCode(result, 2)
|
||||
expect(result.stderr).toMatch(/JSON object/i)
|
||||
})
|
||||
|
||||
it('[P0] --inputs and --inputs-file are mutually exclusive — returns usage error', async () => {
|
||||
// Spec: mutually exclusive flags return a usage error
|
||||
const inputsFile = join(fx.configDir, 'inputs.json')
|
||||
await writeFile(inputsFile, JSON.stringify({ x: 'file-val' }))
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
'{"x":"flag-val"}',
|
||||
'--inputs-file',
|
||||
inputsFile,
|
||||
])
|
||||
assertExitCode(result, 2)
|
||||
expect(result.stderr).toMatch(/mutually exclusive/i)
|
||||
})
|
||||
|
||||
it('[P0] positional message passed to workflow app returns usage error', async () => {
|
||||
// Spec: execution fails when required positional parameter is missing (workflow)
|
||||
const result = await fx.r(['run', 'app', E.workflowAppId, 'positional-msg'])
|
||||
assertExitCode(result, 2)
|
||||
expect(result.stderr).toMatch(/workflow apps do not accept a positional message/i)
|
||||
})
|
||||
|
||||
it('[P0] --inputs-file reads JSON inputs from a file', async () => {
|
||||
const inputsFile = join(fx.configDir, 'wf-inputs.json')
|
||||
await writeFile(inputsFile, JSON.stringify({ x: 'from-file', num: 42, enum_var: 'A', paragraph: 'short text' }))
|
||||
const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs-file', inputsFile])
|
||||
assertExitCode(result, 0)
|
||||
assertStdoutContains(result, 'from-file')
|
||||
})
|
||||
|
||||
it('[P0] required inputs missing causes execution failure (exit code non-zero)', async () => {
|
||||
// Spec 4.1.11: workflow app fails when required inputs are not provided.
|
||||
// Passing an empty object omits the required "x" field; the server
|
||||
// returns a validation error and the CLI exits with a non-zero code.
|
||||
const result = await fx.r(['run', 'app', E.workflowAppId, '--inputs', '{}'])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P0] paragraph input within limit succeeds; exceeding max_length returns error', async () => {
|
||||
// Spec 4.1.19: paragraph input exceeding max_length (100) returns validation error
|
||||
// App: basic_auto_test — variable "paragraph" (text-input, max_length=100, optional)
|
||||
|
||||
// ── Within limit: 50 chars ──────────────────────────────────────────
|
||||
const shortResult = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({
|
||||
x: 'hello',
|
||||
num: 42,
|
||||
enum_var: 'A',
|
||||
paragraph: 'A'.repeat(50),
|
||||
}),
|
||||
])
|
||||
assertExitCode(shortResult, 0)
|
||||
|
||||
// ── Exceeding limit: 101 chars ──────────────────────────────────────
|
||||
const longResult = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({
|
||||
x: 'hello',
|
||||
num: 42,
|
||||
enum_var: 'A',
|
||||
paragraph: 'A'.repeat(101),
|
||||
}),
|
||||
])
|
||||
expect(longResult.exitCode).not.toBe(0)
|
||||
expect(longResult.stderr).toMatch(/paragraph.*less than 100|paragraph.*100 characters/i)
|
||||
})
|
||||
|
||||
it('[P0] valid inputs of all types execute successfully; invalid typed/enum inputs return errors', async () => {
|
||||
// Spec 4.1.17: non-typed input value returns a validation error
|
||||
// Spec 4.1.18: invalid enum value returns a validation error
|
||||
//
|
||||
// App: basic_auto_test (DIFY_E2E_WORKFLOW_APP_ID)
|
||||
// Input schema:
|
||||
// x — text-input (required)
|
||||
// num — number (required, Spec 4.1.17)
|
||||
// enum_var — select (required, options: A/B/C, Spec 4.1.18)
|
||||
|
||||
// ── Happy path: all correct values ──────────────────────────────────
|
||||
const happyResult = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'hello', num: 42, enum_var: 'A', paragraph: 'short text' }),
|
||||
])
|
||||
assertExitCode(happyResult, 0)
|
||||
assertStdoutContains(happyResult, 'echo:hello')
|
||||
|
||||
// ── 4.1.17: number field receives a string value ─────────────────────
|
||||
const typedResult = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'hello', num: 'not-a-number', enum_var: 'A' }),
|
||||
])
|
||||
expect(typedResult.exitCode).not.toBe(0)
|
||||
expect(typedResult.stderr).toMatch(/num.*number|must be a valid number/i)
|
||||
|
||||
// ── 4.1.18: enum field receives a value outside the allowed options ──
|
||||
const enumResult = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'hello', num: 42, enum_var: 'invalid' }),
|
||||
])
|
||||
expect(enumResult.exitCode).not.toBe(0)
|
||||
expect(enumResult.stderr).toMatch(/enum_var.*must be one of|one of the following/i)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Error scenarios
|
||||
// =========================================================================
|
||||
|
||||
describe('Error scenarios', () => {
|
||||
it('[P0] non-existent app returns error — exit code 1', async () => {
|
||||
// Spec 4.1.20: non-existent app returns an error with not-found message
|
||||
// Spec 4.1.21: exit code is exactly 1
|
||||
const result = await fx.r(['run', 'app', 'app-id-does-not-exist-e2e-xyz', 'hello'])
|
||||
assertExitCode(result, 1)
|
||||
expect(result.stderr).toMatch(/not.?found/i)
|
||||
})
|
||||
|
||||
it('[P0] missing app id returns error (exit code 1 — CLI returns 1 for missing required arg)', async () => {
|
||||
// Spec: missing app id returns a usage error
|
||||
// Actual behaviour: CLI framework returns exit 1 (not 2) for missing required argument
|
||||
const result = await fx.r(['run', 'app'])
|
||||
assertExitCode(result, 1)
|
||||
expect(result.stderr).toMatch(/missing required argument/i)
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated run app returns auth error (exit code 4)', async () => {
|
||||
// Spec 4.1.22: unauthenticated run app returns auth error message
|
||||
// Spec 4.1.23: exit code is exactly 4
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['run', 'app', E.chatAppId, 'hello'], {
|
||||
configDir: unauthTmp.configDir,
|
||||
})
|
||||
assertExitCode(result, 4)
|
||||
expect(result.stderr).toMatch(/not.?logged.?in|auth.?login/i)
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] network error returns non-zero exit code and error message', async () => {
|
||||
// Spec 4.1.26: when the host is unreachable the CLI returns a network error.
|
||||
// Uses a local port that has nothing listening (127.0.0.1:19999) so the
|
||||
// connection is refused immediately without waiting for DNS.
|
||||
const networkTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(networkTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: http://127.0.0.1:19999`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoa_fake_token_network_test`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(
|
||||
['run', 'app', E.chatAppId, 'hello'],
|
||||
{ configDir: networkTmp.configDir, timeout: 15_000 },
|
||||
)
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr.length).toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await networkTmp.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Non-interactive mode / CI environment
|
||||
// =========================================================================
|
||||
|
||||
describe('Non-interactive mode (CI)', () => {
|
||||
it('[P0] CI=1 environment has no spinner — stdout has no ANSI colour', async () => {
|
||||
// Spec: ANSI colour is disabled in non-TTY environment; spinner is suppressed in non-interactive mode
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'ci-test'], { CI: '1', NO_COLOR: '1' })
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
assertNoAnsi(result.stderr, 'stderr')
|
||||
})
|
||||
|
||||
it('[P0] non-interactive mode exit code is correctly propagated', async () => {
|
||||
// Spec: non-interactive mode exit code is correct
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'code'])
|
||||
expect(typeof result.exitCode).toBe('number')
|
||||
expect(result.exitCode).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Workspace override
|
||||
// =========================================================================
|
||||
|
||||
describe('workspace override', () => {
|
||||
it('[P1] --workspace flag overrides the default workspace', async () => {
|
||||
// Spec: workspace override takes effect
|
||||
// run app uses --workspace (no -w short form)
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
'ws-override',
|
||||
'--workspace',
|
||||
E.workspaceId,
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
itWithSso('[P1] external SSO user: --workspace parameter is silently ignored', async () => {
|
||||
// Spec 4.1.25: SSO subjects operate without workspace scoping.
|
||||
// Passing --workspace must not change the outcome — the parameter
|
||||
// should be ignored, so both calls produce the same exit code.
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(ssoTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`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`
|
||||
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
|
||||
// Run WITHOUT --workspace
|
||||
const resultWithout = await run(
|
||||
['run', 'app', E.chatAppId, 'hello'],
|
||||
{ configDir: ssoTmp.configDir },
|
||||
)
|
||||
|
||||
// Run WITH --workspace (should be ignored → same exit code)
|
||||
const resultWith = await run(
|
||||
['run', 'app', E.chatAppId, 'hello', '--workspace', E.workspaceId],
|
||||
{ configDir: ssoTmp.configDir },
|
||||
)
|
||||
|
||||
// If --workspace were honoured for SSO users it would change behaviour;
|
||||
// identical exit codes confirm the parameter is silently ignored.
|
||||
expect(resultWith.exitCode).toBe(resultWithout.exitCode)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ── local helper (avoids import confusion) ─────────────────────────────────
|
||||
function assertNonZeroExit(result: import('../../helpers/cli.js').RunResult): void {
|
||||
expect(result.exitCode, 'exit code should be non-zero').not.toBe(0)
|
||||
}
|
||||
@ -1,362 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl run app --conversation — Conversation mode
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/Conversation Mode (24 cases)
|
||||
* Cases migrated from: run-app-basic.e2e.ts (Conversation mode describe block)
|
||||
*
|
||||
* Prerequisites (DIFY_E2E_* env vars):
|
||||
* DIFY_E2E_CHAT_APP_ID — echo-chat app, mode=chat, outputs "echo: {query}"
|
||||
*/
|
||||
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertPipeFriendlyJson,
|
||||
assertStderrContains,
|
||||
} from '../../helpers/assert.js'
|
||||
import { registerConversation } from '../../helpers/cleanup-registry.js'
|
||||
import { run, spawn_background, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
|
||||
describe('E2E / difyctl run app --conversation', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── Create & reuse ──────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] chat app can create a new conversation — stderr contains hint', async () => {
|
||||
// Spec: chat app can create a new conversation
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'start-conv'])
|
||||
assertExitCode(result, 0)
|
||||
assertStderrContains(result, '--conversation')
|
||||
})
|
||||
|
||||
it('[P0] JSON output includes conversation_id', async () => {
|
||||
// Spec: JSON output includes conversation_id
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'conv-json', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ conversation_id: string }>(result)
|
||||
expect(typeof parsed.conversation_id).toBe('string')
|
||||
expect(parsed.conversation_id.length).toBeGreaterThan(0)
|
||||
registerConversation(E.host, E.token, E.chatAppId, parsed.conversation_id)
|
||||
})
|
||||
|
||||
it('[P0] --conversation flag works — conversation_id is reused in subsequent requests', async () => {
|
||||
// Spec: --conversation flag works; conversation_id is reused in subsequent requests
|
||||
const first = await fx.r(['run', 'app', E.chatAppId, 'first-msg', '-o', 'json'])
|
||||
assertExitCode(first, 0)
|
||||
const { conversation_id } = assertJson<{ conversation_id: string }>(first)
|
||||
registerConversation(E.host, E.token, E.chatAppId, conversation_id)
|
||||
|
||||
const second = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
'second-msg',
|
||||
'--conversation',
|
||||
conversation_id,
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(second, 0)
|
||||
const secondParsed = assertJson<{ conversation_id: string }>(second)
|
||||
expect(secondParsed.conversation_id).toBe(conversation_id)
|
||||
})
|
||||
|
||||
it('[P0] a new session is auto-created when conversation_id is omitted', async () => {
|
||||
// Spec 4.3.5: omitting --conversation creates a brand-new session each time;
|
||||
// the new conversation_id must be non-empty and distinct from the previous one.
|
||||
// withRetry: echo-chat app may return empty answer on back-to-back calls under load.
|
||||
const firstId = await withRetry(async () => {
|
||||
const r = await fx.r(['run', 'app', E.chatAppId, 'new-conv-1', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const { conversation_id } = assertJson<{ conversation_id: string }>(r)
|
||||
expect(conversation_id, 'first call must return a non-empty conversation_id').toBeTruthy()
|
||||
return conversation_id
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
|
||||
const secondId = await withRetry(async () => {
|
||||
const r = await fx.r(['run', 'app', E.chatAppId, 'new-conv-2', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const { conversation_id } = assertJson<{ conversation_id: string }>(r)
|
||||
expect(conversation_id, 'second call must return a non-empty conversation_id').toBeTruthy()
|
||||
return conversation_id
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
|
||||
expect(secondId, 'omitting --conversation must create a new session, not reuse the previous one')
|
||||
.not
|
||||
.toBe(firstId)
|
||||
})
|
||||
|
||||
// ── Error scenarios ─────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] invalid conversation_id returns error (exit code 1)', async () => {
|
||||
// Spec 4.3.9: passing a non-existent conversation_id should return a
|
||||
// "conversation not found" error with exit code exactly 1.
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
'bad-conv',
|
||||
'--conversation',
|
||||
'invalid-conv-id-xyz-not-exist',
|
||||
])
|
||||
assertExitCode(result, 1)
|
||||
expect(result.stderr).toMatch(/not.?found|conversation|404/i)
|
||||
})
|
||||
|
||||
// ── Combined flags ──────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] conversation mode supports streaming', async () => {
|
||||
// Spec 4.3.6: --conversation <cid> --stream should work and the streaming
|
||||
// reply must carry the same conversation_id as the one used in the request.
|
||||
// withRetry: echo-chat may return empty answer (no conversation_id) under load.
|
||||
await withRetry(async () => {
|
||||
const first = await fx.r(['run', 'app', E.chatAppId, 'init', '-o', 'json'])
|
||||
assertExitCode(first, 0)
|
||||
const { conversation_id } = assertJson<{ conversation_id: string }>(first)
|
||||
expect(conversation_id, 'first call should return a conversation_id').toBeTruthy()
|
||||
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
'continue',
|
||||
'--conversation',
|
||||
conversation_id,
|
||||
'--stream',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
const streamed = assertJson<{ conversation_id?: string, answer?: string }>(result)
|
||||
expect(streamed.conversation_id, 'streaming reply must carry the same conversation_id')
|
||||
.toBe(conversation_id)
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
})
|
||||
|
||||
it('[P1] conversation output supports piping (-o json pipe-friendly format)', async () => {
|
||||
// Spec: conversation output supports piping
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'pipe-conv', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
assertPipeFriendlyJson(result)
|
||||
})
|
||||
|
||||
// ── Auth error scenarios ────────────────────────────────────────────────
|
||||
|
||||
it('[P0] unauthenticated conversation run returns auth error (exit code 4)', async () => {
|
||||
// Spec 4.3.16: running --conversation without a valid session must return
|
||||
// an authentication error with exit code exactly 4.
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(
|
||||
['run', 'app', E.chatAppId, 'hello', '--conversation', 'any-conv-id'],
|
||||
{ configDir: unauthTmp.configDir },
|
||||
)
|
||||
assertExitCode(result, 4)
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
itWithSso('[P0] SSO (dfoe_) token can run conversation mode (exit code 0)', async () => {
|
||||
// Spec 4.3.17: an external SSO token (dfoe_) must be able to start a new
|
||||
// conversation and receive a valid response; exit code must be 0.
|
||||
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 })
|
||||
const hostsYml = `${[
|
||||
`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`
|
||||
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await withRetry(
|
||||
() => run(['run', 'app', E.chatAppId, 'sso-conv-test', '-o', 'json'], {
|
||||
configDir: ssoTmp.configDir,
|
||||
}),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ conversation_id?: string }>(result)
|
||||
expect(parsed.conversation_id, 'SSO conversation run should return a conversation_id').toBeTruthy()
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── P1 additions ────────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] JSON output includes message_id field', async () => {
|
||||
// Spec 4.3.15: -o json response must include a non-empty message_id field.
|
||||
const result = await withRetry(async () => {
|
||||
const r = await fx.r(['run', 'app', E.chatAppId, 'msg-id-check', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const parsed = assertJson<{ message_id?: string }>(r)
|
||||
expect(parsed.message_id, 'message_id must be non-empty').toBeTruthy()
|
||||
return r
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
it('[P1] after streaming interruption the same conversation_id remains usable', async () => {
|
||||
// Spec 4.3.18: interrupting a streaming run must not corrupt the conversation;
|
||||
// a subsequent non-streaming call with the same conversation_id must succeed.
|
||||
const conversation_id = await withRetry(async () => {
|
||||
const r = await fx.r(['run', 'app', E.chatAppId, 'pre-interrupt', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const { conversation_id: cid } = assertJson<{ conversation_id: string }>(r)
|
||||
expect(cid, 'setup call must return a conversation_id').toBeTruthy()
|
||||
return cid
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
|
||||
// Start a streaming run and interrupt it after 800 ms.
|
||||
const proc = spawn_background(
|
||||
['run', 'app', E.chatAppId, 'streaming-msg', '--conversation', conversation_id, '--stream'],
|
||||
{ configDir: fx.configDir },
|
||||
)
|
||||
await new Promise(res => setTimeout(res, 800))
|
||||
proc.interrupt()
|
||||
await proc.wait()
|
||||
|
||||
// The conversation must still be usable after the interruption.
|
||||
const resume = await withRetry(
|
||||
() => fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
'after-interrupt',
|
||||
'--conversation',
|
||||
conversation_id,
|
||||
'-o',
|
||||
'json',
|
||||
]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(resume, 0)
|
||||
const parsed = assertJson<{ conversation_id: string }>(resume)
|
||||
expect(parsed.conversation_id, 'resumed call must carry the same conversation_id')
|
||||
.toBe(conversation_id)
|
||||
})
|
||||
|
||||
it('[P1] conversation run with unreachable host returns network error (exit non-zero)', async () => {
|
||||
// Spec 4.3.19: when the configured host is unreachable, the CLI must return
|
||||
// a network error with a non-zero exit code.
|
||||
const { writeFile, mkdir } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const networkTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(networkTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: http://127.0.0.1:19999`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoa_fake_token_network_test`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(
|
||||
['run', 'app', E.chatAppId, 'hello', '--conversation', 'any-conv-id'],
|
||||
{ configDir: networkTmp.configDir, timeout: 15_000 },
|
||||
)
|
||||
expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0)
|
||||
expect(result.stderr.length, 'stderr should contain an error message').toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await networkTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P1] invalid conversation_id with -o json outputs JSON error envelope on stderr', async () => {
|
||||
// Spec 4.3.22: when conversation_id is invalid and -o json is active,
|
||||
// stderr must contain a valid JSON error envelope.
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
'bad-conv-json',
|
||||
'--conversation',
|
||||
'nonexistent-conv-id-json-e2e',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
expect(result.exitCode, 'invalid conversation in json mode should exit non-zero').not.toBe(0)
|
||||
assertErrorEnvelope(result)
|
||||
})
|
||||
|
||||
it('[P1] passing --conversation to a workflow app does not crash (stable fallback)', async () => {
|
||||
// Spec 4.3.23: workflow apps do not support conversations; the CLI must not
|
||||
// crash. The server silently ignores the parameter and runs the workflow normally.
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'conv-wf-test', num: 1, enum_var: 'A', paragraph: 'ok' }),
|
||||
'--conversation',
|
||||
'any-conv-id-for-wf',
|
||||
])
|
||||
expect(result.exitCode, '--conversation on workflow must not cause an unhandled crash (exit 2)').not.toBe(2)
|
||||
expect(result.stderr).not.toMatch(/unhandled|uncaught|TypeError|ReferenceError/i)
|
||||
})
|
||||
|
||||
it('[P1] same conversation_id remains stable across 3 consecutive calls', async () => {
|
||||
// Spec 4.3.24: reusing the same conversation_id multiple times must always
|
||||
// succeed; each call must exit 0 and return the same conversation_id.
|
||||
const conversation_id = await withRetry(async () => {
|
||||
const r = await fx.r(['run', 'app', E.chatAppId, 'stable-1', '-o', 'json'])
|
||||
assertExitCode(r, 0)
|
||||
const { conversation_id: cid } = assertJson<{ conversation_id: string }>(r)
|
||||
expect(cid, 'initial call must return a conversation_id').toBeTruthy()
|
||||
return cid
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
|
||||
for (let i = 2; i <= 3; i++) {
|
||||
const result = await withRetry(
|
||||
() => fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
`stable-${i}`,
|
||||
'--conversation',
|
||||
conversation_id,
|
||||
'-o',
|
||||
'json',
|
||||
]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ conversation_id: string }>(result)
|
||||
expect(parsed.conversation_id, `call ${i}: conversation_id must be stable`).toBe(conversation_id)
|
||||
}
|
||||
})
|
||||
})
|
||||
@ -1,337 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl run app --file — file input specialisation
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/File Input (31 cases)
|
||||
*
|
||||
* Prerequisites:
|
||||
* DIFY_E2E_FILE_APP_ID — workflow app with a required 'doc' file variable
|
||||
* All file-related cases are skipped when this variable is not configured.
|
||||
*/
|
||||
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { assertExitCode, assertJson, assertNoAnsi } from '../../helpers/assert.js'
|
||||
import { injectAuth, run, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalDescribe, optionalIt } from '../../helpers/skip.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
// supportsLocalUpload capability removed — local file upload probe is no longer
|
||||
// performed in global-setup. Default to false (skip upload-specific cases).
|
||||
const supportsLocalUpload = true
|
||||
|
||||
const describeSuite = optionalDescribe(Boolean(E.fileAppId))
|
||||
|
||||
describeSuite('E2E / difyctl run app --file', () => {
|
||||
let configDir: string
|
||||
let fileDir: string
|
||||
let cleanupConfig: () => Promise<void>
|
||||
|
||||
beforeEach(async () => {
|
||||
const tmp = await withTempConfig()
|
||||
configDir = tmp.configDir
|
||||
cleanupConfig = tmp.cleanup
|
||||
fileDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-files-'))
|
||||
await injectAuth(configDir, {
|
||||
host: E.host,
|
||||
bearer: E.token,
|
||||
workspaceId: E.workspaceId,
|
||||
workspaceName: E.workspaceName,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupConfig()
|
||||
await rm(fileDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function r(argv: string[]) {
|
||||
return run(argv, { configDir })
|
||||
}
|
||||
|
||||
// Minimal 1×1 white PNG — used as the required 'picture' (image) fixture.
|
||||
async function writePng(path: string): Promise<void> {
|
||||
const { Buffer } = await import('node:buffer')
|
||||
const pngBytes = Buffer.from(
|
||||
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI6QAAAABJRU5ErkJggg==',
|
||||
'base64',
|
||||
)
|
||||
await writeFile(path, pngBytes)
|
||||
}
|
||||
|
||||
const itLocalUpload = optionalIt(supportsLocalUpload)
|
||||
|
||||
itLocalUpload('[P0] run app supports single file upload (key=@path) — app executes correctly', async () => {
|
||||
// Spec: run app supports single file upload + app executes correctly after upload
|
||||
const filePath = join(fileDir, 'test.txt')
|
||||
const picPath = join(fileDir, 'test.png')
|
||||
await writeFile(filePath, 'E2E test file content — single upload')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
itLocalUpload('[P0] file input argument name maps correctly (key binds to correct input field)', async () => {
|
||||
// Spec: file input argument name maps correctly
|
||||
const filePath = join(fileDir, 'mapping.txt')
|
||||
const picPath = join(fileDir, 'mapping.png')
|
||||
await writeFile(filePath, 'mapping test content')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`, '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<Record<string, unknown>>(result)
|
||||
expect(parsed).toBeDefined()
|
||||
})
|
||||
|
||||
itLocalUpload('[P0] run app --file syntax is key=@path (local file upload)', async () => {
|
||||
// Spec: run app --file syntax is key=@path
|
||||
const filePath = join(fileDir, 'syntax.txt')
|
||||
const picPath = join(fileDir, 'syntax.png')
|
||||
await writeFile(filePath, 'syntax verification')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
it('[P0] --file remote URL syntax (key=https://...) requires no local upload', async () => {
|
||||
// Spec: run app --file with remote URL executes the workflow correctly
|
||||
// file_auto_test requires both 'doc' (document) and 'picture' (image) fields.
|
||||
const result = await r([
|
||||
'run',
|
||||
'app',
|
||||
E.fileAppId,
|
||||
'--file',
|
||||
'doc=https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf',
|
||||
'--file',
|
||||
'picture=https://www.w3.org/Icons/w3c_home.png',
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
it('[P0] non-existent file path returns an error', async () => {
|
||||
// Spec: non-existent file path returns an error
|
||||
const result = await r([
|
||||
'run',
|
||||
'app',
|
||||
E.fileAppId,
|
||||
'--file',
|
||||
'doc=@/nonexistent/path/missing-file.txt',
|
||||
])
|
||||
expect(result.exitCode).not.toBe(0)
|
||||
expect(result.stderr).toMatch(/failed|not.?found|upload|no such file|ENOENT/i)
|
||||
})
|
||||
|
||||
it('[P1] malformed --file argument returns usage error (exit code 2)', async () => {
|
||||
// Spec: malformed --file argument returns a usage error
|
||||
const result = await r([
|
||||
'run',
|
||||
'app',
|
||||
E.chatAppId,
|
||||
'hello',
|
||||
'--file',
|
||||
'invalidformat',
|
||||
])
|
||||
assertExitCode(result, 2)
|
||||
expect(result.stderr).toMatch(/--file|key[^\n\r@\u2028\u2029]*@.*path|invalid.*file/i)
|
||||
})
|
||||
|
||||
itLocalUpload('[P1] file path containing spaces can be uploaded correctly', async () => {
|
||||
// Spec: file path containing spaces can be uploaded correctly
|
||||
const filePath = join(fileDir, 'file with spaces.txt')
|
||||
const picPath = join(fileDir, 'pic spaces.png')
|
||||
await writeFile(filePath, 'space in name test')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${filePath}`, '--file', `picture=@${picPath}`])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
itLocalUpload('[P1] txt file upload is supported', async () => {
|
||||
// Spec: txt file upload is supported
|
||||
const f = join(fileDir, 'note.txt')
|
||||
const picPath = join(fileDir, 'note.png')
|
||||
await writeFile(f, 'plain text content')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${f}`, '--file', `picture=@${picPath}`])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
itLocalUpload('[P1] --file combined with --stream works correctly', async () => {
|
||||
// Spec: run app --file combined with --stream
|
||||
const f = join(fileDir, 'stream.txt')
|
||||
const picPath = join(fileDir, 'stream.png')
|
||||
await writeFile(f, 'stream + file test')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${f}`, '--file', `picture=@${picPath}`, '--stream'])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated file upload returns auth error (exit code 4)', async () => {
|
||||
// Spec: unauthenticated file upload returns an auth error
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const f = join(fileDir, 'unauth.txt')
|
||||
await writeFile(f, 'test')
|
||||
const result = await run(
|
||||
['run', 'app', E.fileAppId || E.chatAppId, '--file', `doc=@${f}`],
|
||||
{ configDir: unauthTmp.configDir },
|
||||
)
|
||||
assertExitCode(result, 4)
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── P0 additions ────────────────────────────────────────────────────────
|
||||
|
||||
itLocalUpload('[P0] pdf file upload is supported (4.4.8)', async () => {
|
||||
// Spec 4.4.8: .pdf is a valid document type for the doc field.
|
||||
const pdfPath = join(fileDir, 'test.pdf')
|
||||
const picPath = join(fileDir, 'pdf-pic.png')
|
||||
await writeFile(pdfPath, '%PDF-1.4\n1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj '
|
||||
+ '2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj '
|
||||
+ '3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 3 3]>>endobj\n'
|
||||
+ 'xref\n0 4\n0000000000 65535 f \n0000000009 00000 n \n'
|
||||
+ '0000000058 00000 n \n0000000115 00000 n \n'
|
||||
+ 'trailer<</Size 4/Root 1 0 R>>\nstartxref\n190\n%%EOF\n')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${pdfPath}`, '--file', `picture=@${picPath}`])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
itWithSso('[P0] SSO (dfoe_) token can execute file run (exit code 0) (4.4.23)', async () => {
|
||||
// Spec 4.4.23: an SSO-provisioned token must be able to run a file app.
|
||||
// Note: DIFY_E2E_SSO_TOKEN may be a dfoa_ token in dev environments;
|
||||
// the test verifies the token can execute the app regardless of prefix.
|
||||
const { mkdir, writeFile: wf } = await import('node:fs/promises')
|
||||
const { join: pjoin } = await import('node:path')
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(ssoTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: ${E.host}`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: ${E.ssoToken}`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "E2E SSO Workspace"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "E2E SSO Workspace"`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await wf(pjoin(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const docPath = pjoin(fileDir, 'sso-doc.txt')
|
||||
const picPath = pjoin(fileDir, 'sso-pic.png')
|
||||
await writeFile(docPath, 'sso file run test')
|
||||
await writePng(picPath)
|
||||
const result = await withRetry(
|
||||
() => run(
|
||||
['run', 'app', E.fileAppId, '--file', `doc=@${docPath}`, '--file', `picture=@${picPath}`],
|
||||
{ configDir: ssoTmp.configDir },
|
||||
),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── P1 additions ────────────────────────────────────────────────────────
|
||||
|
||||
itLocalUpload('[P1] empty file upload returns stable result without crash (4.4.11)', async () => {
|
||||
// Spec 4.4.11: uploading a zero-byte file must not crash the CLI (exit code != 2).
|
||||
const emptyPath = join(fileDir, 'empty.txt')
|
||||
const picPath = join(fileDir, 'empty-pic.png')
|
||||
await writeFile(emptyPath, '')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${emptyPath}`, '--file', `picture=@${picPath}`])
|
||||
expect(result.exitCode, 'empty file must not cause CLI crash (exit 2)').not.toBe(2)
|
||||
expect(result.stderr).not.toMatch(/unhandled|uncaught|TypeError|ReferenceError/i)
|
||||
})
|
||||
|
||||
itLocalUpload('[P1] --file and --inputs flags can coexist (4.4.15 / 4.4.29)', async () => {
|
||||
// Spec 4.4.15: passing both --file and --inputs must not cause a CLI error.
|
||||
// Spec 4.4.29: workflow app accepts --inputs + --file together.
|
||||
// file_auto_test has no non-file inputs; empty --inputs '{}' is passed to verify
|
||||
// the CLI accepts both flags without a usage error.
|
||||
const docPath = join(fileDir, 'inputs-doc.txt')
|
||||
const picPath = join(fileDir, 'inputs-pic.png')
|
||||
await writeFile(docPath, 'inputs + file coexist test')
|
||||
await writePng(picPath)
|
||||
const result = await r([
|
||||
'run',
|
||||
'app',
|
||||
E.fileAppId,
|
||||
'--inputs',
|
||||
'{}',
|
||||
'--file',
|
||||
`doc=@${docPath}`,
|
||||
'--file',
|
||||
`picture=@${picPath}`,
|
||||
])
|
||||
expect(result.exitCode, '--inputs and --file together must not cause CLI usage error (exit 2)').not.toBe(2)
|
||||
})
|
||||
|
||||
itLocalUpload('[P1] files with same name in different paths upload without conflict (4.4.16)', async () => {
|
||||
// Spec 4.4.16: multiple --file entries with the same filename (different paths)
|
||||
// must all upload successfully without collision.
|
||||
const { mkdtemp: mkd } = await import('node:fs/promises')
|
||||
const { tmpdir: td } = await import('node:os')
|
||||
const dir2 = await mkd(join(td(), 'difyctl-e2e-samename-'))
|
||||
try {
|
||||
const docPath = join(fileDir, 'same.txt') // doc field
|
||||
const picPath = join(dir2, 'same.png') // picture field — same base name, different dir
|
||||
await writeFile(docPath, 'same name doc test')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${docPath}`, '--file', `picture=@${picPath}`])
|
||||
assertExitCode(result, 0)
|
||||
}
|
||||
finally {
|
||||
const { rm: rmDir } = await import('node:fs/promises')
|
||||
await rmDir(dir2, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
itLocalUpload('[P1] -o json after file upload contains workflow response fields (4.4.21)', async () => {
|
||||
// Spec 4.4.21: -o json output after a file run must contain structured response metadata.
|
||||
const docPath = join(fileDir, 'json-doc.txt')
|
||||
const picPath = join(fileDir, 'json-pic.png')
|
||||
await writeFile(docPath, 'json output test')
|
||||
await writePng(picPath)
|
||||
const result = await r([
|
||||
'run',
|
||||
'app',
|
||||
E.fileAppId,
|
||||
'--file',
|
||||
`doc=@${docPath}`,
|
||||
'--file',
|
||||
`picture=@${picPath}`,
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<Record<string, unknown>>(result)
|
||||
// workflow response must contain at minimum a mode field
|
||||
expect(parsed.mode, 'JSON output must contain mode field').toBeTruthy()
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
})
|
||||
|
||||
itLocalUpload('[P1] file path with CJK characters uploads correctly (4.4.26)', async () => {
|
||||
// Spec 4.4.26: a file whose path contains CJK (Chinese) characters must upload
|
||||
// and execute successfully.
|
||||
const cjkPath = join(fileDir, '中文测试文档.txt')
|
||||
const picPath = join(fileDir, 'cjk-pic.png')
|
||||
await writeFile(cjkPath, 'CJK path upload test — 中文内容')
|
||||
await writePng(picPath)
|
||||
const result = await r(['run', 'app', E.fileAppId, '--file', `doc=@${cjkPath}`, '--file', `picture=@${picPath}`])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
})
|
||||
@ -1,142 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl run app + difyctl resume app — HITL human-in-the-loop specialisation
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/HITL Human Intervention (19 cases)
|
||||
*
|
||||
* Prerequisites:
|
||||
* DIFY_E2E_HITL_APP_ID — workflow app containing a Human Input node with display_in_ui=true
|
||||
* All HITL cases are skipped when this variable is not configured.
|
||||
*/
|
||||
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { afterEach, beforeEach, expect, it } from 'vitest'
|
||||
import { assertExitCode, assertJson, assertStderrContains } from '../../helpers/assert.js'
|
||||
import { withAuthFixture } from '../../helpers/cli.js'
|
||||
import { optionalDescribe } from '../../helpers/skip.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
|
||||
const describeSuite = optionalDescribe(Boolean(E.hitlAppId))
|
||||
|
||||
describeSuite('E2E / difyctl run app — HITL human intervention', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
it('[P0] workflow HITL pause outputs a pause block on stdout — exit code 0', async () => {
|
||||
// Spec: workflow HITL pause outputs a pause block + exit code 0
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'hitl-e2e' }),
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).toMatch(/Workflow paused|pause/i)
|
||||
})
|
||||
|
||||
it('[P0] HITL pause JSON contains all required fields', async () => {
|
||||
// Spec: HITL pause JSON output contains all required fields
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'hitl-json' }),
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
const p = assertJson<Record<string, unknown>>(result)
|
||||
expect(p).toHaveProperty('status', 'paused')
|
||||
expect(p).toHaveProperty('form_token')
|
||||
expect(p).toHaveProperty('workflow_run_id')
|
||||
expect(p).toHaveProperty('node_title')
|
||||
expect(p).toHaveProperty('form_content')
|
||||
expect(p).toHaveProperty('actions')
|
||||
})
|
||||
|
||||
it('[P0] HITL pause hint contains the full resume command', async () => {
|
||||
// Spec: HITL pause hint contains the full resume command
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'hint-test' }),
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
assertStderrContains(result, 'difyctl resume app')
|
||||
assertStderrContains(result, '--workflow-run-id')
|
||||
})
|
||||
|
||||
it('[P0] AI Agent automation — extract form_token from JSON and auto-resume', async () => {
|
||||
// Spec: AI Agent automation — extract form_token via jq and auto-resume
|
||||
// Step 1: run → pause, obtain JSON envelope
|
||||
const pauseResult = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'auto-resume' }),
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(pauseResult, 0)
|
||||
const envelope = assertJson<{
|
||||
form_token: string
|
||||
workflow_run_id: string
|
||||
app_id?: string
|
||||
actions?: Array<{ id: string }>
|
||||
}>(pauseResult)
|
||||
expect(envelope.form_token).toBeTruthy()
|
||||
expect(envelope.workflow_run_id).toBeTruthy()
|
||||
|
||||
// Step 2: resume — use the first action id from the pause response so
|
||||
// the test is not coupled to any specific action label.
|
||||
const actionId = envelope.actions?.[0]?.id ?? 'submit'
|
||||
const resumeResult = await fx.r([
|
||||
'resume',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
envelope.form_token,
|
||||
'--workflow-run-id',
|
||||
envelope.workflow_run_id,
|
||||
'--action',
|
||||
actionId,
|
||||
])
|
||||
assertExitCode(resumeResult, 0)
|
||||
})
|
||||
|
||||
it('[P0] resume app auto-selects the single action — workflow continues execution', async () => {
|
||||
// Spec: resume app auto-selects the single action without requiring --action
|
||||
const pause = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'auto-action' }),
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(pause, 0)
|
||||
const { form_token, workflow_run_id } = assertJson<{ form_token: string, workflow_run_id: string }>(pause)
|
||||
// Resume without --action (single action auto-selected)
|
||||
const resume = await fx.r([
|
||||
'resume',
|
||||
'app',
|
||||
E.hitlAppId,
|
||||
form_token,
|
||||
'--workflow-run-id',
|
||||
workflow_run_id,
|
||||
])
|
||||
assertExitCode(resume, 0)
|
||||
})
|
||||
})
|
||||
@ -1,340 +0,0 @@
|
||||
/**
|
||||
* E2E: difyctl run app --stream — streaming output specialisation
|
||||
*
|
||||
* Test cases sourced from: Dify CLI Enhanced spec — Dify CLI/Run/Streaming Output (24 cases)
|
||||
*
|
||||
* Covers scenarios that run-app-basic.e2e.ts cannot handle:
|
||||
* - Ctrl+C interruption (SIGINT)
|
||||
* - Chunk arrival order verification (timing)
|
||||
* - Cases migrated from run-app-basic.e2e.ts: exit code, stderr separation,
|
||||
* -o json envelope, unauthenticated, pipe, workflow succeeded status
|
||||
*/
|
||||
|
||||
import type { Buffer } from 'node:buffer'
|
||||
import type { AuthFixture } from '../../helpers/cli.js'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertErrorEnvelope,
|
||||
assertExitCode,
|
||||
assertJson,
|
||||
assertNoAnsi,
|
||||
assertStderrContains,
|
||||
} from '../../helpers/assert.js'
|
||||
import { BIN, BUN, run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
|
||||
import { withRetry } from '../../helpers/retry.js'
|
||||
import { optionalIt } from '../../helpers/skip.js'
|
||||
import { loadE2EEnv } from '../../setup/env.js'
|
||||
|
||||
const E = loadE2EEnv()
|
||||
const itWithSso = optionalIt(Boolean(E.ssoToken))
|
||||
|
||||
describe('E2E / difyctl run app --stream (specialisation)', () => {
|
||||
let fx: AuthFixture
|
||||
|
||||
beforeEach(async () => {
|
||||
fx = await withAuthFixture(E)
|
||||
})
|
||||
afterEach(async () => {
|
||||
await fx.cleanup()
|
||||
})
|
||||
|
||||
// ── Chunk timing & token order ──────────────────────────────────────────
|
||||
|
||||
it('[P0] streaming output arrives in real-time chunks (stdout non-empty, echo complete)', async () => {
|
||||
// Spec: streaming output is printed in real-time by chunk + token order is preserved
|
||||
// withRetry: staging SSE connections may fail transiently on cold start
|
||||
await withRetry(async () => {
|
||||
const query = 'chunk-order-test'
|
||||
const proc = spawn(BUN, [BIN, 'run', 'app', E.chatAppId, query, '--stream'], {
|
||||
env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' },
|
||||
})
|
||||
|
||||
const chunks: string[] = []
|
||||
proc.stdout.on('data', (d: Buffer) => {
|
||||
chunks.push(d.toString('utf8'))
|
||||
})
|
||||
|
||||
let stderr = ''
|
||||
proc.stderr.on('data', (d: Buffer) => {
|
||||
stderr += d.toString('utf8')
|
||||
})
|
||||
|
||||
const exitCode = await new Promise<number>((res) => {
|
||||
proc.on('close', code => res(code ?? 1))
|
||||
})
|
||||
|
||||
assertExitCode({ stdout: chunks.join(''), stderr, exitCode }, 0)
|
||||
// May arrive in multiple chunks; the concatenated result must contain the full query
|
||||
expect(chunks.join('')).toContain(query)
|
||||
}, { attempts: 3, delayMs: 2000 })
|
||||
})
|
||||
|
||||
// ── Basic streaming behaviour ───────────────────────────────────────────
|
||||
|
||||
it('[P0] exit code is 0 after streaming completes', async () => {
|
||||
// Spec: streaming exits normally after completion
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'end-ok', '--stream'])
|
||||
assertExitCode(result, 0)
|
||||
})
|
||||
|
||||
it('[P1] stderr is not mixed into stdout in streaming mode', async () => {
|
||||
// Spec: stderr is not mixed into stdout in streaming mode
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'sep', '--stream'])
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout).not.toContain('hint:')
|
||||
assertStderrContains(result, '--conversation')
|
||||
})
|
||||
|
||||
it('[P1] --stream -o json outputs a valid JSON envelope', async () => {
|
||||
// Spec: streaming mode produces valid JSON output
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'sjson', '--stream', '-o', 'json'])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ mode: string, answer: string }>(result)
|
||||
expect(parsed.mode).toMatch(/chat/)
|
||||
})
|
||||
|
||||
it('[P1] streaming mode output supports piping (no ANSI, ends with \\n)', async () => {
|
||||
// Spec: streaming mode output supports piping
|
||||
const result = await fx.r(['run', 'app', E.chatAppId, 'pipe-s', '--stream'])
|
||||
assertExitCode(result, 0)
|
||||
assertNoAnsi(result.stdout, 'stdout')
|
||||
expect(result.stdout.endsWith('\n')).toBe(true)
|
||||
})
|
||||
|
||||
it('[P0] workflow streaming output contains succeeded status', async () => {
|
||||
// Spec: workflow streaming output includes succeeded status
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'wf-stream-val' }),
|
||||
'--stream',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data?: { status?: string } }>(result)
|
||||
expect(parsed.data?.status).toBe('succeeded')
|
||||
})
|
||||
|
||||
// ── Error scenarios ─────────────────────────────────────────────────────
|
||||
|
||||
it('[P0] server-side error event causes CLI to exit with non-zero code', async () => {
|
||||
// Spec: server-side error event causes CLI to exit with non-zero code
|
||||
// Use a non-existent app ID to force a server-side error.
|
||||
const proc = spawn(BUN, [BIN, 'run', 'app', 'nonexistent-app-xyz-e2e', 'hi', '--stream'], {
|
||||
env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' },
|
||||
})
|
||||
let stderr = ''
|
||||
proc.stderr.on('data', (d: Buffer) => {
|
||||
stderr += d.toString('utf8')
|
||||
})
|
||||
const exitCode = await new Promise<number>((res) => {
|
||||
proc.on('close', code => res(code ?? 1))
|
||||
})
|
||||
expect(exitCode, 'error event should cause non-zero exit').not.toBe(0)
|
||||
expect(stderr.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('[P0] unauthenticated streaming returns auth error (exit code 4)', async () => {
|
||||
// Spec: unauthenticated streaming returns an auth error
|
||||
const unauthTmp = await withTempConfig()
|
||||
try {
|
||||
const result = await run(['run', 'app', E.chatAppId, 'hi', '--stream'], {
|
||||
configDir: unauthTmp.configDir,
|
||||
})
|
||||
assertExitCode(result, 4)
|
||||
}
|
||||
finally {
|
||||
await unauthTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
it('[P0] streaming fails when a required input is missing (exit code non-zero)', async () => {
|
||||
// Spec: streaming fails when a required input is missing
|
||||
// workflow app requires variable x (required); the server should return a validation error
|
||||
// immediately, and the CLI exits with a non-zero code.
|
||||
//
|
||||
// ⚠️ Depends on feat/cli API version (server-side pre-validation of missing required inputs).
|
||||
// Current local server 1.14.1 does not support this check; test passes once upgraded.
|
||||
const proc = spawn(BUN, [BIN, 'run', 'app', E.workflowAppId, '--stream'], {
|
||||
env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' },
|
||||
})
|
||||
let stderr = ''
|
||||
proc.stderr.on('data', (d: Buffer) => {
|
||||
stderr += d.toString('utf8')
|
||||
})
|
||||
const exitCode = await new Promise<number>((res) => {
|
||||
proc.on('close', code => res(code ?? 1))
|
||||
})
|
||||
expect(exitCode).not.toBe(0)
|
||||
// The server should return a clear validation error rather than timing out
|
||||
expect(stderr).toMatch(/validation|required|invalid|missing/i)
|
||||
})
|
||||
|
||||
// ── SIGINT ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('[P1] Ctrl+C interrupts streaming (SIGINT → non-zero exit code)', async () => {
|
||||
// Spec: Ctrl+C interrupts streaming + exit code is non-zero after Ctrl+C
|
||||
const proc = spawn(BUN, [BIN, 'run', 'app', E.chatAppId, 'ctrl-c-test', '--stream'], {
|
||||
env: { ...process.env, DIFY_CONFIG_DIR: fx.configDir, CI: '1', NO_COLOR: '1' },
|
||||
})
|
||||
|
||||
let _stdout = ''
|
||||
let _stderr = ''
|
||||
proc.stdout.on('data', (d: Buffer) => {
|
||||
_stdout += d.toString('utf8')
|
||||
})
|
||||
proc.stderr.on('data', (d: Buffer) => {
|
||||
_stderr += d.toString('utf8')
|
||||
})
|
||||
|
||||
// Wait for the process to start streaming, then interrupt.
|
||||
await new Promise(res => setTimeout(res, 800))
|
||||
proc.kill('SIGINT')
|
||||
|
||||
const exitCode = await new Promise<number>((res) => {
|
||||
proc.on('close', code => res(code ?? 1))
|
||||
})
|
||||
|
||||
expect(exitCode, 'SIGINT should cause non-zero exit').not.toBe(0)
|
||||
})
|
||||
|
||||
// ── Multiple inputs in streaming mode (4.2.8) ──────────────────────────
|
||||
|
||||
it('[P1] workflow streaming with multiple inputs passes all params correctly', async () => {
|
||||
// Spec 4.2.8: multiple --inputs entries take effect simultaneously in streaming mode
|
||||
const result = await withRetry(
|
||||
() => fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'multi-stream-k1', num: 42, enum_var: 'A', paragraph: 'short text' }),
|
||||
'--stream',
|
||||
'-o',
|
||||
'json',
|
||||
]),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
const parsed = assertJson<{ data?: { status?: string } }>(result)
|
||||
expect(parsed.data?.status).toBe('succeeded')
|
||||
})
|
||||
|
||||
// ── Unreachable host in streaming mode (4.2.13) ────────────────────────
|
||||
|
||||
it('[P0] streaming with unreachable host returns network error (exit code non-zero)', async () => {
|
||||
// Spec 4.2.13: unreachable host → network error, exit code non-zero
|
||||
// 127.0.0.1:19999 is a local port with nothing listening — ECONNREFUSED immediately.
|
||||
const { writeFile, mkdir } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const networkTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(networkTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`current_host: http://127.0.0.1:19999`,
|
||||
`token_storage: file`,
|
||||
`tokens:`,
|
||||
` bearer: dfoa_fake_token_network_test`,
|
||||
`workspace:`,
|
||||
` id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
`available_workspaces:`,
|
||||
` - id: ${E.workspaceId}`,
|
||||
` name: "E2E Test Workspace"`,
|
||||
` role: owner`,
|
||||
].join('\n')}\n`
|
||||
await writeFile(join(networkTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await run(
|
||||
['run', 'app', E.chatAppId, 'hello', '--stream'],
|
||||
{ configDir: networkTmp.configDir, timeout: 15_000 },
|
||||
)
|
||||
expect(result.exitCode, 'unreachable host should cause non-zero exit').not.toBe(0)
|
||||
expect(result.stderr.length, 'stderr should contain error message').toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await networkTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── Wrong-type input in streaming mode (4.2.15) ────────────────────────
|
||||
|
||||
it('[P0] streaming with wrong-type input returns validation error (exit code non-zero)', async () => {
|
||||
// Spec 4.2.15: passing a value of the wrong type triggers server-side validation failure
|
||||
// The workflow app expects `num` to be a number; passing a string should cause a validation error.
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
E.workflowAppId,
|
||||
'--inputs',
|
||||
JSON.stringify({ x: 'ok', num: 'not-a-number', enum_var: 'A', paragraph: 'short text' }),
|
||||
'--stream',
|
||||
])
|
||||
expect(result.exitCode, 'wrong-type input should cause non-zero exit').not.toBe(0)
|
||||
expect(result.stderr).toMatch(/validation|invalid|type|400|server_5xx|must be/i)
|
||||
})
|
||||
|
||||
// ── Non-existent app with positional query (4.2.16) ────────────────────
|
||||
|
||||
it('[P0] streaming with non-existent app id and query exits 1 with app-not-found error', async () => {
|
||||
// Spec 4.2.16: non-existent app id + positional query → app not found, exit code 1
|
||||
// Distinct from the earlier server-error test: this checks exit=1 precisely and the not-found message.
|
||||
const result = await fx.r(['run', 'app', 'nonexistent-app-id-404-streaming-e2e', 'hello', '--stream'])
|
||||
expect(result.exitCode, 'app not found should exit with code 1').toBe(1)
|
||||
expect(result.stderr).toMatch(/not.?found|404|does not exist/i)
|
||||
})
|
||||
|
||||
// ── SSO (dfoe_) token in streaming mode (4.2.18) ──────────────────────
|
||||
|
||||
itWithSso('[P0] streaming with SSO (dfoe_) token succeeds (exit code 0, stdout non-empty)', async () => {
|
||||
// Spec 4.2.18: dfoe_ token can invoke streaming run on an authorised app
|
||||
const { writeFile, mkdir } = await import('node:fs/promises')
|
||||
const { join } = await import('node:path')
|
||||
const ssoTmp = await withTempConfig()
|
||||
try {
|
||||
await mkdir(ssoTmp.configDir, { recursive: true })
|
||||
const hostsYml = `${[
|
||||
`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`
|
||||
await writeFile(join(ssoTmp.configDir, 'hosts.yml'), hostsYml, { mode: 0o600 })
|
||||
const result = await withRetry(
|
||||
() => run(['run', 'app', E.chatAppId, 'sso-stream-test', '--stream'], {
|
||||
configDir: ssoTmp.configDir,
|
||||
}),
|
||||
{ attempts: 3, delayMs: 2000 },
|
||||
)
|
||||
assertExitCode(result, 0)
|
||||
expect(result.stdout.length, 'SSO streaming should produce output').toBeGreaterThan(0)
|
||||
}
|
||||
finally {
|
||||
await ssoTmp.cleanup()
|
||||
}
|
||||
})
|
||||
|
||||
// ── JSON error envelope for non-existent app in -o json mode (4.2.23) ─
|
||||
|
||||
it('[P1] non-existent app with --stream -o json outputs JSON error envelope on stderr', async () => {
|
||||
// Spec 4.2.23: when app does not exist and -o json is set, stderr must be a valid JSON error envelope
|
||||
const result = await fx.r([
|
||||
'run',
|
||||
'app',
|
||||
'nonexistent-app-id-json-streaming-e2e',
|
||||
'hello',
|
||||
'--stream',
|
||||
'-o',
|
||||
'json',
|
||||
])
|
||||
expect(result.exitCode, 'should exit non-zero').not.toBe(0)
|
||||
assertErrorEnvelope(result)
|
||||
})
|
||||
})
|
||||
@ -1,86 +0,0 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { defineConfig } from 'vite-plus'
|
||||
import { resolveBuildInfo } from './scripts/lib/resolve-buildinfo.js'
|
||||
|
||||
const buildInfo = resolveBuildInfo()
|
||||
|
||||
// Load .env.e2e into process.env (only if the file exists; in CI vars are
|
||||
// injected directly via GitHub Actions secrets).
|
||||
const envFilePath = resolve(process.cwd(), '.env.e2e')
|
||||
try {
|
||||
const raw = readFileSync(envFilePath, 'utf8')
|
||||
for (const line of raw.split('\n')) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed || trimmed.startsWith('#'))
|
||||
continue
|
||||
const eqIdx = trimmed.indexOf('=')
|
||||
if (eqIdx === -1)
|
||||
continue
|
||||
const key = trimmed.slice(0, eqIdx).trim()
|
||||
const val = trimmed.slice(eqIdx + 1).trim()
|
||||
if (key && !(key in process.env))
|
||||
process.env[key] = val
|
||||
}
|
||||
}
|
||||
catch {
|
||||
// .env.e2e not found — rely on environment variables already set in the shell
|
||||
}
|
||||
|
||||
/**
|
||||
* Vitest configuration for E2E tests.
|
||||
*
|
||||
* E2E tests run against a real staging Dify server and require
|
||||
* DIFY_E2E_* environment variables to be set (see test/e2e/setup/env.ts).
|
||||
*
|
||||
* Run: bun vitest --config vitest.e2e.config.ts
|
||||
*/
|
||||
export default defineConfig({
|
||||
pack: {
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
outDir: 'dist',
|
||||
target: 'node22',
|
||||
define: {
|
||||
__DIFYCTL_VERSION__: JSON.stringify(buildInfo.version),
|
||||
__DIFYCTL_COMMIT__: JSON.stringify(buildInfo.commit),
|
||||
__DIFYCTL_BUILD_DATE__: JSON.stringify(buildInfo.buildDate),
|
||||
__DIFYCTL_CHANNEL__: JSON.stringify(buildInfo.channel),
|
||||
__DIFYCTL_MIN_DIFY__: JSON.stringify(buildInfo.minDify),
|
||||
__DIFYCTL_MAX_DIFY__: JSON.stringify(buildInfo.maxDify),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
globalSetup: ['test/e2e/setup/global-setup.ts'],
|
||||
// E2E tests do NOT use the unit-test setup.ts (no globalThis stubs needed —
|
||||
// the real binary sets its own globals at startup).
|
||||
setupFiles: [],
|
||||
include: process.env.DIFY_E2E_MODE === 'local'
|
||||
? ['test/e2e/suites/config/**/*.e2e.ts']
|
||||
: [
|
||||
// auth tests first (most others depend on a valid session)
|
||||
'test/e2e/suites/auth/status.e2e.ts',
|
||||
'test/e2e/suites/auth/use.e2e.ts',
|
||||
'test/e2e/suites/auth/whoami.e2e.ts',
|
||||
// config (local, no network)
|
||||
'test/e2e/suites/config/**/*.e2e.ts',
|
||||
// discovery (get app / describe app)
|
||||
'test/e2e/suites/discovery/**/*.e2e.ts',
|
||||
// run tests (require valid token)
|
||||
'test/e2e/suites/run/**/*.e2e.ts',
|
||||
// devices + logout LAST — both can revoke tokens
|
||||
'test/e2e/suites/auth/devices.e2e.ts',
|
||||
'test/e2e/suites/auth/logout.e2e.ts',
|
||||
],
|
||||
// E2E calls a real staging server — allow plenty of time per test.
|
||||
testTimeout: 60_000,
|
||||
hookTimeout: 30_000,
|
||||
// Retry up to 2 times on staging flakiness.
|
||||
retry: 0, // flaky tests use withRetry() locally; global retry masks non-idempotent failures
|
||||
// Run suites sequentially to avoid workspace-level conflicts on staging.
|
||||
pool: 'forks',
|
||||
fileParallelism: false,
|
||||
reporters: ['verbose'],
|
||||
},
|
||||
})
|
||||
25
dify-agent/docker/shellctl/Dockerfile
Normal file
25
dify-agent/docker/shellctl/Dockerfile
Normal file
@ -0,0 +1,25 @@
|
||||
FROM python:3.13-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
tmux \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
RUN python -m pip install --no-cache-dir \
|
||||
shell-session-manager==2.1.1 \
|
||||
uv
|
||||
|
||||
RUN useradd --create-home --shell /bin/sh dify
|
||||
|
||||
USER dify
|
||||
WORKDIR /home/dify
|
||||
|
||||
EXPOSE 5004
|
||||
|
||||
CMD ["shellctl", "serve", "--listen", "0.0.0.0:5004"]
|
||||
195
dify-agent/docs/dify-agent/concepts/run-lifecycle/index.md
Normal file
195
dify-agent/docs/dify-agent/concepts/run-lifecycle/index.md
Normal file
@ -0,0 +1,195 @@
|
||||
# Agent Run Lifecycle
|
||||
|
||||
This page explains, from a caller's perspective, how an `agent run` relates to a
|
||||
`workflow run` and how callers control Agenton layer exit behavior with exit
|
||||
signals.
|
||||
|
||||
## Relationship between agent runs and workflow runs
|
||||
|
||||
A `workflow run` is one full workflow execution. An `agent run` is one Agent
|
||||
execution started by an Agent node while the workflow is running. They are not a
|
||||
one-to-one mapping: one `workflow run` often contains multiple `agent run`s.
|
||||
|
||||
### First entry into an Agent node
|
||||
|
||||
When a `workflow run` first reaches an Agent node, the caller starts the first
|
||||
`agent run` for that node.
|
||||
|
||||
The `agent run` enters the layers defined in its composition:
|
||||
|
||||
- If the request does not include `session_snapshot`, each layer enters with a
|
||||
fresh state and initializes its own runtime state.
|
||||
- If the request includes a previously returned `session_snapshot`, each layer
|
||||
restores its runtime state from that snapshot and continues from there.
|
||||
|
||||
After entering layers, the Agent runs the LLM and tool calls until the current
|
||||
`agent run` reaches a terminal result. This means the `agent run` has ended; it
|
||||
does not necessarily mean the outer workflow has ended.
|
||||
|
||||
### Ending with a `final_output` tool call
|
||||
|
||||
If the Agent ends with a `final_output` tool call, the Agent node has produced
|
||||
its final output for this pass. The caller should read the terminal output of the
|
||||
current `agent run` and let the `workflow run` continue to downstream nodes.
|
||||
|
||||
The current `agent run` has ended, but the returned `session_snapshot` can still
|
||||
be saved. If the same `workflow run` may enter the same Agent session again, the
|
||||
caller should keep using that snapshot.
|
||||
|
||||
### Ending with a human tool call
|
||||
|
||||
If the Agent ends with a human tool call, the Agent needs human input before the
|
||||
business process can continue. A common misconception is to treat this as a
|
||||
paused agent run. **Agent runs do not have a pause state.** With a human tool, the
|
||||
current `agent run` has ended; the outer `workflow run` is what should be paused.
|
||||
|
||||
The caller should handle this flow as follows:
|
||||
|
||||
1. Read the current `agent run` result and detect the HITL (human-in-the-loop)
|
||||
requirement.
|
||||
2. Enter workflow HITL handling and pause graphon.
|
||||
3. Wait for the human input to be completed.
|
||||
4. When resuming the workflow, insert the human tool response into the same Agent
|
||||
session's history layer.
|
||||
5. Start a second `agent run` on the same Agent node and reuse the same history
|
||||
session.
|
||||
|
||||
In other words, a human tool does not mean “pause this agent run until it is
|
||||
resumed.” It means “this agent run ended with a result that requires human
|
||||
input.” After the caller completes HITL handling, it should create a new
|
||||
`agent run` using the same history/session snapshot to continue.
|
||||
|
||||
### Entering another Agent node
|
||||
|
||||
When the same `workflow run` continues and reaches another Agent node, it starts
|
||||
another `agent run`. That next Agent node may be a different Agent, or it may be
|
||||
the same Agent reused by a roaster.
|
||||
|
||||
Therefore, callers should save and pass `session_snapshot` by Agent session, not
|
||||
assume that one `workflow run` has only one `agent run`.
|
||||
|
||||
## Agent run exit signals
|
||||
|
||||
When an `agent run` ends, Dify Agent exits the layers that were entered by the
|
||||
current run. Callers control whether each layer is suspended or deleted through
|
||||
`CreateRunRequest.on_exit`.
|
||||
|
||||
Exit signals control the **layer lifecycle state**, not the execution state of an
|
||||
`agent run`. The default policy is `suspend`, so a successful `agent run` returns
|
||||
a reusable `session_snapshot`.
|
||||
|
||||
### Default: suspend layers
|
||||
|
||||
If a request does not explicitly set `on_exit`, it is equivalent to:
|
||||
|
||||
```json
|
||||
{
|
||||
"on_exit": {
|
||||
"default": "suspend",
|
||||
"layers": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This means every entered layer exits as `suspended` and is written into the
|
||||
returned `session_snapshot`. The caller can submit that snapshot in the next
|
||||
`agent run` to resume those layers.
|
||||
|
||||
For normal Agent execution inside a workflow, including both `final_output` and
|
||||
human-tool endings, callers should keep the default suspend policy unless they
|
||||
know the Agent session will never be resumed.
|
||||
|
||||
### Delete layers when the workflow run ends
|
||||
|
||||
When the whole `workflow run` has ended, the caller should start one more cleanup
|
||||
`agent run`:
|
||||
|
||||
- Reuse the last available `session_snapshot`.
|
||||
- Omit the LLM layer, because this run is only for entering and cleaning existing
|
||||
state; it does not need to call the model again.
|
||||
- Exit layers with the `delete` signal.
|
||||
|
||||
The cleanup request should use an exit signal like this:
|
||||
|
||||
```json
|
||||
{
|
||||
"on_exit": {
|
||||
"default": "delete",
|
||||
"layers": {}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After this run, the corresponding layers exit through the delete path. A snapshot
|
||||
returned after deletion should not be used to resume the Agent session again.
|
||||
|
||||
### Override selected layers
|
||||
|
||||
The caller can also suspend by default while deleting only selected layers:
|
||||
|
||||
```json
|
||||
{
|
||||
"on_exit": {
|
||||
"default": "suspend",
|
||||
"layers": {
|
||||
"temporary_context": "delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Only `temporary_context` exits with `delete`; all other active layers exit with
|
||||
the default `suspend` behavior.
|
||||
|
||||
## Exit signal API reference
|
||||
|
||||
Fields related to exit control in `CreateRunRequest`:
|
||||
|
||||
| Field | Type | Required | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `session_snapshot` | `CompositorSessionSnapshot \| None` | no | The session snapshot returned by the previous `agent run`. It resumes the same Agent session. |
|
||||
| `on_exit` | `LayerExitSignals` | no | The exit policy used when this `agent run` exits layers. If omitted, all active layers are suspended by default. |
|
||||
|
||||
`LayerExitSignals` has this structure:
|
||||
|
||||
| Field | Type | Default | Meaning |
|
||||
| --- | --- | --- | --- |
|
||||
| `default` | `"suspend" \| "delete"` | `"suspend"` | Exit intent for layers not explicitly listed in `layers`. |
|
||||
| `layers` | `dict[str, "suspend" \| "delete"]` | `{}` | Per-layer exit intent overrides by layer name. Each key must refer to a layer name in the current composition. |
|
||||
|
||||
Exit intent semantics:
|
||||
|
||||
| Exit intent | Layer exit state | Effect |
|
||||
| --- | --- | --- |
|
||||
| `suspend` | `suspended` | Keep the layer runtime state and make the returned `session_snapshot` usable by a later `agent run`. |
|
||||
| `delete` | `closed` | Delete/close the layer context. The corresponding layer snapshot should not be resumed again. |
|
||||
|
||||
Python DTO example:
|
||||
|
||||
```python {test="skip" lint="skip"}
|
||||
from agenton.layers import ExitIntent
|
||||
from dify_agent.protocol import CreateRunRequest, LayerExitSignals
|
||||
|
||||
|
||||
request = CreateRunRequest(
|
||||
composition=composition,
|
||||
session_snapshot=previous_snapshot,
|
||||
on_exit=LayerExitSignals(
|
||||
default=ExitIntent.SUSPEND,
|
||||
layers={
|
||||
"temporary_context": ExitIntent.DELETE,
|
||||
},
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
Notes:
|
||||
|
||||
- `on_exit` only controls layer exit behavior; it does not cancel an `agent run`.
|
||||
- Agent runs do not have a pause state. Human-tool waiting is handled by the
|
||||
outer workflow/HITL flow.
|
||||
- Keys in `on_exit.layers` must refer to layer names in the current composition.
|
||||
- Use `suspend` and save the returned `session_snapshot` when the same Agent
|
||||
session needs to continue later.
|
||||
- After the whole `workflow run` ends, start one more cleanup run without an LLM
|
||||
layer and use `delete`.
|
||||
202
dify-agent/docs/dify-agent/user-manual/shell-layer/index.md
Normal file
202
dify-agent/docs/dify-agent/user-manual/shell-layer/index.md
Normal file
@ -0,0 +1,202 @@
|
||||
# Shell layer
|
||||
|
||||
The shell layer lets a Dify Agent run expose a `shellctl`-backed workspace to the
|
||||
model. This page is for Dify Agent clients that build `CreateRunRequest`
|
||||
payloads. It explains how to add the layer to a run composition and how the
|
||||
server-side runtime must be wired.
|
||||
|
||||
The layer type id is `dify.shell`. Its public config is intentionally empty:
|
||||
|
||||
```python
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.protocol import RunLayerSpec
|
||||
|
||||
RunLayerSpec(
|
||||
name="shell",
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
config=DifyShellLayerConfig(),
|
||||
)
|
||||
```
|
||||
|
||||
Server-only settings, such as the `shellctl` HTTP entrypoint and auth token, are
|
||||
injected by the Dify Agent runtime provider. They are not part of
|
||||
`DifyShellLayerConfig` and should not be submitted by clients in the public run
|
||||
request.
|
||||
|
||||
## Runtime requirements
|
||||
|
||||
When a run includes `dify.shell`, the Dify Agent server must construct its layer
|
||||
providers with a non-empty shellctl entrypoint:
|
||||
|
||||
```python
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
|
||||
layer_providers = create_default_layer_providers(
|
||||
plugin_daemon_url="http://localhost:5002",
|
||||
plugin_daemon_api_key="replace-with-plugin-daemon-key",
|
||||
shellctl_entrypoint="http://127.0.0.1:5004",
|
||||
shellctl_auth_token="replace-with-shellctl-token", # optional; defaults to no token
|
||||
)
|
||||
```
|
||||
|
||||
In the FastAPI server, these values are read from environment-backed
|
||||
`ServerSettings` fields:
|
||||
|
||||
```env
|
||||
DIFY_AGENT_SHELLCTL_ENTRYPOINT=http://127.0.0.1:5004
|
||||
DIFY_AGENT_SHELLCTL_AUTH_TOKEN=replace-with-shellctl-token
|
||||
```
|
||||
|
||||
`DIFY_AGENT_SHELLCTL_AUTH_TOKEN` defaults to `None`/empty, which keeps the shell
|
||||
client on the no-token path. Set it only when the shellctl server is started with
|
||||
bearer authentication.
|
||||
|
||||
## Client request shape
|
||||
|
||||
A client adds the shell layer as an ordinary composition layer. The shell layer
|
||||
does not need dependencies. A typical run still also includes:
|
||||
|
||||
- a prompt layer that supplies the task;
|
||||
- an execution-context layer carrying tenant/user context;
|
||||
- an LLM layer named `llm`.
|
||||
|
||||
When clients want the shell workspace and shellctl job records to be removed at
|
||||
the end of the run, set `on_exit.default` to `delete`.
|
||||
|
||||
## Example: CSV analysis run
|
||||
|
||||
The following example mirrors a verified run with a real Gemini model and a
|
||||
temporary shellctl server. The client gives the model a small CSV-shaped dataset
|
||||
and asks for computed metrics without prescribing the exact shell commands.
|
||||
|
||||
### Request
|
||||
|
||||
```python {test="skip" lint="skip"}
|
||||
from agenton.layers import ExitIntent
|
||||
from agenton_collections.layers.plain import PromptLayerConfig
|
||||
from dify_agent.layers.dify_plugin.configs import DifyPluginLLMLayerConfig
|
||||
from dify_agent.layers.execution_context import (
|
||||
DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
DifyExecutionContextLayerConfig,
|
||||
)
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.protocol import DIFY_AGENT_MODEL_LAYER_ID
|
||||
from dify_agent.protocol.schemas import CreateRunRequest, LayerExitSignals, RunComposition, RunLayerSpec
|
||||
|
||||
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(
|
||||
name="prompt",
|
||||
type="plain.prompt",
|
||||
config=PromptLayerConfig(
|
||||
prefix="You are a practical data analyst. Give a concise final answer.",
|
||||
user="""Analyze this small sales dataset with pandas. Use any local computation you think is useful.
|
||||
|
||||
region,product,units,unit_price
|
||||
north,widget,12,3.50
|
||||
north,gadget,5,9.00
|
||||
south,widget,7,3.50
|
||||
south,gadget,9,9.00
|
||||
west,widget,4,3.50
|
||||
west,gadget,11,9.00
|
||||
|
||||
Report the total revenue, the region with the most revenue, total units by
|
||||
product, and a SHA-256 hash of the CSV content.""",
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="shell",
|
||||
type=DIFY_SHELL_LAYER_TYPE_ID,
|
||||
config=DifyShellLayerConfig(),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(
|
||||
tenant_id="92cca973-2d6f-45e0-906e-0b7eda5f2ccf",
|
||||
invoke_from="workflow_run",
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type="dify.plugin.llm",
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/gemini",
|
||||
model_provider="google",
|
||||
model="gemini-3.5-flash",
|
||||
credentials={"google_api_key": "<redacted>"},
|
||||
),
|
||||
),
|
||||
]
|
||||
),
|
||||
on_exit=LayerExitSignals(default=ExitIntent.DELETE),
|
||||
)
|
||||
```
|
||||
|
||||
The same request serialized as JSON has these important layer entries:
|
||||
|
||||
```json
|
||||
{
|
||||
"composition": {
|
||||
"schema_version": 1,
|
||||
"layers": [
|
||||
{"name": "prompt", "type": "plain.prompt"},
|
||||
{"name": "shell", "type": "dify.shell", "config": {}},
|
||||
{"name": "execution_context", "type": "dify.execution_context"},
|
||||
{
|
||||
"name": "llm",
|
||||
"type": "dify.plugin.llm",
|
||||
"deps": {"execution_context": "execution_context"}
|
||||
}
|
||||
]
|
||||
},
|
||||
"on_exit": {"default": "delete", "layers": {}}
|
||||
}
|
||||
```
|
||||
|
||||
### Final answer
|
||||
|
||||
The terminal `run_succeeded` output was:
|
||||
|
||||
````markdown
|
||||
Here is the analysis of the sales dataset:
|
||||
|
||||
* **Total Revenue:** **$305.50**
|
||||
* **Top Region:** **west** with **$113.00**
|
||||
* **Total Units by Product:** gadget: 25 units, widget: 23 units
|
||||
* **SHA-256 Hash:** `e86521a0d759037a09b059cb3cb2419f0a3f06e674db8151ccf2f93811dac0b8`
|
||||
````
|
||||
|
||||
## Running shellctl in Docker
|
||||
|
||||
Build the shellctl image from the Dify Agent package root:
|
||||
|
||||
```bash
|
||||
docker build -f docker/shellctl/Dockerfile -t dify-agent-shellctl:local .
|
||||
```
|
||||
|
||||
Run it with a bearer token and publish the API on localhost:
|
||||
|
||||
```bash
|
||||
docker run --rm --name dify-agent-shellctl \
|
||||
-e SHELLCTL_AUTH_TOKEN=replace-with-a-token \
|
||||
-p 127.0.0.1:5004:5004 \
|
||||
dify-agent-shellctl:local
|
||||
```
|
||||
|
||||
The image starts `shellctl serve --listen 0.0.0.0:5004` as the non-root
|
||||
`dify` user and leaves shellctl state/runtime directories at their package
|
||||
defaults.
|
||||
|
||||
## Docker image contents
|
||||
|
||||
The provided `docker/shellctl/Dockerfile` installs:
|
||||
|
||||
- `tmux`, required by `shellctl` to manage shell jobs;
|
||||
- `shell-session-manager==2.1.1`, which provides the `shellctl` CLI/server;
|
||||
- `uv`, so uv shebang scripts with PEP 723 metadata can run inside the shell
|
||||
workspace;
|
||||
- a non-root default user named `dify`.
|
||||
@ -14,10 +14,13 @@ nav:
|
||||
- Examples: agenton/examples/index.md
|
||||
- Dify Agent:
|
||||
- Overview: dify-agent/index.md
|
||||
- Concepts:
|
||||
- Agent Run Lifecycle: dify-agent/concepts/run-lifecycle/index.md
|
||||
- User Manual:
|
||||
- Get Started: dify-agent/get-started/index.md
|
||||
- Prompt Layer: dify-agent/user-manual/prompt-layer/index.md
|
||||
- Execution Context Layer: dify-agent/user-manual/execution-context-layer/index.md
|
||||
- Shell Layer: dify-agent/user-manual/shell-layer/index.md
|
||||
- Plugin LLM Layer: dify-agent/user-manual/plugin-llm-layer/index.md
|
||||
- Plugin Tool Layer: dify-agent/user-manual/plugin-tool-layer/index.md
|
||||
- History Layer: dify-agent/user-manual/history-layer/index.md
|
||||
|
||||
@ -19,6 +19,7 @@ server = [
|
||||
"pydantic-ai-slim[anthropic,google,openai]>=1.85.1,<2.0.0",
|
||||
"pydantic-settings>=2.12.0,<3.0.0",
|
||||
"redis>=7.4.0,<8.0.0",
|
||||
"shell-session-manager==2.1.1",
|
||||
"uvicorn[standard]==0.46.0",
|
||||
]
|
||||
|
||||
|
||||
@ -2,8 +2,9 @@
|
||||
|
||||
Agenton core composes reusable stateless layer graph plans, creates a fresh
|
||||
``CompositorRun`` for each invocation, hydrates and advances serializable layer
|
||||
``runtime_state`` through run slots, and emits session snapshots. It intentionally
|
||||
does not own resources, handles, clients, cleanup callbacks, or any other
|
||||
``runtime_state`` through run slots, enters each layer's active-scope
|
||||
``resource_context()``, and emits session snapshots. It intentionally never
|
||||
serializes resources, handles, clients, cleanup callbacks, or any other
|
||||
non-serializable runtime object.
|
||||
|
||||
Each ``Compositor`` stores only graph nodes and layer providers. Every enter call
|
||||
|
||||
@ -11,7 +11,8 @@ types.
|
||||
``Compositor`` itself stores no live layer instances, run lifecycle state,
|
||||
session state, resources, or handles. Each ``enter(...)`` call creates a fresh
|
||||
``CompositorRun`` with new layer instances, direct dependency binding, optional
|
||||
snapshot hydration, and the next ``session_snapshot`` after exit.
|
||||
snapshot hydration, entered per-layer ``resource_context()`` scopes, and the
|
||||
next ``session_snapshot`` after exit.
|
||||
``LifecycleState.ACTIVE`` remains internal-only and session snapshots contain
|
||||
only ordered layer lifecycle state plus serializable ``runtime_state``.
|
||||
|
||||
|
||||
@ -4,7 +4,8 @@
|
||||
transformers. Each ``enter(...)`` call validates node-name keyed configs before
|
||||
any provider factory runs, optionally validates and hydrates a session snapshot,
|
||||
creates fresh layer instances, binds direct dependencies, and returns a new
|
||||
``CompositorRun`` for that invocation only.
|
||||
``CompositorRun`` for that invocation only. Dependency targets must point to
|
||||
preceding graph nodes so resource scopes can nest in dependency order.
|
||||
|
||||
``Compositor.from_config(...)`` resolves serializable provider type ids rather
|
||||
than import paths. Named ``node_providers`` override type-id providers for the
|
||||
@ -49,8 +50,9 @@ class LayerNode:
|
||||
|
||||
``implementation`` may be a layer class or an explicit ``LayerProvider``.
|
||||
``deps`` maps dependency field names on this node's layer class to other
|
||||
compositor node names. ``metadata`` is graph description data only; it is
|
||||
not passed to provider factories and is never included in session snapshots.
|
||||
preceding compositor node names. ``metadata`` is graph description data
|
||||
only; it is not passed to provider factories and is never included in
|
||||
session snapshots.
|
||||
"""
|
||||
|
||||
name: str
|
||||
@ -89,7 +91,8 @@ class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT,
|
||||
``tool_transformer`` are post-aggregation hooks on each run. Use two type
|
||||
arguments for identity aggregation, four when prompt/tool layer item types
|
||||
differ from exposed item types, or all six when user prompt item types also
|
||||
differ.
|
||||
differ. Graph order is meaningful for lifecycle nesting, so dependency edges
|
||||
must point only to earlier nodes.
|
||||
"""
|
||||
|
||||
__slots__ = ("_nodes", "prompt_transformer", "tool_transformer", "user_prompt_transformer")
|
||||
@ -188,10 +191,14 @@ class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT,
|
||||
"""
|
||||
run = self._create_run(configs=configs, session_snapshot=session_snapshot)
|
||||
await run._enter_layers()
|
||||
body_error: BaseException | None = None
|
||||
try:
|
||||
yield run
|
||||
except BaseException as exc:
|
||||
body_error = exc
|
||||
raise
|
||||
finally:
|
||||
await run._exit_layers()
|
||||
await run._exit_layers(body_error)
|
||||
|
||||
def _create_run(
|
||||
self,
|
||||
@ -254,6 +261,8 @@ class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT,
|
||||
raise ValueError(f"Duplicate layer name '{node.name}'.")
|
||||
layer_names.add(node.name)
|
||||
|
||||
layer_index_by_name = {node.name: index for index, node in enumerate(self._nodes)}
|
||||
|
||||
for node in self._nodes:
|
||||
declared_deps = node.provider.layer_type.dependency_names()
|
||||
unknown_dep_keys = set(node.deps) - declared_deps
|
||||
@ -265,6 +274,20 @@ class Compositor(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPromptT,
|
||||
names = ", ".join(sorted(missing_targets))
|
||||
raise ValueError(f"Layer '{node.name}' depends on undefined layer names: {names}.")
|
||||
|
||||
non_preceding_targets = {
|
||||
dep_name: target_name
|
||||
for dep_name, target_name in node.deps.items()
|
||||
if layer_index_by_name[target_name] >= layer_index_by_name[node.name]
|
||||
}
|
||||
if non_preceding_targets:
|
||||
targets = ", ".join(
|
||||
f"{dep_name}->{target_name}" for dep_name, target_name in sorted(non_preceding_targets.items())
|
||||
)
|
||||
raise ValueError(
|
||||
f"Layer '{node.name}' dependencies must target preceding layer nodes in compositor order: "
|
||||
f"{targets}."
|
||||
)
|
||||
|
||||
def _validate_run_configs(self, configs: Mapping[str, LayerConfigInput] | None) -> dict[str, LayerConfigInput]:
|
||||
config_by_name = dict(configs or {})
|
||||
known_names = {node.name for node in self._nodes}
|
||||
|
||||
@ -1,11 +1,18 @@
|
||||
"""Active compositor run lifecycle, snapshots, and aggregation.
|
||||
|
||||
``CompositorRun`` is the only compositor object that exposes live layer
|
||||
instances. It owns invocation-local lifecycle state, per-layer exit intent, and
|
||||
the next ``session_snapshot`` after exit. Layers enter in graph order and exit
|
||||
in reverse graph order. Prompt aggregation preserves graph ordering: prefix
|
||||
prompts first-to-last, suffix prompts last-to-first, user prompts first-to-last,
|
||||
and tools in graph order.
|
||||
instances. It owns invocation-local lifecycle state, per-layer exit intent,
|
||||
entered layer resource scopes, and the next ``session_snapshot`` after exit.
|
||||
Layers enter in graph order and exit in reverse graph order. A layer's
|
||||
``resource_context()`` wraps that layer's enter hook, the active run body while
|
||||
the layer remains active, and the layer's exit hook. Prompt aggregation
|
||||
preserves graph ordering: prefix prompts first-to-last, suffix prompts
|
||||
last-to-first, user prompts first-to-last, and tools in graph order.
|
||||
|
||||
Enter hooks transition a slot to ``LifecycleState.ACTIVE`` only after returning
|
||||
successfully. If ``on_context_create`` or ``on_context_resume`` raises, the run
|
||||
still exits any already-entered resource contexts, but it does not call the
|
||||
layer's normal suspend/delete hook for that failed enter attempt.
|
||||
|
||||
Prompt, user prompt, and tool transformers run only after layer-level wrapping
|
||||
and run-level aggregation. When no transformer is installed, the wrapped items
|
||||
@ -14,6 +21,7 @@ are returned unchanged.
|
||||
|
||||
from collections import OrderedDict
|
||||
from collections.abc import Sequence
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Generic, cast, overload
|
||||
|
||||
@ -36,11 +44,12 @@ from .types import (
|
||||
|
||||
@dataclass(slots=True)
|
||||
class LayerRunSlot:
|
||||
"""Invocation-local lifecycle and exit state for one fresh layer instance."""
|
||||
"""Invocation-local lifecycle, resource scope, and exit state for one layer."""
|
||||
|
||||
layer: Layer[Any, Any, Any, Any, Any, Any]
|
||||
lifecycle_state: LifecycleState
|
||||
exit_intent: ExitIntent = ExitIntent.DELETE
|
||||
active_resource_context: AbstractAsyncContextManager[None] | None = None
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@ -123,40 +132,57 @@ class CompositorRun(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPrompt
|
||||
await self._enter_slot(slot)
|
||||
entered_slots.append(slot)
|
||||
except BaseException as enter_error:
|
||||
hook_error = await self._exit_slots_reversed(entered_slots)
|
||||
hook_error = await self._exit_slots_reversed(entered_slots, wrapped_error=enter_error)
|
||||
self.session_snapshot = self.snapshot_session()
|
||||
if hook_error is not None:
|
||||
raise hook_error from enter_error
|
||||
raise
|
||||
|
||||
async def _exit_layers(self) -> None:
|
||||
hook_error = await self._exit_slots_reversed(list(self.slots.values()))
|
||||
async def _exit_layers(self, wrapped_error: BaseException | None = None) -> None:
|
||||
hook_error = await self._exit_slots_reversed(list(self.slots.values()), wrapped_error=wrapped_error)
|
||||
self.session_snapshot = self.snapshot_session()
|
||||
if hook_error is not None:
|
||||
raise hook_error
|
||||
|
||||
async def _enter_slot(self, slot: LayerRunSlot) -> None:
|
||||
if slot.lifecycle_state is LifecycleState.NEW:
|
||||
slot.exit_intent = ExitIntent.DELETE
|
||||
await slot.layer.on_context_create()
|
||||
slot.lifecycle_state = LifecycleState.ACTIVE
|
||||
return
|
||||
if slot.lifecycle_state is LifecycleState.SUSPENDED:
|
||||
slot.exit_intent = ExitIntent.DELETE
|
||||
await slot.layer.on_context_resume()
|
||||
slot.lifecycle_state = LifecycleState.ACTIVE
|
||||
return
|
||||
raise RuntimeError(f"Cannot enter layer from lifecycle state '{slot.lifecycle_state}'.")
|
||||
resource_context = slot.layer.resource_context()
|
||||
await resource_context.__aenter__()
|
||||
slot.active_resource_context = resource_context
|
||||
try:
|
||||
if slot.lifecycle_state is LifecycleState.NEW:
|
||||
slot.exit_intent = ExitIntent.DELETE
|
||||
await slot.layer.on_context_create()
|
||||
slot.lifecycle_state = LifecycleState.ACTIVE
|
||||
return
|
||||
if slot.lifecycle_state is LifecycleState.SUSPENDED:
|
||||
slot.exit_intent = ExitIntent.DELETE
|
||||
await slot.layer.on_context_resume()
|
||||
slot.lifecycle_state = LifecycleState.ACTIVE
|
||||
return
|
||||
raise RuntimeError(f"Cannot enter layer from lifecycle state '{slot.lifecycle_state}'.")
|
||||
except BaseException as enter_error:
|
||||
resource_error = await self._exit_resource_context(slot, wrapped_error=enter_error)
|
||||
if resource_error is not None:
|
||||
raise resource_error from enter_error
|
||||
raise
|
||||
|
||||
async def _exit_slots_reversed(self, slots: Sequence[LayerRunSlot]) -> BaseException | None:
|
||||
async def _exit_slots_reversed(
|
||||
self,
|
||||
slots: Sequence[LayerRunSlot],
|
||||
*,
|
||||
wrapped_error: BaseException | None = None,
|
||||
) -> BaseException | None:
|
||||
hook_error: BaseException | None = None
|
||||
propagating_error = wrapped_error
|
||||
for slot in reversed(slots):
|
||||
if slot.lifecycle_state is not LifecycleState.ACTIVE:
|
||||
continue
|
||||
slot_error: BaseException | None = None
|
||||
if slot.exit_intent is ExitIntent.SUSPEND:
|
||||
try:
|
||||
await slot.layer.on_context_suspend()
|
||||
except BaseException as exc:
|
||||
slot_error = exc
|
||||
hook_error = hook_error or exc
|
||||
finally:
|
||||
slot.lifecycle_state = LifecycleState.SUSPENDED
|
||||
@ -164,12 +190,43 @@ class CompositorRun(Generic[PromptT, ToolT, LayerPromptT, LayerToolT, UserPrompt
|
||||
try:
|
||||
await slot.layer.on_context_delete()
|
||||
except BaseException as exc:
|
||||
slot_error = exc
|
||||
hook_error = hook_error or exc
|
||||
finally:
|
||||
slot.lifecycle_state = LifecycleState.CLOSED
|
||||
|
||||
slot_scope_error = slot_error or propagating_error
|
||||
resource_error = await self._exit_resource_context(slot, wrapped_error=slot_scope_error)
|
||||
if resource_error is not None:
|
||||
hook_error = hook_error or resource_error
|
||||
propagating_error = resource_error
|
||||
continue
|
||||
propagating_error = slot_scope_error
|
||||
|
||||
return hook_error
|
||||
|
||||
async def _exit_resource_context(
|
||||
self,
|
||||
slot: LayerRunSlot,
|
||||
*,
|
||||
wrapped_error: BaseException | None,
|
||||
) -> BaseException | None:
|
||||
resource_context = slot.active_resource_context
|
||||
if resource_context is None:
|
||||
return None
|
||||
|
||||
slot.active_resource_context = None
|
||||
exc_type = type(wrapped_error) if wrapped_error is not None else None
|
||||
exc_traceback = wrapped_error.__traceback__ if wrapped_error is not None else None
|
||||
try:
|
||||
# Resource scopes exist for deterministic live-resource cleanup. They
|
||||
# should observe the exception leaving the wrapped scope, but Agenton
|
||||
# still preserves its own hook/body error propagation rules.
|
||||
await resource_context.__aexit__(exc_type, wrapped_error, exc_traceback)
|
||||
except BaseException as exc:
|
||||
return exc
|
||||
return None
|
||||
|
||||
def _set_layer_exit_intent(self, name: str, intent: ExitIntent) -> None:
|
||||
try:
|
||||
slot = self.slots[name]
|
||||
|
||||
@ -4,6 +4,8 @@ Graph config and session snapshots are separate boundaries on purpose. Graph
|
||||
config describes only reusable composition state: schema version, ordered node
|
||||
names, provider type ids, dependency mappings, and metadata. Session snapshots
|
||||
carry only ordered layer lifecycle state plus serializable ``runtime_state``.
|
||||
Live resources acquired inside ``Layer.resource_context()`` are active-scope
|
||||
only and can never appear in these DTOs.
|
||||
|
||||
External DTOs are revalidated even when callers pass an already-constructed
|
||||
Pydantic model instance. These models are mutable, so dumping and validating
|
||||
@ -99,8 +101,9 @@ class CompositorSessionSnapshot(BaseModel):
|
||||
"""Serializable compositor session snapshot.
|
||||
|
||||
Snapshots include ordered layer lifecycle state and serializable runtime
|
||||
state only. Live resources, handles, dependencies, prompts, tools, and
|
||||
config are outside Agenton snapshots and are never captured here.
|
||||
state only. Live resources from ``Layer.resource_context()``, handles,
|
||||
dependencies, prompts, tools, and config are outside Agenton snapshots and
|
||||
are never captured here.
|
||||
"""
|
||||
|
||||
schema_version: int = 1
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
"""Invocation-scoped core layer abstractions and typed dependency binding.
|
||||
|
||||
Agenton core deliberately manages only three concerns: stateless layer graph
|
||||
composition, serializable ``runtime_state`` lifecycle, and session snapshots. It
|
||||
does not own live resources, process handles, HTTP clients, cleanup stacks, or
|
||||
any other non-serializable runtime object. Those belong to application layers or
|
||||
integration code outside the core.
|
||||
Agenton core deliberately manages four concerns: stateless layer graph
|
||||
composition, serializable ``runtime_state`` lifecycle, per-active-invocation
|
||||
resource scopes, and session snapshots. Live resources remain layer-owned:
|
||||
Agenton may enter ``Layer.resource_context()`` for the active scope, but it
|
||||
never serializes or snapshots clients, process handles, cleanup stacks, or any
|
||||
other non-serializable runtime object.
|
||||
|
||||
Layers declare their dependency shape with
|
||||
``Layer[DepsT, PromptT, UserPromptT, ToolT, ConfigT, RuntimeStateT]``.
|
||||
@ -24,18 +25,27 @@ when possible, while still allowing subclasses to set them explicitly for
|
||||
unusual inheritance patterns.
|
||||
|
||||
``Layer`` is an invocation-scoped business object. It owns ``config``, direct
|
||||
``deps``, and serializable ``runtime_state`` plus prompt/tool authoring surfaces,
|
||||
but it does not own lifecycle state, exit intent, graph owner tokens, entry
|
||||
stacks, resources, or cleanup callbacks. ``CompositorRun`` owns lifecycle state
|
||||
and exit intent for one entry. ``SessionSnapshot`` objects are the only supported
|
||||
cross-call state carrier.
|
||||
``deps``, serializable ``runtime_state``, prompt/tool authoring surfaces, and
|
||||
any live resource fields managed by ``resource_context()``. It does not own
|
||||
lifecycle state, exit intent, graph owner tokens, or entry stacks.
|
||||
``CompositorRun`` owns lifecycle state and exit intent for one entry and
|
||||
orchestrates entering and exiting each layer's resource scope. ``SessionSnapshot``
|
||||
objects are the only supported cross-call state carrier.
|
||||
|
||||
Lifecycle hooks are no-argument business hooks on the layer instance:
|
||||
``on_context_create/resume/suspend/delete(self)``. They should read dependencies
|
||||
from ``self.deps`` and read or mutate serializable invocation state through
|
||||
``self.runtime_state``. Resource acquisition and deterministic cleanup should be
|
||||
handled outside Agenton core, for example by integration-specific context
|
||||
managers that wrap compositor entry.
|
||||
``self.runtime_state``. ``resource_context(self)`` is the symmetric active-scope
|
||||
API for live resources. Agenton enters it before ``on_context_create`` or
|
||||
``on_context_resume`` and exits it after ``on_context_suspend`` or
|
||||
``on_context_delete``. Create-versus-resume differences stay in the business
|
||||
hooks; ``resource_context`` should manage only live resource setup and cleanup.
|
||||
Agenton marks a slot ``ACTIVE`` only after ``on_context_create`` or
|
||||
``on_context_resume`` returns successfully. If either enter hook raises, normal
|
||||
``on_context_suspend``/``on_context_delete`` hooks do not run for that failed
|
||||
attempt. Enter hooks therefore own any business compensation or idempotency for
|
||||
partial side effects, while Agenton guarantees only ``resource_context()``
|
||||
cleanup, not hook rollback.
|
||||
|
||||
``Layer`` is framework-neutral over system prompt, user prompt, and tool item
|
||||
types. The native ``prefix_prompts``, ``suffix_prompts``, ``user_prompts``, and
|
||||
@ -47,11 +57,13 @@ native values without changing layer implementations.
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Mapping, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
from types import UnionType
|
||||
from typing import (
|
||||
Any,
|
||||
AsyncIterator,
|
||||
ClassVar,
|
||||
Generic,
|
||||
Union,
|
||||
@ -183,10 +195,11 @@ class Layer(
|
||||
snapshot, and then runs no-argument lifecycle hooks. The run owns lifecycle
|
||||
state and exit intent; layers never expose a public entry context manager.
|
||||
|
||||
Live resources and handles are intentionally outside this abstraction. Only
|
||||
``runtime_state`` is managed and snapshotted by Agenton core. Lifecycle hooks
|
||||
should operate on ``self`` and keep any non-serializable cleanup policy in
|
||||
integration code that wraps the compositor.
|
||||
``runtime_state`` is the only mutable data Agenton snapshots across calls.
|
||||
Live resources belong on the layer instance itself and should be acquired in
|
||||
``resource_context()``. Agenton keeps that resource scope active while the
|
||||
corresponding enter hook, run body, and exit hook execute, then tears it
|
||||
down deterministically even when later hooks or the body fail.
|
||||
"""
|
||||
|
||||
deps_type: type[_DepsT]
|
||||
@ -267,17 +280,46 @@ class Layer(
|
||||
resolved_deps[name] = deps[name]
|
||||
self.deps = self.deps_type(**resolved_deps)
|
||||
|
||||
@asynccontextmanager
|
||||
async def resource_context(self) -> AsyncIterator[None]:
|
||||
"""Wrap one active invocation with live non-serializable resources.
|
||||
|
||||
Agenton enters this no-argument context before ``on_context_create`` or
|
||||
``on_context_resume`` and exits it after ``on_context_suspend`` or
|
||||
``on_context_delete``. Use it for live clients, process handles, or
|
||||
other non-serializable objects stored on ``self``. Keep create-versus-
|
||||
resume business differences in the corresponding lifecycle hooks.
|
||||
"""
|
||||
yield
|
||||
|
||||
async def on_context_create(self) -> None:
|
||||
"""Run when the run slot enters from ``LifecycleState.NEW``."""
|
||||
"""Run when the run slot enters from ``LifecycleState.NEW``.
|
||||
|
||||
``resource_context()`` is already active for this layer when this hook
|
||||
runs. If this hook raises, the layer never becomes ``ACTIVE`` and no
|
||||
normal ``on_context_delete()`` hook runs for that failed enter attempt.
|
||||
"""
|
||||
|
||||
async def on_context_delete(self) -> None:
|
||||
"""Run when the run slot exits with ``ExitIntent.DELETE``."""
|
||||
"""Run when the run slot exits with ``ExitIntent.DELETE``.
|
||||
|
||||
``resource_context()`` remains active while this hook runs.
|
||||
"""
|
||||
|
||||
async def on_context_suspend(self) -> None:
|
||||
"""Run when the run slot exits with ``ExitIntent.SUSPEND``."""
|
||||
"""Run when the run slot exits with ``ExitIntent.SUSPEND``.
|
||||
|
||||
``resource_context()`` remains active while this hook runs.
|
||||
"""
|
||||
|
||||
async def on_context_resume(self) -> None:
|
||||
"""Run when the run slot enters from ``LifecycleState.SUSPENDED``."""
|
||||
"""Run when the run slot enters from ``LifecycleState.SUSPENDED``.
|
||||
|
||||
``resource_context()`` is already active for this layer when this hook
|
||||
runs. If this hook raises, the layer never becomes ``ACTIVE`` and no
|
||||
normal ``on_context_suspend()`` or ``on_context_delete()`` hook runs for
|
||||
that failed resume attempt.
|
||||
"""
|
||||
|
||||
@property
|
||||
def prefix_prompts(self) -> Sequence[_PromptT]:
|
||||
|
||||
10
dify-agent/src/dify_agent/layers/shell/__init__.py
Normal file
10
dify-agent/src/dify_agent/layers/shell/__init__.py
Normal file
@ -0,0 +1,10 @@
|
||||
"""Client-safe exports for the Dify shell layer DTOs.
|
||||
|
||||
The runtime layer implementation lives in ``layer.py`` and imports shellctl
|
||||
client code plus server-side lifecycle behavior. Keep this package root
|
||||
import-safe for client code that only needs to build run requests.
|
||||
"""
|
||||
|
||||
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
|
||||
__all__ = ["DIFY_SHELL_LAYER_TYPE_ID", "DifyShellLayerConfig"]
|
||||
25
dify-agent/src/dify_agent/layers/shell/configs.py
Normal file
25
dify-agent/src/dify_agent/layers/shell/configs.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""Client-safe DTOs for the Dify shell Agenton layer.
|
||||
|
||||
This first shell layer version intentionally has no public configuration beyond
|
||||
its stable type id. Server-only shellctl connection settings are injected by the
|
||||
runtime provider factory so client code cannot accidentally depend on process
|
||||
environment or transport details.
|
||||
"""
|
||||
|
||||
from typing import ClassVar, Final
|
||||
|
||||
from pydantic import ConfigDict
|
||||
|
||||
from agenton.layers import LayerConfig
|
||||
|
||||
|
||||
DIFY_SHELL_LAYER_TYPE_ID: Final[str] = "dify.shell"
|
||||
|
||||
|
||||
class DifyShellLayerConfig(LayerConfig):
|
||||
"""Empty public config for the shellctl-backed Dify shell layer."""
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
__all__ = ["DIFY_SHELL_LAYER_TYPE_ID", "DifyShellLayerConfig"]
|
||||
733
dify-agent/src/dify_agent/layers/shell/layer.py
Normal file
733
dify-agent/src/dify_agent/layers/shell/layer.py
Normal file
@ -0,0 +1,733 @@
|
||||
"""Shellctl-backed Dify shell layer.
|
||||
|
||||
``DifyShellLayer`` is a stateful pydantic-ai tool layer that exposes exactly
|
||||
``shell.run``, ``shell.wait``, ``shell.input``, and ``shell.interrupt``. The
|
||||
layer persists only JSON-safe shell session state in ``runtime_state`` and keeps
|
||||
its live shellctl HTTP client on the layer instance only while
|
||||
``resource_context()`` is active. Agenton enters that resource scope before
|
||||
``on_context_create`` or ``on_context_resume`` and exits it after
|
||||
``on_context_suspend`` or ``on_context_delete``, so business hooks and shell
|
||||
tools can rely on a live client without ever serializing it into snapshots.
|
||||
|
||||
The runtime state tracks shellctl job ids for both user-visible shell jobs and
|
||||
internal lifecycle jobs such as workspace mkdir/cleanup commands. Those internal
|
||||
jobs are intentionally not deleted ad hoc; shellctl job-state deletion is
|
||||
centralized in ``on_context_delete`` so one lifecycle hook owns exit-time
|
||||
cleanup for successful create/resume flows. If ``on_context_create`` or a later
|
||||
side-effecting ``on_context_resume`` attempt fails after issuing shellctl jobs,
|
||||
Agenton still exits ``resource_context()`` but never transitions the layer to
|
||||
``ACTIVE``. In that failed-enter path, normal suspend/delete hooks do not run,
|
||||
so the enter hook itself must perform best-effort business compensation before
|
||||
re-raising the failure.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator, Callable, Sequence
|
||||
from contextlib import asynccontextmanager
|
||||
import logging
|
||||
import re
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import ClassVar, NotRequired, Protocol, TypedDict
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, NonNegativeInt, field_validator, model_validator
|
||||
from pydantic_ai import Tool
|
||||
from shell_session_manager.shellctl.client import ShellctlClient, ShellctlClientError
|
||||
from shell_session_manager.shellctl.shared import (
|
||||
DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
DEFAULT_TIMEOUT_SECONDS,
|
||||
DeleteJobResponse,
|
||||
JobResult,
|
||||
JobStatusView,
|
||||
)
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from agenton.layers import NoLayerDeps, PydanticAILayer, PydanticAIPrompt, PydanticAITool
|
||||
from dify_agent.layers.shell.configs import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WORKSPACE_ROOT = "~/workspace"
|
||||
_WORKSPACE_COLLISION_EXIT_CODE = 17
|
||||
_SESSION_TIME_HEX_MASK = 0xFFFFF
|
||||
_SESSION_RANDOM_HEX_LENGTH = 2
|
||||
_SESSION_ID_ATTEMPT_LIMIT = 256
|
||||
_SESSION_ID_PATTERN = re.compile(r"^[0-9a-f]{7}$")
|
||||
_SHELL_LAYER_PREFIX_PROMPT = """You have access to a shell layer. It provides four tools:
|
||||
|
||||
1. shell.run
|
||||
Start a new shell job in the current isolated workspace.
|
||||
Use it to execute commands or scripts.
|
||||
|
||||
2. shell.wait
|
||||
Wait for more output or completion from an existing shell job.
|
||||
Use it when shell.run returns done=false.
|
||||
|
||||
3. shell.input
|
||||
Send stdin text to a running shell job, then wait for new output.
|
||||
Use it for interactive commands that are waiting for input.
|
||||
|
||||
4. shell.interrupt
|
||||
Interrupt a running shell job.
|
||||
Use it to stop a long-running, stuck, or no-longer-needed command.
|
||||
|
||||
Common arguments:
|
||||
|
||||
- script:
|
||||
The command or script to execute. Used by shell.run.
|
||||
|
||||
- job_id:
|
||||
The id of a shell job returned by shell.run.
|
||||
Use it with shell.wait, shell.input, and shell.interrupt.
|
||||
Never invent a job_id.
|
||||
|
||||
- timeout:
|
||||
Maximum time, in seconds, to wait for output or completion for this tool call.
|
||||
A timeout does not necessarily mean the job has stopped; if done=false, use shell.wait again.
|
||||
|
||||
- text:
|
||||
Text to send to the running process stdin. Used by shell.input.
|
||||
Include "\\n" if the process expects Enter.
|
||||
|
||||
- grace_seconds:
|
||||
Time to wait after interrupting before forceful cleanup. Used by shell.interrupt.
|
||||
|
||||
Usage rules:
|
||||
|
||||
- Start with shell.run.
|
||||
- If shell.run returns done=false, call shell.wait with the returned job_id.
|
||||
- Use shell.input only when the job is running and waiting for stdin.
|
||||
- Use shell.interrupt when a job is stuck or should be stopped.
|
||||
|
||||
The script argument of shell.run can be a normal shell script, or a shebang script.
|
||||
If the first line is a shebang, the shell layer executes the script directly.
|
||||
|
||||
Tips:
|
||||
|
||||
- When using Python, prefer a uv script with a PEP 723 dependency header.
|
||||
|
||||
Example:
|
||||
|
||||
#!/usr/bin/env -S uv run --quiet --script
|
||||
# /// script
|
||||
# requires-python = ">=3.12"
|
||||
# dependencies = [
|
||||
# "httpx==0.28.1",
|
||||
# "rich>=13.8.0",
|
||||
# ]
|
||||
# ///
|
||||
|
||||
import httpx
|
||||
from rich import print
|
||||
|
||||
response = httpx.get("https://example.com", timeout=10)
|
||||
print(f"[green]status:[/green] {response.status_code}")"""
|
||||
|
||||
|
||||
class ShellJobObservation(TypedDict):
|
||||
"""JSON-safe output-oriented shell tool observation."""
|
||||
|
||||
job_id: str
|
||||
status: str
|
||||
done: bool
|
||||
exit_code: int | None
|
||||
output: str
|
||||
offset: int
|
||||
truncated: bool
|
||||
output_path: str
|
||||
|
||||
|
||||
class ShellJobStatusObservation(TypedDict):
|
||||
"""JSON-safe status-only shell tool observation."""
|
||||
|
||||
job_id: str
|
||||
status: str
|
||||
done: bool
|
||||
exit_code: int | None
|
||||
offset: int
|
||||
|
||||
|
||||
class ShellToolErrorObservation(TypedDict):
|
||||
"""Tool-visible failure payload for expected shell-layer errors."""
|
||||
|
||||
error: str
|
||||
job_id: NotRequired[str]
|
||||
|
||||
|
||||
type ShellRunToolResult = ShellJobObservation | ShellToolErrorObservation
|
||||
type ShellInterruptToolResult = ShellJobStatusObservation | ShellToolErrorObservation
|
||||
|
||||
|
||||
class ShellctlClientProtocol(Protocol):
|
||||
"""Boundary that the shell layer needs from a shellctl client."""
|
||||
|
||||
async def run(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None = None,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> JobResult: ...
|
||||
|
||||
async def wait(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
offset: int,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> JobResult: ...
|
||||
|
||||
async def input(
|
||||
self,
|
||||
job_id: str,
|
||||
text: str,
|
||||
*,
|
||||
offset: int,
|
||||
timeout: float = DEFAULT_TIMEOUT_SECONDS,
|
||||
) -> JobResult: ...
|
||||
|
||||
async def terminate(
|
||||
self,
|
||||
job_id: str,
|
||||
grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
) -> JobStatusView: ...
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
grace_seconds: float | None = None,
|
||||
) -> DeleteJobResponse: ...
|
||||
|
||||
async def close(self) -> None: ...
|
||||
|
||||
|
||||
type ShellctlClientFactory = Callable[[str], ShellctlClientProtocol]
|
||||
|
||||
|
||||
class DifyShellRuntimeState(BaseModel):
|
||||
"""Serializable shell session state stored in Agenton snapshots.
|
||||
|
||||
``job_ids`` and ``job_offsets`` contain both user-facing jobs and internal
|
||||
lifecycle jobs so resumed sessions can still clean up shellctl state that was
|
||||
created before suspension. Callers should replace the stored list/dict values
|
||||
rather than mutating them in place so Pydantic assignment validation keeps
|
||||
guarding the serialized state. Hydrated public snapshots must keep
|
||||
``session_id`` in the proposal's safe lowercase-hex format and must keep
|
||||
``workspace_cwd`` exactly aligned with ``~/workspace/<session_id>`` so resume
|
||||
and delete paths cannot escape the isolated workspace root or inject shell
|
||||
syntax into lifecycle commands. Shellctl job ids remain opaque strings here;
|
||||
the layer only enforces uniqueness plus the invariant that any stored offset
|
||||
entry must belong to a tracked job id in the same runtime state.
|
||||
"""
|
||||
|
||||
session_id: str | None = None
|
||||
workspace_cwd: str | None = None
|
||||
job_ids: list[str] = Field(default_factory=list)
|
||||
job_offsets: dict[str, NonNegativeInt] = Field(default_factory=dict)
|
||||
|
||||
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
@field_validator("session_id")
|
||||
@classmethod
|
||||
def validate_session_id(cls, value: str | None) -> str | None:
|
||||
"""Accept only the short lowercase-hex session ids defined by the proposal."""
|
||||
if value is None:
|
||||
return value
|
||||
return _validated_session_id(value)
|
||||
|
||||
@field_validator("job_ids")
|
||||
@classmethod
|
||||
def validate_job_ids(cls, value: list[str]) -> list[str]:
|
||||
"""Keep tracked shellctl job ids unique within one serialized session."""
|
||||
if len(value) != len(set(value)):
|
||||
raise ValueError("job_ids must not contain duplicates.")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_workspace_and_offsets(self) -> Self:
|
||||
"""Keep resumed workspace identity and tracked offset keys self-consistent."""
|
||||
if self.workspace_cwd is not None:
|
||||
if self.session_id is None:
|
||||
raise ValueError("workspace_cwd requires a matching session_id.")
|
||||
expected_workspace = _workspace_cwd(self.session_id)
|
||||
if self.workspace_cwd != expected_workspace:
|
||||
raise ValueError(
|
||||
f"workspace_cwd must equal {expected_workspace!r} for session_id {self.session_id!r}."
|
||||
)
|
||||
unknown_offset_job_ids = set(self.job_offsets) - set(self.job_ids)
|
||||
if unknown_offset_job_ids:
|
||||
names = ", ".join(sorted(unknown_offset_job_ids))
|
||||
raise ValueError(f"job_offsets contains unknown job ids: {names}.")
|
||||
return self
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DifyShellLayer(PydanticAILayer[NoLayerDeps, object, DifyShellLayerConfig, DifyShellRuntimeState]):
|
||||
"""Shell tool layer backed by a live shellctl client while active.
|
||||
|
||||
The mutable serializable state lives in ``runtime_state``; the live client is
|
||||
intentionally kept off-snapshot in ``_shellctl_client``. Tool methods update
|
||||
tracked job ids and output offsets after every successful shellctl response so
|
||||
later ``shell.wait``/``shell.input`` calls can resume from the last known
|
||||
offset without exposing offsets as model-controlled inputs.
|
||||
"""
|
||||
|
||||
type_id: ClassVar[str | None] = DIFY_SHELL_LAYER_TYPE_ID
|
||||
|
||||
config: DifyShellLayerConfig
|
||||
shellctl_entrypoint: str
|
||||
shellctl_client_factory: ShellctlClientFactory
|
||||
_shellctl_client: ShellctlClientProtocol | None = None
|
||||
|
||||
@classmethod
|
||||
@override
|
||||
def from_config(cls, config: DifyShellLayerConfig) -> Self:
|
||||
"""Reject construction that omits server-injected shellctl settings."""
|
||||
del config
|
||||
raise TypeError("DifyShellLayer requires server-side shellctl settings and must use a provider factory.")
|
||||
|
||||
@classmethod
|
||||
def from_config_with_settings(
|
||||
cls,
|
||||
config: DifyShellLayerConfig,
|
||||
*,
|
||||
shellctl_entrypoint: str | None,
|
||||
shellctl_client_factory: ShellctlClientFactory,
|
||||
) -> Self:
|
||||
"""Create the layer from public config plus server-only shellctl settings."""
|
||||
normalized_entrypoint = (shellctl_entrypoint or "").strip()
|
||||
if not normalized_entrypoint:
|
||||
raise ValueError(
|
||||
"DifyShellLayer requires a non-empty DIFY_AGENT_SHELLCTL_ENTRYPOINT when the 'dify.shell' layer is used."
|
||||
)
|
||||
return cls(
|
||||
config=config,
|
||||
shellctl_entrypoint=normalized_entrypoint,
|
||||
shellctl_client_factory=shellctl_client_factory,
|
||||
)
|
||||
|
||||
@property
|
||||
@override
|
||||
def prefix_prompts(self) -> Sequence[PydanticAIPrompt[object]]:
|
||||
return [_shell_layer_prefix_prompt]
|
||||
|
||||
@property
|
||||
@override
|
||||
def tools(self) -> Sequence[PydanticAITool[object]]:
|
||||
return [
|
||||
Tool(self._tool_run, name="shell.run"),
|
||||
Tool(self._tool_wait, name="shell.wait"),
|
||||
Tool(self._tool_input, name="shell.input"),
|
||||
Tool(self._tool_interrupt, name="shell.interrupt"),
|
||||
]
|
||||
|
||||
@override
|
||||
@asynccontextmanager
|
||||
async def resource_context(self) -> AsyncGenerator[None]:
|
||||
"""Hold one live shellctl client for one active Agenton layer scope.
|
||||
|
||||
The shellctl client is a non-serializable live resource, so Agenton owns
|
||||
only the timing of this scope, not the client itself. Business hooks and
|
||||
tools should call ``_require_client()`` to ensure they are running inside
|
||||
an active resource scope.
|
||||
"""
|
||||
if self._shellctl_client is not None:
|
||||
raise RuntimeError("DifyShellLayer resource_context() is already active for this layer instance.")
|
||||
|
||||
client = self.shellctl_client_factory(self.shellctl_entrypoint)
|
||||
self._shellctl_client = client
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self._shellctl_client = None
|
||||
await client.close()
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
"""Allocate a new workspace session using the active live shellctl client.
|
||||
|
||||
If workspace setup partially succeeds and this hook later raises, the
|
||||
layer never becomes ``ACTIVE``. In that path Agenton still exits
|
||||
``resource_context()``, but ``on_context_delete()`` will not run, so this
|
||||
hook must clean up any tracked shellctl job artifacts before re-raising.
|
||||
"""
|
||||
try:
|
||||
_ = self._require_client()
|
||||
session_id, workspace_cwd = await self._allocate_workspace()
|
||||
except BaseException:
|
||||
await self._cleanup_create_failure()
|
||||
raise
|
||||
self.runtime_state = DifyShellRuntimeState.model_validate(
|
||||
{
|
||||
**self.runtime_state.model_dump(mode="python"),
|
||||
"session_id": session_id,
|
||||
"workspace_cwd": workspace_cwd,
|
||||
}
|
||||
)
|
||||
|
||||
@override
|
||||
async def on_context_resume(self) -> None:
|
||||
"""Resume an existing serialized shell session inside an active resource scope.
|
||||
|
||||
If a future resume path adds self-heal side effects before raising, this
|
||||
hook must compensate for them itself because failed resume attempts never
|
||||
transition the slot back to ``ACTIVE`` and therefore do not receive a
|
||||
normal suspend/delete hook.
|
||||
"""
|
||||
_ = self._require_client()
|
||||
_ = self._require_session_identity()
|
||||
|
||||
@override
|
||||
async def on_context_suspend(self) -> None:
|
||||
"""Preserve workspace and job state while the live client remains active.
|
||||
|
||||
``resource_context()`` owns client teardown after this hook returns.
|
||||
"""
|
||||
_ = self._require_client()
|
||||
|
||||
@override
|
||||
async def on_context_delete(self) -> None:
|
||||
"""Best-effort cleanup for workspace deletion and tracked shellctl jobs.
|
||||
|
||||
Workspace removal must happen before tracked shellctl job deletion because
|
||||
the cleanup itself is implemented as an internal shellctl run. That means
|
||||
deleting job state first would prevent the layer from issuing the
|
||||
proposal-required ``rm -rf`` cleanup job and then cleaning up that final
|
||||
job record along with the rest of the session's tracked shellctl state.
|
||||
``resource_context()`` closes the live client only after this hook
|
||||
finishes.
|
||||
"""
|
||||
_ = self._require_client()
|
||||
|
||||
cleanup_job_id: str | None = None
|
||||
identity = self._try_session_identity()
|
||||
if identity is not None:
|
||||
session_id, _workspace_cwd = identity
|
||||
try:
|
||||
cleanup_result = await self._run_internal_job_to_completion(
|
||||
_workspace_cleanup_script(session_id=session_id),
|
||||
cwd=None,
|
||||
)
|
||||
cleanup_job_id = cleanup_result["job_id"]
|
||||
if cleanup_result["exit_code"] != 0:
|
||||
logger.warning(
|
||||
"Shell workspace cleanup job %s for session %s exited with code %s.",
|
||||
cleanup_job_id,
|
||||
session_id,
|
||||
cleanup_result["exit_code"],
|
||||
)
|
||||
except (RuntimeError, ValueError, ShellctlClientError) as exc:
|
||||
logger.warning("Failed to remove shell workspace for session %s: %s", session_id, exc)
|
||||
|
||||
tracked_job_ids = _deduplicate_preserving_order(
|
||||
[*self.runtime_state.job_ids, *([cleanup_job_id] if cleanup_job_id is not None else [])]
|
||||
)
|
||||
await self._delete_tracked_jobs_best_effort(tracked_job_ids)
|
||||
self._clear_tracked_jobs()
|
||||
|
||||
async def _tool_run(self, script: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult:
|
||||
"""Start a new shell job inside the session workspace."""
|
||||
try:
|
||||
client = self._require_client()
|
||||
result = await client.run(script, cwd=self._require_workspace_cwd(), timeout=timeout)
|
||||
self._track_job_result(result)
|
||||
return _job_result_observation(result)
|
||||
except (RuntimeError, ValueError, ShellctlClientError) as exc:
|
||||
return _tool_error(str(exc))
|
||||
|
||||
async def _tool_wait(self, job_id: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult:
|
||||
"""Wait for more output or completion from a tracked shell job."""
|
||||
try:
|
||||
client = self._require_client()
|
||||
offset = self._tracked_offset(job_id)
|
||||
result = await client.wait(job_id, offset=offset, timeout=timeout)
|
||||
self._track_job_result(result)
|
||||
return _job_result_observation(result)
|
||||
except (RuntimeError, ValueError, ShellctlClientError) as exc:
|
||||
return _tool_error(str(exc), job_id=job_id)
|
||||
|
||||
async def _tool_input(self, job_id: str, text: str, timeout: float = DEFAULT_TIMEOUT_SECONDS) -> ShellRunToolResult:
|
||||
"""Send text input to a tracked shell job and wait for output."""
|
||||
try:
|
||||
client = self._require_client()
|
||||
offset = self._tracked_offset(job_id)
|
||||
result = await client.input(job_id, text, offset=offset, timeout=timeout)
|
||||
self._track_job_result(result)
|
||||
return _job_result_observation(result)
|
||||
except (RuntimeError, ValueError, ShellctlClientError) as exc:
|
||||
return _tool_error(str(exc), job_id=job_id)
|
||||
|
||||
async def _tool_interrupt(
|
||||
self,
|
||||
job_id: str,
|
||||
grace_seconds: float = DEFAULT_TERMINATE_GRACE_SECONDS,
|
||||
) -> ShellInterruptToolResult:
|
||||
"""Interrupt a tracked shell job without removing its persisted shellctl state."""
|
||||
try:
|
||||
client = self._require_client()
|
||||
self._ensure_tracked_job(job_id)
|
||||
result = await client.terminate(job_id, grace_seconds=grace_seconds)
|
||||
self._track_job_status(result)
|
||||
return _job_status_observation(result)
|
||||
except (RuntimeError, ValueError, ShellctlClientError) as exc:
|
||||
return _tool_error(str(exc), job_id=job_id)
|
||||
|
||||
async def _allocate_workspace(self) -> tuple[str, str]:
|
||||
"""Allocate a unique ``~/workspace/<session_id>`` directory by mkdir collision checks."""
|
||||
for _attempt in range(_SESSION_ID_ATTEMPT_LIMIT):
|
||||
session_id = _generate_session_id()
|
||||
mkdir_result = await self._run_internal_job_to_completion(
|
||||
_workspace_mkdir_script(session_id=session_id),
|
||||
cwd=None,
|
||||
)
|
||||
if mkdir_result["exit_code"] == _WORKSPACE_COLLISION_EXIT_CODE:
|
||||
continue
|
||||
if mkdir_result["exit_code"] != 0:
|
||||
raise RuntimeError(
|
||||
f"Failed to create shell workspace {_workspace_cwd(session_id)}: {mkdir_result['status']} exit_code={mkdir_result['exit_code']}"
|
||||
)
|
||||
return session_id, _workspace_cwd(session_id)
|
||||
raise RuntimeError("Failed to allocate a unique shell workspace session id after 256 attempts.")
|
||||
|
||||
async def _cleanup_create_failure(self) -> None:
|
||||
"""Best-effort shellctl job cleanup for create failures before ACTIVE state.
|
||||
|
||||
Agenton only calls ``on_context_delete`` for layers that successfully
|
||||
entered ``ACTIVE``. If ``on_context_create`` fails after issuing one or
|
||||
more internal shellctl jobs, those tracked job artifacts would otherwise
|
||||
leak because no later lifecycle hook owns them. ``resource_context()``
|
||||
still closes the live client for this failed enter attempt after the hook
|
||||
unwinds.
|
||||
"""
|
||||
if not self.runtime_state.job_ids:
|
||||
return
|
||||
try:
|
||||
await self._delete_tracked_jobs_best_effort(self.runtime_state.job_ids)
|
||||
finally:
|
||||
self._clear_tracked_jobs()
|
||||
|
||||
async def _run_internal_job_to_completion(
|
||||
self,
|
||||
script: str,
|
||||
*,
|
||||
cwd: str | None,
|
||||
) -> ShellJobObservation:
|
||||
"""Run an internal lifecycle command, track it, and wait for completion."""
|
||||
client = self._require_client()
|
||||
result = await client.run(script, cwd=cwd, timeout=DEFAULT_TIMEOUT_SECONDS)
|
||||
self._track_job_result(result)
|
||||
while not result.done:
|
||||
result = await client.wait(
|
||||
result.job_id,
|
||||
offset=self._tracked_offset(result.job_id),
|
||||
timeout=DEFAULT_TIMEOUT_SECONDS,
|
||||
)
|
||||
self._track_job_result(result)
|
||||
return _job_result_observation(result)
|
||||
|
||||
def _require_client(self) -> ShellctlClientProtocol:
|
||||
"""Return the live client or reject tool/lifecycle use without one."""
|
||||
if self._shellctl_client is None:
|
||||
raise RuntimeError(
|
||||
"DifyShellLayer requires an active shellctl client inside resource_context(); "
|
||||
+ "enter the layer through Agenton or wrap direct hook/tool usage in resource_context()."
|
||||
)
|
||||
return self._shellctl_client
|
||||
|
||||
def _require_workspace_cwd(self) -> str:
|
||||
"""Return the configured workspace directory for user-facing shell jobs."""
|
||||
_session_id, workspace_cwd = self._require_session_identity()
|
||||
return workspace_cwd
|
||||
|
||||
def _require_session_identity(self) -> tuple[str, str]:
|
||||
"""Return the stored session id and workspace path or raise for corrupt state."""
|
||||
identity = self._try_session_identity()
|
||||
if identity is None:
|
||||
raise ValueError("DifyShellLayer runtime state is missing session_id or workspace_cwd.")
|
||||
session_id, workspace_cwd = identity
|
||||
expected_workspace = _workspace_cwd(session_id)
|
||||
if workspace_cwd != expected_workspace:
|
||||
raise ValueError(
|
||||
f"DifyShellLayer runtime state has inconsistent workspace_cwd {workspace_cwd!r}; expected {expected_workspace!r}."
|
||||
)
|
||||
return session_id, workspace_cwd
|
||||
|
||||
def _try_session_identity(self) -> tuple[str, str] | None:
|
||||
session_id = self.runtime_state.session_id
|
||||
workspace_cwd = self.runtime_state.workspace_cwd
|
||||
if session_id is None or workspace_cwd is None:
|
||||
return None
|
||||
return session_id, workspace_cwd
|
||||
|
||||
def _ensure_tracked_job(self, job_id: str) -> None:
|
||||
"""Reject tool access to job ids not tracked in the current runtime state.
|
||||
|
||||
This first version treats shellctl job ids as opaque strings and uses
|
||||
membership in ``runtime_state.job_ids`` as the tool-access boundary for
|
||||
wait/input/interrupt operations.
|
||||
"""
|
||||
if job_id not in self.runtime_state.job_ids:
|
||||
raise ValueError(f"Unknown shell job id for this session: {job_id}.")
|
||||
|
||||
def _tracked_offset(self, job_id: str) -> int:
|
||||
"""Return the stored offset for a tracked job, defaulting legacy state to zero."""
|
||||
self._ensure_tracked_job(job_id)
|
||||
return int(self.runtime_state.job_offsets.get(job_id, 0))
|
||||
|
||||
def _track_job_result(self, result: JobResult) -> None:
|
||||
"""Track one output-oriented shellctl result in serializable runtime state."""
|
||||
self._remember_job_id(result.job_id)
|
||||
self._remember_job_offset(result.job_id, result.offset)
|
||||
|
||||
def _track_job_status(self, result: JobStatusView) -> None:
|
||||
"""Track status-only shellctl results that still carry the latest offset."""
|
||||
self._remember_job_id(result.job_id)
|
||||
self._remember_job_offset(result.job_id, result.offset)
|
||||
|
||||
def _remember_job_id(self, job_id: str) -> None:
|
||||
if job_id in self.runtime_state.job_ids:
|
||||
return
|
||||
self.runtime_state.job_ids = [*self.runtime_state.job_ids, job_id]
|
||||
|
||||
def _remember_job_offset(self, job_id: str, offset: int) -> None:
|
||||
job_offsets = dict(self.runtime_state.job_offsets)
|
||||
job_offsets[job_id] = offset
|
||||
self.runtime_state.job_offsets = job_offsets
|
||||
|
||||
async def _delete_tracked_jobs_best_effort(self, job_ids: Sequence[str]) -> None:
|
||||
"""Force-delete tracked shellctl jobs, ignoring already-missing ones."""
|
||||
client = self._require_client()
|
||||
for job_id in _deduplicate_preserving_order(job_ids):
|
||||
try:
|
||||
_ = await client.delete(job_id, force=True)
|
||||
except ShellctlClientError as exc:
|
||||
if exc.code == "job_not_found":
|
||||
continue
|
||||
logger.warning(
|
||||
"Failed to delete shellctl job %s for session %s: %s",
|
||||
job_id,
|
||||
self.runtime_state.session_id,
|
||||
exc,
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
logger.warning(
|
||||
"Failed to delete shellctl job %s for session %s: %s",
|
||||
job_id,
|
||||
self.runtime_state.session_id,
|
||||
exc,
|
||||
)
|
||||
|
||||
def _clear_tracked_jobs(self) -> None:
|
||||
self.runtime_state.job_offsets = {}
|
||||
self.runtime_state.job_ids = []
|
||||
|
||||
|
||||
def _shell_layer_prefix_prompt() -> str:
|
||||
"""Return the static model-facing shell tool usage guidance."""
|
||||
return _SHELL_LAYER_PREFIX_PROMPT
|
||||
|
||||
|
||||
def create_shellctl_client_factory(*, token: str) -> ShellctlClientFactory:
|
||||
"""Return the default shellctl client factory used by server-side providers."""
|
||||
|
||||
def factory(entrypoint: str) -> ShellctlClientProtocol:
|
||||
return ShellctlClient(entrypoint, token=token)
|
||||
|
||||
return factory
|
||||
|
||||
|
||||
def _job_result_observation(result: JobResult) -> ShellJobObservation:
|
||||
return {
|
||||
"job_id": result.job_id,
|
||||
"status": result.status.value,
|
||||
"done": result.done,
|
||||
"exit_code": result.exit_code,
|
||||
"output": result.output,
|
||||
"offset": result.offset,
|
||||
"truncated": result.truncated,
|
||||
"output_path": result.output_path,
|
||||
}
|
||||
|
||||
|
||||
def _job_status_observation(result: JobStatusView) -> ShellJobStatusObservation:
|
||||
return {
|
||||
"job_id": result.job_id,
|
||||
"status": result.status.value,
|
||||
"done": result.done,
|
||||
"exit_code": result.exit_code,
|
||||
"offset": result.offset,
|
||||
}
|
||||
|
||||
|
||||
def _tool_error(message: str, *, job_id: str | None = None) -> ShellToolErrorObservation:
|
||||
result: ShellToolErrorObservation = {"error": message}
|
||||
if job_id is not None:
|
||||
result["job_id"] = job_id
|
||||
return result
|
||||
|
||||
|
||||
def _generate_session_id() -> str:
|
||||
time_component = int(time.time()) & _SESSION_TIME_HEX_MASK
|
||||
random_component = secrets.token_hex(1)
|
||||
if len(random_component) != _SESSION_RANDOM_HEX_LENGTH:
|
||||
raise RuntimeError("Expected a one-byte random hex suffix for Dify shell session ids.")
|
||||
return f"{time_component:05x}{random_component}"
|
||||
|
||||
|
||||
def _workspace_cwd(session_id: str) -> str:
|
||||
return f"{_WORKSPACE_ROOT}/{_validated_session_id(session_id)}"
|
||||
|
||||
|
||||
def _workspace_mkdir_script(*, session_id: str) -> str:
|
||||
"""Return the internal mkdir command used for proposal-defined collision checks.
|
||||
|
||||
The parent ``$HOME/workspace`` directory is created with ``mkdir -p`` so it
|
||||
can already exist, but the final session directory intentionally uses plain
|
||||
``mkdir``. That second call is the collision detector: when the target
|
||||
already exists, the script maps that case to ``_WORKSPACE_COLLISION_EXIT_CODE``
|
||||
so ``on_context_create()`` can retry with a different random suffix instead
|
||||
of silently reusing another session's workspace.
|
||||
"""
|
||||
safe_session_id = _validated_session_id(session_id)
|
||||
workspace_dir = f'$HOME/workspace/{safe_session_id}'
|
||||
return (
|
||||
'mkdir -p "$HOME/workspace"; '
|
||||
f'if mkdir "{workspace_dir}"; then exit 0; fi; '
|
||||
f'if [ -e "{workspace_dir}" ]; then exit {_WORKSPACE_COLLISION_EXIT_CODE}; fi; '
|
||||
'exit 1'
|
||||
)
|
||||
|
||||
|
||||
def _workspace_cleanup_script(*, session_id: str) -> str:
|
||||
return f'rm -rf -- "$HOME/workspace/{_validated_session_id(session_id)}"'
|
||||
|
||||
|
||||
def _validated_session_id(session_id: str) -> str:
|
||||
if not _SESSION_ID_PATTERN.fullmatch(session_id):
|
||||
raise ValueError("session_id must match the 5+2 lowercase hex format '<5 hex><2 hex>'.")
|
||||
return session_id
|
||||
|
||||
|
||||
def _deduplicate_preserving_order(values: Sequence[str]) -> list[str]:
|
||||
seen: set[str] = set()
|
||||
result: list[str] = []
|
||||
for value in values:
|
||||
if value in seen:
|
||||
continue
|
||||
seen.add(value)
|
||||
result.append(value)
|
||||
return result
|
||||
|
||||
|
||||
__all__ = [
|
||||
"DifyShellLayer",
|
||||
"DifyShellRuntimeState",
|
||||
"ShellctlClientFactory",
|
||||
"ShellctlClientProtocol",
|
||||
"create_shellctl_client_factory",
|
||||
]
|
||||
@ -2,18 +2,22 @@
|
||||
|
||||
Only explicitly allowed provider type ids are constructible here. The default
|
||||
provider set contains prompt layers, the optional pydantic-ai history layer, the
|
||||
state-free Dify structured output layer, the Dify execution-context layer, and
|
||||
the Dify plugin business-layer family:
|
||||
state-free Dify structured output layer, the Dify execution-context layer, the
|
||||
stateful Dify shell layer, and the Dify plugin business-layer family:
|
||||
|
||||
- ``dify.execution_context`` for shared tenant/user/run daemon context,
|
||||
- ``dify.shell`` for shellctl-backed shell job control,
|
||||
- ``dify.plugin.llm`` for plugin-backed model selection, and
|
||||
- ``dify.plugin.tools`` for prepared plugin tool exposure.
|
||||
|
||||
Public DTOs provide Dify context plus plugin/model/tool data, while server-only
|
||||
plugin daemon settings are injected through the provider factory for
|
||||
``DifyExecutionContextLayer``. The resulting ``Compositor`` remains Agenton
|
||||
state-only: live resources such as the plugin daemon HTTP client are supplied
|
||||
later by the runtime and never enter providers, layers, or session snapshots.
|
||||
``DifyExecutionContextLayer`` and the optional shellctl entrypoint/auth token plus
|
||||
client factory are injected for ``DifyShellLayer``. The resulting ``Compositor``
|
||||
remains Agenton state-only at the snapshot boundary: live resources such as
|
||||
HTTP clients are injected by runtime-owned providers, may be held on active
|
||||
layer instances inside ``resource_context()``, and never enter session
|
||||
snapshots.
|
||||
"""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
@ -31,6 +35,8 @@ from dify_agent.layers.dify_plugin.tools_layer import DifyPluginToolsLayer
|
||||
from dify_agent.layers.execution_context.configs import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.output.output_layer import DifyOutputLayer
|
||||
from dify_agent.layers.shell.configs import DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer, create_shellctl_client_factory
|
||||
|
||||
|
||||
type DifyAgentLayerProvider = LayerProvider[Any]
|
||||
@ -40,8 +46,18 @@ def create_default_layer_providers(
|
||||
*,
|
||||
plugin_daemon_url: str = "http://localhost:5002",
|
||||
plugin_daemon_api_key: str = "",
|
||||
shellctl_entrypoint: str | None = None,
|
||||
shellctl_auth_token: str | None = None,
|
||||
) -> tuple[DifyAgentLayerProvider, ...]:
|
||||
"""Return the server provider set of safe config-constructible layers."""
|
||||
"""Return the server provider set of safe config-constructible layers.
|
||||
|
||||
``shellctl_auth_token`` defaults to no token. Passing an explicit empty string
|
||||
to ``create_shellctl_client_factory`` prevents ``ShellctlClient`` from falling
|
||||
back to the Dify Agent process's ``SHELLCTL_AUTH_TOKEN`` environment variable;
|
||||
deployments that enable shellctl bearer auth must set the Dify Agent server
|
||||
setting explicitly.
|
||||
"""
|
||||
shellctl_token = shellctl_auth_token or ""
|
||||
return (
|
||||
LayerProvider.from_layer_type(PromptLayer),
|
||||
LayerProvider.from_layer_type(PydanticAIHistoryLayer),
|
||||
@ -54,6 +70,14 @@ def create_default_layer_providers(
|
||||
daemon_api_key=plugin_daemon_api_key,
|
||||
),
|
||||
),
|
||||
LayerProvider.from_factory(
|
||||
layer_type=DifyShellLayer,
|
||||
create=lambda config: DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shellctl_entrypoint=shellctl_entrypoint,
|
||||
shellctl_client_factory=create_shellctl_client_factory(token=shellctl_token),
|
||||
),
|
||||
),
|
||||
LayerProvider.from_layer_type(DifyPluginLLMLayer),
|
||||
LayerProvider.from_layer_type(DifyPluginToolsLayer),
|
||||
)
|
||||
|
||||
@ -110,16 +110,20 @@ class AgentRunRunner:
|
||||
async def _run_agent(self) -> tuple[JsonValue, CompositorSessionSnapshot]:
|
||||
"""Run pydantic-ai inside an entered Agenton run.
|
||||
|
||||
Known input-shaped Agenton enter-time runtime errors, such as trying to
|
||||
resume a ``CLOSED`` snapshot layer, are normalized to
|
||||
``AgentRunValidationError``. Output/history-layer graph invariants are
|
||||
validated from the public composition before entering Agenton so
|
||||
misnamed or extra reserved layers never silently degrade. Later runtime
|
||||
failures still propagate as execution errors so they become terminal
|
||||
failed runs rather than client validation responses. Structured output
|
||||
uses a resolved contract whose type itself encodes both the model-facing
|
||||
schema and the runtime validation hooks, so invalid model outputs can be
|
||||
corrected before Dify Agent emits success.
|
||||
Known request-shaped Agenton enter-time failures are normalized to
|
||||
``AgentRunValidationError``. That includes the existing small class of
|
||||
enter-time ``RuntimeError`` values reported by Agenton plus
|
||||
layer-construction or snapshot-hydration ``ValueError`` failures that
|
||||
arise before the run becomes active, such as missing shell settings for a
|
||||
requested ``dify.shell`` layer or malformed serialized shell offsets.
|
||||
Output/history-layer graph invariants are validated from the public
|
||||
composition before entering Agenton so misnamed or extra reserved layers
|
||||
never silently degrade. Later runtime failures still propagate as
|
||||
execution errors so they become terminal failed runs rather than client
|
||||
validation responses. Structured output uses a resolved contract whose
|
||||
type itself encodes both the model-facing schema and the runtime
|
||||
validation hooks, so invalid model outputs can be corrected before Dify
|
||||
Agent emits success.
|
||||
"""
|
||||
try:
|
||||
validate_output_layer_composition(self.request.composition)
|
||||
@ -172,6 +176,10 @@ class AgentRunRunner:
|
||||
if not entered_run and is_agenton_enter_validation_runtime_error(exc):
|
||||
raise AgentRunValidationError(str(exc)) from exc
|
||||
raise
|
||||
except ValueError as exc:
|
||||
if not entered_run:
|
||||
raise AgentRunValidationError(str(exc)) from exc
|
||||
raise
|
||||
|
||||
if run.session_snapshot is None:
|
||||
raise RuntimeError("Agenton run did not produce a session snapshot after exit.")
|
||||
|
||||
@ -6,7 +6,8 @@ route wiring, and a process-local scheduler. Run execution happens in background
|
||||
cancel the agent runtime. Redis persists run records and per-run event streams
|
||||
with configured retention only; it is not used as a job queue. Agenton layers and
|
||||
providers stay state-only: they borrow the lifespan-owned plugin daemon client
|
||||
through the runner and never create or close it themselves.
|
||||
through the runner and receive shell-layer server settings through provider
|
||||
construction rather than reading environment variables themselves.
|
||||
"""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
@ -29,6 +30,8 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
layer_providers = create_default_layer_providers(
|
||||
plugin_daemon_url=resolved_settings.plugin_daemon_url,
|
||||
plugin_daemon_api_key=resolved_settings.plugin_daemon_api_key,
|
||||
shellctl_entrypoint=resolved_settings.shellctl_entrypoint,
|
||||
shellctl_auth_token=resolved_settings.shellctl_auth_token,
|
||||
)
|
||||
state: dict[str, object] = {}
|
||||
|
||||
|
||||
@ -3,7 +3,9 @@
|
||||
Plugin daemon HTTP client settings describe the single FastAPI lifespan-owned
|
||||
``httpx.AsyncClient`` shared by local run tasks. Layers and Agenton providers do
|
||||
not own that client, so these settings are process resource limits rather than
|
||||
per-run lifecycle knobs.
|
||||
per-run lifecycle knobs. Optional shell-layer settings stay here as well because
|
||||
the server injects them into layer providers instead of letting runtime modules
|
||||
read process environment variables directly.
|
||||
"""
|
||||
|
||||
from typing import ClassVar
|
||||
@ -15,7 +17,7 @@ DEFAULT_RUN_RETENTION_SECONDS = 3 * 24 * 60 * 60
|
||||
|
||||
|
||||
class ServerSettings(BaseSettings):
|
||||
"""Environment-backed settings for Redis, scheduling, and plugin daemon access."""
|
||||
"""Environment-backed settings for Redis, scheduling, plugin, and shell access."""
|
||||
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
redis_prefix: str = "dify-agent"
|
||||
@ -23,6 +25,8 @@ class ServerSettings(BaseSettings):
|
||||
run_retention_seconds: int = Field(default=DEFAULT_RUN_RETENTION_SECONDS, ge=1)
|
||||
plugin_daemon_url: str = "http://localhost:5002"
|
||||
plugin_daemon_api_key: str = ""
|
||||
shellctl_entrypoint: str | None = None
|
||||
shellctl_auth_token: str | None = None
|
||||
plugin_daemon_connect_timeout: float = Field(default=10.0, ge=0)
|
||||
plugin_daemon_read_timeout: float = Field(default=600.0, ge=0)
|
||||
plugin_daemon_write_timeout: float = Field(default=30.0, ge=0)
|
||||
|
||||
@ -113,6 +113,16 @@ def test_undefined_dependency_target_is_rejected_for_compositor_construction() -
|
||||
Compositor([LayerNode("consumer", RenamedConsumerLayer, deps={"renamed": "missing_target"})])
|
||||
|
||||
|
||||
def test_dependency_target_must_precede_dependent_layer_in_graph_order() -> None:
|
||||
with pytest.raises(ValueError, match="must target preceding layer nodes"):
|
||||
Compositor(
|
||||
[
|
||||
LayerNode("consumer", RenamedConsumerLayer, deps={"renamed": "target"}),
|
||||
LayerNode("target", _object_provider("value")),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_duplicate_layer_node_name_is_rejected() -> None:
|
||||
with pytest.raises(ValueError, match="Duplicate layer name 'same'"):
|
||||
Compositor(
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
from collections.abc import AsyncGenerator, Iterator
|
||||
from contextlib import asynccontextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import count
|
||||
|
||||
@ -388,6 +389,586 @@ def test_closed_snapshot_enter_is_rejected_before_hooks_run() -> None:
|
||||
assert created_layers[0].events == []
|
||||
|
||||
|
||||
class ResourceState(BaseModel):
|
||||
created_with_resource: bool = False
|
||||
deleted_with_resource: bool = False
|
||||
saw_dependency_resource: bool = False
|
||||
|
||||
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ParentResourceLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, ResourceState]):
|
||||
events: list[str] = field(default_factory=list)
|
||||
live_resource: object | None = None
|
||||
|
||||
@override
|
||||
@asynccontextmanager
|
||||
async def resource_context(self) -> AsyncGenerator[None]:
|
||||
self.events.append("parent.resource.enter")
|
||||
self.live_resource = object()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.events.append("parent.resource.exit")
|
||||
self.live_resource = None
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
assert self.live_resource is not None
|
||||
self.events.append("parent.create")
|
||||
self.runtime_state.created_with_resource = True
|
||||
|
||||
@override
|
||||
async def on_context_delete(self) -> None:
|
||||
assert self.live_resource is not None
|
||||
self.events.append("parent.delete")
|
||||
self.runtime_state.deleted_with_resource = True
|
||||
|
||||
@override
|
||||
async def on_context_suspend(self) -> None:
|
||||
assert self.live_resource is not None
|
||||
self.events.append("parent.suspend")
|
||||
|
||||
|
||||
class ChildResourceDeps(NoLayerDeps):
|
||||
parent: ParentResourceLayer # pyright: ignore[reportUninitializedInstanceVariable]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ChildResourceLayer(PlainLayer[ChildResourceDeps, EmptyLayerConfig, ResourceState]):
|
||||
events: list[str] = field(default_factory=list)
|
||||
live_resource: object | None = None
|
||||
|
||||
@override
|
||||
@asynccontextmanager
|
||||
async def resource_context(self) -> AsyncGenerator[None]:
|
||||
self.events.append("child.resource.enter")
|
||||
self.live_resource = object()
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.events.append("child.resource.exit")
|
||||
self.live_resource = None
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
assert self.live_resource is not None
|
||||
self.events.append("child.create")
|
||||
self.runtime_state.created_with_resource = True
|
||||
self.runtime_state.saw_dependency_resource = self.deps.parent.live_resource is not None
|
||||
|
||||
@override
|
||||
async def on_context_delete(self) -> None:
|
||||
assert self.live_resource is not None
|
||||
self.events.append("child.delete")
|
||||
self.runtime_state.deleted_with_resource = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class CreateFailureResourceLayer(PlainLayer[NoLayerDeps]):
|
||||
events: list[str] = field(default_factory=list)
|
||||
live_resource: bool = False
|
||||
|
||||
@override
|
||||
@asynccontextmanager
|
||||
async def resource_context(self) -> AsyncGenerator[None]:
|
||||
self.events.append("resource.enter")
|
||||
self.live_resource = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.events.append("resource.exit")
|
||||
self.live_resource = False
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
assert self.live_resource is True
|
||||
self.events.append("create")
|
||||
raise RuntimeError("create failed")
|
||||
|
||||
@override
|
||||
async def on_context_delete(self) -> None:
|
||||
self.events.append("delete")
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeleteFailureResourceLayer(PlainLayer[NoLayerDeps]):
|
||||
events: list[str] = field(default_factory=list)
|
||||
live_resource: bool = False
|
||||
|
||||
@override
|
||||
@asynccontextmanager
|
||||
async def resource_context(self) -> AsyncGenerator[None]:
|
||||
self.events.append("resource.enter")
|
||||
self.live_resource = True
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
self.events.append("resource.exit")
|
||||
self.live_resource = False
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
assert self.live_resource is True
|
||||
self.events.append("create")
|
||||
|
||||
@override
|
||||
async def on_context_delete(self) -> None:
|
||||
assert self.live_resource is True
|
||||
self.events.append("delete")
|
||||
raise RuntimeError("delete failed")
|
||||
|
||||
|
||||
class ResumeResourceState(BaseModel):
|
||||
created_with_resource: bool = False
|
||||
resumed_with_resource: bool = False
|
||||
suspended_with_resource: bool = False
|
||||
deleted_with_resource: bool = False
|
||||
|
||||
model_config = ConfigDict(extra="forbid", validate_assignment=True)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SuspendResumeResourceLayer(PlainLayer[NoLayerDeps, EmptyLayerConfig, ResumeResourceState]):
|
||||
events: list[str]
|
||||
next_resource_id: Iterator[int]
|
||||
live_resource: str | None = None
|
||||
|
||||
@override
|
||||
@asynccontextmanager
|
||||
async def resource_context(self) -> AsyncGenerator[None]:
|
||||
self.live_resource = f"resource-{next(self.next_resource_id)}"
|
||||
self.events.append(f"resource.enter:{self.live_resource}")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
assert self.live_resource is not None
|
||||
self.events.append(f"resource.exit:{self.live_resource}")
|
||||
self.live_resource = None
|
||||
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
assert self.live_resource is not None
|
||||
self.events.append(f"create:{self.live_resource}")
|
||||
self.runtime_state.created_with_resource = True
|
||||
|
||||
@override
|
||||
async def on_context_resume(self) -> None:
|
||||
assert self.live_resource is not None
|
||||
self.events.append(f"resume:{self.live_resource}")
|
||||
self.runtime_state.resumed_with_resource = True
|
||||
|
||||
@override
|
||||
async def on_context_suspend(self) -> None:
|
||||
assert self.live_resource is not None
|
||||
self.events.append(f"suspend:{self.live_resource}")
|
||||
self.runtime_state.suspended_with_resource = True
|
||||
|
||||
@override
|
||||
async def on_context_delete(self) -> None:
|
||||
assert self.live_resource is not None
|
||||
self.events.append(f"delete:{self.live_resource}")
|
||||
self.runtime_state.deleted_with_resource = True
|
||||
|
||||
|
||||
class CreateFailureChildResourceLayer(ChildResourceLayer):
|
||||
@override
|
||||
async def on_context_create(self) -> None:
|
||||
assert self.live_resource is not None
|
||||
self.events.append("child.create")
|
||||
raise RuntimeError("child create failed")
|
||||
|
||||
|
||||
class SuspendFailureChildResourceLayer(ChildResourceLayer):
|
||||
@override
|
||||
async def on_context_suspend(self) -> None:
|
||||
assert self.live_resource is not None
|
||||
self.events.append("child.suspend")
|
||||
raise RuntimeError("child suspend failed")
|
||||
|
||||
|
||||
def test_resource_context_wraps_hooks_and_body_in_dependency_order() -> None:
|
||||
events: list[str] = []
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode(
|
||||
"parent",
|
||||
LayerProvider.from_factory(
|
||||
layer_type=ParentResourceLayer,
|
||||
create=lambda config: ParentResourceLayer(events),
|
||||
),
|
||||
),
|
||||
LayerNode(
|
||||
"child",
|
||||
LayerProvider.from_factory(
|
||||
layer_type=ChildResourceLayer,
|
||||
create=lambda config: ChildResourceLayer(events),
|
||||
),
|
||||
deps={"parent": "parent"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def run() -> CompositorSessionSnapshot:
|
||||
async with compositor.enter() as active_run:
|
||||
parent = active_run.get_layer("parent", ParentResourceLayer)
|
||||
child = active_run.get_layer("child", ChildResourceLayer)
|
||||
assert parent.live_resource is not None
|
||||
assert child.live_resource is not None
|
||||
assert child.deps.parent is parent
|
||||
events.append("body")
|
||||
assert active_run.session_snapshot is not None
|
||||
assert parent.live_resource is None
|
||||
assert child.live_resource is None
|
||||
return active_run.session_snapshot
|
||||
|
||||
snapshot = asyncio.run(run())
|
||||
|
||||
assert events == [
|
||||
"parent.resource.enter",
|
||||
"parent.create",
|
||||
"child.resource.enter",
|
||||
"child.create",
|
||||
"body",
|
||||
"child.delete",
|
||||
"child.resource.exit",
|
||||
"parent.delete",
|
||||
"parent.resource.exit",
|
||||
]
|
||||
assert snapshot.model_dump(mode="json") == {
|
||||
"schema_version": 1,
|
||||
"layers": [
|
||||
{
|
||||
"name": "parent",
|
||||
"lifecycle_state": "closed",
|
||||
"runtime_state": {
|
||||
"created_with_resource": True,
|
||||
"deleted_with_resource": True,
|
||||
"saw_dependency_resource": False,
|
||||
},
|
||||
},
|
||||
{
|
||||
"name": "child",
|
||||
"lifecycle_state": "closed",
|
||||
"runtime_state": {
|
||||
"created_with_resource": True,
|
||||
"deleted_with_resource": True,
|
||||
"saw_dependency_resource": True,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_resource_context_wraps_resume_and_suspend_with_fresh_resource_scope() -> None:
|
||||
events: list[str] = []
|
||||
resource_ids = count(1)
|
||||
created_layers: list[SuspendResumeResourceLayer] = []
|
||||
|
||||
def create_layer(config: EmptyLayerConfig) -> SuspendResumeResourceLayer:
|
||||
layer = SuspendResumeResourceLayer(events=events, next_resource_id=resource_ids)
|
||||
created_layers.append(layer)
|
||||
return layer
|
||||
|
||||
compositor = Compositor(
|
||||
[LayerNode("trace", LayerProvider.from_factory(layer_type=SuspendResumeResourceLayer, create=create_layer))]
|
||||
)
|
||||
|
||||
async def run() -> tuple[CompositorSessionSnapshot, CompositorSessionSnapshot]:
|
||||
async with compositor.enter() as first_run:
|
||||
first_layer = first_run.get_layer("trace", SuspendResumeResourceLayer)
|
||||
assert first_layer.live_resource == "resource-1"
|
||||
first_run.suspend_on_exit()
|
||||
assert first_run.session_snapshot is not None
|
||||
|
||||
async with compositor.enter(session_snapshot=first_run.session_snapshot) as resumed_run:
|
||||
resumed_layer = resumed_run.get_layer("trace", SuspendResumeResourceLayer)
|
||||
assert resumed_layer.live_resource == "resource-2"
|
||||
assert resumed_layer.live_resource != "resource-1"
|
||||
assert resumed_run.session_snapshot is not None
|
||||
return first_run.session_snapshot, resumed_run.session_snapshot
|
||||
|
||||
suspended_snapshot, resumed_snapshot = asyncio.run(run())
|
||||
|
||||
assert len(created_layers) == 2
|
||||
assert all(layer.live_resource is None for layer in created_layers)
|
||||
assert events == [
|
||||
"resource.enter:resource-1",
|
||||
"create:resource-1",
|
||||
"suspend:resource-1",
|
||||
"resource.exit:resource-1",
|
||||
"resource.enter:resource-2",
|
||||
"resume:resource-2",
|
||||
"delete:resource-2",
|
||||
"resource.exit:resource-2",
|
||||
]
|
||||
assert suspended_snapshot.model_dump(mode="json") == {
|
||||
"schema_version": 1,
|
||||
"layers": [
|
||||
{
|
||||
"name": "trace",
|
||||
"lifecycle_state": "suspended",
|
||||
"runtime_state": {
|
||||
"created_with_resource": True,
|
||||
"resumed_with_resource": False,
|
||||
"suspended_with_resource": True,
|
||||
"deleted_with_resource": False,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
assert resumed_snapshot.model_dump(mode="json") == {
|
||||
"schema_version": 1,
|
||||
"layers": [
|
||||
{
|
||||
"name": "trace",
|
||||
"lifecycle_state": "closed",
|
||||
"runtime_state": {
|
||||
"created_with_resource": True,
|
||||
"resumed_with_resource": True,
|
||||
"suspended_with_resource": True,
|
||||
"deleted_with_resource": True,
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_resource_context_exits_when_run_body_raises() -> None:
|
||||
events: list[str] = []
|
||||
created_layers: list[ParentResourceLayer | ChildResourceLayer] = []
|
||||
|
||||
def create_parent(config: EmptyLayerConfig) -> ParentResourceLayer:
|
||||
layer = ParentResourceLayer(events)
|
||||
created_layers.append(layer)
|
||||
return layer
|
||||
|
||||
def create_child(config: EmptyLayerConfig) -> ChildResourceLayer:
|
||||
layer = ChildResourceLayer(events)
|
||||
created_layers.append(layer)
|
||||
return layer
|
||||
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("parent", LayerProvider.from_factory(layer_type=ParentResourceLayer, create=create_parent)),
|
||||
LayerNode(
|
||||
"child",
|
||||
LayerProvider.from_factory(layer_type=ChildResourceLayer, create=create_child),
|
||||
deps={"parent": "parent"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter():
|
||||
events.append("body")
|
||||
raise RuntimeError("body failed")
|
||||
|
||||
with pytest.raises(RuntimeError, match="body failed"):
|
||||
asyncio.run(run())
|
||||
|
||||
assert [layer.live_resource for layer in created_layers] == [None, None]
|
||||
assert events == [
|
||||
"parent.resource.enter",
|
||||
"parent.create",
|
||||
"child.resource.enter",
|
||||
"child.create",
|
||||
"body",
|
||||
"child.delete",
|
||||
"child.resource.exit",
|
||||
"parent.delete",
|
||||
"parent.resource.exit",
|
||||
]
|
||||
|
||||
|
||||
def test_resource_context_exits_when_run_body_is_cancelled() -> None:
|
||||
events: list[str] = []
|
||||
created_layers: list[ParentResourceLayer | ChildResourceLayer] = []
|
||||
|
||||
def create_parent(config: EmptyLayerConfig) -> ParentResourceLayer:
|
||||
layer = ParentResourceLayer(events)
|
||||
created_layers.append(layer)
|
||||
return layer
|
||||
|
||||
def create_child(config: EmptyLayerConfig) -> ChildResourceLayer:
|
||||
layer = ChildResourceLayer(events)
|
||||
created_layers.append(layer)
|
||||
return layer
|
||||
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("parent", LayerProvider.from_factory(layer_type=ParentResourceLayer, create=create_parent)),
|
||||
LayerNode(
|
||||
"child",
|
||||
LayerProvider.from_factory(layer_type=ChildResourceLayer, create=create_child),
|
||||
deps={"parent": "parent"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter():
|
||||
events.append("body")
|
||||
task = asyncio.current_task()
|
||||
assert task is not None
|
||||
task.cancel()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
asyncio.run(run())
|
||||
|
||||
assert [layer.live_resource for layer in created_layers] == [None, None]
|
||||
assert events == [
|
||||
"parent.resource.enter",
|
||||
"parent.create",
|
||||
"child.resource.enter",
|
||||
"child.create",
|
||||
"body",
|
||||
"child.delete",
|
||||
"child.resource.exit",
|
||||
"parent.delete",
|
||||
"parent.resource.exit",
|
||||
]
|
||||
|
||||
|
||||
def test_dependency_resource_contexts_exit_when_child_create_fails() -> None:
|
||||
events: list[str] = []
|
||||
created_layers: list[ParentResourceLayer | CreateFailureChildResourceLayer] = []
|
||||
|
||||
def create_parent(config: EmptyLayerConfig) -> ParentResourceLayer:
|
||||
layer = ParentResourceLayer(events)
|
||||
created_layers.append(layer)
|
||||
return layer
|
||||
|
||||
def create_child(config: EmptyLayerConfig) -> CreateFailureChildResourceLayer:
|
||||
layer = CreateFailureChildResourceLayer(events)
|
||||
created_layers.append(layer)
|
||||
return layer
|
||||
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("parent", LayerProvider.from_factory(layer_type=ParentResourceLayer, create=create_parent)),
|
||||
LayerNode(
|
||||
"child",
|
||||
LayerProvider.from_factory(layer_type=CreateFailureChildResourceLayer, create=create_child),
|
||||
deps={"parent": "parent"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="child create failed"):
|
||||
asyncio.run(_enter_once(compositor))
|
||||
|
||||
assert [layer.live_resource for layer in created_layers] == [None, None]
|
||||
assert events == [
|
||||
"parent.resource.enter",
|
||||
"parent.create",
|
||||
"child.resource.enter",
|
||||
"child.create",
|
||||
"child.resource.exit",
|
||||
"parent.delete",
|
||||
"parent.resource.exit",
|
||||
]
|
||||
|
||||
|
||||
def test_dependency_resource_contexts_exit_when_child_suspend_fails() -> None:
|
||||
events: list[str] = []
|
||||
created_layers: list[ParentResourceLayer | SuspendFailureChildResourceLayer] = []
|
||||
|
||||
def create_parent(config: EmptyLayerConfig) -> ParentResourceLayer:
|
||||
layer = ParentResourceLayer(events)
|
||||
created_layers.append(layer)
|
||||
return layer
|
||||
|
||||
def create_child(config: EmptyLayerConfig) -> SuspendFailureChildResourceLayer:
|
||||
layer = SuspendFailureChildResourceLayer(events)
|
||||
created_layers.append(layer)
|
||||
return layer
|
||||
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode("parent", LayerProvider.from_factory(layer_type=ParentResourceLayer, create=create_parent)),
|
||||
LayerNode(
|
||||
"child",
|
||||
LayerProvider.from_factory(layer_type=SuspendFailureChildResourceLayer, create=create_child),
|
||||
deps={"parent": "parent"},
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
async def run() -> None:
|
||||
async with compositor.enter() as active_run:
|
||||
events.append("body")
|
||||
active_run.suspend_on_exit()
|
||||
|
||||
with pytest.raises(RuntimeError, match="child suspend failed"):
|
||||
asyncio.run(run())
|
||||
|
||||
assert [layer.live_resource for layer in created_layers] == [None, None]
|
||||
assert events == [
|
||||
"parent.resource.enter",
|
||||
"parent.create",
|
||||
"child.resource.enter",
|
||||
"child.create",
|
||||
"body",
|
||||
"child.suspend",
|
||||
"child.resource.exit",
|
||||
"parent.suspend",
|
||||
"parent.resource.exit",
|
||||
]
|
||||
|
||||
|
||||
def test_resource_context_exits_when_create_hook_raises() -> None:
|
||||
created_layers: list[CreateFailureResourceLayer] = []
|
||||
|
||||
def create_layer(config: EmptyLayerConfig) -> CreateFailureResourceLayer:
|
||||
layer = CreateFailureResourceLayer()
|
||||
created_layers.append(layer)
|
||||
return layer
|
||||
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode(
|
||||
"trace",
|
||||
LayerProvider.from_factory(layer_type=CreateFailureResourceLayer, create=create_layer),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="create failed"):
|
||||
asyncio.run(_enter_once(compositor))
|
||||
|
||||
assert len(created_layers) == 1
|
||||
assert created_layers[0].events == ["resource.enter", "create", "resource.exit"]
|
||||
assert created_layers[0].live_resource is False
|
||||
|
||||
|
||||
def test_resource_context_exits_when_delete_hook_raises() -> None:
|
||||
deleted_layers: list[DeleteFailureResourceLayer] = []
|
||||
|
||||
def create_layer(config: EmptyLayerConfig) -> DeleteFailureResourceLayer:
|
||||
layer = DeleteFailureResourceLayer()
|
||||
deleted_layers.append(layer)
|
||||
return layer
|
||||
|
||||
compositor = Compositor(
|
||||
[
|
||||
LayerNode(
|
||||
"trace",
|
||||
LayerProvider.from_factory(layer_type=DeleteFailureResourceLayer, create=create_layer),
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="delete failed"):
|
||||
asyncio.run(_enter_once(compositor))
|
||||
|
||||
assert len(deleted_layers) == 1
|
||||
assert deleted_layers[0].events == ["resource.enter", "create", "delete", "resource.exit"]
|
||||
assert deleted_layers[0].live_resource is False
|
||||
|
||||
|
||||
async def _enter_once(
|
||||
compositor: Compositor,
|
||||
*,
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
|
||||
import dify_agent.layers.shell as shell_exports
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
|
||||
|
||||
def test_shell_package_exports_client_safe_config_symbols_only() -> None:
|
||||
assert shell_exports.__all__ == ["DIFY_SHELL_LAYER_TYPE_ID", "DifyShellLayerConfig"]
|
||||
assert DIFY_SHELL_LAYER_TYPE_ID == "dify.shell"
|
||||
assert not hasattr(shell_exports, "DifyShellLayer")
|
||||
|
||||
|
||||
def test_shell_layer_config_is_empty_and_forbids_unknown_fields() -> None:
|
||||
config = DifyShellLayerConfig()
|
||||
|
||||
assert config.model_dump() == {}
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
_ = DifyShellLayerConfig.model_validate({"entrypoint": "http://shellctl"})
|
||||
588
dify-agent/tests/local/dify_agent/layers/shell/test_layer.py
Normal file
588
dify-agent/tests/local/dify_agent/layers/shell/test_layer.py
Normal file
@ -0,0 +1,588 @@
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest
|
||||
|
||||
from agenton.compositor import Compositor, LayerNode, LayerProvider
|
||||
from agenton.layers import LifecycleState
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer, DifyShellRuntimeState, ShellctlClientFactory
|
||||
from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView
|
||||
|
||||
|
||||
def _job_result(
|
||||
job_id: str,
|
||||
*,
|
||||
status: JobStatusName = JobStatusName.RUNNING,
|
||||
done: bool = False,
|
||||
exit_code: int | None = None,
|
||||
output: str = "",
|
||||
offset: int = 0,
|
||||
truncated: bool = False,
|
||||
output_path: str = "/tmp/output.log",
|
||||
) -> JobResult:
|
||||
return JobResult(
|
||||
job_id=job_id,
|
||||
status=status,
|
||||
done=done,
|
||||
exit_code=exit_code,
|
||||
output=output,
|
||||
offset=offset,
|
||||
truncated=truncated,
|
||||
output_path=output_path,
|
||||
)
|
||||
|
||||
|
||||
def _job_status(
|
||||
job_id: str,
|
||||
*,
|
||||
status: JobStatusName = JobStatusName.RUNNING,
|
||||
done: bool = False,
|
||||
exit_code: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> JobStatusView:
|
||||
return JobStatusView(
|
||||
job_id=job_id,
|
||||
status=status,
|
||||
done=done,
|
||||
exit_code=exit_code,
|
||||
created_at="2026-05-28T12:00:00Z",
|
||||
started_at="2026-05-28T12:00:01Z",
|
||||
ended_at="2026-05-28T12:00:02Z" if done else None,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
|
||||
def _assert_error_observation(result: object, *, job_id: str | None = None, includes: str | None = None) -> None:
|
||||
assert isinstance(result, dict)
|
||||
assert isinstance(result.get("error"), str)
|
||||
assert result["error"]
|
||||
if job_id is None:
|
||||
assert "job_id" not in result
|
||||
else:
|
||||
assert result.get("job_id") == job_id
|
||||
if includes is not None:
|
||||
assert includes in result["error"]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class RunCall:
|
||||
script: str
|
||||
cwd: str | None
|
||||
timeout: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class WaitCall:
|
||||
job_id: str
|
||||
offset: int
|
||||
timeout: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InputCall:
|
||||
job_id: str
|
||||
text: str
|
||||
offset: int
|
||||
timeout: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TerminateCall:
|
||||
job_id: str
|
||||
grace_seconds: float
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DeleteCall:
|
||||
job_id: str
|
||||
force: bool
|
||||
grace_seconds: float | None
|
||||
|
||||
|
||||
class FakeShellctlClient:
|
||||
run_calls: list[RunCall]
|
||||
wait_calls: list[WaitCall]
|
||||
input_calls: list[InputCall]
|
||||
terminate_calls: list[TerminateCall]
|
||||
delete_calls: list[DeleteCall]
|
||||
events: list[tuple[str, str]]
|
||||
closed: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
run_handler: Callable[[str, str | None, float], JobResult] | None = None,
|
||||
wait_handler: Callable[[str, int, float], JobResult] | None = None,
|
||||
input_handler: Callable[[str, str, int, float], JobResult] | None = None,
|
||||
terminate_handler: Callable[[str, float], JobStatusView] | None = None,
|
||||
delete_handler: Callable[[str, bool, float | None], DeleteJobResponse] | None = None,
|
||||
) -> None:
|
||||
self._run_handler = run_handler
|
||||
self._wait_handler = wait_handler
|
||||
self._input_handler = input_handler
|
||||
self._terminate_handler = terminate_handler
|
||||
self._delete_handler = delete_handler
|
||||
self.run_calls = []
|
||||
self.wait_calls = []
|
||||
self.input_calls = []
|
||||
self.terminate_calls = []
|
||||
self.delete_calls = []
|
||||
self.events = []
|
||||
self.closed = False
|
||||
|
||||
async def run(self, script: str, *, cwd: str | None = None, timeout: float = 10.0) -> JobResult:
|
||||
self.run_calls.append(RunCall(script=script, cwd=cwd, timeout=timeout))
|
||||
self.events.append(("run", script))
|
||||
if self._run_handler is None:
|
||||
raise AssertionError("Unexpected run() call")
|
||||
return self._run_handler(script, cwd, timeout)
|
||||
|
||||
async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult:
|
||||
self.wait_calls.append(WaitCall(job_id=job_id, offset=offset, timeout=timeout))
|
||||
self.events.append(("wait", job_id))
|
||||
if self._wait_handler is None:
|
||||
raise AssertionError("Unexpected wait() call")
|
||||
return self._wait_handler(job_id, offset, timeout)
|
||||
|
||||
async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult:
|
||||
self.input_calls.append(InputCall(job_id=job_id, text=text, offset=offset, timeout=timeout))
|
||||
self.events.append(("input", job_id))
|
||||
if self._input_handler is None:
|
||||
raise AssertionError("Unexpected input() call")
|
||||
return self._input_handler(job_id, text, offset, timeout)
|
||||
|
||||
async def terminate(self, job_id: str, grace_seconds: float = 2.0) -> JobStatusView:
|
||||
self.terminate_calls.append(TerminateCall(job_id=job_id, grace_seconds=grace_seconds))
|
||||
self.events.append(("terminate", job_id))
|
||||
if self._terminate_handler is None:
|
||||
raise AssertionError("Unexpected terminate() call")
|
||||
return self._terminate_handler(job_id, grace_seconds)
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
grace_seconds: float | None = None,
|
||||
) -> DeleteJobResponse:
|
||||
self.delete_calls.append(DeleteCall(job_id=job_id, force=force, grace_seconds=grace_seconds))
|
||||
self.events.append(("delete", job_id))
|
||||
if self._delete_handler is None:
|
||||
return DeleteJobResponse(job_id=job_id)
|
||||
return self._delete_handler(job_id, force, grace_seconds)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
self.events.append(("close", "client"))
|
||||
|
||||
|
||||
def _shell_layer(*, client_factory: ShellctlClientFactory) -> DifyShellLayer:
|
||||
return DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig(),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=client_factory,
|
||||
)
|
||||
|
||||
|
||||
def _shell_provider(*, client_factory: ShellctlClientFactory) -> LayerProvider[DifyShellLayer]:
|
||||
return LayerProvider.from_factory(
|
||||
layer_type=DifyShellLayer,
|
||||
create=lambda config: DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=client_factory,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_shell_type_id_constant_matches_implementation_class() -> None:
|
||||
assert DIFY_SHELL_LAYER_TYPE_ID == DifyShellLayer.type_id
|
||||
|
||||
|
||||
def test_shell_layer_create_generates_5_plus_2_hex_session_id_and_retries_workspace_collision(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
random_suffixes = iter(["aa", "bb"])
|
||||
monkeypatch.setattr(time, "time", lambda: 0x12345F)
|
||||
monkeypatch.setattr(secrets, "token_hex", lambda nbytes: next(random_suffixes))
|
||||
|
||||
def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult:
|
||||
assert cwd is None
|
||||
assert timeout == 30.0
|
||||
if "2345faa" in script:
|
||||
return _job_result("mkdir-collision", status=JobStatusName.EXITED, done=True, exit_code=17)
|
||||
if "2345fbb" in script:
|
||||
return _job_result("mkdir-success", status=JobStatusName.RUNNING, done=False, offset=4)
|
||||
raise AssertionError(f"Unexpected script: {script}")
|
||||
|
||||
def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult:
|
||||
assert job_id == "mkdir-success"
|
||||
assert offset == 4
|
||||
assert timeout == 30.0
|
||||
return _job_result("mkdir-success", status=JobStatusName.EXITED, done=True, exit_code=0, offset=8)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
await layer.on_context_create()
|
||||
assert client.closed is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert layer.runtime_state.session_id == "2345fbb"
|
||||
assert layer.runtime_state.workspace_cwd == "~/workspace/2345fbb"
|
||||
assert layer.runtime_state.job_ids == ["mkdir-collision", "mkdir-success"]
|
||||
assert layer.runtime_state.job_offsets == {"mkdir-collision": 0, "mkdir-success": 8}
|
||||
assert 'mkdir "$HOME/workspace/2345fbb"' in client.run_calls[1].script
|
||||
assert 'mkdir -p "$HOME/workspace/2345fbb"' not in client.run_calls[1].script
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_shell_layer_suspend_leaves_client_open_until_resource_context_exits() -> None:
|
||||
client = FakeShellctlClient()
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
await layer.on_context_suspend()
|
||||
assert client.closed is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_shell_layer_suspend_and_resume_reuse_state_with_fresh_clients() -> None:
|
||||
first_client = FakeShellctlClient(
|
||||
run_handler=lambda _script, _cwd, _timeout: _job_result(
|
||||
"mkdir-job",
|
||||
status=JobStatusName.EXITED,
|
||||
done=True,
|
||||
exit_code=0,
|
||||
)
|
||||
)
|
||||
second_client = FakeShellctlClient()
|
||||
created_entrypoints: list[str] = []
|
||||
clients = iter([first_client, second_client])
|
||||
|
||||
def factory(entrypoint: str) -> FakeShellctlClient:
|
||||
created_entrypoints.append(entrypoint)
|
||||
return next(clients)
|
||||
|
||||
compositor = Compositor([LayerNode("shell", _shell_provider(client_factory=factory))])
|
||||
async def scenario() -> None:
|
||||
async with compositor.enter(configs={"shell": DifyShellLayerConfig()}) as run:
|
||||
shell_layer = run.get_layer("shell", DifyShellLayer)
|
||||
initial_session_id = shell_layer.runtime_state.session_id
|
||||
assert initial_session_id is not None
|
||||
assert shell_layer.runtime_state.workspace_cwd == f"~/workspace/{initial_session_id}"
|
||||
shell_layer.runtime_state.job_ids = [*shell_layer.runtime_state.job_ids, "user-job"]
|
||||
shell_layer.runtime_state.job_offsets = {
|
||||
**shell_layer.runtime_state.job_offsets,
|
||||
"user-job": 42,
|
||||
}
|
||||
assert first_client.closed is False
|
||||
run.suspend_layer_on_exit("shell")
|
||||
|
||||
assert run.session_snapshot is not None
|
||||
assert first_client.closed is True
|
||||
assert run.session_snapshot.layers[0].lifecycle_state is LifecycleState.SUSPENDED
|
||||
|
||||
async with compositor.enter(
|
||||
configs={"shell": DifyShellLayerConfig()},
|
||||
session_snapshot=run.session_snapshot,
|
||||
) as resumed_run:
|
||||
resumed_shell = resumed_run.get_layer("shell", DifyShellLayer)
|
||||
assert second_client.closed is False
|
||||
assert resumed_shell.runtime_state.session_id == initial_session_id
|
||||
assert resumed_shell.runtime_state.workspace_cwd == f"~/workspace/{initial_session_id}"
|
||||
assert set(resumed_shell.runtime_state.job_ids) == {"mkdir-job", "user-job"}
|
||||
assert resumed_shell.runtime_state.job_offsets == {"mkdir-job": 0, "user-job": 42}
|
||||
resumed_run.suspend_layer_on_exit("shell")
|
||||
|
||||
assert second_client.closed is True
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert created_entrypoints == ["http://shellctl", "http://shellctl"]
|
||||
|
||||
|
||||
def test_shell_layer_delete_removes_workspace_then_force_deletes_tracked_jobs_and_closes_client() -> None:
|
||||
def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult:
|
||||
assert script == 'rm -rf -- "$HOME/workspace/abc12ff"'
|
||||
assert cwd is None
|
||||
assert timeout == 30.0
|
||||
return _job_result("cleanup-job", status=JobStatusName.RUNNING, done=False, offset=3)
|
||||
|
||||
def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult:
|
||||
assert job_id == "cleanup-job"
|
||||
assert offset == 3
|
||||
assert timeout == 30.0
|
||||
return _job_result("cleanup-job", status=JobStatusName.EXITED, done=True, exit_code=0, offset=5)
|
||||
|
||||
client = FakeShellctlClient(run_handler=run_handler, wait_handler=wait_handler)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
layer.runtime_state.job_ids = ["user-job", "mkdir-job"]
|
||||
layer.runtime_state.job_offsets = {"user-job": 9, "mkdir-job": 1}
|
||||
await layer.on_context_delete()
|
||||
assert client.closed is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert client.events[:2] == [("run", 'rm -rf -- "$HOME/workspace/abc12ff"'), ("wait", "cleanup-job")]
|
||||
assert {call.job_id for call in client.delete_calls} == {"user-job", "mkdir-job", "cleanup-job"}
|
||||
assert all(client.events.index(("delete", call.job_id)) > client.events.index(("wait", "cleanup-job")) for call in client.delete_calls)
|
||||
assert all(call.force is True for call in client.delete_calls)
|
||||
assert layer.runtime_state.job_ids == []
|
||||
assert layer.runtime_state.job_offsets == {}
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_shell_layer_create_failure_force_deletes_internal_jobs_before_reraising() -> None:
|
||||
client = FakeShellctlClient(
|
||||
run_handler=lambda _script, _cwd, _timeout: _job_result(
|
||||
"mkdir-failed",
|
||||
status=JobStatusName.EXITED,
|
||||
done=True,
|
||||
exit_code=1,
|
||||
)
|
||||
)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(RuntimeError, match="Failed to create shell workspace"):
|
||||
async with layer.resource_context():
|
||||
await layer.on_context_create()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert [call.job_id for call in client.delete_calls] == ["mkdir-failed"]
|
||||
assert all(call.force is True for call in client.delete_calls)
|
||||
assert layer.runtime_state.job_ids == []
|
||||
assert layer.runtime_state.job_offsets == {}
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_shell_layer_tools_map_inputs_to_shellctl_calls_and_maintain_offsets() -> None:
|
||||
def run_handler(script: str, cwd: str | None, timeout: float) -> JobResult:
|
||||
assert script == "pwd"
|
||||
assert cwd == "~/workspace/abc12ff"
|
||||
assert timeout == 2.5
|
||||
return _job_result(
|
||||
"user-job",
|
||||
status=JobStatusName.RUNNING,
|
||||
done=False,
|
||||
offset=10,
|
||||
output="/home/test\n",
|
||||
)
|
||||
|
||||
def wait_handler(job_id: str, offset: int, timeout: float) -> JobResult:
|
||||
assert job_id == "user-job"
|
||||
assert offset == 10
|
||||
assert timeout == 4.0
|
||||
return _job_result(
|
||||
"user-job",
|
||||
status=JobStatusName.RUNNING,
|
||||
done=False,
|
||||
offset=18,
|
||||
output="more\n",
|
||||
)
|
||||
|
||||
def input_handler(job_id: str, text: str, offset: int, timeout: float) -> JobResult:
|
||||
assert job_id == "user-job"
|
||||
assert text == "ls\n"
|
||||
assert offset == 18
|
||||
assert timeout == 5.0
|
||||
return _job_result(
|
||||
"user-job",
|
||||
status=JobStatusName.EXITED,
|
||||
done=True,
|
||||
exit_code=0,
|
||||
offset=22,
|
||||
output="file.txt\n",
|
||||
)
|
||||
|
||||
def terminate_handler(job_id: str, grace_seconds: float) -> JobStatusView:
|
||||
assert job_id == "user-job"
|
||||
assert grace_seconds == 1.5
|
||||
return _job_status(
|
||||
"user-job",
|
||||
status=JobStatusName.TERMINATED,
|
||||
done=True,
|
||||
exit_code=130,
|
||||
offset=22,
|
||||
)
|
||||
|
||||
client = FakeShellctlClient(
|
||||
run_handler=run_handler,
|
||||
wait_handler=wait_handler,
|
||||
input_handler=input_handler,
|
||||
terminate_handler=terminate_handler,
|
||||
)
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
tools = {tool.name: tool for tool in layer.tools}
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
|
||||
run_tool_def = await tools["shell.run"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
|
||||
wait_tool_def = await tools["shell.wait"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
|
||||
input_tool_def = await tools["shell.input"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
|
||||
interrupt_tool_def = await tools["shell.interrupt"].prepare_tool_def(None) # pyright: ignore[reportArgumentType]
|
||||
|
||||
run_result = await tools["shell.run"].function_schema.call(
|
||||
{"script": "pwd", "timeout": 2.5},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
wait_result = await tools["shell.wait"].function_schema.call(
|
||||
{"job_id": "user-job", "timeout": 4.0},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
input_result = await tools["shell.input"].function_schema.call(
|
||||
{"job_id": "user-job", "text": "ls\n", "timeout": 5.0},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
interrupt_result = await tools["shell.interrupt"].function_schema.call(
|
||||
{"job_id": "user-job", "grace_seconds": 1.5},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
assert run_tool_def is not None
|
||||
assert wait_tool_def is not None
|
||||
assert input_tool_def is not None
|
||||
assert interrupt_tool_def is not None
|
||||
assert "offset" not in run_tool_def.parameters_json_schema.get("properties", {})
|
||||
assert "offset" not in wait_tool_def.parameters_json_schema.get("properties", {})
|
||||
assert "offset" not in input_tool_def.parameters_json_schema.get("properties", {})
|
||||
assert "offset" not in interrupt_tool_def.parameters_json_schema.get("properties", {})
|
||||
assert set(tools) == {"shell.run", "shell.wait", "shell.input", "shell.interrupt"}
|
||||
assert run_result["job_id"] == "user-job"
|
||||
assert run_result["offset"] == 10
|
||||
assert wait_result["offset"] == 18
|
||||
assert input_result["offset"] == 22
|
||||
assert interrupt_result == {
|
||||
"job_id": "user-job",
|
||||
"status": "terminated",
|
||||
"done": True,
|
||||
"exit_code": 130,
|
||||
"offset": 22,
|
||||
}
|
||||
assert client.closed is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert layer.runtime_state.job_ids == ["user-job"]
|
||||
assert layer.runtime_state.job_offsets == {"user-job": 22}
|
||||
assert client.closed is True
|
||||
|
||||
|
||||
def test_shell_layer_tools_reject_untracked_job_ids_without_shellctl_calls() -> None:
|
||||
client = FakeShellctlClient()
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
tools = {tool.name: tool for tool in layer.tools}
|
||||
|
||||
async def scenario() -> None:
|
||||
async with layer.resource_context():
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
|
||||
wait_result = await tools["shell.wait"].function_schema.call(
|
||||
{"job_id": "missing-job"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
input_result = await tools["shell.input"].function_schema.call(
|
||||
{"job_id": "missing-job", "text": "hello"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
interrupt_result = await tools["shell.interrupt"].function_schema.call(
|
||||
{"job_id": "missing-job"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
|
||||
_assert_error_observation(wait_result, job_id="missing-job")
|
||||
_assert_error_observation(input_result, job_id="missing-job")
|
||||
_assert_error_observation(interrupt_result, job_id="missing-job")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert client.wait_calls == []
|
||||
assert client.input_calls == []
|
||||
assert client.terminate_calls == []
|
||||
|
||||
|
||||
def test_shell_layer_hooks_and_tools_fail_clearly_outside_active_resource_context() -> None:
|
||||
client = FakeShellctlClient()
|
||||
layer = _shell_layer(client_factory=lambda _entrypoint: client)
|
||||
layer.runtime_state = DifyShellRuntimeState(session_id="abc12ff", workspace_cwd="~/workspace/abc12ff")
|
||||
tools = {tool.name: tool for tool in layer.tools}
|
||||
|
||||
async def scenario() -> None:
|
||||
with pytest.raises(RuntimeError, match="resource_context"):
|
||||
await layer.on_context_suspend()
|
||||
|
||||
run_result = await tools["shell.run"].function_schema.call(
|
||||
{"script": "pwd"},
|
||||
None, # pyright: ignore[reportArgumentType]
|
||||
)
|
||||
_assert_error_observation(run_result, includes="resource_context")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert client.run_calls == []
|
||||
|
||||
|
||||
def test_shell_runtime_state_rejects_unsafe_resumed_workspace_identity() -> None:
|
||||
with pytest.raises(ValueError, match="session_id must match"):
|
||||
_ = DifyShellRuntimeState.model_validate(
|
||||
{
|
||||
"session_id": "../../tmp",
|
||||
"workspace_cwd": "~/workspace/../../tmp",
|
||||
"job_ids": [],
|
||||
"job_offsets": {},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="workspace_cwd must equal"):
|
||||
_ = DifyShellRuntimeState.model_validate(
|
||||
{
|
||||
"session_id": "abc12ff",
|
||||
"workspace_cwd": "~/workspace/def34aa",
|
||||
"job_ids": [],
|
||||
"job_offsets": {},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_shell_runtime_state_treats_job_ids_as_opaque_strings_and_rejects_unknown_offset_keys() -> None:
|
||||
state = DifyShellRuntimeState.model_validate(
|
||||
{
|
||||
"session_id": "abc12ff",
|
||||
"workspace_cwd": "~/workspace/abc12ff",
|
||||
"job_ids": ['job"bad with spaces'],
|
||||
"job_offsets": {'job"bad with spaces': 0},
|
||||
}
|
||||
)
|
||||
|
||||
assert state.job_ids == ['job"bad with spaces']
|
||||
assert state.job_offsets == {'job"bad with spaces': 0}
|
||||
|
||||
with pytest.raises(ValueError, match="unknown job ids"):
|
||||
_ = DifyShellRuntimeState.model_validate(
|
||||
{
|
||||
"session_id": "abc12ff",
|
||||
"workspace_cwd": "~/workspace/abc12ff",
|
||||
"job_ids": ["job-1"],
|
||||
"job_offsets": {"job-2": 3},
|
||||
}
|
||||
)
|
||||
@ -0,0 +1,73 @@
|
||||
import pytest
|
||||
|
||||
import dify_agent.runtime.compositor_factory as compositor_factory_module
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
|
||||
|
||||
class FakeFactoryClient:
|
||||
async def close(self) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def test_default_layer_providers_register_shell_layer_with_configured_token_factory(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured_tokens: list[str] = []
|
||||
captured_entrypoints: list[str] = []
|
||||
fake_client = FakeFactoryClient()
|
||||
|
||||
def fake_create_shellctl_client_factory(*, token: str):
|
||||
captured_tokens.append(token)
|
||||
|
||||
def factory(entrypoint: str) -> FakeFactoryClient:
|
||||
captured_entrypoints.append(entrypoint)
|
||||
return fake_client
|
||||
|
||||
return factory
|
||||
|
||||
monkeypatch.setattr(compositor_factory_module, "create_shellctl_client_factory", fake_create_shellctl_client_factory)
|
||||
|
||||
providers = create_default_layer_providers(
|
||||
shellctl_entrypoint="http://shellctl.example",
|
||||
shellctl_auth_token="shell-secret",
|
||||
)
|
||||
shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID)
|
||||
shell_layer = shell_provider.create_layer(DifyShellLayerConfig())
|
||||
|
||||
assert isinstance(shell_layer, DifyShellLayer)
|
||||
assert shell_layer.shellctl_entrypoint == "http://shellctl.example"
|
||||
assert captured_tokens == ["shell-secret"]
|
||||
assert shell_layer.shellctl_client_factory(shell_layer.shellctl_entrypoint) is fake_client
|
||||
assert captured_entrypoints == ["http://shellctl.example"]
|
||||
|
||||
|
||||
def test_default_layer_providers_keep_empty_shellctl_token_by_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
captured_tokens: list[str] = []
|
||||
|
||||
def fake_create_shellctl_client_factory(*, token: str):
|
||||
captured_tokens.append(token)
|
||||
|
||||
def factory(_entrypoint: str) -> FakeFactoryClient:
|
||||
return FakeFactoryClient()
|
||||
|
||||
return factory
|
||||
|
||||
monkeypatch.setattr(compositor_factory_module, "create_shellctl_client_factory", fake_create_shellctl_client_factory)
|
||||
|
||||
providers = create_default_layer_providers(shellctl_entrypoint="http://shellctl.example")
|
||||
shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID)
|
||||
_ = shell_provider.create_layer(DifyShellLayerConfig())
|
||||
|
||||
assert captured_tokens == [""]
|
||||
|
||||
|
||||
def test_shell_provider_rejects_blank_settings_entrypoint_only_when_shell_layer_is_created() -> None:
|
||||
providers = create_default_layer_providers(shellctl_entrypoint=" ")
|
||||
shell_provider = next(provider for provider in providers if provider.type_id == DIFY_SHELL_LAYER_TYPE_ID)
|
||||
|
||||
with pytest.raises(ValueError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"):
|
||||
_ = shell_provider.create_layer(DifyShellLayerConfig())
|
||||
@ -25,6 +25,8 @@ from agenton.layers import ExitIntent, LifecycleState
|
||||
from agenton_collections.layers.pydantic_ai import PYDANTIC_AI_HISTORY_LAYER_TYPE_ID, PydanticAIHistoryRuntimeState
|
||||
from agenton_collections.layers.plain import PromptLayerConfig, ToolsLayer
|
||||
from dify_agent.layers.execution_context import DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID, DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.shell import DIFY_SHELL_LAYER_TYPE_ID, DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
from dify_agent.layers.dify_plugin.configs import (
|
||||
DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
DifyPluginLLMLayerConfig,
|
||||
@ -48,12 +50,60 @@ from dify_agent.protocol.schemas import (
|
||||
from dify_agent.runtime.event_sink import InMemoryRunEventSink
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
from dify_agent.runtime.runner import AgentRunRunner, AgentRunValidationError
|
||||
from shell_session_manager.shellctl.shared import DeleteJobResponse, JobResult, JobStatusName, JobStatusView
|
||||
|
||||
|
||||
class StaticToolsTestLayer(ToolsLayer):
|
||||
type_id: ClassVar[str] = "test.static.tools"
|
||||
|
||||
|
||||
class FakeRunnerShellctlClient:
|
||||
run_calls: list[tuple[str, str | None, float]]
|
||||
closed: bool
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.run_calls = []
|
||||
self.closed = False
|
||||
|
||||
async def run(self, script: str, *, cwd: str | None = None, timeout: float = 10.0) -> JobResult:
|
||||
self.run_calls.append((script, cwd, timeout))
|
||||
return JobResult(
|
||||
job_id="mkdir-job",
|
||||
status=JobStatusName.EXITED,
|
||||
done=True,
|
||||
exit_code=0,
|
||||
output_path="/tmp/output.log",
|
||||
output="",
|
||||
offset=0,
|
||||
truncated=False,
|
||||
)
|
||||
|
||||
async def close(self) -> None:
|
||||
self.closed = True
|
||||
|
||||
async def wait(self, job_id: str, *, offset: int, timeout: float = 10.0) -> JobResult:
|
||||
del job_id, offset, timeout
|
||||
raise AssertionError("wait() should not be called in this test")
|
||||
|
||||
async def input(self, job_id: str, text: str, *, offset: int, timeout: float = 10.0) -> JobResult:
|
||||
del job_id, text, offset, timeout
|
||||
raise AssertionError("input() should not be called in this test")
|
||||
|
||||
async def terminate(self, job_id: str, grace_seconds: float = 2.0) -> JobStatusView:
|
||||
del job_id, grace_seconds
|
||||
raise AssertionError("terminate() should not be called in this test")
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
force: bool = False,
|
||||
grace_seconds: float | None = None,
|
||||
) -> DeleteJobResponse:
|
||||
del job_id, force, grace_seconds
|
||||
raise AssertionError("delete() should not be called in this test")
|
||||
|
||||
|
||||
def _request(
|
||||
user: str | list[str] = "hello",
|
||||
*,
|
||||
@ -597,6 +647,116 @@ def test_runner_rejects_duplicate_tool_names_between_static_and_dynamic_tools(
|
||||
assert sink.statuses["run-static-dynamic-duplicate-tools"] == "failed"
|
||||
|
||||
|
||||
def test_runner_rejects_duplicate_tool_names_between_shell_and_other_layers(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
create_agent_called = False
|
||||
shell_client = FakeRunnerShellctlClient()
|
||||
|
||||
def fake_get_model(_self: DifyPluginLLMLayer, *, http_client: httpx.AsyncClient):
|
||||
assert http_client.is_closed is False
|
||||
return TestModel(custom_output_text="done") # pyright: ignore[reportReturnType]
|
||||
|
||||
async def fake_get_tools(_self: DifyPluginToolsLayer, *, http_client: httpx.AsyncClient) -> list[Tool[object]]:
|
||||
assert http_client.is_closed is False
|
||||
|
||||
async def duplicate_shell_run() -> str:
|
||||
return "tool"
|
||||
|
||||
return [Tool(duplicate_shell_run, name="shell.run")]
|
||||
|
||||
def fake_create_agent(model: object, *, tools: list[Tool[object]], output_type: object) -> object:
|
||||
del model, tools, output_type
|
||||
nonlocal create_agent_called
|
||||
create_agent_called = True
|
||||
raise AssertionError("create_agent should not be called when duplicate tool names are detected")
|
||||
|
||||
monkeypatch.setattr(DifyPluginLLMLayer, "get_model", fake_get_model)
|
||||
monkeypatch.setattr(DifyPluginToolsLayer, "get_tools", fake_get_tools)
|
||||
monkeypatch.setattr("dify_agent.runtime.runner.create_agent", fake_create_agent)
|
||||
|
||||
shell_provider = LayerProvider.from_factory(
|
||||
layer_type=DifyShellLayer,
|
||||
create=lambda config: DifyShellLayer.from_config_with_settings(
|
||||
DifyShellLayerConfig.model_validate(config),
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_client_factory=lambda _entrypoint: shell_client,
|
||||
),
|
||||
)
|
||||
layer_providers = tuple(
|
||||
provider for provider in create_default_layer_providers(shellctl_entrypoint="http://unused")
|
||||
if provider.type_id != DIFY_SHELL_LAYER_TYPE_ID
|
||||
) + (shell_provider,)
|
||||
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(
|
||||
name="prompt",
|
||||
type="plain.prompt",
|
||||
config=PromptLayerConfig(prefix="system", user="hello"),
|
||||
),
|
||||
RunLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, config=DifyShellLayerConfig()),
|
||||
RunLayerSpec(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type="dify.plugin.llm",
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="demo-model",
|
||||
credentials={"api_key": "secret"},
|
||||
),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name="tools",
|
||||
type=DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID,
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginToolsLayerConfig(
|
||||
tools=[
|
||||
DifyPluginToolConfig(
|
||||
plugin_id="langgenius/tools",
|
||||
provider="search",
|
||||
tool_name="web_search",
|
||||
credential_type="api-key",
|
||||
parameters=_prepared_plugin_tool_parameters(),
|
||||
parameters_json_schema=_prepared_plugin_tool_schema(),
|
||||
)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(
|
||||
AgentRunValidationError,
|
||||
match="unique tool names across all layers, got duplicates: shell.run",
|
||||
):
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=request,
|
||||
run_id="run-shell-duplicate-tools",
|
||||
plugin_daemon_http_client=client,
|
||||
layer_providers=layer_providers,
|
||||
).run()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert create_agent_called is False
|
||||
assert shell_client.closed is True
|
||||
assert [event.type for event in sink.events["run-shell-duplicate-tools"]] == ["run_started", "run_failed"]
|
||||
assert sink.statuses["run-shell-duplicate-tools"] == "failed"
|
||||
|
||||
|
||||
def test_runner_passes_temporary_system_prompt_prefix_without_history_layer(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
model = RecordingTestModel(custom_output_text="done")
|
||||
|
||||
@ -1433,3 +1593,123 @@ def test_runner_rejects_closed_session_snapshot_as_validation_error() -> None:
|
||||
|
||||
assert [event.type for event in sink.events["run-closed-snapshot"]] == ["run_started", "run_failed"]
|
||||
assert sink.statuses["run-closed-snapshot"] == "failed"
|
||||
|
||||
|
||||
def test_runner_treats_missing_shell_entrypoint_as_validation_error() -> None:
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(
|
||||
name="prompt",
|
||||
type="plain.prompt",
|
||||
config=PromptLayerConfig(prefix="system", user="hello"),
|
||||
),
|
||||
RunLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, config=DifyShellLayerConfig()),
|
||||
RunLayerSpec(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type="dify.plugin.llm",
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="demo-model",
|
||||
credentials={"api_key": "secret"},
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
)
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(AgentRunValidationError, match="DIFY_AGENT_SHELLCTL_ENTRYPOINT"):
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=request,
|
||||
run_id="run-missing-shell-entrypoint",
|
||||
plugin_daemon_http_client=client,
|
||||
).run()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert [event.type for event in sink.events["run-missing-shell-entrypoint"]] == ["run_started", "run_failed"]
|
||||
assert sink.statuses["run-missing-shell-entrypoint"] == "failed"
|
||||
|
||||
|
||||
def test_runner_treats_invalid_shell_snapshot_offsets_as_validation_error() -> None:
|
||||
request = CreateRunRequest(
|
||||
composition=RunComposition(
|
||||
layers=[
|
||||
RunLayerSpec(
|
||||
name="prompt",
|
||||
type="plain.prompt",
|
||||
config=PromptLayerConfig(prefix="system", user="hello"),
|
||||
),
|
||||
RunLayerSpec(name="shell", type=DIFY_SHELL_LAYER_TYPE_ID, config=DifyShellLayerConfig()),
|
||||
RunLayerSpec(
|
||||
name="execution_context",
|
||||
type=DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID,
|
||||
config=DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run"),
|
||||
),
|
||||
RunLayerSpec(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
type="dify.plugin.llm",
|
||||
deps={"execution_context": "execution_context"},
|
||||
config=DifyPluginLLMLayerConfig(
|
||||
plugin_id="langgenius/openai",
|
||||
model_provider="openai",
|
||||
model="demo-model",
|
||||
credentials={"api_key": "secret"},
|
||||
),
|
||||
),
|
||||
]
|
||||
),
|
||||
session_snapshot=CompositorSessionSnapshot(
|
||||
layers=[
|
||||
LayerSessionSnapshot(name="prompt", lifecycle_state=LifecycleState.SUSPENDED, runtime_state={}),
|
||||
LayerSessionSnapshot(
|
||||
name="shell",
|
||||
lifecycle_state=LifecycleState.SUSPENDED,
|
||||
runtime_state={
|
||||
"session_id": "abc12ff",
|
||||
"workspace_cwd": "~/workspace/abc12ff",
|
||||
"job_ids": ["job-1"],
|
||||
"job_offsets": {"job-1": -1},
|
||||
},
|
||||
),
|
||||
LayerSessionSnapshot(
|
||||
name="execution_context",
|
||||
lifecycle_state=LifecycleState.SUSPENDED,
|
||||
runtime_state={},
|
||||
),
|
||||
LayerSessionSnapshot(
|
||||
name=DIFY_AGENT_MODEL_LAYER_ID,
|
||||
lifecycle_state=LifecycleState.SUSPENDED,
|
||||
runtime_state={},
|
||||
),
|
||||
]
|
||||
),
|
||||
)
|
||||
sink = InMemoryRunEventSink()
|
||||
|
||||
async def scenario() -> None:
|
||||
async with httpx.AsyncClient() as client:
|
||||
with pytest.raises(AgentRunValidationError, match="job_offsets"):
|
||||
await AgentRunRunner(
|
||||
sink=sink,
|
||||
request=request,
|
||||
run_id="run-invalid-shell-offset",
|
||||
plugin_daemon_http_client=client,
|
||||
layer_providers=create_default_layer_providers(shellctl_entrypoint="http://shellctl"),
|
||||
).run()
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert [event.type for event in sink.events["run-invalid-shell-offset"]] == ["run_started", "run_failed"]
|
||||
assert sink.statuses["run-invalid-shell-offset"] == "failed"
|
||||
|
||||
@ -1,13 +1,17 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from shell_session_manager.shellctl.client import ShellctlClient
|
||||
|
||||
import dify_agent.server.app as app_module
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.layers.execution_context.layer import DifyExecutionContextLayer
|
||||
from dify_agent.layers.shell import DifyShellLayerConfig
|
||||
from dify_agent.layers.shell.layer import DifyShellLayer
|
||||
from dify_agent.runtime.compositor_factory import DifyAgentLayerProvider
|
||||
from dify_agent.server.app import create_app, create_plugin_daemon_http_client
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
@ -133,6 +137,8 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
run_retention_seconds=7,
|
||||
plugin_daemon_url="http://plugin-daemon",
|
||||
plugin_daemon_api_key="daemon-secret",
|
||||
shellctl_entrypoint="http://shellctl",
|
||||
shellctl_auth_token="shell-secret",
|
||||
plugin_daemon_connect_timeout=1,
|
||||
plugin_daemon_read_timeout=2,
|
||||
plugin_daemon_write_timeout=3,
|
||||
@ -154,9 +160,17 @@ def test_create_app_creates_scheduler_and_closes_after_shutdown(monkeypatch: pyt
|
||||
execution_context_layer = execution_context_provider.create_layer(
|
||||
DifyExecutionContextLayerConfig(tenant_id="tenant-1", invoke_from="workflow_run")
|
||||
)
|
||||
shell_provider = next(provider for provider in layer_providers if provider.type_id == "dify.shell")
|
||||
shell_layer = shell_provider.create_layer(DifyShellLayerConfig())
|
||||
assert isinstance(execution_context_layer, DifyExecutionContextLayer)
|
||||
assert isinstance(shell_layer, DifyShellLayer)
|
||||
assert execution_context_layer.daemon_url == "http://plugin-daemon"
|
||||
assert execution_context_layer.daemon_api_key == "daemon-secret"
|
||||
assert shell_layer.shellctl_entrypoint == "http://shellctl"
|
||||
shellctl_client = shell_layer.shellctl_client_factory("http://shellctl")
|
||||
assert isinstance(shellctl_client, ShellctlClient)
|
||||
assert shellctl_client.token == "shell-secret"
|
||||
asyncio.run(shellctl_client.close())
|
||||
http_client = scheduler.plugin_daemon_http_client
|
||||
assert http_client is fake_http_client
|
||||
assert http_client.is_closed is False
|
||||
|
||||
33
dify-agent/tests/local/dify_agent/server/test_settings.py
Normal file
33
dify-agent/tests/local/dify_agent/server/test_settings.py
Normal file
@ -0,0 +1,33 @@
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from dify_agent.server.settings import ServerSettings
|
||||
|
||||
|
||||
def test_server_settings_reads_shellctl_entrypoint_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_SHELLCTL_ENTRYPOINT", "http://shellctl.example")
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.shellctl_entrypoint == "http://shellctl.example"
|
||||
|
||||
|
||||
def test_server_settings_reads_shellctl_auth_token_from_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv("DIFY_AGENT_SHELLCTL_AUTH_TOKEN", "shell-secret")
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.shellctl_auth_token == "shell-secret"
|
||||
|
||||
|
||||
def test_server_settings_defaults_shellctl_auth_token_to_none(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.delenv("DIFY_AGENT_SHELLCTL_AUTH_TOKEN", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.shellctl_auth_token is None
|
||||
@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import textwrap
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def test_client_public_exports_work_with_default_dependencies_only(tmp_path: Path) -> None:
|
||||
"""Install the package without extras and verify client-facing imports work."""
|
||||
uv = shutil.which("uv")
|
||||
if uv is None:
|
||||
pytest.skip("uv is required to verify default-dependency imports in an isolated environment")
|
||||
|
||||
project_root = Path(__file__).resolve().parents[3]
|
||||
venv_path = tmp_path / "client-default-venv"
|
||||
python_path = venv_path / "bin" / "python"
|
||||
|
||||
subprocess.run([uv, "venv", str(venv_path)], cwd=project_root, check=True)
|
||||
subprocess.run(
|
||||
[uv, "pip", "install", "--python", str(python_path), "."],
|
||||
cwd=project_root,
|
||||
check=True,
|
||||
)
|
||||
|
||||
script = textwrap.dedent(
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from importlib.metadata import PackageNotFoundError, distribution
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
|
||||
|
||||
def requirement_name(requirement: str) -> str:
|
||||
match = re.match(r"\\s*([A-Za-z0-9_.-]+)", requirement)
|
||||
if match is None:
|
||||
raise AssertionError(f"Cannot parse requirement name: {requirement!r}")
|
||||
return match.group(1).lower().replace("_", "-")
|
||||
|
||||
|
||||
project_root = Path(sys.argv[1])
|
||||
pyproject = tomllib.loads((project_root / "pyproject.toml").read_text())
|
||||
default_dependency_names = {
|
||||
requirement_name(requirement)
|
||||
for requirement in pyproject["project"].get("dependencies", [])
|
||||
}
|
||||
server_dependency_names = {
|
||||
requirement_name(requirement)
|
||||
for requirement in pyproject["project"].get("optional-dependencies", {}).get("server", [])
|
||||
}
|
||||
server_only_dependency_names = server_dependency_names - default_dependency_names
|
||||
|
||||
agenton_layers = importlib.import_module("agenton.layers")
|
||||
agenton_compositor = importlib.import_module("agenton.compositor")
|
||||
agenton_collections = importlib.import_module("agenton_collections")
|
||||
plain_layers = importlib.import_module("agenton_collections.layers.plain")
|
||||
pydantic_ai_layers = importlib.import_module("agenton_collections.layers.pydantic_ai")
|
||||
dify_agent = importlib.import_module("dify_agent")
|
||||
client_module = importlib.import_module("dify_agent.client")
|
||||
protocol_module = importlib.import_module("dify_agent.protocol")
|
||||
shell_module = importlib.import_module("dify_agent.layers.shell")
|
||||
execution_context_module = importlib.import_module("dify_agent.layers.execution_context")
|
||||
plugin_module = importlib.import_module("dify_agent.layers.dify_plugin")
|
||||
output_module = importlib.import_module("dify_agent.layers.output")
|
||||
|
||||
assert agenton_layers.ExitIntent is not None
|
||||
assert agenton_layers.LayerConfig is not None
|
||||
assert agenton_compositor.CompositorSessionSnapshot is not None
|
||||
assert agenton_collections.PromptLayer is plain_layers.PromptLayer
|
||||
assert plain_layers.PromptLayerConfig is not None
|
||||
assert pydantic_ai_layers.PydanticAIHistoryLayer is not None
|
||||
assert dify_agent.Client is client_module.Client
|
||||
assert protocol_module.CreateRunRequest is not None
|
||||
assert protocol_module.RunComposition is not None
|
||||
assert protocol_module.RunLayerSpec is not None
|
||||
assert shell_module.DifyShellLayerConfig is not None
|
||||
assert execution_context_module.DifyExecutionContextLayerConfig is not None
|
||||
assert plugin_module.DifyPluginLLMLayerConfig is not None
|
||||
assert output_module.DifyOutputLayerConfig is not None
|
||||
|
||||
unexpectedly_installed = []
|
||||
for dependency_name in sorted(server_only_dependency_names):
|
||||
try:
|
||||
distribution(dependency_name)
|
||||
except PackageNotFoundError:
|
||||
continue
|
||||
unexpectedly_installed.append(dependency_name)
|
||||
assert unexpectedly_installed == []
|
||||
"""
|
||||
)
|
||||
subprocess.run([str(python_path), "-c", script, str(project_root)], cwd=project_root, check=True)
|
||||
@ -83,6 +83,7 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
|
||||
"dify_agent.layers.dify_plugin.llm_layer",
|
||||
"dify_agent.layers.dify_plugin.tools_layer",
|
||||
"dify_agent.layers.output.output_layer",
|
||||
"dify_agent.layers.shell.layer",
|
||||
"dify_agent.runtime",
|
||||
"dify_agent.server",
|
||||
"fastapi",
|
||||
@ -91,18 +92,22 @@ def test_protocol_and_dify_plugin_exports_do_not_import_server_only_modules() ->
|
||||
"openai",
|
||||
"pydantic_settings",
|
||||
"redis",
|
||||
"shell_session_manager.shellctl.client",
|
||||
"shell_session_manager.shellctl.server",
|
||||
],
|
||||
imports=[
|
||||
"dify_agent.protocol",
|
||||
"dify_agent.layers.execution_context",
|
||||
"dify_agent.layers.dify_plugin",
|
||||
"dify_agent.layers.output",
|
||||
"dify_agent.layers.shell",
|
||||
],
|
||||
assertions=[
|
||||
"assert hasattr(dify_agent_protocol, 'PydanticAIStreamRunEvent')",
|
||||
"assert dify_agent_layers_execution_context.__all__ == ['DIFY_EXECUTION_CONTEXT_LAYER_TYPE_ID', 'DifyExecutionContextInvokeFrom', 'DifyExecutionContextLayerConfig']",
|
||||
"assert dify_agent_layers_dify_plugin.__all__ == ['DIFY_PLUGIN_LLM_LAYER_TYPE_ID', 'DIFY_PLUGIN_TOOLS_LAYER_TYPE_ID', 'DifyPluginCredentialValue', 'DifyPluginLLMLayerConfig', 'DifyPluginToolCredentialType', 'DifyPluginToolConfig', 'DifyPluginToolOption', 'DifyPluginToolParameter', 'DifyPluginToolParameterForm', 'DifyPluginToolParameterType', 'DifyPluginToolsLayerConfig', 'DifyPluginToolValue']",
|
||||
"assert dify_agent_layers_output.__all__ == ['DIFY_OUTPUT_LAYER_TYPE_ID', 'DifyOutputLayerConfig']",
|
||||
"assert dify_agent_layers_shell.__all__ == ['DIFY_SHELL_LAYER_TYPE_ID', 'DifyShellLayerConfig']",
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
140
dify-agent/uv.lock
generated
140
dify-agent/uv.lock
generated
@ -19,6 +19,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aiosqlite"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.4"
|
||||
@ -589,6 +598,7 @@ server = [
|
||||
{ name = "pydantic-ai-slim", extra = ["anthropic", "google", "openai"] },
|
||||
{ name = "pydantic-settings" },
|
||||
{ name = "redis" },
|
||||
{ name = "shell-session-manager" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
@ -619,6 +629,7 @@ requires-dist = [
|
||||
{ name = "pydantic-ai-slim", extras = ["anthropic", "google", "openai"], marker = "extra == 'server'", specifier = ">=1.85.1,<2.0.0" },
|
||||
{ name = "pydantic-settings", marker = "extra == 'server'", specifier = ">=2.12.0,<3.0.0" },
|
||||
{ name = "redis", marker = "extra == 'server'", specifier = ">=7.4.0,<8.0.0" },
|
||||
{ name = "shell-session-manager", marker = "extra == 'server'", specifier = "==2.1.1" },
|
||||
{ name = "typing-extensions", specifier = ">=4.12.2,<5.0.0" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'server'", specifier = "==0.46.0" },
|
||||
]
|
||||
@ -811,6 +822,61 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/89/a6340afdaf5169d17a318e00fc685fb67ed99baa602c2cbbbf6af6a76096/graphon-0.2.2-py3-none-any.whl", hash = "sha256:754e544d08779138f99eac6547ab08559463680e2c76488b05e1c978210392b4", size = 340808, upload-time = "2026-04-17T08:52:26.5Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffelib"
|
||||
version = "2.0.2"
|
||||
@ -2946,6 +3012,25 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shell-session-manager"
|
||||
version = "2.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "anyio" },
|
||||
{ name = "fastapi" },
|
||||
{ name = "httpx" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "sqlmodel" },
|
||||
{ name = "typer" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/64/8d12611e48553d61423d5e302d178e67bd968a35f1709e26024f4e04fc3b/shell_session_manager-2.1.1.tar.gz", hash = "sha256:bf490809161244beb95cabad62d32a59b351b7b5993e375d49b6fcf3835ae31c", size = 47064, upload-time = "2026-05-29T20:04:27.625Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/74/64d6db5888f6e7c7dcf0b4960e9ffa8c38425fa906cd60e99ed0bd88def7/shell_session_manager-2.1.1-py3-none-any.whl", hash = "sha256:6b53c813ac386bbf3244c375edf9cce675c89a2041d33a969ef69d8d74f89ac6", size = 45742, upload-time = "2026-05-29T20:04:26.551Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shellingham"
|
||||
version = "1.5.4"
|
||||
@ -3055,6 +3140,61 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/78/d1a1a026ef3af911159398c939b1509d5c36fe524c7b644f34a5146c4e16/spacy_loggers-1.0.5-py3-none-any.whl", hash = "sha256:196284c9c446cc0cdb944005384270d775fdeaf4f494d8e269466cfa497ef645", size = 22343, upload-time = "2023-09-11T12:26:50.586Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlalchemy"
|
||||
version = "2.0.50"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sqlmodel"
|
||||
version = "0.0.38"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ name = "sqlalchemy" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/0d/26ec1329960ea9430131fe63f63a95ea4cb8971d49c891ff7e1f3255421c/sqlmodel-0.0.38.tar.gz", hash = "sha256:d583ec237b14103809f74e8630032bc40ab68cd6b754a610f0813c56911a547b", size = 86710, upload-time = "2026-04-02T21:03:55.571Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/72/c7/10c60af0607ab6fa136264f7f39d205932218516226d38585324ffda705d/sqlmodel-0.0.38-py3-none-any.whl", hash = "sha256:84e3fa990a77395461ded72a6c73173438ce8449d5c1c4d97fbff1b1df692649", size = 27294, upload-time = "2026-04-02T21:03:56.406Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "srsly"
|
||||
version = "2.5.3"
|
||||
|
||||
@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import type { RefObject } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Autocomplete,
|
||||
AutocompleteClear,
|
||||
@ -159,29 +159,6 @@ const virtualizedSuggestions: Suggestion[] = Array.from({ length: 1000 }, (_, in
|
||||
|
||||
const getSuggestionLabel = (item: Suggestion) => item.label
|
||||
|
||||
async function searchSuggestions(
|
||||
suggestions: Suggestion[],
|
||||
query: string,
|
||||
filter: (item: string, query: string) => boolean,
|
||||
): Promise<{ items: Suggestion[], error: string | null }> {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 500))
|
||||
|
||||
if (query === 'will_error') {
|
||||
return {
|
||||
items: [],
|
||||
error: 'Failed to load suggestions. Please try again.',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: suggestions.filter(item => (
|
||||
filter(item.label, query)
|
||||
|| (item.description ? filter(item.description, query) : false)
|
||||
)),
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
const SuggestionItem = ({
|
||||
item,
|
||||
dense,
|
||||
@ -250,7 +227,6 @@ const BasicTagAutocomplete = ({
|
||||
<Autocomplete
|
||||
items={tagSuggestions}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
mode="list"
|
||||
openOnInputClick
|
||||
>
|
||||
<AutocompleteInputGroup size={size}>
|
||||
@ -335,64 +311,32 @@ const LimitedStatus = ({
|
||||
}
|
||||
|
||||
const AsyncSearchDemo = () => {
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [searchResults, setSearchResults] = useState<Suggestion[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const { contains } = useAutocompleteFilter()
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const [value, setValue] = useState('agent')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [items, setItems] = useState(remoteSuggestions)
|
||||
|
||||
const status = (() => {
|
||||
if (isPending)
|
||||
return 'Searching remote suggestions…'
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const timeout = window.setTimeout(() => {
|
||||
setItems(
|
||||
value.trim()
|
||||
? remoteSuggestions.filter(item => item.label.toLowerCase().includes(value.trim().toLowerCase()))
|
||||
: remoteSuggestions,
|
||||
)
|
||||
setLoading(false)
|
||||
}, 500)
|
||||
|
||||
if (error)
|
||||
return error
|
||||
|
||||
if (searchValue === '')
|
||||
return null
|
||||
|
||||
if (searchResults.length === 0)
|
||||
return `No remote suggestion matches "${searchValue}".`
|
||||
|
||||
return `${searchResults.length} remote suggestion${searchResults.length === 1 ? '' : 's'} found`
|
||||
})()
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [value])
|
||||
|
||||
return (
|
||||
<div className={inputWidth}>
|
||||
<Autocomplete
|
||||
items={searchResults}
|
||||
value={searchValue}
|
||||
onValueChange={(nextSearchValue) => {
|
||||
setSearchValue(nextSearchValue)
|
||||
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = controller
|
||||
|
||||
if (nextSearchValue === '') {
|
||||
setSearchResults([])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
startTransition(async () => {
|
||||
setError(null)
|
||||
|
||||
const result = await searchSuggestions(remoteSuggestions, nextSearchValue, contains)
|
||||
|
||||
if (controller.signal.aborted)
|
||||
return
|
||||
|
||||
startTransition(() => {
|
||||
setSearchResults(result.items)
|
||||
setError(result.error)
|
||||
})
|
||||
})
|
||||
}}
|
||||
items={items}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
filter={null}
|
||||
mode="list"
|
||||
openOnInputClick
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
<span className="i-ri-cloud-line ml-2 size-4 shrink-0 text-text-tertiary" aria-hidden="true" />
|
||||
@ -400,15 +344,16 @@ const AsyncSearchDemo = () => {
|
||||
<AutocompleteClear />
|
||||
<AutocompleteTrigger />
|
||||
</AutocompleteInputGroup>
|
||||
<AutocompleteContent portalProps={{ hidden: !status }} popupProps={{ 'aria-busy': isPending || undefined }}>
|
||||
<AutocompleteContent>
|
||||
<AutocompleteStatus>
|
||||
{status}
|
||||
{loading ? 'Loading suggestions…' : `${items.length} remote suggestions`}
|
||||
</AutocompleteStatus>
|
||||
<AutocompleteList>
|
||||
{(item: Suggestion) => (
|
||||
<SuggestionItem key={item.value} item={item} />
|
||||
)}
|
||||
</AutocompleteList>
|
||||
<AutocompleteEmpty>No remote suggestion. Keep the typed query.</AutocompleteEmpty>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
</div>
|
||||
@ -522,7 +467,6 @@ const FuzzyMatchingDemo = () => {
|
||||
onValueChange={setValue}
|
||||
filter={contains}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
mode="list"
|
||||
openOnInputClick
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
@ -623,7 +567,6 @@ export const GroupedSuggestions: Story = {
|
||||
<Autocomplete
|
||||
items={groupedSuggestions}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
mode="list"
|
||||
openOnInputClick
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
@ -652,7 +595,6 @@ export const LimitResults: Story = {
|
||||
items={workflowSuggestions}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
limit={5}
|
||||
mode="list"
|
||||
openOnInputClick
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
@ -685,7 +627,6 @@ export const CommandPalette: Story = {
|
||||
inline
|
||||
items={commandGroups}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
mode="list"
|
||||
autoHighlight="always"
|
||||
keepHighlight
|
||||
>
|
||||
@ -708,7 +649,6 @@ const VirtualizedLongSuggestionsDemo = () => {
|
||||
<Autocomplete
|
||||
items={virtualizedSuggestions}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
mode="list"
|
||||
virtualized
|
||||
openOnInputClick
|
||||
onItemHighlighted={(item, details) => {
|
||||
@ -746,7 +686,6 @@ export const Empty: Story = {
|
||||
items={tagSuggestions}
|
||||
itemToStringValue={getSuggestionLabel}
|
||||
defaultValue="private-release-note"
|
||||
mode="list"
|
||||
openOnInputClick
|
||||
>
|
||||
<AutocompleteInputGroup>
|
||||
@ -771,7 +710,7 @@ export const Empty: Story = {
|
||||
export const DisabledAndReadOnly: Story = {
|
||||
render: () => (
|
||||
<div className="flex w-80 flex-col gap-3">
|
||||
<Autocomplete items={tagSuggestions} itemToStringValue={getSuggestionLabel} defaultValue="feature" mode="list" disabled>
|
||||
<Autocomplete items={tagSuggestions} itemToStringValue={getSuggestionLabel} defaultValue="feature" disabled>
|
||||
<AutocompleteInputGroup>
|
||||
<AutocompleteInput aria-label="Disabled tag autocomplete" />
|
||||
<AutocompleteClear />
|
||||
@ -785,7 +724,7 @@ export const DisabledAndReadOnly: Story = {
|
||||
</AutocompleteList>
|
||||
</AutocompleteContent>
|
||||
</Autocomplete>
|
||||
<Autocomplete items={promptCompletions} itemToStringValue={getSuggestionLabel} defaultValue="summarize this conversation" mode="both" readOnly>
|
||||
<Autocomplete items={promptCompletions} itemToStringValue={getSuggestionLabel} defaultValue="summarize this conversation" readOnly>
|
||||
<AutocompleteInputGroup>
|
||||
<AutocompleteInput aria-label="Read-only prompt autocomplete" />
|
||||
<AutocompleteClear />
|
||||
|
||||
@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import type { Virtualizer } from '@tanstack/react-virtual'
|
||||
import type { RefObject } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { useEffect, useMemo, useRef, useState, useTransition } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxChip,
|
||||
@ -26,7 +26,6 @@ import {
|
||||
ComboboxStatus,
|
||||
ComboboxTrigger,
|
||||
ComboboxValue,
|
||||
useComboboxFilter,
|
||||
useComboboxFilteredItems,
|
||||
} from '.'
|
||||
import { cn } from '../cn'
|
||||
@ -179,34 +178,8 @@ const defaultPopupDataSource = dataSourceOptions[1]!
|
||||
const readOnlyDataSource = dataSourceOptions[2]!
|
||||
const defaultTool = toolGroups[0]!.items[0]!
|
||||
const defaultReviewers = [reviewerOptions[0]!, reviewerOptions[1]!]
|
||||
const defaultAsyncReviewers = [reviewerOptions[1]!]
|
||||
const defaultTag = tagOptions[2]!
|
||||
|
||||
const getOptionLabel = (option: Option) => option.label
|
||||
|
||||
async function searchOptions(
|
||||
options: Option[],
|
||||
query: string,
|
||||
filter: (item: string, query: string) => boolean,
|
||||
): Promise<{ items: Option[], error: string | null }> {
|
||||
await new Promise(resolve => window.setTimeout(resolve, 450))
|
||||
|
||||
if (query === 'will_error') {
|
||||
return {
|
||||
items: [],
|
||||
error: 'Failed to fetch matches. Please try again.',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
items: options.filter(option => (
|
||||
filter(option.label, query)
|
||||
|| (option.meta ? filter(option.meta, query) : false)
|
||||
)),
|
||||
error: null,
|
||||
}
|
||||
}
|
||||
|
||||
const renderOptionItem = (option: Option) => (
|
||||
<ComboboxItem key={option.value} value={option} disabled={option.disabled} className="h-auto min-h-8 py-1.5">
|
||||
<ComboboxItemText className="flex items-center gap-2 px-0">
|
||||
@ -375,88 +348,35 @@ const VirtualizedLongListDemo = () => {
|
||||
}
|
||||
|
||||
const AsyncDirectoryDemo = () => {
|
||||
const [searchResults, setSearchResults] = useState<Option[]>([])
|
||||
const [selectedValue, setSelectedValue] = useState<Option | null>(null)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const { contains } = useComboboxFilter()
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const trimmedSearchValue = searchValue.trim()
|
||||
const items = useMemo(() => {
|
||||
if (!selectedValue || searchResults.some(option => option.value === selectedValue.value))
|
||||
return searchResults
|
||||
const [inputValue, setInputValue] = useState('ma')
|
||||
const [value, setValue] = useState<Option | null>(null)
|
||||
const [items, setItems] = useState(directoryOptions.slice(0, 3))
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
return [...searchResults, selectedValue]
|
||||
}, [searchResults, selectedValue])
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const timeout = window.setTimeout(() => {
|
||||
const query = inputValue.trim().toLowerCase()
|
||||
setItems(
|
||||
query
|
||||
? directoryOptions.filter(option => `${option.label} ${option.meta}`.toLowerCase().includes(query))
|
||||
: directoryOptions.slice(0, 5),
|
||||
)
|
||||
setLoading(false)
|
||||
}, 450)
|
||||
|
||||
const status = (() => {
|
||||
if (isPending)
|
||||
return 'Searching directory matches…'
|
||||
|
||||
if (error)
|
||||
return error
|
||||
|
||||
if (trimmedSearchValue === '')
|
||||
return selectedValue ? null : 'Start typing to search owners…'
|
||||
|
||||
if (searchResults.length === 0)
|
||||
return `No matches for "${trimmedSearchValue}".`
|
||||
|
||||
return `${searchResults.length} owner${searchResults.length === 1 ? '' : 's'} found`
|
||||
})()
|
||||
|
||||
const emptyMessage = trimmedSearchValue === '' || isPending || searchResults.length > 0 || error
|
||||
? null
|
||||
: 'Try a different owner search.'
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [inputValue])
|
||||
|
||||
return (
|
||||
<FieldRoot name="owner" className={fieldWidth}>
|
||||
<FieldLabel>Owner</FieldLabel>
|
||||
<Combobox
|
||||
items={items}
|
||||
itemToStringLabel={getOptionLabel}
|
||||
filter={null}
|
||||
value={selectedValue}
|
||||
onOpenChangeComplete={(open) => {
|
||||
if (!open && selectedValue)
|
||||
setSearchResults([selectedValue])
|
||||
}}
|
||||
onValueChange={(nextSelectedValue) => {
|
||||
setSelectedValue(nextSelectedValue)
|
||||
setSearchValue('')
|
||||
setError(null)
|
||||
}}
|
||||
onInputValueChange={(nextSearchValue, { reason }) => {
|
||||
setSearchValue(nextSearchValue)
|
||||
|
||||
if (nextSearchValue === '') {
|
||||
setSearchResults([])
|
||||
setError(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (reason === 'item-press')
|
||||
return
|
||||
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = controller
|
||||
|
||||
startTransition(async () => {
|
||||
setError(null)
|
||||
|
||||
const result = await searchOptions(directoryOptions, nextSearchValue, contains)
|
||||
|
||||
if (controller.signal.aborted)
|
||||
return
|
||||
|
||||
startTransition(() => {
|
||||
setSearchResults(result.items)
|
||||
setError(result.error)
|
||||
})
|
||||
})
|
||||
}}
|
||||
items={value && !items.some(item => item.value === value.value) ? [value, ...items] : items}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={setInputValue}
|
||||
>
|
||||
<ComboboxInputGroup className="h-8 min-h-8 px-2">
|
||||
<span aria-hidden className="mr-0.5 i-ri-search-line size-4 shrink-0 text-components-input-text-placeholder" />
|
||||
@ -464,12 +384,12 @@ const AsyncDirectoryDemo = () => {
|
||||
<ComboboxClear className="mr-0.5" />
|
||||
<ComboboxInputTrigger className="mr-0" />
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent popupClassName="w-[420px]" popupProps={{ 'aria-busy': isPending || undefined }}>
|
||||
<ComboboxContent popupClassName="w-[420px]">
|
||||
<ComboboxStatus className="border-b border-divider-subtle">
|
||||
{status}
|
||||
{loading ? 'Loading directory matches…' : `${items.length} selectable owners`}
|
||||
</ComboboxStatus>
|
||||
<ComboboxList>{renderOptionItem}</ComboboxList>
|
||||
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
|
||||
<ComboboxEmpty>No owner matches this query</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
</FieldRoot>
|
||||
@ -477,111 +397,38 @@ const AsyncDirectoryDemo = () => {
|
||||
}
|
||||
|
||||
const AsyncReviewerDemo = () => {
|
||||
const [searchResults, setSearchResults] = useState<Option[]>([])
|
||||
const [selectedValues, setSelectedValues] = useState<Option[]>(defaultAsyncReviewers)
|
||||
const [searchValue, setSearchValue] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [blockStartStatus, setBlockStartStatus] = useState(false)
|
||||
const [isPending, startTransition] = useTransition()
|
||||
const { contains } = useComboboxFilter()
|
||||
const abortControllerRef = useRef<AbortController | null>(null)
|
||||
const selectedValuesRef = useRef<Option[]>(defaultAsyncReviewers)
|
||||
const trimmedSearchValue = searchValue.trim()
|
||||
const [inputValue, setInputValue] = useState('ma')
|
||||
const [value, setValue] = useState<Option[]>([reviewerOptions[1]!])
|
||||
const [items, setItems] = useState(reviewerOptions.slice(0, 3))
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (selectedValues.length === 0)
|
||||
return searchResults
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
const timeout = window.setTimeout(() => {
|
||||
const query = inputValue.trim().toLowerCase()
|
||||
const matches = query
|
||||
? reviewerOptions.filter(option => `${option.label} ${option.meta}`.toLowerCase().includes(query))
|
||||
: reviewerOptions
|
||||
|
||||
const merged = [...searchResults]
|
||||
setItems(matches)
|
||||
setLoading(false)
|
||||
}, 450)
|
||||
|
||||
selectedValues.forEach((selected) => {
|
||||
if (!searchResults.some(result => result.value === selected.value))
|
||||
merged.push(selected)
|
||||
})
|
||||
return () => window.clearTimeout(timeout)
|
||||
}, [inputValue])
|
||||
|
||||
return merged
|
||||
}, [searchResults, selectedValues])
|
||||
|
||||
const status = (() => {
|
||||
if (isPending)
|
||||
return 'Searching reviewer matches…'
|
||||
|
||||
if (error)
|
||||
return error
|
||||
|
||||
if (trimmedSearchValue === '' && !blockStartStatus)
|
||||
return selectedValues.length > 0 ? null : 'Start typing to search reviewers…'
|
||||
|
||||
if (searchResults.length === 0 && !blockStartStatus)
|
||||
return `No matches for "${trimmedSearchValue}".`
|
||||
|
||||
return `${searchResults.length} reviewer${searchResults.length === 1 ? '' : 's'} found`
|
||||
})()
|
||||
|
||||
const emptyMessage = trimmedSearchValue === '' || isPending || searchResults.length > 0 || error
|
||||
? null
|
||||
: 'Try a different reviewer search.'
|
||||
const selectedItems = value.filter(selected => !items.some(item => item.value === selected.value))
|
||||
|
||||
return (
|
||||
<FieldRoot name="asyncReviewers" className={fieldWidth}>
|
||||
<FieldLabel>Async reviewers</FieldLabel>
|
||||
<Combobox
|
||||
items={items}
|
||||
itemToStringLabel={getOptionLabel}
|
||||
items={[...selectedItems, ...items]}
|
||||
multiple
|
||||
filter={null}
|
||||
value={selectedValues}
|
||||
onOpenChangeComplete={(open) => {
|
||||
if (!open) {
|
||||
setSearchResults(selectedValuesRef.current)
|
||||
setBlockStartStatus(false)
|
||||
}
|
||||
}}
|
||||
onValueChange={(nextSelectedValues) => {
|
||||
selectedValuesRef.current = nextSelectedValues
|
||||
setSelectedValues(nextSelectedValues)
|
||||
setSearchValue('')
|
||||
setError(null)
|
||||
|
||||
if (nextSelectedValues.length === 0) {
|
||||
setSearchResults([])
|
||||
setBlockStartStatus(false)
|
||||
}
|
||||
else {
|
||||
setBlockStartStatus(true)
|
||||
}
|
||||
}}
|
||||
onInputValueChange={(nextSearchValue, { reason }) => {
|
||||
setSearchValue(nextSearchValue)
|
||||
|
||||
const controller = new AbortController()
|
||||
abortControllerRef.current?.abort()
|
||||
abortControllerRef.current = controller
|
||||
|
||||
if (nextSearchValue === '') {
|
||||
setSearchResults(selectedValuesRef.current)
|
||||
setError(null)
|
||||
setBlockStartStatus(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (reason === 'item-press')
|
||||
return
|
||||
|
||||
startTransition(async () => {
|
||||
setError(null)
|
||||
|
||||
const result = await searchOptions(reviewerOptions, nextSearchValue, contains)
|
||||
|
||||
if (controller.signal.aborted)
|
||||
return
|
||||
|
||||
startTransition(() => {
|
||||
setSearchResults(result.items)
|
||||
setError(result.error)
|
||||
})
|
||||
})
|
||||
}}
|
||||
value={value}
|
||||
onValueChange={setValue}
|
||||
inputValue={inputValue}
|
||||
onInputValueChange={setInputValue}
|
||||
>
|
||||
<ComboboxInputGroup className="h-auto min-h-8 items-start py-1">
|
||||
<ComboboxChips>
|
||||
@ -600,12 +447,12 @@ const AsyncReviewerDemo = () => {
|
||||
</ComboboxValue>
|
||||
</ComboboxChips>
|
||||
</ComboboxInputGroup>
|
||||
<ComboboxContent popupClassName="w-[420px]" popupProps={{ 'aria-busy': isPending || undefined }}>
|
||||
<ComboboxContent popupClassName="w-[420px]">
|
||||
<ComboboxStatus className="border-b border-divider-subtle">
|
||||
{status}
|
||||
{loading ? 'Loading reviewer matches…' : `${items.length} selectable reviewers`}
|
||||
</ComboboxStatus>
|
||||
<ComboboxList>{renderOptionItem}</ComboboxList>
|
||||
<ComboboxEmpty>{emptyMessage}</ComboboxEmpty>
|
||||
<ComboboxEmpty>No reviewer matches this query</ComboboxEmpty>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
<FieldDescription>Selected reviewers stay available while async matches change.</FieldDescription>
|
||||
|
||||
@ -17,7 +17,7 @@ const Placeholder = ({
|
||||
loadingFileName,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className={cn(wrapClassName, 'p-3')}>
|
||||
<div className={wrapClassName}>
|
||||
<SkeletonRow>
|
||||
<div
|
||||
className="flex h-10 w-10 items-center justify-center gap-2 rounded-[10px] border-[0.5px]
|
||||
|
||||
@ -30,7 +30,7 @@ const Installed: FC<Props> = ({
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3">
|
||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
<p className="system-md-regular text-text-secondary">{(isFailed && errMsg) ? errMsg : t(`installModal.${isFailed ? 'installFailedDesc' : 'installedSuccessfullyDesc'}`, { ns: 'plugin' })}</p>
|
||||
{payload && (
|
||||
<div className="flex flex-wrap content-start items-start gap-1 self-stretch rounded-2xl bg-background-section-burn p-2">
|
||||
|
||||
@ -116,7 +116,7 @@ const Installed: FC<Props> = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3">
|
||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
<p>{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}</p>
|
||||
<p>
|
||||
|
||||
@ -106,7 +106,7 @@ const Uploading: FC<Props> = ({
|
||||
}, [handleUpload])
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start justify-center gap-2 self-stretch px-6 py-3">
|
||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
<div className="flex items-center gap-1 self-stretch">
|
||||
<span className="i-ri-loader-2-line size-4 animate-spin-slow text-text-accent" />
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
|
||||
Reference in New Issue
Block a user