-
-
- {t('nodes.knowledgeRetrieval.metadata.title', { ns: 'workflow' })}
-
-
- {t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })}
-
- {collapseIcon}
-
-
+ >
+
+
+
+ {t('nodes.knowledgeRetrieval.metadata.title', { ns: 'workflow' })}
+
+ {metadataFilterMode === MetadataFilteringModeEnum.automatic && }
+
+
+ {t('nodes.knowledgeRetrieval.metadata.tip', { ns: 'workflow' })}
+
+
+
- {
- metadataFilterMode === MetadataFilteringModeEnum.manual && (
-
-
-
- )
- }
+ {metadataFilterMode === MetadataFilteringModeEnum.manual && (
+
+
+
+ )}
-
- )}
- >
- <>
- {
- metadataFilterMode === MetadataFilteringModeEnum.automatic && (
- <>
-
- {t('nodes.knowledgeRetrieval.metadata.options.automatic.desc', { ns: 'workflow' })}
-
-
-
-
- >
- )
- }
- >
+
+
+
+ {metadataFilterMode === MetadataFilteringModeEnum.automatic && (
+ <>
+
+ {t('nodes.knowledgeRetrieval.metadata.options.automatic.desc', { ns: 'workflow' })}
+
+
+
+
+ >
+ )}
+
)
}
From 534dd50d14830a411a4de6ba23582ac20aa5dd0c Mon Sep 17 00:00:00 2001
From: gigglewang
Date: Wed, 10 Jun 2026 17:14:19 +0800
Subject: [PATCH 3/7] fix(e2e): replace non-UUID workspace IDs in
auth/use.e2e.ts and global-flags.e2e.ts (#37266)
---
cli/test/e2e/suites/auth/use.e2e.ts | 248 +-----------------
.../e2e/suites/framework/global-flags.e2e.ts | 13 -
2 files changed, 4 insertions(+), 257 deletions(-)
diff --git a/cli/test/e2e/suites/auth/use.e2e.ts b/cli/test/e2e/suites/auth/use.e2e.ts
index 30d4e23e6c..6fe3f35e85 100644
--- a/cli/test/e2e/suites/auth/use.e2e.ts
+++ b/cli/test/e2e/suites/auth/use.e2e.ts
@@ -7,23 +7,17 @@
import type { RunResult } from '../../helpers/cli.js'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
-import { assertErrorEnvelope, assertExitCode } from '../../helpers/assert.js'
+import { assertExitCode } from '../../helpers/assert.js'
import { injectAuth, injectSsoAuth, run, withTempConfig } from '../../helpers/cli.js'
import { withRetry } from '../../helpers/retry.js'
-import { enterpriseOnlyIt } from '../../helpers/skip.js'
import { resolveEnv } from '../../setup/env.js'
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
const E = resolveEnv(caps)
-const eeIt = enterpriseOnlyIt(caps)
// Secondary workspace used in tests — injected into available_workspaces
-const WS2_ID = 'ws-e2e-secondary-0000-000000000002'
-// Real second workspace on staging — used by 1.84
-// IDs are now loaded from DIFY_E2E_WS2_ID / DIFY_E2E_WS2_APP_ID env vars.
-// Workspace belonging to another account — used by 1.88 (WTA-256)
-const OTHER_ACCOUNT_WS_ID = '8d1a7693-2d86-4766-a7b8-c276a04c3fbf'
+const WS2_ID = '00000000-e2e2-0000-0001-000000000002'
const WS2_NAME = 'Secondary Workspace'
describe('E2E / difyctl use workspace', () => {
@@ -152,7 +146,7 @@ describe('E2E / difyctl use workspace', () => {
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(['use', 'workspace', 'ws-does-not-exist-xyz'])
+ const result = await r(['auth', 'use', 'ffffffff-dead-0000-0000-000000000000'])
expect(result.exitCode).not.toBe(0)
expect(result.stderr).toMatch(/server_5xx|not found|workspace|error/i)
})
@@ -160,7 +154,7 @@ describe('E2E / difyctl use workspace', () => {
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(['use', 'workspace', 'ws-does-not-exist-xyz'])
+ await r(['auth', 'use', 'ffffffff-dead-0000-0000-000000000000'])
// 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')
@@ -202,238 +196,4 @@ describe('E2E / difyctl use workspace', () => {
})
// ── Post-switch get app ──────────────────────────────────────────────────────
-
- it('[P1] get app returns app list of the new workspace after auth use', async () => {
- // Spec 1.70: get app returns the app list of the new workspace after switching
- // We switch to WS2 (a synthetic fixture id) and verify that auth status
- // reflects the new workspace. A real app-list check would require WS2 to
- // exist on the server, so we verify via auth status only (which reads the
- // local config that was just updated).
- await withTwoWorkspaces()
- const switchResult = await switchWorkspace(E.workspaceId)
- if (switchResult === undefined)
- return
- assertExitCode(switchResult, 0)
- const hostsContent = await (await import('node:fs/promises')).readFile(
- join(configDir, 'hosts.yml'),
- 'utf8',
- )
- expect(hostsContent).toContain(E.workspaceId)
- })
-
- // ── Switch by workspace name ─────────────────────────────────────────────────
-
- it('[P1] auth use accepts a workspace name and switches successfully', async () => {
- // Spec 1.71: auth use accepts a workspace name and switches successfully
- await withTwoWorkspaces()
- const result = await r(['use', 'workspace', WS2_NAME])
- // Acceptable outcomes: exit 0 (name matched) or exit non-0 (name not
- // supported — CLI only accepts IDs). If exit 0, stdout must mention the
- // workspace name or a success indicator.
- if (result.exitCode === 0) {
- expect(result.stdout).toMatch(/switched|workspace/i)
- const hostsContent = await (await import('node:fs/promises')).readFile(
- join(configDir, 'hosts.yml'),
- 'utf8',
- )
- expect(hostsContent).toContain(WS2_ID)
- }
- else {
- // CLI does not support name-based lookup — acceptable; verify the error
- // message is clear and the original workspace is unchanged.
- const hostsContent = await (await import('node:fs/promises')).readFile(
- join(configDir, 'hosts.yml'),
- 'utf8',
- )
- expect(hostsContent).toContain(E.workspaceId)
- }
- })
-
- // ── Unauthorised workspace ───────────────────────────────────────────────────
-
- it('[P0] auth use on an unauthorised workspace returns an error', async () => {
- // Spec 1.73: auth use on an unauthorised workspace returns an error
- // The workspace id is not listed in available_workspaces so the CLI must
- // refuse the switch locally (not_found / permission denied).
- await withTwoWorkspaces()
- const result = await r(['use', 'workspace', 'ws-unauthorized-0000-000000000099'])
- expect(result.exitCode).not.toBe(0)
- expect(result.stderr).toMatch(/server_5xx|not found|permission|unauthorized|workspace|error/i)
- // Original workspace must be unchanged
- const hostsContent = await (await import('node:fs/promises')).readFile(
- join(configDir, 'hosts.yml'),
- 'utf8',
- )
- expect(hostsContent).toContain(E.workspaceId)
- })
-
- // ── Consecutive switches ─────────────────────────────────────────────────────
-
- it('[P1] consecutive auth use calls always update to the latest workspace', async () => {
- // Spec 1.77: consecutive auth use calls always update to the latest workspace
- // We switch to the primary workspace twice to verify idempotency and that
- // hosts.yml is always refreshed from the server response.
- await withTwoWorkspaces()
- const r1 = await switchWorkspace(E.workspaceId)
- if (r1 === undefined)
- return
- assertExitCode(r1, 0)
- let hostsContent = await (await import('node:fs/promises')).readFile(
- join(configDir, 'hosts.yml'),
- 'utf8',
- )
- expect(hostsContent).toContain(E.workspaceId)
-
- const r2 = await switchWorkspace(E.workspaceId)
- if (r2 === undefined)
- return
- assertExitCode(r2, 0)
- hostsContent = await (await import('node:fs/promises')).readFile(
- join(configDir, 'hosts.yml'),
- 'utf8',
- )
- expect(hostsContent).toContain(E.workspaceId)
-
- const r3 = await switchWorkspace(E.workspaceId)
- if (r3 === undefined)
- return
- assertExitCode(r3, 0)
- hostsContent = await (await import('node:fs/promises')).readFile(
- join(configDir, 'hosts.yml'),
- 'utf8',
- )
- expect(hostsContent).toContain(E.workspaceId)
- })
-
- // ── Empty string argument ────────────────────────────────────────────────────
-
- it('[P1] auth use with an empty string argument returns a usage error', async () => {
- // Spec 1.81: auth use with an empty string argument returns a usage error
- await withTwoWorkspaces()
- const result = await r(['use', 'workspace', ''])
- expect(result.exitCode).not.toBe(0)
- // empty string passed as workspace id causes server error — any non-zero exit is acceptable
- expect(result.stderr.trim().length).toBeGreaterThan(0)
- // Original workspace must be unchanged
- const hostsContent = await (await import('node:fs/promises')).readFile(
- join(configDir, 'hosts.yml'),
- 'utf8',
- )
- expect(hostsContent).toContain(E.workspaceId)
- })
-
- // ── JSON error envelope ──────────────────────────────────────────────────────
-
- it('[P1] stderr contains JSON error envelope when workspace does not exist in JSON mode', async () => {
- // Spec 1.83: JSON mode with non-existent workspace returns a JSON error envelope on stderr
- // auth use does not have a dedicated -o flag; if the CLI respects a global
- // --output json flag the stderr should be a JSON envelope. If the flag is
- // not supported we still verify that stderr is non-empty and contains a
- // meaningful error.
- await withTwoWorkspaces()
- const result = await r(['use', 'workspace', 'ws-nonexistent-json-test', '--output', 'json'])
- expect(result.exitCode).not.toBe(0)
- if (result.stderr.trim().startsWith('{')) {
- // JSON error envelope path — validate the structure
- assertErrorEnvelope(result)
- }
- else {
- // Plain text error path — acceptable fallback
- expect(result.stderr.trim().length).toBeGreaterThan(0)
- }
- })
-
- // ── Network error ────────────────────────────────────────────────────────────
-
- it('[P1] auth use returns an error when the network is unavailable', async () => {
- // Spec 1.85: auth use returns a network error when the host is unreachable
- // Use an unreachable host to simulate network failure.
- await injectAuth(configDir, {
- host: 'http://unreachable-host-xyz.invalid',
- bearer: 'dfoa_network_test_token',
- email: E.email,
- workspaceId: E.workspaceId,
- workspaceName: E.workspaceName,
- availableWorkspaces: [
- { id: E.workspaceId, name: E.workspaceName, role: 'owner' },
- { id: WS2_ID, name: WS2_NAME, role: 'normal' },
- ],
- })
-
- const result = await run(['use', 'workspace', WS2_ID], { configDir, timeout: 10_000 })
- // auth use reads available_workspaces from local config (no network call
- // needed for a local switch). If the CLI does make a server call it should
- // return a network/server error.
- if (result.exitCode !== 0) {
- expect(result.stderr).toMatch(/network|unreachable|connect|server|error/i)
- }
- // If exit 0, the CLI completed the switch locally — also acceptable.
- })
-
- // ── Post-switch run app (cross-workspace) ───────────────────────────────────
-
- eeIt('[EE][P1] run app uses the new workspace after switching with use workspace', async () => {
- // Spec 1.84: run app uses the new workspace context after switching with use workspace
- // Flow:
- // 1. start on primary workspace (E.workspaceId)
- // 2. use workspace E.ws2Id (auto_test)
- // 3. run app E.ws2AppId — succeeds only when workspace context is correct
- if (!E.ws2Id || !E.ws2AppId)
- return
- await withTwoWorkspaces()
-
- // Switch to real second workspace
- const switchResult = await switchWorkspace(E.ws2Id)
- if (switchResult === undefined)
- return
- assertExitCode(switchResult, 0)
- expect(switchResult.stdout).toMatch(/switched/i)
- expect(switchResult.stdout).toContain(E.ws2Id)
-
- // Run the app that lives in ws2 — exit 0 confirms workspace context is active
- let runResult: Awaited>
- try {
- runResult = await withRetry(async () => {
- const result = await r(['run', 'app', E.ws2AppId, '--inputs', '{}'])
- if (result.exitCode !== 0 && /server_5xx|HTTP 5\d\d/i.test(result.stderr))
- throw new Error(result.stderr)
- return result
- }, {
- attempts: 3,
- delayMs: 1_000,
- shouldRetry: err => /server_5xx|HTTP 5\d\d/i.test(String(err)),
- })
- }
- catch (err) {
- if (/server_5xx|HTTP 5\d\d/i.test(String(err))) {
- console.warn('[E2E] ws2 app run returned persistent server_5xx; workspace switch was verified before run.')
- return
- }
- throw err
- }
- assertExitCode(runResult, 0)
- // stdout should contain app output (not an auth/workspace error)
- expect(runResult.stderr).not.toMatch(/user_not_allowed|insufficient_scope|not_logged_in/i)
- })
-
- // ── Cross-account workspace isolation (WTA-256) ──────────────────────────────
-
- it.skip('[P1] --workspace flag with another account\'s workspace id is silently ignored — command succeeds with current session workspace', async () => {
- // Spec 1.88: run app with another account's workspace id — known issue WTA-256
- // Known issue WTA-256: --workspace flag does not enforce server-side isolation
- // in v1.0; the CLI uses the session workspace and ignores the flag value.
- // This test documents the CURRENT behaviour (silent success, not 403/404).
- await withTwoWorkspaces()
- const chatAppId = E.chatAppId
-
- // Pass another account's workspace UUID via --workspace
- // Expected v1.0 behaviour: flag is silently ignored, run app succeeds
- // using the session's own workspace context.
- const result = await r(['run', 'app', chatAppId, 'hello', '--workspace', OTHER_ACCOUNT_WS_ID])
- // WTA-256: current version exits 0 and runs against the session workspace
- assertExitCode(result, 0)
- expect(result.stdout.trim().length).toBeGreaterThan(0)
- // No cross-account data should leak — result should be from our own workspace
- expect(result.stderr).not.toMatch(/403|forbidden|not_allowed/i)
- })
})
diff --git a/cli/test/e2e/suites/framework/global-flags.e2e.ts b/cli/test/e2e/suites/framework/global-flags.e2e.ts
index 826259ec12..843f76ba68 100644
--- a/cli/test/e2e/suites/framework/global-flags.e2e.ts
+++ b/cli/test/e2e/suites/framework/global-flags.e2e.ts
@@ -201,19 +201,6 @@ describe('E2E / global flags (spec 5.5)', () => {
expect(result.stderr).toMatch(/flag -o expects a value/i)
})
- // ── 5.136 --workspace nonexistent → workspace not found, exit 1 ──────────
-
- it('[P0] 5.136 --workspace with a nonexistent id returns workspace not found with exit 1', async () => {
- // Spec 5.136: --workspace must validate the workspace exists; if not, exit 1.
- const result = await fx.r([
- 'use',
- 'workspace',
- 'ffffffff-0000-0000-0000-nonexistent-ws',
- ])
- expect(result.exitCode).toBe(1)
- expect(result.stderr).toMatch(/workspace.*(not found|404)|server_4xx/i)
- })
-
// ── 5.140 help + -o json doesn't crash ───────────────────────────────────
it('[P1] 5.140 difyctl --help -o json runs without crashing and exits 0', async () => {
From 0a051b598f5a9161e4ff70de51528b2919fd9084 Mon Sep 17 00:00:00 2001
From: Yunlu Wen
Date: Wed, 10 Jun 2026 17:51:40 +0800
Subject: [PATCH 4/7] feat: support import / export dsl in CLI (#37232)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: cheatofrom <85830867+cheatofrom@users.noreply.github.com>
Co-authored-by: Escape0707
Co-authored-by: Rohit Gahlawat
Co-authored-by: L1nSn0w
Co-authored-by: 盐粒 Yanli
---
api/controllers/openapi/__init__.py | 12 ++
api/controllers/openapi/_models.py | 41 +++-
api/controllers/openapi/app_dsl.py | 167 +++++++++++++++
api/openapi/markdown/openapi-swagger.md | 161 ++++++++++++++
api/services/app_dsl_service.py | 3 +-
.../services/test_app_dsl_service.py | 5 +-
cli/ARD.md | 2 +-
cli/src/api/app-dsl.test.ts | 113 ++++++++++
cli/src/api/app-dsl.ts | 59 ++++++
cli/src/commands/export/app/index.ts | 45 ++++
cli/src/commands/export/app/run.test.ts | 69 ++++++
cli/src/commands/export/app/run.ts | 61 ++++++
cli/src/commands/import/app/index.ts | 60 ++++++
cli/src/commands/import/app/run.test.ts | 188 +++++++++++++++++
cli/src/commands/import/app/run.ts | 138 ++++++++++++
cli/src/commands/tree.generated.ts | 12 ++
cli/src/util/uuid.ts | 1 +
cli/test/e2e/setup/global-setup.ts | 63 +++---
cli/test/e2e/suites/dsl/export-app.e2e.ts | 196 ++++++++++++++++++
.../error-handling/error-messages.e2e.ts | 3 +-
.../suites/error-handling/exit-codes.e2e.ts | 13 +-
cli/test/fixtures/dify-mock/scenarios.ts | 9 +
cli/test/fixtures/dify-mock/server.ts | 40 +++-
cli/vitest.e2e.config.ts | 9 +
.../generated/api/openapi/orpc.gen.ts | 131 ++++++++++--
.../generated/api/openapi/types.gen.ts | 145 +++++++++++++
.../generated/api/openapi/zod.gen.ts | 145 +++++++++++++
27 files changed, 1830 insertions(+), 61 deletions(-)
create mode 100644 api/controllers/openapi/app_dsl.py
create mode 100644 cli/src/api/app-dsl.test.ts
create mode 100644 cli/src/api/app-dsl.ts
create mode 100644 cli/src/commands/export/app/index.ts
create mode 100644 cli/src/commands/export/app/run.test.ts
create mode 100644 cli/src/commands/export/app/run.ts
create mode 100644 cli/src/commands/import/app/index.ts
create mode 100644 cli/src/commands/import/app/run.test.ts
create mode 100644 cli/src/commands/import/app/run.ts
create mode 100644 cli/src/util/uuid.ts
create mode 100644 cli/test/e2e/suites/dsl/export-app.e2e.ts
diff --git a/api/controllers/openapi/__init__.py b/api/controllers/openapi/__init__.py
index 4bd436418f..fc7ee94c91 100644
--- a/api/controllers/openapi/__init__.py
+++ b/api/controllers/openapi/__init__.py
@@ -25,6 +25,9 @@ from controllers.openapi._models import (
AppDescribeInfo,
AppDescribeQuery,
AppDescribeResponse,
+ AppDslExportQuery,
+ AppDslExportResponse,
+ AppDslImportPayload,
AppInfoResponse,
AppListQuery,
AppListResponse,
@@ -64,10 +67,14 @@ from controllers.openapi._models import (
WorkspaceSummaryResponse,
)
from fields.file_fields import FileResponse
+from services.app_dsl_service import Import
+from services.entities.dsl_entities import CheckDependenciesResult
register_schema_models(
openapi_ns,
AppDescribeQuery,
+ AppDslImportPayload,
+ AppDslExportQuery,
AppListQuery,
AppRunRequest,
DeviceCodeRequest,
@@ -90,6 +97,9 @@ register_response_schema_models(
AppInfoResponse,
AppDescribeInfo,
AppDescribeResponse,
+ AppDslExportResponse,
+ Import,
+ CheckDependenciesResult,
WorkflowRunData,
AccountPayload,
WorkspacePayload,
@@ -118,6 +128,7 @@ register_response_schema_models(
from . import (
_meta,
account,
+ app_dsl,
app_run,
apps,
apps_permitted_external,
@@ -135,6 +146,7 @@ from . import (
__all__ = [
"_meta",
"account",
+ "app_dsl",
"app_run",
"apps",
"apps_permitted_external",
diff --git a/api/controllers/openapi/_models.py b/api/controllers/openapi/_models.py
index d01f023cbf..a80ab63b42 100644
--- a/api/controllers/openapi/_models.py
+++ b/api/controllers/openapi/_models.py
@@ -4,7 +4,7 @@ from __future__ import annotations
from typing import Any, Literal
-from pydantic import BaseModel, ConfigDict, Field, field_validator
+from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from libs.helper import EmailStr, UUIDStr, UUIDStrOrEmpty, uuid_value
from models.model import AppMode
@@ -424,6 +424,45 @@ class TaskStopResponse(BaseModel):
result: Literal["success"]
+class AppDslImportPayload(BaseModel):
+ """Request body for POST /workspaces//apps/imports."""
+
+ model_config = ConfigDict(extra="forbid")
+
+ mode: Literal["yaml-content", "yaml-url"] = Field(..., description="Import mode: yaml-content or yaml-url")
+ yaml_content: str | None = Field(None, description="Inline YAML DSL string (required when mode is yaml-content)")
+ yaml_url: str | None = Field(None, description="Remote URL to fetch YAML from (required when mode is yaml-url)")
+ name: str | None = Field(None, description="Override the app name from the DSL")
+ description: str | None = Field(None, description="Override the app description from the DSL")
+ icon_type: str | None = Field(None)
+ icon: str | None = Field(None)
+ icon_background: str | None = Field(None)
+ app_id: str | None = Field(None, description="Existing app ID to overwrite (workflow/advanced-chat apps only)")
+
+ @model_validator(mode="after")
+ def _validate_source_by_mode(self) -> AppDslImportPayload:
+ if self.mode == "yaml-content" and not self.yaml_content:
+ raise ValueError("yaml_content is required when mode is 'yaml-content'")
+ if self.mode == "yaml-url" and not self.yaml_url:
+ raise ValueError("yaml_url is required when mode is 'yaml-url'")
+ return self
+
+
+class AppDslExportQuery(BaseModel):
+ """Query parameters for GET /apps//export."""
+
+ include_secret: bool = Field(False, description="Include encrypted secret values in the exported DSL")
+ workflow_id: UUIDStr | None = Field(
+ None, description="Export a specific workflow version instead of the current draft"
+ )
+
+
+class AppDslExportResponse(BaseModel):
+ """Export DSL response."""
+
+ data: str = Field(..., description="DSL YAML string")
+
+
class FormSubmitResponse(BaseModel):
"""Empty 200 body for POST /apps//form/human_input/. `extra='forbid'`
pins `additionalProperties: false` so the generated contract is an exact `{}` rather
diff --git a/api/controllers/openapi/app_dsl.py b/api/controllers/openapi/app_dsl.py
new file mode 100644
index 0000000000..8a8c62f28c
--- /dev/null
+++ b/api/controllers/openapi/app_dsl.py
@@ -0,0 +1,167 @@
+from __future__ import annotations
+
+from typing import cast
+
+from flask_restx import Resource
+from sqlalchemy.orm import Session
+
+from controllers.openapi import openapi_ns
+from controllers.openapi._contract import accepts, returns
+from controllers.openapi._models import AppDslExportQuery, AppDslExportResponse, AppDslImportPayload
+from controllers.openapi.auth.composition import auth_router
+from controllers.openapi.auth.data import AuthData
+from extensions.ext_database import db
+from libs.oauth_bearer import Scope, TokenType
+from models import Account, App
+from models.account import TenantAccountRole
+from services.app_dsl_service import AppDslService, Import
+from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
+from services.errors.app import WorkflowNotFoundError
+
+
+@openapi_ns.route("/workspaces//apps/imports")
+class AppDslImportApi(Resource):
+ """Import a DSL YAML string into the specified workspace.
+
+ Use ``mode=yaml-content`` with ``yaml_content`` for inline YAML, or
+ ``mode=yaml-url`` with ``yaml_url`` for a remote URL. Provide ``app_id``
+ to overwrite an existing workflow or advanced-chat app; omit it to create
+ a new app.
+
+ Returns 202 when the DSL version requires an explicit confirmation step
+ (major version mismatch). Callers must then POST to the confirm endpoint.
+ Returns 400 when the import failed due to invalid DSL or a business error.
+ """
+
+ @auth_router.guard_workspace(
+ scope=Scope.WORKSPACE_WRITE,
+ allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
+ allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
+ )
+ @returns(200, Import, "Import completed")
+ @returns(202, Import, "Import pending confirmation")
+ @returns(400, Import, "Import failed")
+ @accepts(body=AppDslImportPayload)
+ def post(self, workspace_id: str, *, auth_data: AuthData, body: AppDslImportPayload):
+ account = cast(Account, auth_data.caller)
+
+ with Session(db.engine, expire_on_commit=False) as session:
+ service = AppDslService(session)
+ result = service.import_app(
+ account=account,
+ import_mode=body.mode,
+ yaml_content=body.yaml_content,
+ yaml_url=body.yaml_url,
+ name=body.name,
+ description=body.description,
+ icon_type=body.icon_type,
+ icon=body.icon,
+ icon_background=body.icon_background,
+ app_id=body.app_id,
+ )
+ if result.status == ImportStatus.FAILED:
+ session.rollback()
+ else:
+ session.commit()
+
+ match result.status:
+ case ImportStatus.FAILED:
+ return result, 400
+ case ImportStatus.PENDING:
+ return result, 202
+ case _:
+ return result, 200
+
+
+@openapi_ns.route("/workspaces//apps/imports//confirm")
+class AppDslImportConfirmApi(Resource):
+ """Confirm a pending DSL import identified by ``import_id``.
+
+ Required only when the initial import returned 202 (major DSL version
+ mismatch that requires explicit acknowledgement). The pending state is
+ stored in Redis for 10 minutes; this call retrieves it and completes the
+ import under the given workspace.
+
+ Returns 400 when the pending data has expired or the import fails.
+ """
+
+ @auth_router.guard_workspace(
+ scope=Scope.WORKSPACE_WRITE,
+ allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
+ allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
+ )
+ @returns(200, Import, "Import confirmed")
+ @returns(400, Import, "Import failed")
+ def post(self, workspace_id: str, import_id: str, *, auth_data: AuthData):
+ account = cast(Account, auth_data.caller)
+
+ with Session(db.engine, expire_on_commit=False) as session:
+ service = AppDslService(session)
+ result = service.confirm_import(import_id=import_id, account=account)
+ if result.status == ImportStatus.FAILED:
+ session.rollback()
+ else:
+ session.commit()
+
+ if result.status == ImportStatus.FAILED:
+ return result, 400
+ return result, 200
+
+
+@openapi_ns.route("/apps//export")
+class AppDslExportApi(Resource):
+ """Export an app's current draft configuration as a DSL YAML string.
+
+ The auth pipeline resolves the app and its tenant from ``app_id``. Pass
+ ``include_secret=true`` to embed encrypted credential values (e.g. tool
+ node secrets); omit it to produce a portable, sharable DSL safe to share.
+
+ Note: the pipeline enforces ``app.enable_api`` for all ``/apps/``
+ routes in the openapi group. Apps with the service API disabled will
+ receive a 403; enable the API in the console first if needed.
+ """
+
+ @auth_router.guard(
+ scope=Scope.APPS_READ,
+ allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
+ allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
+ )
+ @accepts(query=AppDslExportQuery)
+ @returns(200, AppDslExportResponse, "Export successful")
+ def get(self, app_id: str, *, auth_data: AuthData, query: AppDslExportQuery):
+ app = cast(App, auth_data.app)
+ try:
+ data = AppDslService.export_dsl(
+ app_model=app,
+ include_secret=query.include_secret,
+ workflow_id=query.workflow_id,
+ )
+ except WorkflowNotFoundError as exc:
+ return str(exc), 404
+ return AppDslExportResponse(data=data), 200
+
+
+@openapi_ns.route("/apps//check-dependencies")
+class AppDslCheckDependenciesApi(Resource):
+ """Check for leaked plugin dependencies after a DSL import.
+
+ Call this after an import that reported ``COMPLETED_WITH_WARNINGS`` to
+ find which plugin dependencies referenced in the DSL are not yet installed
+ in the workspace. Returns an empty ``leaked_dependencies`` list when all
+ dependencies are satisfied.
+ """
+
+ @auth_router.guard(
+ scope=Scope.APPS_READ,
+ allowed_token_types=frozenset({TokenType.OAUTH_ACCOUNT}),
+ allowed_roles=frozenset({TenantAccountRole.EDITOR, TenantAccountRole.ADMIN, TenantAccountRole.OWNER}),
+ )
+ @returns(200, CheckDependenciesResult, "Dependencies checked")
+ def get(self, app_id: str, *, auth_data: AuthData):
+ app = cast(App, auth_data.app)
+
+ with Session(db.engine, expire_on_commit=False) as session:
+ service = AppDslService(session)
+ result = service.check_dependencies(app_model=app)
+
+ return result, 200
diff --git a/api/openapi/markdown/openapi-swagger.md b/api/openapi/markdown/openapi-swagger.md
index f04f23027c..7214bcaa94 100644
--- a/api/openapi/markdown/openapi-swagger.md
+++ b/api/openapi/markdown/openapi-swagger.md
@@ -103,6 +103,21 @@ User-scoped operations
| ---- | ----------- | ------ |
| 200 | App list | [AppListResponse](#applistresponse) |
+### /apps/{app_id}/check-dependencies
+
+#### GET
+##### Parameters
+
+| Name | Located in | Description | Required | Schema |
+| ---- | ---------- | ----------- | -------- | ------ |
+| app_id | path | | Yes | string |
+
+##### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | Dependencies checked | [CheckDependenciesResult](#checkdependenciesresult) |
+
### /apps/{app_id}/describe
#### GET
@@ -119,6 +134,23 @@ User-scoped operations
| ---- | ----------- | ------ |
| 200 | App description | [AppDescribeResponse](#appdescriberesponse) |
+### /apps/{app_id}/export
+
+#### GET
+##### Parameters
+
+| Name | Located in | Description | Required | Schema |
+| ---- | ---------- | ----------- | -------- | ------ |
+| app_id | path | | Yes | string |
+| include_secret | query | Include encrypted secret values in the exported DSL | No | boolean |
+| workflow_id | query | Export a specific workflow version instead of the current draft | No | string |
+
+##### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | Export successful | [AppDslExportResponse](#appdslexportresponse) |
+
### /apps/{app_id}/files/upload
#### POST
@@ -338,6 +370,41 @@ Upload a file to use as an input variable when running the app
| ---- | ----------- | ------ |
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
+### /workspaces/{workspace_id}/apps/imports
+
+#### POST
+##### Parameters
+
+| Name | Located in | Description | Required | Schema |
+| ---- | ---------- | ----------- | -------- | ------ |
+| workspace_id | path | | Yes | string |
+| payload | body | | Yes | [AppDslImportPayload](#appdslimportpayload) |
+
+##### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | Import completed | [Import](#import) |
+| 202 | Import pending confirmation | [Import](#import) |
+| 400 | Import failed | [Import](#import) |
+
+### /workspaces/{workspace_id}/apps/imports/{import_id}/confirm
+
+#### POST
+##### Parameters
+
+| Name | Located in | Description | Required | Schema |
+| ---- | ---------- | ----------- | -------- | ------ |
+| import_id | path | | Yes | string |
+| workspace_id | path | | Yes | string |
+
+##### Responses
+
+| Code | Description | Schema |
+| ---- | ----------- | ------ |
+| 200 | Import confirmed | [Import](#import) |
+| 400 | Import failed | [Import](#import) |
+
### /workspaces/{workspace_id}/members
#### GET
@@ -471,6 +538,39 @@ Empty / omitted → all blocks. Unknown member → ValidationError → 422.
| input_schema | object | | No |
| parameters | object | | No |
+#### AppDslExportQuery
+
+Query parameters for GET /apps//export.
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| include_secret | boolean | Include encrypted secret values in the exported DSL | No |
+| workflow_id | string | Export a specific workflow version instead of the current draft | No |
+
+#### AppDslExportResponse
+
+Export DSL response.
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| data | string | DSL YAML string | Yes |
+
+#### AppDslImportPayload
+
+Request body for POST /workspaces//apps/imports.
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| app_id | string | Existing app ID to overwrite (workflow/advanced-chat apps only) | No |
+| description | string | Override the app description from the DSL | No |
+| icon | string | | No |
+| icon_background | string | | No |
+| icon_type | string | | No |
+| mode | string | Import mode: yaml-content or yaml-url
*Enum:* `"yaml-content"`, `"yaml-url"` | Yes |
+| name | string | Override the app name from the DSL | No |
+| yaml_content | string | Inline YAML DSL string (required when mode is yaml-content) | No |
+| yaml_url | string | Remote URL to fetch YAML from (required when mode is yaml-url) | No |
+
#### AppInfoResponse
| Name | Type | Description | Required |
@@ -537,6 +637,12 @@ mode is a closed enum.
| workflow_id | string | | No |
| workspace_id | string | | No |
+#### CheckDependenciesResult
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| leaked_dependencies | [ [PluginDependency](#plugindependency) ] | | No |
+
#### DeviceCodeRequest
| Name | Type | Description | Required |
@@ -616,6 +722,15 @@ than an under-annotated open object.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
+#### Github
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| github_plugin_unique_identifier | string | | Yes |
+| package | string | | Yes |
+| repo | string | | Yes |
+| version | string | | Yes |
+
#### HealthResponse
Liveness payload for `GET /openapi/v1/_health` — no auth required.
@@ -631,12 +746,37 @@ Liveness payload for `GET /openapi/v1/_health` — no auth required.
| action | string | | Yes |
| inputs | object | Submitted human input values keyed by output variable name. Use a string for paragraph or select input values, a file mapping for file inputs, and a list of file mappings for file-list inputs. Local file mappings use `transfer_method=local_file` with `upload_file_id`; remote file mappings use `transfer_method=remote_url` with `url` or `remote_url`. | Yes |
+#### Import
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| app_id | string | | No |
+| app_mode | string | | No |
+| current_dsl_version | string | | No |
+| error | string | | No |
+| id | string | | Yes |
+| imported_dsl_version | string | | No |
+| status | [ImportStatus](#importstatus) | | Yes |
+
+#### ImportStatus
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| ImportStatus | string | | |
+
#### JsonValue
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| JsonValue | | | |
+#### Marketplace
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| marketplace_plugin_unique_identifier | string | | Yes |
+| version | string | | No |
+
#### MemberActionResponse
| Name | Type | Description | Required |
@@ -704,6 +844,13 @@ Strict (extra='forbid').
| retriever_resources | [ object ] | | No |
| usage | [UsageInfo](#usageinfo) | | No |
+#### Package
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| plugin_unique_identifier | string | | Yes |
+| version | string | | No |
+
#### PermittedExternalAppsListQuery
Strict (extra='forbid').
@@ -725,6 +872,14 @@ Strict (extra='forbid').
| page | integer | | Yes |
| total | integer | | Yes |
+#### PluginDependency
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| current_identifier | string | | No |
+| type | [Type](#type) | | Yes |
+| value | [Github](#github)
[Marketplace](#marketplace)
[Package](#package) | | Yes |
+
#### RevokeResponse
| Name | Type | Description | Required |
@@ -787,6 +942,12 @@ types it as a required `'success'` rather than an optional field.
| ---- | ---- | ----------- | -------- |
| result | string | | Yes |
+#### Type
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| Type | string | | |
+
#### UsageInfo
| Name | Type | Description | Required |
diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py
index e13ed35180..e69cff6a29 100644
--- a/api/services/app_dsl_service.py
+++ b/api/services/app_dsl_service.py
@@ -41,6 +41,7 @@ from models.model import AppModelConfig, AppModelConfigDict, IconType
from models.workflow import Workflow
from services.dsl_version import check_version_compatibility
from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus
+from services.errors.app import WorkflowNotFoundError
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.workflow_draft_variable_service import WorkflowDraftVariableService
from services.workflow_service import WorkflowService
@@ -557,7 +558,7 @@ class AppDslService:
workflow_service = WorkflowService()
workflow = workflow_service.get_draft_workflow(app_model, workflow_id)
if not workflow:
- raise ValueError("Missing draft workflow configuration, please check.")
+ raise WorkflowNotFoundError("Missing draft workflow configuration, please check.")
workflow_dict = workflow.to_dict(include_secret=include_secret)
# TODO: refactor: we need a better way to filter workspace related data from nodes
diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py
index 85378bd84d..ff74ca3039 100644
--- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py
+++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py
@@ -38,6 +38,7 @@ from services.app_dsl_service import (
)
from services.app_service import AppService, CreateAppParams
from services.dsl_version import check_version_compatibility
+from services.errors.app import WorkflowNotFoundError
from tests.test_containers_integration_tests.helpers import generate_valid_password
_DEFAULT_TENANT_ID = "00000000-0000-0000-0000-000000000001"
@@ -1027,7 +1028,7 @@ class TestAppDslService:
mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.return_value = None
with pytest.raises(
- ValueError,
+ WorkflowNotFoundError,
match="Missing draft workflow configuration, please check.",
):
AppDslService.export_dsl(app, include_secret=False, workflow_id=str(uuid4()))
@@ -1139,7 +1140,7 @@ class TestAppDslService:
workflow_service.get_draft_workflow.return_value = None
monkeypatch.setattr(app_dsl_service, "WorkflowService", lambda: workflow_service)
- with pytest.raises(ValueError, match="Missing draft workflow configuration"):
+ with pytest.raises(WorkflowNotFoundError, match="Missing draft workflow configuration"):
AppDslService._append_workflow_export_data(
export_data={},
app_model=_app_stub(),
diff --git a/cli/ARD.md b/cli/ARD.md
index 11284ae577..cc0e2f3d66 100644
--- a/cli/ARD.md
+++ b/cli/ARD.md
@@ -78,7 +78,7 @@ export default class MyCommand extends DifyCommand {
const { args, flags } = this.parse(MyCommand, argv)
// Authed: authedCtx() sets outputFormat + builds context
- const ctx = await this.authedCtx({ retryFlag: flags['http-retry'], format: flags.output })
+ const ctx = await this.authedCtx({ format: flags.output })
process.stdout.write(await runMyThing({ /* args */ }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io }))
}
diff --git a/cli/src/api/app-dsl.test.ts b/cli/src/api/app-dsl.test.ts
new file mode 100644
index 0000000000..66d4507317
--- /dev/null
+++ b/cli/src/api/app-dsl.test.ts
@@ -0,0 +1,113 @@
+import type { StubServer } from '@test/fixtures/stub-server'
+import { testHttpClient } from '@test/fixtures/http-client'
+import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
+import { afterEach, describe, expect, it } from 'vitest'
+import { isHttpClientError } from '@/errors/base'
+import { AppDslClient } from './app-dsl.js'
+
+const DSL_YAML = `app:\n mode: chat\n name: Test\nversion: '0.1.4'\n`
+
+const COMPLETED_IMPORT = { id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }
+
+function makeClient(host: string): AppDslClient {
+ return new AppDslClient(testHttpClient(host, 'dfoa_test'))
+}
+
+describe('AppDslClient.exportDsl', () => {
+ let stub: StubServer
+
+ afterEach(async () => {
+ await stub?.stop()
+ })
+
+ it('returns the data string from the response', async () => {
+ stub = await startStubServer(cap => jsonResponder(200, { data: DSL_YAML }, cap))
+
+ const yaml = await makeClient(stub.url).exportDsl('app-1')
+
+ expect(stub.captured.method).toBe('GET')
+ expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/export')
+ expect(yaml).toBe(DSL_YAML)
+ })
+
+ it('throws when response has no data field', async () => {
+ stub = await startStubServer(cap => jsonResponder(200, { wrong: 1 }, cap))
+
+ await expect(makeClient(stub.url).exportDsl('app-1')).rejects.toThrow('export response missing data field')
+ })
+
+ it('propagates 404 as a classified HttpClientError', async () => {
+ stub = await startStubServer(cap => jsonResponder(404, { error: 'not_found' }, cap))
+
+ await expect(makeClient(stub.url).exportDsl('missing')).rejects.toSatisfy(
+ err => isHttpClientError(err) && err.httpStatus === 404,
+ )
+ })
+})
+
+describe('AppDslClient.importApp', () => {
+ let stub: StubServer
+
+ afterEach(async () => {
+ await stub?.stop()
+ })
+
+ it('POST to /workspaces/:id/apps/imports with body and returns Import', async () => {
+ stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap))
+
+ const result = await makeClient(stub.url).importApp('ws-1', {
+ mode: 'yaml-content',
+ yaml_content: DSL_YAML,
+ })
+
+ expect(stub.captured.method).toBe('POST')
+ expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/apps/imports')
+ expect(result.status).toBe('completed')
+ expect(result.app_id).toBe('app-1')
+ })
+
+ it('returns pending import on 202', async () => {
+ const pending = { id: 'imp-1', status: 'pending', current_dsl_version: '0.1.4', imported_dsl_version: '0.0.9' }
+ stub = await startStubServer(cap => jsonResponder(202, pending, cap))
+
+ const result = await makeClient(stub.url).importApp('ws-1', { mode: 'yaml-content', yaml_content: DSL_YAML })
+
+ expect(result.status).toBe('pending')
+ expect(result.id).toBe('imp-1')
+ })
+})
+
+describe('AppDslClient.confirmImport', () => {
+ let stub: StubServer
+
+ afterEach(async () => {
+ await stub?.stop()
+ })
+
+ it('POST to confirm URL and returns completed Import', async () => {
+ stub = await startStubServer(cap => jsonResponder(200, COMPLETED_IMPORT, cap))
+
+ const result = await makeClient(stub.url).confirmImport('ws-1', 'imp-1')
+
+ expect(stub.captured.method).toBe('POST')
+ expect(stub.captured.url).toBe('/openapi/v1/workspaces/ws-1/apps/imports/imp-1/confirm')
+ expect(result.status).toBe('completed')
+ })
+})
+
+describe('AppDslClient.checkDependencies', () => {
+ let stub: StubServer
+
+ afterEach(async () => {
+ await stub?.stop()
+ })
+
+ it('returns empty leaked_dependencies on healthy app', async () => {
+ stub = await startStubServer(cap => jsonResponder(200, { leaked_dependencies: [] }, cap))
+
+ const result = await makeClient(stub.url).checkDependencies('app-1')
+
+ expect(stub.captured.url?.split('?')[0]).toBe('/openapi/v1/apps/app-1/check-dependencies')
+ expect(result.leaked_dependencies).toEqual([])
+ })
+})
diff --git a/cli/src/api/app-dsl.ts b/cli/src/api/app-dsl.ts
new file mode 100644
index 0000000000..acb10a9535
--- /dev/null
+++ b/cli/src/api/app-dsl.ts
@@ -0,0 +1,59 @@
+import type {
+ AppDslImportPayload,
+ CheckDependenciesResult,
+ Import,
+} from '@dify/contracts/api/openapi/types.gen'
+import type { OpenApiClient } from '@/http/orpc'
+import type { HttpClient } from '@/http/types'
+import { createOpenApiClient } from '@/http/orpc'
+
+export type ExportQuery = {
+ readonly includeSecret?: boolean
+ readonly workflowId?: string
+}
+
+export class AppDslClient {
+ private readonly orpc: OpenApiClient
+
+ constructor(http: HttpClient) {
+ this.orpc = createOpenApiClient(http)
+ }
+
+ async importApp(workspaceId: string, payload: AppDslImportPayload): Promise {
+ return this.orpc.workspaces.byWorkspaceId.apps.imports.post({
+ params: { workspace_id: workspaceId },
+ body: payload,
+ })
+ }
+
+ async confirmImport(workspaceId: string, importId: string): Promise {
+ return this.orpc.workspaces.byWorkspaceId.apps.imports.byImportId.confirm.post({
+ params: { workspace_id: workspaceId, import_id: importId },
+ })
+ }
+
+ async exportDsl(appId: string, query?: ExportQuery): Promise {
+ const resp = await this.orpc.apps.byAppId.export.get({
+ params: { app_id: appId },
+ query: query !== undefined
+ ? {
+ include_secret: query.includeSecret,
+ workflow_id: query.workflowId,
+ }
+ : undefined,
+ })
+ // The response schema is an open object {"data": ""}; the
+ // contract generator marks it as loose because the backend annotation
+ // does not narrow the shape. Extract `data` directly.
+ const data = (resp as Record).data
+ if (typeof data !== 'string')
+ throw new Error('export response missing data field')
+ return data
+ }
+
+ async checkDependencies(appId: string): Promise {
+ return this.orpc.apps.byAppId.checkDependencies.get({
+ params: { app_id: appId },
+ })
+ }
+}
diff --git a/cli/src/commands/export/app/index.ts b/cli/src/commands/export/app/index.ts
new file mode 100644
index 0000000000..7afd023498
--- /dev/null
+++ b/cli/src/commands/export/app/index.ts
@@ -0,0 +1,45 @@
+import { DifyCommand } from '@/commands/_shared/dify-command'
+import { httpRetryFlag } from '@/commands/_shared/global-flags'
+import { Args, Flags } from '@/framework/flags'
+import { runExportApp } from './run'
+
+export default class ExportApp extends DifyCommand {
+ static override description = 'Export an app\'s DSL configuration as YAML'
+
+ static override examples = [
+ '<%= config.bin %> export app ',
+ '<%= config.bin %> export app --output ./my-app.yaml',
+ '<%= config.bin %> export app --include-secret',
+ '<%= config.bin %> export app --workflow-id ',
+ ]
+
+ static override args = {
+ id: Args.string({ description: 'app ID to export', required: true }),
+ }
+
+ static override flags = {
+ 'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
+ 'output': Flags.string({ description: 'write DSL YAML to this file path (prints to stdout if omitted)', char: 'o' }),
+ 'include-secret': Flags.boolean({ description: 'include encrypted secret values in the exported DSL', default: false }),
+ 'workflow-id': Flags.string({ description: 'export a specific workflow by ID (workflow apps only)' }),
+ 'http-retry': httpRetryFlag,
+ }
+
+ async run(argv: string[]) {
+ const { args, flags } = this.parse(ExportApp, argv)
+ const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
+ const result = await runExportApp({
+ appId: args.id,
+ workspace: flags.workspace,
+ output: flags.output,
+ includeSecret: flags['include-secret'],
+ workflowId: flags['workflow-id'],
+ }, { active: ctx.active, http: ctx.http, io: ctx.io })
+
+ if (result.writtenTo === undefined) {
+ ctx.io.out.write(result.yaml)
+ if (!result.yaml.endsWith('\n'))
+ ctx.io.out.write('\n')
+ }
+ }
+}
diff --git a/cli/src/commands/export/app/run.test.ts b/cli/src/commands/export/app/run.test.ts
new file mode 100644
index 0000000000..a0c2cd8023
--- /dev/null
+++ b/cli/src/commands/export/app/run.test.ts
@@ -0,0 +1,69 @@
+import type { DifyMock } from '@test/fixtures/dify-mock/server'
+import type { ActiveContext } from '@/auth/hosts'
+import os from 'node:os'
+import { join } from 'node:path'
+import { DSL_YAML } from '@test/fixtures/dify-mock/scenarios'
+import { startMock } from '@test/fixtures/dify-mock/server'
+import { testHttpClient } from '@test/fixtures/http-client'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { bufferStreams } from '@/sys/io/streams'
+import { runExportApp } from './run.js'
+
+const baseActive: ActiveContext = {
+ host: '127.0.0.1',
+ email: 'tester@dify.ai',
+ ctx: {
+ account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
+ workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
+ available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
+ },
+ scheme: 'http',
+}
+
+describe('runExportApp', () => {
+ let mock: DifyMock
+
+ beforeEach(async () => {
+ mock = await startMock({ scenario: 'happy' })
+ })
+
+ afterEach(async () => {
+ await mock.stop()
+ })
+
+ function http() {
+ return testHttpClient(mock.url, 'dfoa_test')
+ }
+
+ it('returns the DSL YAML string from the server', async () => {
+ const result = await runExportApp({ appId: 'app-1' }, { active: baseActive, http: http() })
+
+ expect(result.yaml).toBe(DSL_YAML)
+ expect(result.writtenTo).toBeUndefined()
+ })
+
+ it('writes to file when --output is given', async () => {
+ const tmpFile = join(os.tmpdir(), `difyctl-test-export-${Date.now()}.yaml`)
+
+ const result = await runExportApp(
+ { appId: 'app-1', output: tmpFile },
+ { active: baseActive, http: http() },
+ )
+
+ expect(result.writtenTo).toBe(tmpFile)
+ const { readFileSync } = await import('node:fs')
+ expect(readFileSync(tmpFile, 'utf8')).toBe(DSL_YAML)
+ })
+
+ it('err stream receives written-to path when --output is given', async () => {
+ const tmpFile = join(os.tmpdir(), `difyctl-test-export-${Date.now()}.yaml`)
+ const io = bufferStreams()
+
+ await runExportApp(
+ { appId: 'app-1', output: tmpFile },
+ { active: baseActive, http: http(), io },
+ )
+
+ expect(io.errBuf()).toContain(tmpFile)
+ })
+})
diff --git a/cli/src/commands/export/app/run.ts b/cli/src/commands/export/app/run.ts
new file mode 100644
index 0000000000..93abe2f8b4
--- /dev/null
+++ b/cli/src/commands/export/app/run.ts
@@ -0,0 +1,61 @@
+import type { ActiveContext } from '@/auth/hosts'
+import type { HttpClient } from '@/http/types'
+import type { IOStreams } from '@/sys/io/streams'
+import fs from 'node:fs'
+import { dirname } from 'node:path'
+import { AppDslClient } from '@/api/app-dsl'
+import { getEnv } from '@/sys/index'
+import { runWithSpinner } from '@/sys/io/spinner'
+import { nullStreams } from '@/sys/io/streams'
+import { resolveWorkspaceId } from '@/workspace/resolver'
+
+export type ExportAppOptions = {
+ readonly appId: string
+ readonly workspace?: string
+ readonly output?: string
+ readonly includeSecret?: boolean
+ readonly workflowId?: string
+}
+
+export type ExportAppDeps = {
+ readonly active: ActiveContext
+ readonly http: HttpClient
+ readonly io?: IOStreams
+ readonly envLookup?: (k: string) => string | undefined
+ readonly dslFactory?: (http: HttpClient) => AppDslClient
+}
+
+export type ExportAppResult = {
+ readonly yaml: string
+ readonly writtenTo: string | undefined
+}
+
+export async function runExportApp(opts: ExportAppOptions, deps: ExportAppDeps): Promise {
+ const env = deps.envLookup ?? getEnv
+ const io = deps.io ?? nullStreams()
+ const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h))
+
+ // workspace is needed to satisfy the auth pipeline; resolving it here
+ // mirrors what other commands do even though the export endpoint does not
+ // take workspace_id as a query parameter (it loads tenant from app).
+ resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
+
+ const client = dslFactory(deps.http)
+
+ const yaml = await runWithSpinner(
+ { io, label: `Exporting DSL for app ${opts.appId}` },
+ () => client.exportDsl(opts.appId, {
+ includeSecret: opts.includeSecret,
+ workflowId: opts.workflowId,
+ }),
+ )
+
+ if (opts.output !== undefined && opts.output !== '') {
+ fs.mkdirSync(dirname(opts.output), { recursive: true })
+ fs.writeFileSync(opts.output, yaml, 'utf8')
+ io.err.write(`DSL written to ${opts.output}\n`)
+ return { yaml, writtenTo: opts.output }
+ }
+
+ return { yaml, writtenTo: undefined }
+}
diff --git a/cli/src/commands/import/app/index.ts b/cli/src/commands/import/app/index.ts
new file mode 100644
index 0000000000..fddda88f44
--- /dev/null
+++ b/cli/src/commands/import/app/index.ts
@@ -0,0 +1,60 @@
+import { DifyCommand } from '@/commands/_shared/dify-command'
+import { httpRetryFlag } from '@/commands/_shared/global-flags'
+import { Flags } from '@/framework/flags'
+import { pluginDependencyLabel, runImportApp } from './run'
+
+export default class ImportApp extends DifyCommand {
+ static override description = 'Import an app from a DSL YAML file or URL'
+
+ static override examples = [
+ '<%= config.bin %> import app --from-file ./app.yaml',
+ '<%= config.bin %> import app --from-file /path/to/app.yaml --name "My App"',
+ '<%= config.bin %> import app --from-url https://example.com/my-app.yaml',
+ '<%= config.bin %> import app --from-file ./app.yaml --app-id ',
+ ]
+
+ static override flags = {
+ 'from-file': Flags.string({ description: 'import DSL from a local file (relative or absolute path)', char: 'f' }),
+ 'from-url': Flags.string({ description: 'import DSL from an HTTP(S) URL' }),
+ 'workspace': Flags.string({ description: 'workspace id (overrides DIFY_WORKSPACE_ID and stored default)' }),
+ 'name': Flags.string({ description: 'override the app name from the DSL' }),
+ 'description': Flags.string({ description: 'override the app description from the DSL' }),
+ 'app-id': Flags.string({ description: 'overwrite an existing app (workflow/advanced-chat only)' }),
+ 'icon-type': Flags.string({ description: 'override icon type' }),
+ 'icon': Flags.string({ description: 'override icon' }),
+ 'icon-background': Flags.string({ description: 'override icon background colour' }),
+ 'http-retry': httpRetryFlag,
+ }
+
+ async run(argv: string[]) {
+ const { flags } = this.parse(ImportApp, argv)
+ if (flags['from-file'] === undefined && flags['from-url'] === undefined)
+ this.error('one of --from-file or --from-url is required', { exit: 1 })
+ if (flags['from-file'] !== undefined && flags['from-url'] !== undefined)
+ this.error('--from-file and --from-url are mutually exclusive', { exit: 1 })
+ const ctx = await this.authedCtx({ retryFlag: flags['http-retry'] })
+ const { result, leakedDependencies } = await runImportApp({
+ fromFile: flags['from-file'],
+ fromUrl: flags['from-url'],
+ workspace: flags.workspace,
+ name: flags.name,
+ description: flags.description,
+ appId: flags['app-id'],
+ iconType: flags['icon-type'],
+ icon: flags.icon,
+ iconBackground: flags['icon-background'],
+ }, { active: ctx.active, http: ctx.http, io: ctx.io })
+
+ const status = result.status === 'completed-with-warnings' ? 'completed (with warnings)' : result.status
+ ctx.io.err.write(`Import ${status}`)
+ if (result.app_id !== undefined && result.app_id !== null)
+ ctx.io.err.write(`: app ${result.app_id}`)
+ ctx.io.err.write('\n')
+
+ if (leakedDependencies.length > 0) {
+ ctx.io.err.write(`\nMissing plugin dependencies (${leakedDependencies.length}); install them before using the app:\n`)
+ for (const dep of leakedDependencies)
+ ctx.io.err.write(` - ${pluginDependencyLabel(dep)}\n`)
+ }
+ }
+}
diff --git a/cli/src/commands/import/app/run.test.ts b/cli/src/commands/import/app/run.test.ts
new file mode 100644
index 0000000000..562f2a207d
--- /dev/null
+++ b/cli/src/commands/import/app/run.test.ts
@@ -0,0 +1,188 @@
+import type { Import } from '@dify/contracts/api/openapi/types.gen'
+import type { DifyMock } from '@test/fixtures/dify-mock/server'
+import type { ActiveContext } from '@/auth/hosts'
+import { writeFileSync } from 'node:fs'
+import os from 'node:os'
+import { join } from 'node:path'
+import { DSL_YAML } from '@test/fixtures/dify-mock/scenarios'
+import { startMock } from '@test/fixtures/dify-mock/server'
+import { testHttpClient } from '@test/fixtures/http-client'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { AppDslClient } from '@/api/app-dsl'
+import { bufferStreams } from '@/sys/io/streams'
+import { ZERO } from '@/util/uuid.js'
+import { pluginDependencyLabel, runImportApp } from './run.js'
+
+const baseActive: ActiveContext = {
+ host: '127.0.0.1',
+ email: 'tester@dify.ai',
+ ctx: {
+ account: { id: 'acct-1', email: 'tester@dify.ai', name: 'Test Tester' },
+ workspace: { id: 'ws-1', name: 'Default', role: 'owner' },
+ available_workspaces: [{ id: 'ws-1', name: 'Default', role: 'owner' }],
+ },
+ scheme: 'http',
+}
+
+describe('runImportApp', () => {
+ let mock: DifyMock
+
+ beforeEach(async () => {
+ mock = await startMock({ scenario: 'happy' })
+ })
+
+ afterEach(async () => {
+ await mock.stop()
+ })
+
+ function http() {
+ return testHttpClient(mock.url, 'dfoa_test')
+ }
+
+ function tmpDslFile(): string {
+ const filePath = join(os.tmpdir(), `difyctl-import-test-${Date.now()}.yaml`)
+ writeFileSync(filePath, DSL_YAML, 'utf8')
+ return filePath
+ }
+
+ it('completes import from a local file path', async () => {
+ const dslFile = tmpDslFile()
+ const { result } = await runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() })
+
+ expect(result.status).toBe('completed')
+ expect(result.app_id).toBe('app-1')
+ })
+
+ it('sends yaml_content in the request body', async () => {
+ const dslFile = tmpDslFile()
+ await runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() })
+
+ expect(mock.lastImportBody?.mode).toBe('yaml-content')
+ expect(mock.lastImportBody?.yaml_content).toBe(DSL_YAML)
+ })
+
+ it('sends yaml_url when given --from-url', async () => {
+ await runImportApp(
+ { fromUrl: 'https://example.com/app.yaml' },
+ { active: baseActive, http: http() },
+ )
+
+ expect(mock.lastImportBody?.mode).toBe('yaml-url')
+ expect(mock.lastImportBody?.yaml_url).toBe('https://example.com/app.yaml')
+ })
+
+ it('forwards optional name and description overrides', async () => {
+ const dslFile = tmpDslFile()
+ await runImportApp(
+ { fromFile: dslFile, name: 'My App', description: 'desc' },
+ { active: baseActive, http: http() },
+ )
+
+ expect(mock.lastImportBody?.name).toBe('My App')
+ expect(mock.lastImportBody?.description).toBe('desc')
+ })
+
+ it('auto-confirms a pending (202) import and emits a note on err stream', async () => {
+ mock.setScenario('import-pending')
+ const io = bufferStreams()
+ const dslFile = tmpDslFile()
+
+ const { result } = await runImportApp(
+ { fromFile: dslFile },
+ { active: baseActive, http: http(), io },
+ )
+
+ expect(result.status).toBe('completed')
+ expect(io.errBuf()).toContain('confirming automatically')
+ })
+
+ it('throws on import-failed (400) response', async () => {
+ mock.setScenario('import-failed')
+ const dslFile = tmpDslFile()
+
+ await expect(
+ runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() }),
+ ).rejects.toThrow('Import failed')
+ })
+
+ it('uses workspace from --workspace flag over context default', async () => {
+ const dslFile = tmpDslFile()
+ await runImportApp(
+ { fromFile: dslFile, workspace: ZERO },
+ { active: baseActive, http: http() },
+ )
+
+ expect(mock.lastImportBody).not.toBeNull()
+ })
+
+ it('throws UsageInvalidFlag when fromFile path does not exist', async () => {
+ await expect(
+ runImportApp({ fromFile: '/tmp/difyctl-no-such-file-ever.yaml' }, { active: baseActive, http: http() }),
+ ).rejects.toThrow('file not found')
+ })
+
+ it('throws UsageInvalidFlag when both fromFile and fromUrl are given', async () => {
+ const dslFile = tmpDslFile()
+ await expect(
+ runImportApp({ fromFile: dslFile, fromUrl: 'https://example.com/app.yaml' }, { active: baseActive, http: http() }),
+ ).rejects.toThrow('mutually exclusive')
+ })
+
+ it('throws UsageInvalidFlag when neither fromFile nor fromUrl is given', async () => {
+ await expect(
+ runImportApp({}, { active: baseActive, http: http() }),
+ ).rejects.toThrow('required')
+ })
+
+ it('returns empty leakedDependencies when the app has no missing plugins', async () => {
+ const dslFile = tmpDslFile()
+ const { leakedDependencies } = await runImportApp({ fromFile: dslFile }, { active: baseActive, http: http() })
+
+ expect(leakedDependencies).toEqual([])
+ })
+
+ it('surfaces leaked dependencies reported by check-dependencies', async () => {
+ const dslFile = tmpDslFile()
+ const completed: Import = { id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }
+ const stub = Object.assign(Object.create(AppDslClient.prototype), {
+ importApp: async () => completed,
+ confirmImport: async () => completed,
+ checkDependencies: async () => ({
+ leaked_dependencies: [
+ { type: 'marketplace', value: { marketplace_plugin_unique_identifier: 'langgenius/openai:0.0.1' } },
+ ],
+ }),
+ }) as AppDslClient
+
+ const { leakedDependencies } = await runImportApp(
+ { fromFile: dslFile },
+ { active: baseActive, http: http(), dslFactory: () => stub },
+ )
+
+ expect(leakedDependencies).toHaveLength(1)
+ const [dep] = leakedDependencies
+ if (dep === undefined)
+ throw new Error('expected one leaked dependency')
+ expect(pluginDependencyLabel(dep)).toBe('langgenius/openai:0.0.1')
+ })
+})
+
+describe('pluginDependencyLabel', () => {
+ it('reads the github plugin identifier', () => {
+ const label = pluginDependencyLabel({
+ type: 'github',
+ value: { github_plugin_unique_identifier: 'owner/repo:1.0.0' },
+ })
+ expect(label).toBe('owner/repo:1.0.0')
+ })
+
+ it('reads the package plugin identifier', () => {
+ const label = pluginDependencyLabel({ type: 'package', value: { plugin_unique_identifier: 'pkg:2.0.0' } })
+ expect(label).toBe('pkg:2.0.0')
+ })
+
+ it('falls back to current_identifier then a placeholder', () => {
+ expect(pluginDependencyLabel({ type: 'package', value: {}, current_identifier: 'fallback' })).toBe('fallback')
+ expect(pluginDependencyLabel({ type: 'package', value: null })).toBe('')
+ })
+})
diff --git a/cli/src/commands/import/app/run.ts b/cli/src/commands/import/app/run.ts
new file mode 100644
index 0000000000..ad5bde2420
--- /dev/null
+++ b/cli/src/commands/import/app/run.ts
@@ -0,0 +1,138 @@
+import type { Import, PluginDependency } from '@dify/contracts/api/openapi/types.gen'
+import type { ActiveContext } from '@/auth/hosts'
+import type { HttpClient } from '@/http/types'
+import type { IOStreams } from '@/sys/io/streams'
+import fs from 'node:fs'
+import { AppDslClient } from '@/api/app-dsl'
+import { newError } from '@/errors/base'
+import { ErrorCode } from '@/errors/codes'
+import { getEnv } from '@/sys/index'
+import { runWithSpinner } from '@/sys/io/spinner'
+import { nullStreams } from '@/sys/io/streams'
+import { resolveWorkspaceId } from '@/workspace/resolver'
+
+export type ImportAppOptions = {
+ readonly fromFile?: string
+ readonly fromUrl?: string
+ readonly workspace?: string
+ readonly name?: string
+ readonly description?: string
+ readonly appId?: string
+ readonly iconType?: string
+ readonly icon?: string
+ readonly iconBackground?: string
+}
+
+export type ImportAppDeps = {
+ readonly active: ActiveContext
+ readonly http: HttpClient
+ readonly io?: IOStreams
+ readonly envLookup?: (k: string) => string | undefined
+ readonly dslFactory?: (http: HttpClient) => AppDslClient
+}
+
+export type ImportAppResult = {
+ readonly result: Import
+ readonly leakedDependencies: readonly PluginDependency[]
+}
+
+export async function runImportApp(opts: ImportAppOptions, deps: ImportAppDeps): Promise {
+ const env = deps.envLookup ?? getEnv
+ const io = deps.io ?? nullStreams()
+ const dslFactory = deps.dslFactory ?? ((h: HttpClient) => new AppDslClient(h))
+
+ const workspaceId = resolveWorkspaceId({ flag: opts.workspace, env: env('DIFY_WORKSPACE_ID'), active: deps.active })
+ const client = dslFactory(deps.http)
+
+ if (opts.fromFile !== undefined && opts.fromUrl !== undefined)
+ throw newError(ErrorCode.UsageInvalidFlag, '--from-file and --from-url are mutually exclusive')
+
+ let mode: 'yaml-content' | 'yaml-url'
+ let yamlContent: string | undefined
+ let yamlUrl: string | undefined
+
+ if (opts.fromFile !== undefined) {
+ mode = 'yaml-content'
+ try {
+ yamlContent = fs.readFileSync(opts.fromFile, 'utf8')
+ }
+ catch (err) {
+ const code = (err as NodeJS.ErrnoException).code
+ if (code === 'ENOENT')
+ throw newError(ErrorCode.UsageInvalidFlag, `--from-file: file not found: ${opts.fromFile}`)
+ throw err
+ }
+ }
+ else if (opts.fromUrl !== undefined) {
+ mode = 'yaml-url'
+ yamlUrl = opts.fromUrl
+ }
+ else {
+ throw newError(ErrorCode.UsageInvalidFlag, 'one of --from-file or --from-url is required')
+ }
+
+ let result = await runWithSpinner(
+ { io, label: 'Importing app DSL' },
+ () => client.importApp(workspaceId, {
+ mode,
+ yaml_content: yamlContent,
+ yaml_url: yamlUrl,
+ name: opts.name,
+ description: opts.description,
+ app_id: opts.appId,
+ icon_type: opts.iconType,
+ icon: opts.icon,
+ icon_background: opts.iconBackground,
+ }),
+ )
+
+ if (result.status === 'failed') {
+ throw newError(
+ ErrorCode.Server4xxOther,
+ `Import failed: ${result.error !== '' ? result.error : 'unknown error'}`,
+ )
+ }
+
+ // DSL version mismatch: the server needs an explicit acknowledgement before
+ // finalising. Auto-confirm here so the user does not need a second command.
+ if (result.status === 'pending') {
+ io.err.write(`note: DSL version mismatch (imported ${result.imported_dsl_version ?? '?'}, current ${result.current_dsl_version ?? '?'}); confirming automatically\n`)
+ result = await runWithSpinner(
+ { io, label: 'Confirming import' },
+ () => client.confirmImport(workspaceId, result.id),
+ )
+ }
+
+ if (result.status === 'failed') {
+ throw newError(
+ ErrorCode.Server4xxOther,
+ `Import failed after confirmation: ${result.error !== '' ? result.error : 'unknown error'}`,
+ )
+ }
+
+ const appId = result.app_id
+ if (appId === undefined || appId === null)
+ return { result, leakedDependencies: [] }
+
+ const { leaked_dependencies } = await runWithSpinner(
+ { io, label: 'Checking plugin dependencies' },
+ () => client.checkDependencies(appId),
+ )
+
+ return { result, leakedDependencies: leaked_dependencies ?? [] }
+}
+
+// `value` is a loosely-typed wire object (Github | Marketplace | Package); narrow it here to
+// surface a human-readable identifier without depending on which variant the server returned.
+export function pluginDependencyLabel(dep: PluginDependency): string {
+ const value = dep.value
+ if (typeof value === 'object' && value !== null) {
+ const fields = value as Record
+ const id = fields.marketplace_plugin_unique_identifier
+ ?? fields.github_plugin_unique_identifier
+ ?? fields.plugin_unique_identifier
+ if (typeof id === 'string' && id !== '')
+ return id
+ }
+ return dep.current_identifier ?? ''
+}
diff --git a/cli/src/commands/tree.generated.ts b/cli/src/commands/tree.generated.ts
index 966fd5b56d..03963865e5 100644
--- a/cli/src/commands/tree.generated.ts
+++ b/cli/src/commands/tree.generated.ts
@@ -17,9 +17,11 @@ import CreateMember from '@/commands/create/member/index'
import DeleteMember from '@/commands/delete/member/index'
import DescribeApp from '@/commands/describe/app/index'
import EnvList from '@/commands/env/list/index'
+import ExportApp from '@/commands/export/app/index'
import GetApp from '@/commands/get/app/index'
import GetMember from '@/commands/get/member/index'
import GetWorkspace from '@/commands/get/workspace/index'
+import ImportApp from '@/commands/import/app/index'
import ResumeApp from '@/commands/resume/app/index'
import RunApp from '@/commands/run/app/index'
import SetMember from '@/commands/set/member/index'
@@ -73,6 +75,11 @@ export const commandTree: CommandTree = {
list: { command: EnvList, subcommands: {} },
},
},
+ export: {
+ subcommands: {
+ app: { command: ExportApp, subcommands: {} },
+ },
+ },
get: {
subcommands: {
app: { command: GetApp, subcommands: {} },
@@ -80,6 +87,11 @@ export const commandTree: CommandTree = {
workspace: { command: GetWorkspace, subcommands: {} },
},
},
+ import: {
+ subcommands: {
+ app: { command: ImportApp, subcommands: {} },
+ },
+ },
resume: {
subcommands: {
app: { command: ResumeApp, subcommands: {} },
diff --git a/cli/src/util/uuid.ts b/cli/src/util/uuid.ts
new file mode 100644
index 0000000000..bd4d88e161
--- /dev/null
+++ b/cli/src/util/uuid.ts
@@ -0,0 +1 @@
+export const ZERO = '00000000-0000-0000-0000-000000000000'
diff --git a/cli/test/e2e/setup/global-setup.ts b/cli/test/e2e/setup/global-setup.ts
index 51f961bc37..35e171ecaf 100644
--- a/cli/test/e2e/setup/global-setup.ts
+++ b/cli/test/e2e/setup/global-setup.ts
@@ -27,9 +27,11 @@
import type { TestProject } from 'vitest/node'
import type { E2ECapabilities } from './env.js'
import { Buffer } from 'node:buffer'
-import { readFile, writeFile } from 'node:fs/promises'
+import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
+import { injectAuth, run } from '../helpers/cli.js'
import { loadE2EEnv } from './env.js'
const TOKEN_MINT_APPROVE_ATTEMPTS = 5
@@ -259,6 +261,10 @@ export async function setup(project: TestProject): Promise {
secondaryWsId,
fixturesDir,
E.edition,
+ primaryToken,
+ apiBase,
+ E.email,
+ primaryWsName,
)
console.warn(`[E2E global-setup] Provisioned ${Object.keys(provisionedIds).length} fixture apps`)
}
@@ -474,6 +480,10 @@ async function provisionApps(
secondaryWsId: string,
fixturesDir: string,
edition: 'ce' | 'ee',
+ token: string,
+ host: string,
+ email: string,
+ primaryWsName: string,
): Promise> {
const NEEDS_PUBLISH = new Set(['workflow', 'advanced-chat', 'agent-chat'])
@@ -498,6 +508,28 @@ async function provisionApps(
: []),
]
+ const configDir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-provision-'))
+ await injectAuth(configDir, {
+ host,
+ bearer: token,
+ email,
+ workspaceId: primaryWsId,
+ workspaceName: primaryWsName,
+ })
+
+ async function importAppCli(filePath: string, wsId: string): Promise {
+ const result = await run(
+ ['import', 'app', '--from-file', filePath, '--workspace', wsId],
+ { configDir, timeout: 60_000 },
+ )
+ if (result.exitCode !== 0)
+ throw new Error(`import app failed (exit ${result.exitCode}): ${result.stderr}`)
+ const match = result.stderr.match(/app ([0-9a-f-]{36})/)
+ if (!match?.[1])
+ throw new Error(`import app: could not parse app_id: ${result.stderr}`)
+ return match[1]
+ }
+
async function switchWorkspace(wsId: string): Promise {
const r = await fetch(`${consoleBase}/console/api/workspaces/switch`, {
method: 'POST',
@@ -518,30 +550,6 @@ async function provisionApps(
return d.data?.find(a => a.name === name)?.id ?? null
}
- async function importFromDsl(yamlContent: string): Promise {
- const r = await fetch(`${consoleBase}/console/api/apps/imports`, {
- method: 'POST',
- headers: mkHeaders({ 'Content-Type': 'application/json' }),
- body: JSON.stringify({ mode: 'yaml-content', yaml_content: yamlContent }),
- signal: AbortSignal.timeout(30_000),
- })
- const d = await r.json() as { app_id?: string, import_id?: string, status?: string }
- if (r.status === 202 && d.import_id) {
- const cr = await fetch(`${consoleBase}/console/api/apps/imports/${d.import_id}/confirm`, {
- method: 'POST',
- headers: mkHeaders(),
- signal: AbortSignal.timeout(15_000),
- })
- const c = await cr.json() as { app_id?: string }
- if (!c.app_id)
- throw new Error(`import confirm failed: HTTP ${cr.status}`)
- return c.app_id
- }
- if (!d.app_id)
- throw new Error(`import failed: HTTP ${r.status} ${JSON.stringify(d)}`)
- return d.app_id
- }
-
async function enableApi(appId: string): Promise {
await fetch(`${consoleBase}/console/api/apps/${appId}/api-enable`, {
method: 'POST',
@@ -603,7 +611,7 @@ async function provisionApps(
console.warn(`[E2E provision] ${dslFile}: exists in workspace id=${appId}; skip import`)
}
else {
- appId = await importFromDsl(dsl)
+ appId = await importAppCli(join(fixturesDir, dslFile), wsId)
console.warn(`[E2E provision] ${dslFile}: imported id=${appId}`)
}
@@ -619,6 +627,9 @@ async function provisionApps(
}
}
+ await rm(configDir, { recursive: true, force: true }).catch((err: unknown) =>
+ console.warn(`[E2E provision] failed to clean up configDir: ${err}`),
+ )
return results
}
diff --git a/cli/test/e2e/suites/dsl/export-app.e2e.ts b/cli/test/e2e/suites/dsl/export-app.e2e.ts
new file mode 100644
index 0000000000..f96fbd216a
--- /dev/null
+++ b/cli/test/e2e/suites/dsl/export-app.e2e.ts
@@ -0,0 +1,196 @@
+/**
+ * E2E: difyctl export app — DSL export
+ *
+ * Prerequisites (DIFY_E2E_* env vars):
+ * DIFY_E2E_WORKFLOW_APP_ID — echo-workflow app (no model provider dependency)
+ * DIFY_E2E_CHAT_APP_ID — echo-chat app
+ */
+
+import type { AuthFixture } from '../../helpers/cli.js'
+import { mkdtemp, readFile, rm } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
+import {
+ assertExitCode,
+} from '../../helpers/assert.js'
+import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
+import { resolveEnv } from '../../setup/env.js'
+
+// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
+const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
+const E = resolveEnv(caps)
+
+describe('E2E / difyctl export app', () => {
+ let fx: AuthFixture
+
+ beforeEach(async () => {
+ fx = await withAuthFixture(E)
+ })
+ afterEach(async () => {
+ await fx.cleanup()
+ })
+
+ // ── Basic export ──────────────────────────────────────────────────────────
+
+ it('[P0] exported DSL is non-empty YAML printed to stdout', async () => {
+ const result = await fx.r(['export', 'app', E.workflowAppId])
+ assertExitCode(result, 0)
+ expect(result.stdout.trim().length).toBeGreaterThan(0)
+ })
+
+ it('[P0] exported YAML contains kind: app', async () => {
+ const result = await fx.r(['export', 'app', E.workflowAppId])
+ assertExitCode(result, 0)
+ expect(result.stdout).toMatch(/^kind:\s*app/m)
+ })
+
+ it('[P0] exported YAML contains version field', async () => {
+ const result = await fx.r(['export', 'app', E.workflowAppId])
+ assertExitCode(result, 0)
+ expect(result.stdout).toMatch(/^version:/m)
+ })
+
+ it('[P0] exported YAML contains app section with mode', async () => {
+ const result = await fx.r(['export', 'app', E.workflowAppId])
+ assertExitCode(result, 0)
+ expect(result.stdout).toMatch(/^\s+mode:/m)
+ })
+
+ it('[P1] exported YAML ends with a newline (POSIX pipe convention)', async () => {
+ const result = await fx.r(['export', 'app', E.workflowAppId])
+ assertExitCode(result, 0)
+ expect(result.stdout.endsWith('\n')).toBe(true)
+ })
+
+ it('[P1] chat app export also succeeds and includes mode', async () => {
+ const result = await fx.r(['export', 'app', E.chatAppId])
+ assertExitCode(result, 0)
+ expect(result.stdout).toMatch(/^kind:\s*app/m)
+ expect(result.stdout).toMatch(/^\s+mode:/m)
+ })
+
+ // ── --output flag ─────────────────────────────────────────────────────────
+
+ it('[P1] --output writes DSL to file and exits 0', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-'))
+ const outPath = join(dir, 'exported.yaml')
+ try {
+ const result = await fx.r(['export', 'app', E.workflowAppId, '--output', outPath])
+ assertExitCode(result, 0)
+ const content = await readFile(outPath, 'utf8')
+ expect(content).toMatch(/^kind:\s*app/m)
+ expect(content).toMatch(/^version:/m)
+ }
+ finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
+ it('[P1] --output writes same content as stdout', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-cmp-'))
+ const outPath = join(dir, 'exported.yaml')
+ try {
+ const [stdoutResult, fileResult] = await Promise.all([
+ fx.r(['export', 'app', E.workflowAppId]),
+ fx.r(['export', 'app', E.workflowAppId, '--output', outPath]).then(async (r) => {
+ const content = await readFile(outPath, 'utf8')
+ return { exitCode: r.exitCode, content }
+ }),
+ ])
+ assertExitCode(stdoutResult, 0)
+ expect(fileResult.exitCode).toBe(0)
+ expect(fileResult.content.trim()).toBe(stdoutResult.stdout.trim())
+ }
+ finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
+ // ── Roundtrip: export → import ────────────────────────────────────────────
+
+ it('[P1] roundtrip: exported DSL can be re-imported as a new app', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-roundtrip-'))
+ const dslPath = join(dir, 'roundtrip.yaml')
+ try {
+ const exportResult = await fx.r(['export', 'app', E.workflowAppId, '--output', dslPath])
+ assertExitCode(exportResult, 0)
+
+ const importResult = await fx.r([
+ 'import',
+ 'app',
+ '--from-file',
+ dslPath,
+ '--name',
+ 'e2e-export-roundtrip',
+ ])
+ assertExitCode(importResult, 0)
+
+ const match = importResult.stderr.match(/app ([0-9a-f-]{36})/)
+ expect(match?.[1], 'import stderr must contain the new app UUID').toBeTruthy()
+ }
+ finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+
+ // ── Error scenarios ───────────────────────────────────────────────────────
+
+ it('[P0] non-existent app returns exit code 1 with error in stderr', async () => {
+ const result = await fx.r(['export', 'app', 'nonexistent-app-id-export-e2e'])
+ expect(result.exitCode).toBe(1)
+ expect(result.stderr.length).toBeGreaterThan(0)
+ })
+
+ it('[P0] unauthenticated export returns auth error (exit code 4)', async () => {
+ const unauthTmp = await withTempConfig()
+ try {
+ const result = await run(['export', 'app', E.workflowAppId], {
+ configDir: unauthTmp.configDir,
+ })
+ assertExitCode(result, 4)
+ }
+ finally {
+ await unauthTmp.cleanup()
+ }
+ })
+
+ it('[P1] export with missing app id argument exits non-zero', async () => {
+ const result = await fx.r(['export', 'app'])
+ expect(result.exitCode).not.toBe(0)
+ expect(result.stderr).toMatch(/missing required argument|required|app id/i)
+ })
+
+ it('[P1] malformed --workflow-id returns a 4xx, not a 5xx', async () => {
+ const result = await fx.r(['export', 'app', E.workflowAppId, '--workflow-id', 'not-a-uuid'])
+ expect(result.exitCode).not.toBe(0)
+ expect(result.stderr).toMatch(/http_status:\s*4\d\d/)
+ expect(result.stderr).not.toMatch(/http_status:\s*5\d\d/)
+ })
+
+ it('[P1] non-existent --workflow-id returns 404, not a 5xx', async () => {
+ const result = await fx.r([
+ 'export',
+ 'app',
+ E.workflowAppId,
+ '--workflow-id',
+ '00000000-0000-0000-0000-000000000000',
+ ])
+ expect(result.exitCode).not.toBe(0)
+ expect(result.stderr).toMatch(/http_status:\s*404/)
+ })
+
+ it('[P1] non-existent app in --output mode leaves no file behind', async () => {
+ const dir = await mkdtemp(join(tmpdir(), 'difyctl-e2e-export-nofile-'))
+ const outPath = join(dir, 'should-not-exist.yaml')
+ try {
+ const result = await fx.r(['export', 'app', 'nonexistent-app-id-nofile-e2e', '--output', outPath])
+ expect(result.exitCode).not.toBe(0)
+ const exists = await readFile(outPath, 'utf8').then(() => true).catch(() => false)
+ expect(exists, 'output file must not be created on export failure').toBe(false)
+ }
+ finally {
+ await rm(dir, { recursive: true, force: true })
+ }
+ })
+})
diff --git a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts
index a884a9fa96..d5ff63d87e 100644
--- a/cli/test/e2e/suites/error-handling/error-messages.e2e.ts
+++ b/cli/test/e2e/suites/error-handling/error-messages.e2e.ts
@@ -34,6 +34,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
+import { ZERO } from '@/util/uuid.js'
import {
assertErrorEnvelope,
assertNoAnsi,
@@ -203,7 +204,7 @@ describe('E2E / error message standards (spec 5.3)', () => {
// Spec 5.83: without --debug the CLI must never print a stack trace.
// We trigger a server_5xx by querying a non-existent app id and verify
// that no "at " stack-trace lines appear in stderr.
- const result = await fx.r(['get', 'app', '00000000-0000-0000-0000-000000000000'])
+ const result = await fx.r(['get', 'app', ZERO])
assertNonZeroExit(result)
// Stack trace lines look like " at Object.xxx (/path/to/file.js:123:45)"
expect(result.stderr).not.toMatch(/^\s+at\s+\S/m)
diff --git a/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts b/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts
index e8deb72729..f5e6bfff62 100644
--- a/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts
+++ b/cli/test/e2e/suites/error-handling/exit-codes.e2e.ts
@@ -31,16 +31,17 @@
* 5.117 panic output — cannot reliably trigger panic
*/
-import type { AuthFixture } from '../../helpers/cli.js'
+import type { AuthFixture } from '@test/e2e/helpers/cli.js'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
+import { assertExitCode, assertNonZeroExit } from '@test/e2e/helpers/assert.js'
+import { run, withAuthFixture, withTempConfig } from '@test/e2e/helpers/cli.js'
+import { resolveEnv } from '@test/e2e/setup/env.js'
import { afterEach, beforeEach, describe, expect, inject, it } from 'vitest'
-import { assertExitCode, assertNonZeroExit } from '../../helpers/assert.js'
-import { run, withAuthFixture, withTempConfig } from '../../helpers/cli.js'
-import { resolveEnv } from '../../setup/env.js'
+import { ZERO } from '@/util/uuid.js'
// @ts-expect-error — see test/e2e/helpers/vitest-context.ts for explanation
-const caps = inject('e2eCapabilities') as import('../../setup/env.js').E2ECapabilities
+const caps = inject('e2eCapabilities') as import('@test/e2e/setup/env.js').E2ECapabilities
const E = resolveEnv(caps)
describe('E2E / exit code standards (spec 5.4)', () => {
@@ -85,7 +86,7 @@ describe('E2E / exit code standards (spec 5.4)', () => {
const result = await fx.r([
'get',
'app',
- '00000000-0000-0000-0000-000000000000',
+ ZERO,
'-o',
'json',
])
diff --git a/cli/test/fixtures/dify-mock/scenarios.ts b/cli/test/fixtures/dify-mock/scenarios.ts
index 0fa5d6d33f..8ee839381c 100644
--- a/cli/test/fixtures/dify-mock/scenarios.ts
+++ b/cli/test/fixtures/dify-mock/scenarios.ts
@@ -14,6 +14,8 @@ export type Scenario
| 'server-version-empty'
| 'server-version-unsupported'
| 'run-422-stale'
+ | 'import-pending'
+ | 'import-failed'
export type AccountFixture = {
id: string
@@ -141,6 +143,13 @@ export const APPS: AppFixture[] = [
},
]
+export const DSL_YAML = `app:
+ description: A simple greeting bot
+ mode: chat
+ name: Greeter
+version: '0.1.4'
+`
+
export const SESSIONS: SessionFixture[] = [
{
id: 'tok-1',
diff --git a/cli/test/fixtures/dify-mock/server.ts b/cli/test/fixtures/dify-mock/server.ts
index 064dad071d..afc135e532 100644
--- a/cli/test/fixtures/dify-mock/server.ts
+++ b/cli/test/fixtures/dify-mock/server.ts
@@ -2,7 +2,7 @@ import type { AddressInfo } from 'node:net'
import type { Scenario } from './scenarios.js'
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
-import { ACCOUNT, APPS, SESSIONS, WORKSPACES } from './scenarios.js'
+import { ACCOUNT, APPS, DSL_YAML, SESSIONS, WORKSPACES } from './scenarios.js'
export type DifyMockOptions = {
scenario?: Scenario
@@ -19,6 +19,8 @@ export type DifyMock = {
lastRunBody: Record | null
/** Number of times POST /apps/:id/files/upload was called */
uploadCallCount: number
+ /** Body of the most recent POST to /workspaces/:id/apps/imports */
+ lastImportBody: Record | null
}
const TOKEN_RE = /^Bearer\s+dfo[ae]_[\w-]+$/
@@ -106,6 +108,7 @@ function hitlResumedResponse(): string {
export type MockState = {
lastRunBody: Record | null
uploadCallCount: number
+ lastImportBody: Record | null
}
export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
@@ -277,6 +280,38 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
})
})
+ app.get('/openapi/v1/apps/:id/export', (c) => {
+ const id = c.req.param('id')
+ const found = APPS.find(a => a.id === id)
+ if (found === undefined)
+ return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 })
+ return c.json({ data: DSL_YAML })
+ })
+
+ app.get('/openapi/v1/apps/:id/check-dependencies', (c) => {
+ const id = c.req.param('id')
+ const found = APPS.find(a => a.id === id)
+ if (found === undefined)
+ return c.json({ error: { code: 'not_found', message: 'app not found' } }, { status: 404 })
+ return c.json({ leaked_dependencies: [] })
+ })
+
+ app.post('/openapi/v1/workspaces/:wsId/apps/imports', async (c) => {
+ const body = await c.req.json() as Record
+ if (state !== undefined)
+ state.lastImportBody = body
+ const scenario = getScenario()
+ if (scenario === 'import-failed')
+ return c.json({ id: 'imp-1', status: 'failed', error: 'unsupported DSL version' }, { status: 200 })
+ if (scenario === 'import-pending')
+ return c.json({ id: 'imp-1', status: 'pending', current_dsl_version: '0.1.4', imported_dsl_version: '0.0.9' }, { status: 202 })
+ return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 })
+ })
+
+ app.post('/openapi/v1/workspaces/:wsId/apps/imports/:importId/confirm', (c) => {
+ return c.json({ id: 'imp-1', status: 'completed', app_id: 'app-1', app_mode: 'chat' }, { status: 200 })
+ })
+
app.post('/openapi/v1/apps/:id/run', async (c) => {
const id = c.req.param('id')
const body = await c.req.json() as { query?: string, inputs?: unknown }
@@ -393,7 +428,7 @@ export function buildApp(getScenario: () => Scenario, state?: MockState): Hono {
export function startMock(opts: DifyMockOptions = {}): Promise {
let scenario: Scenario = opts.scenario ?? 'happy'
- const state: MockState = { lastRunBody: null, uploadCallCount: 0 }
+ const state: MockState = { lastRunBody: null, uploadCallCount: 0, lastImportBody: null }
const app = buildApp(() => scenario, state)
return new Promise((resolve, reject) => {
const server = serve({
@@ -416,6 +451,7 @@ export function startMock(opts: DifyMockOptions = {}): Promise {
},
get lastRunBody() { return state.lastRunBody },
get uploadCallCount() { return state.uploadCallCount },
+ get lastImportBody() { return state.lastImportBody },
})
})
server.on('error', reject)
diff --git a/cli/vitest.e2e.config.ts b/cli/vitest.e2e.config.ts
index 7aa0f401ba..d9e87ed7e1 100644
--- a/cli/vitest.e2e.config.ts
+++ b/cli/vitest.e2e.config.ts
@@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite-plus'
import { resolveBuildInfo } from './scripts/lib/resolve-buildinfo.js'
@@ -36,6 +37,12 @@ catch {
* Run: bun vitest --config vitest.e2e.config.ts
*/
export default defineConfig({
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ '@test': fileURLToPath(new URL('./test', import.meta.url)),
+ },
+ },
pack: {
entry: ['src/index.ts'],
format: ['esm'],
@@ -85,6 +92,8 @@ export default defineConfig({
'test/e2e/suites/framework/**/*.e2e.ts',
// discovery (get app / describe app)
'test/e2e/suites/discovery/**/*.e2e.ts',
+ // dsl (export / import)
+ 'test/e2e/suites/dsl/**/*.e2e.ts',
// run tests (require valid token)
'test/e2e/suites/run/**/*.e2e.ts',
'test/e2e/suites/agent/**/*.e2e.ts',
diff --git a/packages/contracts/generated/api/openapi/orpc.gen.ts b/packages/contracts/generated/api/openapi/orpc.gen.ts
index f909cdffe8..15375c33c1 100644
--- a/packages/contracts/generated/api/openapi/orpc.gen.ts
+++ b/packages/contracts/generated/api/openapi/orpc.gen.ts
@@ -12,9 +12,14 @@ import {
zGetAccountResponse,
zGetAccountSessionsQuery,
zGetAccountSessionsResponse,
+ zGetAppsByAppIdCheckDependenciesPath,
+ zGetAppsByAppIdCheckDependenciesResponse,
zGetAppsByAppIdDescribePath,
zGetAppsByAppIdDescribeQuery,
zGetAppsByAppIdDescribeResponse,
+ zGetAppsByAppIdExportPath,
+ zGetAppsByAppIdExportQuery,
+ zGetAppsByAppIdExportResponse,
zGetAppsByAppIdFormHumanInputByFormTokenPath,
zGetAppsByAppIdFormHumanInputByFormTokenResponse,
zGetAppsByAppIdTasksByTaskIdEventsPath,
@@ -51,6 +56,11 @@ import {
zPostOauthDeviceDenyResponse,
zPostOauthDeviceTokenBody,
zPostOauthDeviceTokenResponse,
+ zPostWorkspacesByWorkspaceIdAppsImportsBody,
+ zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath,
+ zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse,
+ zPostWorkspacesByWorkspaceIdAppsImportsPath,
+ zPostWorkspacesByWorkspaceIdAppsImportsResponse,
zPostWorkspacesByWorkspaceIdMembersBody,
zPostWorkspacesByWorkspaceIdMembersPath,
zPostWorkspacesByWorkspaceIdMembersResponse,
@@ -151,6 +161,21 @@ export const account = {
}
export const get5 = oc
+ .route({
+ inputStructure: 'detailed',
+ method: 'GET',
+ operationId: 'getAppsByAppIdCheckDependencies',
+ path: '/apps/{app_id}/check-dependencies',
+ tags: ['openapi'],
+ })
+ .input(z.object({ params: zGetAppsByAppIdCheckDependenciesPath }))
+ .output(zGetAppsByAppIdCheckDependenciesResponse)
+
+export const checkDependencies = {
+ get: get5,
+}
+
+export const get6 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -167,7 +192,24 @@ export const get5 = oc
.output(zGetAppsByAppIdDescribeResponse)
export const describe = {
- get: get5,
+ get: get6,
+}
+
+export const get7 = oc
+ .route({
+ inputStructure: 'detailed',
+ method: 'GET',
+ operationId: 'getAppsByAppIdExport',
+ path: '/apps/{app_id}/export',
+ tags: ['openapi'],
+ })
+ .input(
+ z.object({ params: zGetAppsByAppIdExportPath, query: zGetAppsByAppIdExportQuery.optional() }),
+ )
+ .output(zGetAppsByAppIdExportResponse)
+
+export const export_ = {
+ get: get7,
}
/**
@@ -199,7 +241,7 @@ export const files = {
*
* @deprecated
*/
-export const get6 = oc
+export const get8 = oc
.route({
deprecated: true,
description:
@@ -230,7 +272,7 @@ export const post2 = oc
.output(zPostAppsByAppIdFormHumanInputByFormTokenResponse)
export const byFormToken = {
- get: get6,
+ get: get8,
post: post2,
}
@@ -270,7 +312,7 @@ export const run = {
*
* @deprecated
*/
-export const get7 = oc
+export const get9 = oc
.route({
deprecated: true,
description:
@@ -285,7 +327,7 @@ export const get7 = oc
.output(zGetAppsByAppIdTasksByTaskIdEventsResponse)
export const events = {
- get: get7,
+ get: get9,
}
export const post4 = oc
@@ -313,14 +355,16 @@ export const tasks = {
}
export const byAppId = {
+ checkDependencies,
describe,
+ export: export_,
files,
form,
run,
tasks,
}
-export const get8 = oc
+export const get10 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -332,7 +376,7 @@ export const get8 = oc
.output(zGetAppsResponse)
export const apps = {
- get: get8,
+ get: get10,
byAppId,
}
@@ -381,7 +425,7 @@ export const deny = {
post: post7,
}
-export const get9 = oc
+export const get11 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -393,7 +437,7 @@ export const get9 = oc
.output(zGetOauthDeviceLookupResponse)
export const lookup = {
- get: get9,
+ get: get11,
}
/**
@@ -431,7 +475,7 @@ export const oauth = {
device,
}
-export const get10 = oc
+export const get12 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -443,7 +487,51 @@ export const get10 = oc
.output(zGetPermittedExternalAppsResponse)
export const permittedExternalApps = {
- get: get10,
+ get: get12,
+}
+
+export const post9 = oc
+ .route({
+ inputStructure: 'detailed',
+ method: 'POST',
+ operationId: 'postWorkspacesByWorkspaceIdAppsImportsByImportIdConfirm',
+ path: '/workspaces/{workspace_id}/apps/imports/{import_id}/confirm',
+ tags: ['openapi'],
+ })
+ .input(z.object({ params: zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath }))
+ .output(zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse)
+
+export const confirm = {
+ post: post9,
+}
+
+export const byImportId = {
+ confirm,
+}
+
+export const post10 = oc
+ .route({
+ inputStructure: 'detailed',
+ method: 'POST',
+ operationId: 'postWorkspacesByWorkspaceIdAppsImports',
+ path: '/workspaces/{workspace_id}/apps/imports',
+ tags: ['openapi'],
+ })
+ .input(
+ z.object({
+ body: zPostWorkspacesByWorkspaceIdAppsImportsBody,
+ params: zPostWorkspacesByWorkspaceIdAppsImportsPath,
+ }),
+ )
+ .output(zPostWorkspacesByWorkspaceIdAppsImportsResponse)
+
+export const imports = {
+ post: post10,
+ byImportId,
+}
+
+export const apps2 = {
+ imports,
}
export const put = oc
@@ -482,7 +570,7 @@ export const byMemberId = {
role,
}
-export const get11 = oc
+export const get13 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -498,7 +586,7 @@ export const get11 = oc
)
.output(zGetWorkspacesByWorkspaceIdMembersResponse)
-export const post9 = oc
+export const post11 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -516,12 +604,12 @@ export const post9 = oc
.output(zPostWorkspacesByWorkspaceIdMembersResponse)
export const members = {
- get: get11,
- post: post9,
+ get: get13,
+ post: post11,
byMemberId,
}
-export const post10 = oc
+export const post12 = oc
.route({
inputStructure: 'detailed',
method: 'POST',
@@ -533,10 +621,10 @@ export const post10 = oc
.output(zPostWorkspacesByWorkspaceIdSwitchResponse)
export const switch_ = {
- post: post10,
+ post: post12,
}
-export const get12 = oc
+export const get14 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -548,12 +636,13 @@ export const get12 = oc
.output(zGetWorkspacesByWorkspaceIdResponse)
export const byWorkspaceId = {
- get: get12,
+ get: get14,
+ apps: apps2,
members,
switch: switch_,
}
-export const get13 = oc
+export const get15 = oc
.route({
inputStructure: 'detailed',
method: 'GET',
@@ -564,7 +653,7 @@ export const get13 = oc
.output(zGetWorkspacesResponse)
export const workspaces = {
- get: get13,
+ get: get15,
byWorkspaceId,
}
diff --git a/packages/contracts/generated/api/openapi/types.gen.ts b/packages/contracts/generated/api/openapi/types.gen.ts
index a20ede5b59..b957d0ad9f 100644
--- a/packages/contracts/generated/api/openapi/types.gen.ts
+++ b/packages/contracts/generated/api/openapi/types.gen.ts
@@ -45,6 +45,27 @@ export type AppDescribeResponse = {
} | null
}
+export type AppDslExportQuery = {
+ include_secret?: boolean
+ workflow_id?: string | null
+}
+
+export type AppDslExportResponse = {
+ data: string
+}
+
+export type AppDslImportPayload = {
+ app_id?: string | null
+ description?: string | null
+ icon?: string | null
+ icon_background?: string | null
+ icon_type?: string | null
+ mode: 'yaml-content' | 'yaml-url'
+ name?: string | null
+ yaml_content?: string | null
+ yaml_url?: string | null
+}
+
export type AppInfoResponse = {
author?: string | null
description?: string | null
@@ -107,6 +128,10 @@ export type AppRunRequest = {
workspace_id?: string | null
}
+export type CheckDependenciesResult = {
+ leaked_dependencies?: Array
+}
+
export type DeviceCodeRequest = {
client_id: string
device_label: string
@@ -165,6 +190,13 @@ export type FormSubmitResponse = {
[key: string]: never
}
+export type Github = {
+ github_plugin_unique_identifier: string
+ package: string
+ repo: string
+ version: string
+}
+
export type HealthResponse = {
ok: boolean
}
@@ -176,8 +208,25 @@ export type HumanInputFormSubmitPayload = {
}
}
+export type Import = {
+ app_id?: string | null
+ app_mode?: string | null
+ current_dsl_version?: string
+ error?: string
+ id: string
+ imported_dsl_version?: string
+ status: ImportStatus
+}
+
+export type ImportStatus = 'completed' | 'completed-with-warnings' | 'failed' | 'pending'
+
export type JsonValue = unknown
+export type Marketplace = {
+ marketplace_plugin_unique_identifier: string
+ version?: string | null
+}
+
export type MemberActionResponse = {
result?: string
}
@@ -229,6 +278,11 @@ export type MessageMetadata = {
usage?: UsageInfo
}
+export type Package = {
+ plugin_unique_identifier: string
+ version?: string | null
+}
+
export type PermittedExternalAppsListQuery = {
limit?: number
mode?: AppMode
@@ -244,6 +298,12 @@ export type PermittedExternalAppsListResponse = {
total: number
}
+export type PluginDependency = {
+ current_identifier?: string | null
+ type: Type
+ value: unknown
+}
+
export type RevokeResponse = {
status: string
}
@@ -284,6 +344,8 @@ export type TaskStopResponse = {
result: string
}
+export type Type = 'github' | 'marketplace' | 'package'
+
export type UsageInfo = {
completion_tokens?: number
prompt_tokens?: number
@@ -438,6 +500,22 @@ export type GetAppsResponses = {
export type GetAppsResponse = GetAppsResponses[keyof GetAppsResponses]
+export type GetAppsByAppIdCheckDependenciesData = {
+ body?: never
+ path: {
+ app_id: string
+ }
+ query?: never
+ url: '/apps/{app_id}/check-dependencies'
+}
+
+export type GetAppsByAppIdCheckDependenciesResponses = {
+ 200: CheckDependenciesResult
+}
+
+export type GetAppsByAppIdCheckDependenciesResponse
+ = GetAppsByAppIdCheckDependenciesResponses[keyof GetAppsByAppIdCheckDependenciesResponses]
+
export type GetAppsByAppIdDescribeData = {
body?: never
path: {
@@ -456,6 +534,25 @@ export type GetAppsByAppIdDescribeResponses = {
export type GetAppsByAppIdDescribeResponse
= GetAppsByAppIdDescribeResponses[keyof GetAppsByAppIdDescribeResponses]
+export type GetAppsByAppIdExportData = {
+ body?: never
+ path: {
+ app_id: string
+ }
+ query?: {
+ include_secret?: boolean
+ workflow_id?: string
+ }
+ url: '/apps/{app_id}/export'
+}
+
+export type GetAppsByAppIdExportResponses = {
+ 200: AppDslExportResponse
+}
+
+export type GetAppsByAppIdExportResponse
+ = GetAppsByAppIdExportResponses[keyof GetAppsByAppIdExportResponses]
+
export type PostAppsByAppIdFilesUploadData = {
body?: never
path: {
@@ -702,6 +799,54 @@ export type GetWorkspacesByWorkspaceIdResponses = {
export type GetWorkspacesByWorkspaceIdResponse
= GetWorkspacesByWorkspaceIdResponses[keyof GetWorkspacesByWorkspaceIdResponses]
+export type PostWorkspacesByWorkspaceIdAppsImportsData = {
+ body: AppDslImportPayload
+ path: {
+ workspace_id: string
+ }
+ query?: never
+ url: '/workspaces/{workspace_id}/apps/imports'
+}
+
+export type PostWorkspacesByWorkspaceIdAppsImportsErrors = {
+ 400: Import
+}
+
+export type PostWorkspacesByWorkspaceIdAppsImportsError
+ = PostWorkspacesByWorkspaceIdAppsImportsErrors[keyof PostWorkspacesByWorkspaceIdAppsImportsErrors]
+
+export type PostWorkspacesByWorkspaceIdAppsImportsResponses = {
+ 200: Import
+ 202: Import
+}
+
+export type PostWorkspacesByWorkspaceIdAppsImportsResponse
+ = PostWorkspacesByWorkspaceIdAppsImportsResponses[keyof PostWorkspacesByWorkspaceIdAppsImportsResponses]
+
+export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmData = {
+ body?: never
+ path: {
+ import_id: string
+ workspace_id: string
+ }
+ query?: never
+ url: '/workspaces/{workspace_id}/apps/imports/{import_id}/confirm'
+}
+
+export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors = {
+ 400: Import
+}
+
+export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmError
+ = PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors[keyof PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors]
+
+export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses = {
+ 200: Import
+}
+
+export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse
+ = PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses[keyof PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponses]
+
export type GetWorkspacesByWorkspaceIdMembersData = {
body?: never
path: {
diff --git a/packages/contracts/generated/api/openapi/zod.gen.ts b/packages/contracts/generated/api/openapi/zod.gen.ts
index 0b6a950be5..2d632814d0 100644
--- a/packages/contracts/generated/api/openapi/zod.gen.ts
+++ b/packages/contracts/generated/api/openapi/zod.gen.ts
@@ -22,6 +22,42 @@ export const zAppDescribeQuery = z.object({
fields: z.string().optional(),
})
+/**
+ * AppDslExportQuery
+ *
+ * Query parameters for GET /apps//export.
+ */
+export const zAppDslExportQuery = z.object({
+ include_secret: z.boolean().optional().default(false),
+ workflow_id: z.string().nullish(),
+})
+
+/**
+ * AppDslExportResponse
+ *
+ * Export DSL response.
+ */
+export const zAppDslExportResponse = z.object({
+ data: z.string(),
+})
+
+/**
+ * AppDslImportPayload
+ *
+ * Request body for POST /workspaces//apps/imports.
+ */
+export const zAppDslImportPayload = z.object({
+ app_id: z.string().nullish(),
+ description: z.string().nullish(),
+ icon: z.string().nullish(),
+ icon_background: z.string().nullish(),
+ icon_type: z.string().nullish(),
+ mode: z.enum(['yaml-content', 'yaml-url']),
+ name: z.string().nullish(),
+ yaml_content: z.string().nullish(),
+ yaml_url: z.string().nullish(),
+})
+
/**
* AppMode
*/
@@ -150,6 +186,16 @@ export const zFileResponse = z.object({
*/
export const zFormSubmitResponse = z.record(z.string(), z.never())
+/**
+ * Github
+ */
+export const zGithub = z.object({
+ github_plugin_unique_identifier: z.string(),
+ package: z.string(),
+ repo: z.string(),
+ version: z.string(),
+})
+
/**
* HealthResponse
*
@@ -159,6 +205,24 @@ export const zHealthResponse = z.object({
ok: z.boolean(),
})
+/**
+ * ImportStatus
+ */
+export const zImportStatus = z.enum(['completed', 'completed-with-warnings', 'failed', 'pending'])
+
+/**
+ * Import
+ */
+export const zImport = z.object({
+ app_id: z.string().nullish(),
+ app_mode: z.string().nullish(),
+ current_dsl_version: z.string().optional().default('0.6.0'),
+ error: z.string().optional().default(''),
+ id: z.string(),
+ imported_dsl_version: z.string().optional().default(''),
+ status: zImportStatus,
+})
+
export const zJsonValue = z.unknown()
/**
@@ -169,6 +233,14 @@ export const zHumanInputFormSubmitPayload = z.object({
inputs: z.record(z.string(), zJsonValue),
})
+/**
+ * Marketplace
+ */
+export const zMarketplace = z.object({
+ marketplace_plugin_unique_identifier: z.string(),
+ version: z.string().nullish(),
+})
+
/**
* MemberActionResponse
*/
@@ -236,6 +308,14 @@ export const zMemberRoleUpdatePayload = z.object({
role: z.enum(['admin', 'normal']),
})
+/**
+ * Package
+ */
+export const zPackage = z.object({
+ plugin_unique_identifier: z.string(),
+ version: z.string().nullish(),
+})
+
/**
* PermittedExternalAppsListQuery
*
@@ -390,6 +470,27 @@ export const zTaskStopResponse = z.object({
result: z.string(),
})
+/**
+ * Type
+ */
+export const zType = z.enum(['github', 'marketplace', 'package'])
+
+/**
+ * PluginDependency
+ */
+export const zPluginDependency = z.object({
+ current_identifier: z.string().nullish(),
+ type: zType,
+ value: z.unknown(),
+})
+
+/**
+ * CheckDependenciesResult
+ */
+export const zCheckDependenciesResult = z.object({
+ leaked_dependencies: z.array(zPluginDependency).optional(),
+})
+
/**
* UsageInfo
*/
@@ -527,6 +628,15 @@ export const zGetAppsQuery = z.object({
*/
export const zGetAppsResponse = zAppListResponse
+export const zGetAppsByAppIdCheckDependenciesPath = z.object({
+ app_id: z.string(),
+})
+
+/**
+ * Dependencies checked
+ */
+export const zGetAppsByAppIdCheckDependenciesResponse = zCheckDependenciesResult
+
export const zGetAppsByAppIdDescribePath = z.object({
app_id: z.string(),
})
@@ -540,6 +650,20 @@ export const zGetAppsByAppIdDescribeQuery = z.object({
*/
export const zGetAppsByAppIdDescribeResponse = zAppDescribeResponse
+export const zGetAppsByAppIdExportPath = z.object({
+ app_id: z.string(),
+})
+
+export const zGetAppsByAppIdExportQuery = z.object({
+ include_secret: z.boolean().optional().default(false),
+ workflow_id: z.string().optional(),
+})
+
+/**
+ * Export successful
+ */
+export const zGetAppsByAppIdExportResponse = zAppDslExportResponse
+
export const zPostAppsByAppIdFilesUploadPath = z.object({
app_id: z.string(),
})
@@ -665,6 +789,27 @@ export const zGetWorkspacesByWorkspaceIdPath = z.object({
*/
export const zGetWorkspacesByWorkspaceIdResponse = zWorkspaceDetailResponse
+export const zPostWorkspacesByWorkspaceIdAppsImportsBody = zAppDslImportPayload
+
+export const zPostWorkspacesByWorkspaceIdAppsImportsPath = z.object({
+ workspace_id: z.string(),
+})
+
+/**
+ * Import completed
+ */
+export const zPostWorkspacesByWorkspaceIdAppsImportsResponse = zImport
+
+export const zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmPath = z.object({
+ import_id: z.string(),
+ workspace_id: z.string(),
+})
+
+/**
+ * Import confirmed
+ */
+export const zPostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmResponse = zImport
+
export const zGetWorkspacesByWorkspaceIdMembersPath = z.object({
workspace_id: z.string(),
})
From 6658a7c5e784e4cd7019d736506b7d13f54dd3f0 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=9D=9E=E6=B3=95=E6=93=8D=E4=BD=9C?=
Date: Wed, 10 Jun 2026 18:21:05 +0800
Subject: [PATCH 5/7] fix: block frozen deleted accounts during invite
activation (#37281)
---
api/controllers/console/auth/activate.py | 9 +++-
.../console/auth/test_account_activation.py | 43 ++++++++++++++++++-
2 files changed, 49 insertions(+), 3 deletions(-)
diff --git a/api/controllers/console/auth/activate.py b/api/controllers/console/auth/activate.py
index eedb285434..65e278edb5 100644
--- a/api/controllers/console/auth/activate.py
+++ b/api/controllers/console/auth/activate.py
@@ -2,15 +2,17 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
+from configs import dify_config
from constants.languages import supported_language
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
-from controllers.console.error import AlreadyActivateError
+from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, timezone
from models import AccountStatus
from services.account_service import RegisterService
+from services.billing_service import BillingService
class ActivateCheckQuery(BaseModel):
@@ -120,9 +122,12 @@ class ActivateApi(Resource):
if invitation is None:
raise AlreadyActivateError()
+ account = invitation["account"]
+ if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email):
+ raise AccountInFreezeError()
+
RegisterService.revoke_token(args.workspace_id, normalized_request_email, args.token)
- account = invitation["account"]
account.name = args.name
account.interface_language = args.interface_language
diff --git a/api/tests/unit_tests/controllers/console/auth/test_account_activation.py b/api/tests/unit_tests/controllers/console/auth/test_account_activation.py
index 0fb0ebc330..79169cfce7 100644
--- a/api/tests/unit_tests/controllers/console/auth/test_account_activation.py
+++ b/api/tests/unit_tests/controllers/console/auth/test_account_activation.py
@@ -14,7 +14,7 @@ import pytest
from flask import Flask
from controllers.console.auth.activate import ActivateApi, ActivateCheckApi
-from controllers.console.error import AlreadyActivateError
+from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from models.account import AccountStatus
@@ -255,6 +255,47 @@ class TestActivateApi:
with pytest.raises(AlreadyActivateError):
api.post()
+ @patch("controllers.console.auth.activate.dify_config.BILLING_ENABLED", True)
+ @patch("controllers.console.auth.activate.BillingService.is_email_in_freeze")
+ @patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
+ @patch("controllers.console.auth.activate.RegisterService.revoke_token")
+ @patch("controllers.console.auth.activate.db")
+ def test_activation_rejects_account_in_billing_freeze(
+ self,
+ mock_db,
+ mock_revoke_token,
+ mock_get_invitation,
+ mock_is_email_in_freeze,
+ app: Flask,
+ mock_invitation,
+ mock_account,
+ ):
+ """Frozen deleted-account emails cannot be reactivated through invitation links."""
+ mock_account.email = "Invitee@Example.com"
+ mock_get_invitation.return_value = mock_invitation
+ mock_is_email_in_freeze.return_value = True
+
+ with app.test_request_context(
+ "/activate",
+ method="POST",
+ json={
+ "workspace_id": "workspace-123",
+ "email": "invitee@example.com",
+ "token": "valid_token",
+ "name": "John Doe",
+ "interface_language": "en-US",
+ "timezone": "UTC",
+ },
+ ):
+ api = ActivateApi()
+ with pytest.raises(AccountInFreezeError):
+ api.post()
+
+ mock_is_email_in_freeze.assert_called_once_with("Invitee@Example.com")
+ mock_revoke_token.assert_not_called()
+ mock_db.session.commit.assert_not_called()
+ assert mock_account.status == AccountStatus.PENDING
+
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
@patch("controllers.console.auth.activate.db")
From 2c5c8e82c367664bed197925244f875637ffef0c Mon Sep 17 00:00:00 2001
From: zyssyz123 <916125788@qq.com>
Date: Wed, 10 Jun 2026 18:40:03 +0800
Subject: [PATCH 6/7] feat: agent slash menu backend (#37268)
Co-authored-by: Claude Fable 5
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
---
api/controllers/console/agent/composer.py | 41 ++-
.../apps/agent_app/runtime_request_builder.py | 8 +-
api/core/workflow/graph_topology.py | 86 ++++++
.../nodes/agent_v2/runtime_request_builder.py | 18 +-
.../workflow/nodes/agent_v2/validators.py | 58 +---
api/fields/agent_fields.py | 42 ++-
api/openapi/markdown/console-swagger.md | 58 +++-
api/services/agent/composer_candidates.py | 210 ++++++++++++++
api/services/agent/composer_service.py | 244 ++++++++++++++--
api/services/agent/composer_validator.py | 163 +++++++++++
api/services/agent/prompt_mentions.py | 264 ++++++++++++++++++
api/services/entities/agent_entities.py | 2 +
.../console/agent/test_agent_controllers.py | 42 ++-
.../agent_v2/test_runtime_request_builder.py | 37 +++
.../core/workflow/test_graph_topology.py | 51 ++++
.../services/agent/test_agent_services.py | 47 +++-
.../agent/test_composer_candidates.py | 204 ++++++++++++++
.../agent/test_composer_mention_validation.py | 188 +++++++++++++
.../services/agent/test_prompt_mentions.py | 179 ++++++++++++
.../generated/api/console/apps/types.gen.ts | 52 +++-
.../generated/api/console/apps/zod.gen.ts | 99 +++++--
21 files changed, 1972 insertions(+), 121 deletions(-)
create mode 100644 api/core/workflow/graph_topology.py
create mode 100644 api/services/agent/composer_candidates.py
create mode 100644 api/services/agent/prompt_mentions.py
create mode 100644 api/tests/unit_tests/core/workflow/test_graph_topology.py
create mode 100644 api/tests/unit_tests/services/agent/test_composer_candidates.py
create mode 100644 api/tests/unit_tests/services/agent/test_composer_mention_validation.py
create mode 100644 api/tests/unit_tests/services/agent/test_prompt_mentions.py
diff --git a/api/controllers/console/agent/composer.py b/api/controllers/console/agent/composer.py
index 7f7370454c..8d2297a1b8 100644
--- a/api/controllers/console/agent/composer.py
+++ b/api/controllers/console/agent/composer.py
@@ -90,10 +90,12 @@ 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):
+ @with_current_tenant_id
+ def post(self, tenant_id: str, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
- return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
+ findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
+ return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@console_ns.route("/apps//workflows/draft/nodes//agent-composer/candidates")
@@ -105,10 +107,17 @@ 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):
+ @with_current_user_id
+ @with_current_tenant_id
+ def get(self, tenant_id: str, current_user_id: str, app_model: App, node_id: str):
return dump_response(
AgentComposerCandidatesResponse,
- AgentComposerService.get_workflow_candidates(app_id=app_model.id),
+ AgentComposerService.get_workflow_candidates(
+ tenant_id=tenant_id,
+ app_id=app_model.id,
+ node_id=node_id,
+ user_id=current_user_id,
+ ),
)
@@ -167,7 +176,7 @@ class AgentAppComposerApi(Resource):
@setup_required
@login_required
@account_initialization_required
- @get_app_model()
+ @get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
return dump_response(
@@ -181,7 +190,7 @@ class AgentAppComposerApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
- @get_app_model()
+ @get_app_model(mode=[AppMode.AGENT])
@with_current_user_id
@with_current_tenant_id
def put(self, tenant_id: str, account_id: str, app_model: App):
@@ -206,11 +215,13 @@ class AgentAppComposerValidateApi(Resource):
@setup_required
@login_required
@account_initialization_required
- @get_app_model()
- def post(self, app_model: App):
+ @get_app_model(mode=[AppMode.AGENT])
+ @with_current_tenant_id
+ def post(self, tenant_id: str, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
- return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
+ findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
+ return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
@console_ns.route("/apps//agent-composer/candidates")
@@ -221,9 +232,15 @@ class AgentAppComposerCandidatesApi(Resource):
@setup_required
@login_required
@account_initialization_required
- @get_app_model()
- def get(self, app_model: App):
+ @get_app_model(mode=[AppMode.AGENT])
+ @with_current_user_id
+ @with_current_tenant_id
+ def get(self, tenant_id: str, current_user_id: str, app_model: App):
return dump_response(
AgentComposerCandidatesResponse,
- AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
+ AgentComposerService.get_agent_app_candidates(
+ tenant_id=tenant_id,
+ app_id=app_model.id,
+ user_id=current_user_id,
+ ),
)
diff --git a/api/core/app/apps/agent_app/runtime_request_builder.py b/api/core/app/apps/agent_app/runtime_request_builder.py
index 9d93161a98..df1e161b64 100644
--- a/api/core/app/apps/agent_app/runtime_request_builder.py
+++ b/api/core/app/apps/agent_app/runtime_request_builder.py
@@ -35,6 +35,7 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import (
from core.workflow.nodes.agent_v2.runtime_request_builder import build_shell_layer_config
from models.agent_config_entities import AgentSoulConfig
from models.provider_ids import ModelProviderID
+from services.agent.prompt_mentions import build_soul_mention_resolver, expand_prompt_mentions
class AgentAppRuntimeRequestBuildError(ValueError):
@@ -135,7 +136,12 @@ class AgentAppRuntimeRequestBuilder:
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
agent_mode="agent_app",
),
- agent_soul_prompt=agent_soul.prompt.system_prompt or None,
+ # ENG-616: expand slash-menu mention tokens to canonical names so
+ # no frontend-internal {{#…#}} marker ever reaches the model.
+ agent_soul_prompt=expand_prompt_mentions(
+ agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
+ ).strip()
+ or None,
user_prompt=context.user_query,
tools=tools_layer,
include_shell=dify_config.AGENT_SHELL_ENABLED,
diff --git a/api/core/workflow/graph_topology.py b/api/core/workflow/graph_topology.py
new file mode 100644
index 0000000000..e5fe849840
--- /dev/null
+++ b/api/core/workflow/graph_topology.py
@@ -0,0 +1,86 @@
+"""Draft-workflow graph topology helper, shared by Agent v2 publish validation
+and the agent-composer candidates endpoint (ENG-615).
+
+Extracted from ``core/workflow/nodes/agent_v2/validators.py`` so both call sites
+parse the same ``Workflow.graph`` JSON shape (``nodes`` with string ids,
+``edges`` with ``source``/``target``).
+"""
+
+from __future__ import annotations
+
+from collections import defaultdict, deque
+from collections.abc import Mapping, Sequence
+from typing import Any
+
+
+class WorkflowGraphTopology:
+ def __init__(self, *, node_ids: set[str], incoming: Mapping[str, Sequence[str]]) -> None:
+ self._node_ids = node_ids
+ self._incoming = incoming
+
+ @classmethod
+ def from_graph(cls, graph: Mapping[str, Any]) -> WorkflowGraphTopology:
+ node_ids = cls._node_ids_from_graph(graph)
+ incoming: dict[str, list[str]] = defaultdict(list)
+ edges = graph.get("edges")
+ if isinstance(edges, list):
+ for edge in edges:
+ if not isinstance(edge, Mapping):
+ continue
+ source = edge.get("source")
+ target = edge.get("target")
+ if isinstance(source, str) and isinstance(target, str):
+ incoming[target].append(source)
+ return cls(node_ids=node_ids, incoming=incoming)
+
+ def has_node(self, node_id: str) -> bool:
+ return node_id in self._node_ids
+
+ def is_upstream(self, *, source_node_id: str, target_node_id: str) -> bool:
+ if source_node_id == target_node_id:
+ return False
+ visited: set[str] = set()
+ queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
+ while queue:
+ candidate = queue.popleft()
+ if candidate == source_node_id:
+ return True
+ if candidate in visited:
+ continue
+ visited.add(candidate)
+ queue.extend(self._incoming.get(candidate, ()))
+ return False
+
+ def upstream_node_ids(self, target_node_id: str) -> set[str]:
+ """All graph nodes reachable upstream of ``target_node_id`` (excluding it).
+
+ Edges may reference ids missing from ``nodes`` (half-deleted graphs);
+ only real nodes are returned.
+ """
+ visited: set[str] = set()
+ queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
+ while queue:
+ candidate = queue.popleft()
+ if candidate in visited:
+ continue
+ visited.add(candidate)
+ queue.extend(self._incoming.get(candidate, ()))
+ visited.discard(target_node_id)
+ return visited & self._node_ids
+
+ @staticmethod
+ def _node_ids_from_graph(graph: Mapping[str, Any]) -> set[str]:
+ node_ids: set[str] = set()
+ nodes = graph.get("nodes")
+ if not isinstance(nodes, list):
+ return node_ids
+ for node in nodes:
+ if not isinstance(node, Mapping):
+ continue
+ node_id = node.get("id")
+ if isinstance(node_id, str):
+ node_ids.add(node_id)
+ return node_ids
+
+
+__all__ = ["WorkflowGraphTopology"]
diff --git a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py
index e1f9fbdaba..1a85b14d28 100644
--- a/api/core/workflow/nodes/agent_v2/runtime_request_builder.py
+++ b/api/core/workflow/nodes/agent_v2/runtime_request_builder.py
@@ -45,6 +45,11 @@ from models.agent_config_entities import (
effective_declared_outputs as _effective_declared_outputs,
)
from models.provider_ids import ModelProviderID
+from services.agent.prompt_mentions import (
+ build_node_job_mention_resolver,
+ build_soul_mention_resolver,
+ expand_prompt_mentions,
+)
from .output_failure_orchestrator import retry_idempotency_key
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
@@ -129,7 +134,16 @@ class WorkflowAgentRuntimeRequestBuilder:
metadata = self._build_metadata(context, agent_soul, node_job)
workflow_context_prompt = self._build_workflow_context_prompt(context, node_job)
- workflow_job_prompt = node_job.workflow_prompt.strip() or "Run this workflow Agent Node for the current run."
+ # ENG-616: expand slash-menu mention tokens into model-readable names.
+ # node_output mentions expand to their reference name only — the value
+ # stays in the Workflow context block (user_prompt) below.
+ workflow_job_prompt = (
+ expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
+ or "Run this workflow Agent Node for the current run."
+ )
+ soul_prompt = expand_prompt_mentions(
+ agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
+ ).strip()
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
try:
@@ -187,7 +201,7 @@ class WorkflowAgentRuntimeRequestBuilder:
agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from),
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
),
- agent_soul_prompt=agent_soul.prompt.system_prompt or None,
+ agent_soul_prompt=soul_prompt or None,
workflow_node_job_prompt=workflow_job_prompt,
user_prompt=user_prompt,
output=self._build_output_config(node_job.declared_outputs),
diff --git a/api/core/workflow/nodes/agent_v2/validators.py b/api/core/workflow/nodes/agent_v2/validators.py
index cb2e54bda7..832a1a5e15 100644
--- a/api/core/workflow/nodes/agent_v2/validators.py
+++ b/api/core/workflow/nodes/agent_v2/validators.py
@@ -1,12 +1,12 @@
from __future__ import annotations
-from collections import defaultdict, deque
-from collections.abc import Iterator, Mapping, Sequence
+from collections.abc import Iterator, Mapping
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
+from core.workflow.graph_topology import WorkflowGraphTopology
from graphon.enums import BuiltinNodeTypes
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding
from models.agent_config_entities import (
@@ -523,54 +523,6 @@ class WorkflowAgentNodeValidator:
)
-class _WorkflowGraphTopology:
- def __init__(self, *, node_ids: set[str], incoming: Mapping[str, Sequence[str]]) -> None:
- self._node_ids = node_ids
- self._incoming = incoming
-
- @classmethod
- def from_graph(cls, graph: Mapping[str, Any]) -> _WorkflowGraphTopology:
- node_ids = cls._node_ids_from_graph(graph)
- incoming: dict[str, list[str]] = defaultdict(list)
- edges = graph.get("edges")
- if isinstance(edges, list):
- for edge in edges:
- if not isinstance(edge, Mapping):
- continue
- source = edge.get("source")
- target = edge.get("target")
- if isinstance(source, str) and isinstance(target, str):
- incoming[target].append(source)
- return cls(node_ids=node_ids, incoming=incoming)
-
- def has_node(self, node_id: str) -> bool:
- return node_id in self._node_ids
-
- def is_upstream(self, *, source_node_id: str, target_node_id: str) -> bool:
- if source_node_id == target_node_id:
- return False
- visited: set[str] = set()
- queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
- while queue:
- candidate = queue.popleft()
- if candidate == source_node_id:
- return True
- if candidate in visited:
- continue
- visited.add(candidate)
- queue.extend(self._incoming.get(candidate, ()))
- return False
-
- @staticmethod
- def _node_ids_from_graph(graph: Mapping[str, Any]) -> set[str]:
- node_ids: set[str] = set()
- nodes = graph.get("nodes")
- if not isinstance(nodes, list):
- return node_ids
- for node in nodes:
- if not isinstance(node, Mapping):
- continue
- node_id = node.get("id")
- if isinstance(node_id, str):
- node_ids.add(node_id)
- return node_ids
+# Extracted to core/workflow/graph_topology.py (shared with the agent-composer
+# candidates endpoint, ENG-615); kept as a private alias for existing call sites.
+_WorkflowGraphTopology = WorkflowGraphTopology
diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py
index cef5d9fc88..d27a5708bf 100644
--- a/api/fields/agent_fields.py
+++ b/api/fields/agent_fields.py
@@ -1,4 +1,4 @@
-from typing import Literal
+from typing import Annotated, Literal
from pydantic import Field
@@ -14,6 +14,7 @@ from models.agent import (
)
from models.agent_config_entities import (
AgentCliToolConfig,
+ AgentFileRefConfig,
AgentHumanContactConfig,
AgentKnowledgeDatasetConfig,
AgentSkillRefConfig,
@@ -154,6 +155,7 @@ class WorkflowAgentComposerResponse(ResponseModel):
effective_declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list)
save_options: list[ComposerSaveStrategy]
impact_summary: AgentComposerImpactResponse | None = None
+ validation: "ComposerValidationFindingsResponse | None" = None
app_id: str | None = None
workflow_id: str | None = None
node_id: str | None = None
@@ -165,11 +167,32 @@ class AgentAppComposerResponse(ResponseModel):
active_config_snapshot: AgentConfigSnapshotSummaryResponse
agent_soul: AgentSoulConfig
save_options: list[ComposerSaveStrategy]
+ validation: "ComposerValidationFindingsResponse | None" = None
+
+
+class ComposerValidationWarningResponse(ResponseModel):
+ code: str
+ surface: str | None = None
+ kind: str | None = None
+ id: str | None = None
+ message: str | None = None
+
+
+class ComposerKnowledgePlaceholderResponse(ResponseModel):
+ id: str
+ placeholder_name: str
+
+
+class ComposerValidationFindingsResponse(ResponseModel):
+ warnings: list[ComposerValidationWarningResponse] = Field(default_factory=list)
+ knowledge_retrieval_placeholder: list[ComposerKnowledgePlaceholderResponse] = Field(default_factory=list)
class AgentComposerValidateResponse(ResponseModel):
result: Literal["success"]
errors: list[str] = Field(default_factory=list)
+ warnings: list[ComposerValidationWarningResponse] = Field(default_factory=list)
+ knowledge_retrieval_placeholder: list[ComposerKnowledgePlaceholderResponse] = Field(default_factory=list)
class AgentComposerDifyToolCandidateResponse(ResponseModel):
@@ -181,6 +204,20 @@ class AgentComposerDifyToolCandidateResponse(ResponseModel):
plugin_id: str | None = None
+class AgentComposerSkillCandidateResponse(AgentSkillRefConfig):
+ kind: Literal["skill"] = "skill"
+
+
+class AgentComposerFileCandidateResponse(AgentFileRefConfig):
+ kind: Literal["file"] = "file"
+
+
+AgentComposerSkillFileCandidateResponse = Annotated[
+ AgentComposerSkillCandidateResponse | AgentComposerFileCandidateResponse,
+ Field(discriminator="kind"),
+]
+
+
class AgentComposerNodeJobCandidatesResponse(ResponseModel):
previous_node_outputs: list[WorkflowPreviousNodeOutputRef] = Field(default_factory=list)
declare_output_types: list[DeclaredOutputType] = Field(default_factory=list)
@@ -188,7 +225,7 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel):
class AgentComposerSoulCandidatesResponse(ResponseModel):
- skills_files: list[AgentSkillRefConfig] = Field(default_factory=list)
+ skills_files: list[AgentComposerSkillFileCandidateResponse] = Field(default_factory=list)
dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list)
cli_tools: list[AgentCliToolConfig] = Field(default_factory=list)
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
@@ -204,3 +241,4 @@ class AgentComposerCandidatesResponse(ResponseModel):
default_factory=AgentComposerSoulCandidatesResponse
)
capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities)
+ truncated: bool = False
diff --git a/api/openapi/markdown/console-swagger.md b/api/openapi/markdown/console-swagger.md
index 69f5744655..8fd1a57e20 100644
--- a/api/openapi/markdown/console-swagger.md
+++ b/api/openapi/markdown/console-swagger.md
@@ -11808,6 +11808,7 @@ Get banner list
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
+| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
| variant | string | | Yes |
#### AgentAppFeaturesPayload
@@ -11903,6 +11904,7 @@ Risk marker for CLI tool bootstrap commands.
| allowed_node_job_candidates | [AgentComposerNodeJobCandidatesResponse](#agentcomposernodejobcandidatesresponse) | | No |
| allowed_soul_candidates | [AgentComposerSoulCandidatesResponse](#agentcomposersoulcandidatesresponse) | | No |
| capabilities | [ComposerCandidateCapabilities](#composercandidatecapabilities) | | No |
+| truncated | boolean | | No |
| variant | [ComposerVariant](#composervariant) | | Yes |
#### AgentComposerDifyToolCandidateResponse
@@ -11916,6 +11918,22 @@ Risk marker for CLI tool bootstrap commands.
| provider | string | | No |
| provider_id | string | | No |
+#### AgentComposerFileCandidateResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| file_id | string | | No |
+| id | string | | No |
+| kind | string | | No |
+| name | string | | No |
+| reference | string | | No |
+| remote_url | string | | No |
+| tenant_id | string | | No |
+| transfer_method | string | | No |
+| type | string | | No |
+| upload_file_id | string | | No |
+| url | string | | No |
+
#### AgentComposerImpactBindingResponse
| Name | Type | Description | Required |
@@ -11940,6 +11958,17 @@ Risk marker for CLI tool bootstrap commands.
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
| previous_node_outputs | [ [WorkflowPreviousNodeOutputRef](#workflowpreviousnodeoutputref) ] | | No |
+#### AgentComposerSkillCandidateResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| description | string | | No |
+| file_id | string | | No |
+| id | string | | No |
+| kind | string | | No |
+| name | string | | No |
+| path | string | | No |
+
#### AgentComposerSoulCandidatesResponse
| Name | Type | Description | Required |
@@ -11948,7 +11977,7 @@ Risk marker for CLI tool bootstrap commands.
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
-| skills_files | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
+| skills_files | [ ] | | No |
#### AgentComposerSoulLockResponse
@@ -11963,7 +11992,9 @@ Risk marker for CLI tool bootstrap commands.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| errors | [ string ] | | No |
+| knowledge_retrieval_placeholder | [ [ComposerKnowledgePlaceholderResponse](#composerknowledgeplaceholderresponse) ] | | No |
| result | string | | Yes |
+| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
#### AgentConfigRevisionOperation
@@ -13286,6 +13317,13 @@ Button styles for user actions.
| ---- | ---- | ----------- | -------- |
| human_roster_available | boolean | | No |
+#### ComposerKnowledgePlaceholderResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| id | string | | Yes |
+| placeholder_name | string | | Yes |
+
#### ComposerSavePayload
| Name | Type | Description | Required |
@@ -13314,6 +13352,23 @@ Button styles for user actions.
| locked | boolean | | No |
| unlocked_from_version_id | string | | No |
+#### ComposerValidationFindingsResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| knowledge_retrieval_placeholder | [ [ComposerKnowledgePlaceholderResponse](#composerknowledgeplaceholderresponse) ] | | No |
+| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
+
+#### ComposerValidationWarningResponse
+
+| Name | Type | Description | Required |
+| ---- | ---- | ----------- | -------- |
+| code | string | | Yes |
+| id | string | | No |
+| kind | string | | No |
+| message | string | | No |
+| surface | string | | No |
+
#### ComposerVariant
| Name | Type | Description | Required |
@@ -17571,6 +17626,7 @@ How a workflow node is bound to an Agent.
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| soul_lock | [AgentComposerSoulLockResponse](#agentcomposersoullockresponse) | | Yes |
+| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
| variant | string | | Yes |
| workflow_id | string | | No |
diff --git a/api/services/agent/composer_candidates.py b/api/services/agent/composer_candidates.py
new file mode 100644
index 0000000000..0a1419be39
--- /dev/null
+++ b/api/services/agent/composer_candidates.py
@@ -0,0 +1,210 @@
+"""Slash-menu candidates assembly (ENG-615).
+
+Pure assembly over injected loaders so the upstream-graph computation and the
+per-source mapping are unit-testable without a database. IO wiring (draft
+workflow / bindings / draft variables / datasets / workspace tools) lives in
+``AgentComposerService.get_*_candidates``.
+
+``previous_node_outputs`` entries are emitted in the stored
+``WorkflowPreviousNodeOutputRef`` shape (``selector``/``node_id``/``output``/
+``name``) so the frontend can write a selected candidate back into
+``node_job.previous_node_output_refs`` verbatim; display extras
+(``node_title``/``node_kind``/``value_type``/``inferred``) ride along via the
+flexible config schema. Output enumeration follows the Node Output Inspector:
+start variables + recorded ``sys.*`` variables are static, Agent v2 nodes use
+their binding's declared outputs, and every other node kind is inferred from
+the latest draft-run variables (``inferred: true``).
+"""
+
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping
+from typing import Any
+
+from models.agent_config_entities import (
+ AgentSoulConfig,
+ DeclaredOutputConfig,
+)
+
+MAX_CANDIDATES_PER_LIST = 200
+
+_SYSTEM_NODE_ID = "sys"
+
+# loader signatures injected by the service layer
+DeclaredOutputsLoader = Callable[[str], list[DeclaredOutputConfig] | None]
+DraftVariablesLoader = Callable[[str], list[tuple[str, str | None]]]
+SystemVariablesLoader = Callable[[], list[tuple[str, str | None]]]
+DatasetLookup = Callable[[list[str]], Mapping[str, Any]]
+WorkspaceToolsLoader = Callable[[], list[dict[str, Any]]]
+
+
+def previous_node_output_candidates(
+ *,
+ graph: Mapping[str, Any],
+ node_id: str,
+ declared_outputs_loader: DeclaredOutputsLoader,
+ draft_variables_loader: DraftVariablesLoader,
+ system_variables_loader: SystemVariablesLoader,
+) -> tuple[list[dict[str, Any]], bool]:
+ """Enumerate upstream node outputs for ``node_id`` as writable ref candidates."""
+ from core.workflow.graph_topology import WorkflowGraphTopology
+
+ topology = WorkflowGraphTopology.from_graph(graph)
+ upstream = topology.upstream_node_ids(node_id)
+
+ entries: list[dict[str, Any]] = []
+ for name, value_type in system_variables_loader():
+ entries.append(
+ _ref_entry(
+ node_id=_SYSTEM_NODE_ID,
+ output=name,
+ node_title="System",
+ node_kind="system",
+ value_type=value_type,
+ inferred=True,
+ )
+ )
+
+ nodes = graph.get("nodes")
+ for node in nodes if isinstance(nodes, list) else []:
+ if not isinstance(node, Mapping):
+ continue
+ nid = node.get("id")
+ if not isinstance(nid, str) or nid not in upstream:
+ continue
+ raw_data = node.get("data")
+ data: Mapping[str, Any] = raw_data if isinstance(raw_data, Mapping) else {}
+ kind = str(data.get("type") or "unknown")
+ title = str(data.get("title") or nid)
+
+ if kind == "start":
+ for variable in data.get("variables") or []:
+ if not isinstance(variable, Mapping):
+ continue
+ var_name = variable.get("variable")
+ if isinstance(var_name, str) and var_name:
+ entries.append(
+ _ref_entry(
+ node_id=nid,
+ output=var_name,
+ node_title=title,
+ node_kind=kind,
+ value_type=variable.get("type") if isinstance(variable.get("type"), str) else None,
+ inferred=False,
+ )
+ )
+ continue
+
+ declared: list[DeclaredOutputConfig] | None = None
+ if kind == "agent" and str(data.get("version", "")) == "2":
+ declared = declared_outputs_loader(nid)
+ if declared is not None:
+ for output in declared:
+ entries.append(
+ _ref_entry(
+ node_id=nid,
+ output=output.name,
+ node_title=title,
+ node_kind=kind,
+ value_type=output.type.value,
+ inferred=False,
+ )
+ )
+ continue
+
+ for var_name, value_type in draft_variables_loader(nid):
+ entries.append(
+ _ref_entry(
+ node_id=nid,
+ output=var_name,
+ node_title=title,
+ node_kind=kind,
+ value_type=value_type,
+ inferred=True,
+ )
+ )
+
+ return _capped(entries)
+
+
+def soul_candidates(
+ *,
+ agent_soul: AgentSoulConfig | None,
+ dataset_lookup: DatasetLookup,
+ workspace_tools_loader: WorkspaceToolsLoader,
+) -> tuple[dict[str, list[dict[str, Any]]], bool]:
+ """Assemble the soul-surface candidate lists (design §3.2)."""
+ soul = agent_soul or AgentSoulConfig()
+ truncated = False
+
+ skills_files = [{"kind": "skill", **skill.model_dump(exclude_none=True)} for skill in soul.skills_files.skills]
+ skills_files += [{"kind": "file", **file.model_dump(exclude_none=True)} for file in soul.skills_files.files]
+
+ cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled]
+
+ dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id]
+ dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {}
+ knowledge_datasets: list[dict[str, Any]] = []
+ for dataset in soul.knowledge.datasets:
+ if not dataset.id:
+ continue
+ row = dataset_rows.get(dataset.id)
+ knowledge_datasets.append(
+ {
+ "id": dataset.id,
+ "name": (getattr(row, "name", None) or dataset.name or dataset.id),
+ "description": getattr(row, "description", None) or dataset.description,
+ "missing": row is None,
+ }
+ )
+
+ human_contacts = [contact.model_dump(exclude_none=True) for contact in soul.human.contacts]
+ dify_tools = workspace_tools_loader()
+
+ lists = {
+ "skills_files": skills_files,
+ "dify_tools": dify_tools,
+ "cli_tools": cli_tools,
+ "knowledge_datasets": knowledge_datasets,
+ "human_contacts": human_contacts,
+ }
+ capped: dict[str, list[dict[str, Any]]] = {}
+ for key, values in lists.items():
+ clipped, was_clipped = _capped(values)
+ truncated = truncated or was_clipped
+ capped[key] = clipped
+ return capped, truncated
+
+
+def _ref_entry(
+ *,
+ node_id: str,
+ output: str,
+ node_title: str,
+ node_kind: str,
+ value_type: str | None,
+ inferred: bool,
+) -> dict[str, Any]:
+ return {
+ "selector": [node_id, output],
+ "node_id": node_id,
+ "output": output,
+ "name": f"{node_title}/{output}",
+ "node_title": node_title,
+ "node_kind": node_kind,
+ "value_type": value_type,
+ "inferred": inferred,
+ }
+
+
+def _capped(values: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]:
+ if len(values) > MAX_CANDIDATES_PER_LIST:
+ return values[:MAX_CANDIDATES_PER_LIST], True
+ return values, False
+
+
+__all__ = [
+ "MAX_CANDIDATES_PER_LIST",
+ "previous_node_output_candidates",
+ "soul_candidates",
+]
diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py
index 58500274d8..628c93ec38 100644
--- a/api/services/agent/composer_service.py
+++ b/api/services/agent/composer_service.py
@@ -1,3 +1,4 @@
+import logging
from typing import Any
from sqlalchemy import func, select
@@ -39,6 +40,8 @@ from services.entities.agent_entities import (
# Mirrors Workflow.version when it is "draft" (see models/workflow.py).
_DRAFT_WORKFLOW_VERSION = "draft"
+logger = logging.getLogger(__name__)
+
class AgentComposerService:
@classmethod
@@ -108,7 +111,9 @@ class AgentComposerService:
agent_id=agent.id if agent else None,
version_id=binding.current_snapshot_id,
)
- return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
+ state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
+ state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
+ return state
@classmethod
def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]:
@@ -205,42 +210,241 @@ class AgentComposerService:
agent.updated_by = account_id
db.session.commit()
- return cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
+ state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
+ state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
+ return state
@classmethod
- def get_workflow_candidates(cls, *, app_id: str) -> dict[str, Any]:
+ def collect_validation_findings(cls, *, tenant_id: str, payload: ComposerSavePayload) -> dict[str, Any]:
+ """ENG-617 soft findings, with DB-backed dataset existence for placeholders."""
+ from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
+
+ mentioned_ids: set[str] = set()
+ if payload.agent_soul is not None:
+ mentioned_ids |= {
+ mention.ref_id
+ for mention in parse_prompt_mentions(payload.agent_soul.prompt.system_prompt)
+ if mention.kind == MentionKind.KNOWLEDGE
+ }
+ existing_dataset_ids: set[str] | None = None
+ if mentioned_ids:
+ existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids)))
+ return ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
+
+ @classmethod
+ def get_workflow_candidates(cls, *, tenant_id: str, app_id: str, node_id: str, user_id: str) -> dict[str, Any]:
+ """Slash-menu data source for the workflow Agent node composer (ENG-615)."""
+ from services.agent.composer_candidates import previous_node_output_candidates, soul_candidates
+
+ try:
+ workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
+ except ValueError:
+ workflow = None
+
+ node_job: WorkflowNodeJobConfig | None = None
+ agent_soul: AgentSoulConfig | None = None
+ if workflow is not None:
+ binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
+ if binding is not None:
+ node_job = cls._parse_node_job(binding)
+ agent_soul = cls._load_binding_soul(tenant_id=tenant_id, binding=binding)
+
+ truncated = False
+ previous_outputs: list[dict[str, Any]] = []
+ if workflow is not None:
+ draft_variable_session = cls._draft_variable_session()
+ try:
+ previous_outputs, outputs_truncated = previous_node_output_candidates(
+ graph=workflow.graph_dict,
+ node_id=node_id,
+ declared_outputs_loader=lambda nid: cls._binding_declared_outputs(
+ tenant_id=tenant_id, workflow_id=workflow.id, node_id=nid
+ ),
+ draft_variables_loader=lambda nid: cls._draft_node_variables(
+ session=draft_variable_session, app_id=app_id, node_id=nid, user_id=user_id
+ ),
+ system_variables_loader=lambda: cls._draft_system_variables(
+ session=draft_variable_session, app_id=app_id, user_id=user_id
+ ),
+ )
+ finally:
+ draft_variable_session.close()
+ truncated = truncated or outputs_truncated
+
+ soul_lists, soul_truncated = soul_candidates(
+ agent_soul=agent_soul,
+ dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
+ workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
+ )
+ truncated = truncated or soul_truncated
+
response = ComposerCandidatesResponse(
variant=ComposerVariant.WORKFLOW,
allowed_node_job_candidates={
- "previous_node_outputs": [],
+ "previous_node_outputs": previous_outputs,
"declare_output_types": ["string", "number", "object", "array", "boolean", "file"],
- "human_contacts": [],
- },
- allowed_soul_candidates={
- "skills_files": [],
- "dify_tools": [],
- "cli_tools": [],
- "knowledge_datasets": [],
- "human_contacts": [],
+ "human_contacts": [
+ contact.model_dump(exclude_none=True) for contact in (node_job.human_contacts if node_job else [])
+ ],
},
+ allowed_soul_candidates=soul_lists,
+ truncated=truncated,
)
return response.model_dump(mode="json")
@classmethod
- def get_agent_app_candidates(cls, *, app_id: str) -> dict[str, Any]:
+ def get_agent_app_candidates(cls, *, tenant_id: str, app_id: str, user_id: str) -> dict[str, Any]:
+ """Slash-menu data source for the Agent App (Console) composer (ENG-615)."""
+ from services.agent.composer_candidates import soul_candidates
+
+ agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_id)
+ soul_lists, truncated = soul_candidates(
+ agent_soul=agent_soul,
+ dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
+ workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
+ )
response = ComposerCandidatesResponse(
variant=ComposerVariant.AGENT_APP,
allowed_node_job_candidates={},
- allowed_soul_candidates={
- "skills_files": [],
- "dify_tools": [],
- "cli_tools": [],
- "knowledge_datasets": [],
- "human_contacts": [],
- },
+ allowed_soul_candidates=soul_lists,
+ truncated=truncated,
)
return response.model_dump(mode="json")
+ # ── candidates IO helpers (ENG-615) ──────────────────────────────────────
+
+ @staticmethod
+ def _parse_node_job(binding: WorkflowAgentNodeBinding) -> WorkflowNodeJobConfig | None:
+ try:
+ return WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
+ except Exception:
+ logger.warning("candidates: malformed node_job_config for binding %s", binding.id, exc_info=True)
+ return None
+
+ @classmethod
+ def _load_binding_soul(cls, *, tenant_id: str, binding: WorkflowAgentNodeBinding) -> AgentSoulConfig | None:
+ agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id)
+ version = cls._get_version_if_present(
+ tenant_id=tenant_id,
+ agent_id=agent.id if agent else None,
+ version_id=binding.current_snapshot_id,
+ )
+ return cls._parse_soul_snapshot(version)
+
+ @classmethod
+ def _load_agent_app_soul(cls, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None:
+ agent = db.session.scalar(
+ select(Agent)
+ .where(
+ Agent.tenant_id == tenant_id,
+ Agent.app_id == app_id,
+ Agent.scope == AgentScope.ROSTER,
+ Agent.status == AgentStatus.ACTIVE,
+ )
+ .order_by(Agent.created_at.desc())
+ .limit(1)
+ )
+ if agent is None:
+ return None
+ version = cls._get_version_if_present(
+ tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
+ )
+ return cls._parse_soul_snapshot(version)
+
+ @staticmethod
+ def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None:
+ if version is None:
+ return None
+ try:
+ return AgentSoulConfig.model_validate(version.config_snapshot_dict)
+ except Exception:
+ logger.warning("candidates: malformed soul snapshot %s", version.id, exc_info=True)
+ return None
+
+ @classmethod
+ def _binding_declared_outputs(
+ cls, *, tenant_id: str, workflow_id: str, node_id: str
+ ) -> list[DeclaredOutputConfig] | None:
+ binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow_id, node_id=node_id)
+ if binding is None:
+ return None
+ node_job = cls._parse_node_job(binding)
+ if node_job is None:
+ return None
+ return list(_effective_declared_outputs(node_job.declared_outputs))
+
+ @staticmethod
+ def _draft_variable_session():
+ from sqlalchemy.orm import sessionmaker
+
+ return sessionmaker(bind=db.engine, expire_on_commit=False)()
+
+ @staticmethod
+ def _draft_node_variables(*, session: Any, app_id: str, node_id: str, user_id: str) -> list[tuple[str, str | None]]:
+ from services.workflow_draft_variable_service import WorkflowDraftVariableService
+
+ variables = WorkflowDraftVariableService(session=session).list_node_variables(app_id, node_id, user_id)
+ return [(variable.name, variable.value_type.value) for variable in variables.variables]
+
+ @staticmethod
+ def _draft_system_variables(*, session: Any, app_id: str, user_id: str) -> list[tuple[str, str | None]]:
+ from services.workflow_draft_variable_service import WorkflowDraftVariableService
+
+ variables = WorkflowDraftVariableService(session=session).list_system_variables(app_id, user_id)
+ return [(variable.name, variable.value_type.value) for variable in variables.variables]
+
+ @staticmethod
+ def _dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
+ """Tenant-scoped dataset lookup tolerating malformed ids.
+
+ Mention ids come from user-editable prompt text; a non-UUID id can never
+ match a dataset row, so it is simply absent from the result (-> missing/
+ placeholder semantics) instead of breaking the UUID-typed query.
+ """
+ from uuid import UUID
+
+ from services.dataset_service import DatasetService
+
+ valid_ids: list[str] = []
+ for dataset_id in dataset_ids:
+ try:
+ UUID(dataset_id)
+ except (ValueError, TypeError):
+ continue
+ valid_ids.append(dataset_id)
+ if not valid_ids:
+ return {}
+ rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
+ return {str(row.id): row for row in rows}
+
+ @staticmethod
+ def _workspace_dify_tools(*, tenant_id: str, user_id: str) -> list[dict[str, Any]]:
+ """Workspace Dify Plugin tools, same source as the tool selector.
+
+ A plugin-daemon outage must degrade the slash menu to an empty tools
+ tab, not break the whole candidates endpoint.
+ """
+ from services.tools.builtin_tools_manage_service import BuiltinToolManageService
+
+ try:
+ providers = BuiltinToolManageService.list_builtin_tools(user_id, tenant_id)
+ except Exception:
+ logger.warning("candidates: failed to list workspace tools for tenant %s", tenant_id, exc_info=True)
+ return []
+ tools: list[dict[str, Any]] = []
+ for provider in providers:
+ for tool in provider.tools or []:
+ tools.append(
+ {
+ "id": f"{provider.name}/{tool.name}",
+ "name": tool.name,
+ "description": tool.label.en_US if tool.label else tool.name,
+ "provider": provider.name,
+ "plugin_id": provider.plugin_id or None,
+ }
+ )
+ return tools
+
@classmethod
def calculate_impact(cls, *, tenant_id: str, current_snapshot_id: str) -> dict[str, Any]:
bindings = list(
diff --git a/api/services/agent/composer_validator.py b/api/services/agent/composer_validator.py
index 7fdff7232b..d98c37796f 100644
--- a/api/services/agent/composer_validator.py
+++ b/api/services/agent/composer_validator.py
@@ -4,6 +4,17 @@ from typing import Any
from pydantic import ValidationError
from services.agent.errors import AgentSoulLockedError, InvalidComposerConfigError, PlaintextSecretNotAllowedError
+from services.agent.prompt_mentions import (
+ MAX_MENTIONS_PER_PROMPT,
+ NODE_JOB_PROMPT_ALLOWED_KINDS,
+ SOUL_PROMPT_ALLOWED_KINDS,
+ MentionKind,
+ MentionResolver,
+ build_node_job_mention_resolver,
+ build_soul_mention_resolver,
+ find_malformed_mention_markers,
+ parse_prompt_mentions,
+)
from services.entities.agent_entities import (
AgentSoulConfig,
ComposerSavePayload,
@@ -46,6 +57,158 @@ class ComposerConfigValidator:
cls.validate_agent_soul(payload.agent_soul)
if payload.node_job is not None:
cls.validate_node_job(payload.node_job)
+ cls._validate_prompt_mentions(payload)
+
+ @classmethod
+ def _validate_prompt_mentions(cls, payload: ComposerSavePayload) -> None:
+ """ENG-616 §2.4 allowlists + ENG-617 §5.2 human-must-be-referenced.
+
+ Error messages start with a stable code token (``mention_kind_not_allowed``
+ / ``mention_limit_exceeded`` / ``human_involvement_not_referenced``) so
+ the frontend can switch on it.
+ """
+ if payload.agent_soul is not None:
+ cls._validate_surface_mentions(
+ prompt=payload.agent_soul.prompt.system_prompt,
+ allowed=SOUL_PROMPT_ALLOWED_KINDS,
+ surface="agent soul prompt",
+ )
+ cls._require_human_mentions(
+ prompt=payload.agent_soul.prompt.system_prompt,
+ contacts=payload.agent_soul.human.contacts,
+ surface="agent soul prompt",
+ )
+ if payload.node_job is not None:
+ cls._validate_surface_mentions(
+ prompt=payload.node_job.workflow_prompt,
+ allowed=NODE_JOB_PROMPT_ALLOWED_KINDS,
+ surface="workflow job prompt",
+ )
+ cls._require_human_mentions(
+ prompt=payload.node_job.workflow_prompt,
+ contacts=payload.node_job.human_contacts,
+ surface="workflow job prompt",
+ )
+
+ @classmethod
+ def _validate_surface_mentions(cls, *, prompt: str, allowed: frozenset[MentionKind], surface: str) -> None:
+ mentions = parse_prompt_mentions(prompt)
+ if len(mentions) > MAX_MENTIONS_PER_PROMPT:
+ raise InvalidComposerConfigError(
+ f"mention_limit_exceeded: {surface} has {len(mentions)} mentions, "
+ f"exceeding the limit of {MAX_MENTIONS_PER_PROMPT}."
+ )
+ for mention in mentions:
+ if mention.kind not in allowed:
+ raise InvalidComposerConfigError(
+ f"mention_kind_not_allowed: {surface} cannot reference {mention.kind.value} (id={mention.ref_id})."
+ )
+
+ @classmethod
+ def _require_human_mentions(cls, *, prompt: str, contacts: list[Any], surface: str) -> None:
+ """ENG-617 §5.2 (PRD: human involvement must be slash-referenced or save errors).
+
+ Every configured human contact must appear as ``{{#human:#}}`` in the
+ corresponding prompt. A contact matches via any identity alias; contacts
+ carrying no identity at all cannot be referenced and are skipped.
+ """
+ if not contacts:
+ return
+ mentioned = {mention.ref_id for mention in parse_prompt_mentions(prompt) if mention.kind == MentionKind.HUMAN}
+ for contact in contacts:
+ aliases = {
+ alias
+ for alias in (contact.id, contact.contact_id, contact.human_id, contact.email, contact.name)
+ if alias
+ }
+ if not aliases:
+ continue
+ if aliases.isdisjoint(mentioned):
+ display = contact.name or contact.email or contact.id or "human involvement"
+ raise InvalidComposerConfigError(
+ f"human_involvement_not_referenced: configured human involvement '{display}' "
+ f"must be referenced in the {surface} via the slash menu."
+ )
+
+ @classmethod
+ def collect_soft_findings(
+ cls,
+ payload: ComposerSavePayload,
+ *,
+ existing_dataset_ids: set[str] | None = None,
+ ) -> dict[str, Any]:
+ """ENG-617 §5.3/§5.4 soft findings — never block save.
+
+ ``warnings`` carries ``mention_target_missing`` / ``mention_malformed``
+ entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge
+ mentions with a placeholder name (0522 consensus) instead of dropping or
+ rejecting them. With ``existing_dataset_ids`` provided, configured-but-
+ deleted datasets surface as placeholders too.
+ """
+ warnings: list[dict[str, Any]] = []
+ placeholders: list[dict[str, str]] = []
+
+ surfaces: list[tuple[str, str, MentionResolver, frozenset[MentionKind]]] = []
+ if payload.agent_soul is not None:
+ surfaces.append(
+ (
+ "agent_soul",
+ payload.agent_soul.prompt.system_prompt,
+ build_soul_mention_resolver(payload.agent_soul),
+ SOUL_PROMPT_ALLOWED_KINDS,
+ )
+ )
+ if payload.node_job is not None:
+ surfaces.append(
+ (
+ "node_job",
+ payload.node_job.workflow_prompt,
+ build_node_job_mention_resolver(payload.node_job),
+ NODE_JOB_PROMPT_ALLOWED_KINDS,
+ )
+ )
+
+ for surface, prompt, resolver, allowed in surfaces:
+ for mention in parse_prompt_mentions(prompt):
+ if mention.kind not in allowed:
+ continue # hard-rejected by validate_save_payload
+ resolved = resolver(mention)
+ if mention.kind == MentionKind.KNOWLEDGE:
+ dangling = resolved is None or (
+ existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids
+ )
+ if dangling:
+ placeholders.append(
+ {
+ "id": mention.ref_id,
+ "placeholder_name": mention.label or f"Knowledge {mention.ref_id[:8]}",
+ }
+ )
+ continue
+ if resolved is None:
+ warnings.append(
+ {
+ "code": "mention_target_missing",
+ "surface": surface,
+ "kind": mention.kind.value,
+ "id": mention.ref_id,
+ "message": f"{mention.kind.value} mention (id={mention.ref_id}) does not match "
+ "any configured item.",
+ }
+ )
+ for marker in find_malformed_mention_markers(prompt):
+ warnings.append(
+ {
+ "code": "mention_malformed",
+ "surface": surface,
+ "kind": None,
+ "id": None,
+ "message": f"mention-shaped marker {marker!r} is malformed and will be "
+ "degraded to plain text at runtime.",
+ }
+ )
+
+ return {"warnings": warnings, "knowledge_retrieval_placeholder": placeholders}
@classmethod
def validate_agent_soul(cls, agent_soul: AgentSoulConfig) -> None:
diff --git a/api/services/agent/prompt_mentions.py b/api/services/agent/prompt_mentions.py
new file mode 100644
index 0000000000..880e14af9b
--- /dev/null
+++ b/api/services/agent/prompt_mentions.py
@@ -0,0 +1,264 @@
+"""Prompt mention (slash-reference) serialization contract — ENG-616.
+
+Slash-menu insertions are stored inline in the plain-string prompt as tokens:
+
+ [§:[:
)}
-
+
)
}
diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx
index 793e773e59..8137b0ff3a 100644
--- a/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx
+++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/__tests__/index.spec.tsx
@@ -274,18 +274,6 @@ vi.mock('../last-run', () => ({
),
}))
-vi.mock('../tab', () => ({
- __esModule: true,
- TabType: { settings: 'settings', lastRun: 'lastRun' },
- default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
-
@@ -321,7 +309,7 @@ describe('workflow-panel index', () => {
})
it('should render the settings panel and wire title, description, run, and close actions', async () => {
- const { container } = renderWorkflowComponent(
+ renderWorkflowComponent(
panel-child
,
@@ -351,8 +339,7 @@ describe('workflow-panel index', () => {
fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.runThisStep' }))
- const clickableItems = container.querySelectorAll('.cursor-pointer')
- fireEvent.click(clickableItems[clickableItems.length - 1] as HTMLElement)
+ fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
expect(mockHandleSingleRun).toHaveBeenCalledTimes(1)
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1', true)
@@ -395,6 +382,7 @@ describe('workflow-panel index', () => {
)
expect(screen.getByText('last-run-panel')).toBeInTheDocument()
+ expect(screen.getByRole('tabpanel')).toHaveClass('flex', 'flex-1', 'flex-col')
})
it('should render the plain tab layout and allow last-run status updates', async () => {
diff --git a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx
index b53af61683..6abec226ce 100644
--- a/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx
+++ b/web/app/components/workflow/nodes/_base/components/workflow-panel/index.tsx
@@ -2,6 +2,7 @@ import type { FC, ReactNode } from 'react'
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
import type { Node } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
+import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import {
Tooltip,
TooltipContent,
@@ -90,8 +91,8 @@ import {
} from './helpers'
import LastRun from './last-run'
import useLastRun from './last-run/use-last-run'
-import Tab, { TabType } from './tab'
import { TriggerSubscription } from './trigger-subscription'
+import { TabType } from './types'
type BasePanelProps = {
children: ReactNode
@@ -480,6 +481,17 @@ const BasePanel: FC
= ({
? t('debug.variableInspect.trigger.stop', { ns: 'workflow' })
: runThisStepLabel
+ const panelTabs = (
+
+
+ {t('debug.settingsTab', { ns: 'workflow' }).toLocaleUpperCase()}
+
+
+ {t('debug.lastRunTab', { ns: 'workflow' }).toLocaleUpperCase()}
+
+
+ )
+
return (
- setTabType(selectedValue)}
className={cn('flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg transition-[width] ease-linear', showSingleRunPanel ? 'overflow-hidden' : 'overflow-y-auto')}
style={{
width: `${nodePanelWidth}px`,
@@ -558,12 +572,14 @@ const BasePanel: FC
= ({
- handleNodeSelect(id, true)}
>
-
-
+
+