Compare commits

...

57 Commits

Author SHA1 Message Date
Stephen Zhou
d15ac951aa perf(api): share OpenAPI exports across controller tests 2026-09-29 07:54:23 +00:00
Stephen Zhou
0d1bd869ad perf(api): avoid lazy module lookups in Redis test fixtures 2026-09-29 07:54:22 +00:00
Stephen Zhou
af7f6abb95 perf(api): disable unused SDK pytest plugins (#43125) 2026-09-29 07:47:43 +00:00
Aniket Kumar
2a1211baa5 test: pin ZIP timestamp in roster archive helper (#43122) 2026-09-29 07:28:43 +00:00
Stephen Zhou
3813c4d552 perf(api): reuse auth matrix routes and batch test data setup (#43128) 2026-09-29 07:26:31 +00:00
yyh
8fc8178ff7 fix(web): align Agent template avatar and translations (#43163) 2026-09-29 07:26:28 +00:00
Asuka Minato
fb0e55f5e7 test: replace plugin model client spec mocks with a recording fake (#43152) 2026-09-29 07:08:49 +00:00
Asuka Minato
fb9b97b26d test: use a real Phoenix tracer and in-memory exporter (#43151) 2026-09-29 07:08:48 +00:00
Asuka Minato
80b34b5ab6 test: use real trace records and SDK span values (#43150) 2026-09-29 07:08:47 +00:00
Asuka Minato
8db1712ccd test: construct real datasource plugin and message fixtures (#43149) 2026-09-29 07:08:46 +00:00
Asuka Minato
6dc9de24d8 test: use real datasource provider fixtures (#43148) 2026-09-29 07:08:45 +00:00
Asuka Minato
9c11e36cc5 test: use real app models in integration fixtures (#43147) 2026-09-29 07:08:44 +00:00
Asuka Minato
1d02154638 test: validate real document configuration entities (#43145) 2026-09-29 07:08:44 +00:00
Asuka Minato
b4bc25cab6 test: compose application services with real Redis wrappers (#43144) 2026-09-29 07:08:43 +00:00
Asuka Minato
948fd166dd test: use real tool runtimes responses and plugin entities (#43142) 2026-09-29 07:08:42 +00:00
Asuka Minato
38f42f357e test: replace agent and upload ORM spec mocks with real models (#43141) 2026-09-29 07:08:41 +00:00
Stephen Zhou
f24586323b perf(api): avoid numbered temporary directories for test databases (#43127) 2026-09-29 06:56:39 +00:00
Denis Ermakov
bc0279572e fix(web): surface SSO login request failures to the user (#43076)
Co-authored-by: SquabbyZ <43177939+SquabbyZ@users.noreply.github.com>
2026-09-29 06:52:02 +00:00
Joel
20d9f15933 chore(web): improve agents roster accessibility (SNP-893) (#43140) 2026-09-29 06:40:29 +00:00
yyh
d42276a5c0 refactor(goto-anything): simplify command context and execution (#43136) 2026-09-29 06:37:41 +00:00
yyh
5c4489911c feat(goto-anything): improve command discovery and keyboard navigation (#43133) 2026-09-29 06:37:40 +00:00
Lucien
657624154d fix(web): preserve dataset uploads after returning to source step (#43080) 2026-09-29 05:50:16 +00:00
Stephen Zhou
be8744264f perf(api): reuse controller OpenAPI documents in schema tests (#43120) 2026-09-29 03:28:43 +00:00
Wu Tianwei
0fec636da5 fix(web): protect workspace admins in resource access (#43118) 2026-09-29 03:07:15 +00:00
Stephen Zhou
ce27ef9dc3 ci: reduce main CI orchestration scheduling overhead (#43114) 2026-09-29 03:05:39 +00:00
Stephen Zhou
f7e5946d81 ci: overlap core E2E startup with web build preparation (#43116) 2026-09-29 02:54:26 +00:00
yyh
90f7fbd468 feat(ui): redesign date and time pickers (#43090) 2026-09-29 02:14:10 +00:00
Harsh Kashyap
1545517fe8 ci(test): gate new session-mock assignments in api unit tests (#42854)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
Co-authored-by: Harsh Kashyap <harsh@harshkashyap.dev>
2026-09-29 02:11:45 +00:00
KUNAL KAKDE
1b9ddf9ae5 docs(api): align audio-to-text supported formats in service-openapi (#41891)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-09-29 02:09:09 +00:00
Denis Ermakov
9bb9935f44 refactor(models): remove implicit-session Account tenant setters (#43064) 2026-09-29 02:03:34 +00:00
Denis Ermakov
1ff3d3db68 refactor(console): dep-inject the activation payload with @model_validate (#43056) 2026-09-29 02:01:34 +00:00
Denis Ermakov
18fea7ff0d chore(api): remove redundant str() on StringUUID ids (#43061) 2026-09-29 02:00:16 +00:00
Stephen Zhou
357f697b16 chore(web): upgrade vinext and Vite+ to 1.0.0 (#43110) 2026-09-29 01:53:48 +00:00
杨俊波
e6f1d77ed5 feat: tool fallback observability (#42596)
Co-authored-by: liuns-yang <liuns-yang@users.noreply.github.com>
Co-authored-by: Crazywoola <100913391+crazywoola@users.noreply.github.com>
2026-09-28 11:10:48 +00:00
Yunlu Wen
4f8bf32b72 feat: support host style in archive s3 client (#43066) 2026-09-28 11:06:38 +00:00
invalid w
d8187b6e51 chore(web): resolve eslint error for node/panel components in CustomNode renderer (#42470) 2026-09-28 09:58:53 +00:00
Stephen Zhou
51940eac9c perf(web): lazy-load SVG code block previews (#42951) 2026-09-28 09:26:33 +00:00
林玮 (Jade Lin)
005f019a80 feat: support New Agent templates and trials (#42837)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-09-28 09:21:50 +00:00
Stephen Zhou
5f6bb5e6d3 perf(workflow): lazy load node configuration panels (#42970) 2026-09-28 09:07:08 +00:00
Joel
7020120197 chore: improve Custom Endpoint accessibility (SNP-876) (#43060) 2026-09-28 08:59:34 +00:00
Harsh Kashyap
fe5dfd756f fix(web): skip shiki WASM engine load for plain-text fences (#42957)
Co-authored-by: Harsh Kashyap <harsh@harshkashyap.dev>
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
2026-09-28 08:54:54 +00:00
Stephen Zhou
4808976d0c fix(web): reset frontend session after logout (#43055) 2026-09-28 08:17:49 +00:00
Joel
1b21b8220d chore(web): focus node panel on keyboard open (SNP-877) (#43047) 2026-09-28 07:52:39 +00:00
Denis Ermakov
bf55d1f6a6 refactor: pass db.session explicitly to instruction_modify_legacy (#37403) (#43045) 2026-09-28 07:38:08 +00:00
Keith
855842410a refactor(models): remove implicit-session AppModelConfig.annotation_reply_dict (#43024) 2026-09-28 07:36:44 +00:00
huhupy
33b2f09325 fix(web): correct knowledge document information fields (#42885) 2026-09-28 07:32:51 +00:00
非法操作
4713c3853b refactor(api): bound suggested question runtime sessions (#42968) 2026-09-28 07:31:24 +00:00
非法操作
89e6b71b2d refactor(api): migrate console suggested questions to shared runtime (#43033) 2026-09-28 07:31:17 +00:00
平衡世界的BOY
27cdf50c7d fix(web): improve snippet run panel accessibility (#43028) 2026-09-28 07:00:30 +00:00
Joel
2b88685fbd chore: improve a11y of trigger integration, agent strategy and extension page (#43023) 2026-09-28 06:41:17 +00:00
yyh
52dcf83de6 refactor(web): scope app UI sessions to their owners (#42984) 2026-09-28 06:39:19 +00:00
yyh
a4a18e0652 refactor(web): separate AppIcon display from picker triggers (#43041) 2026-09-28 06:34:04 +00:00
林玮 (Jade Lin)
6441436b0c feat(api): include Site settings in App DSL (#42863) 2026-09-28 06:17:44 +00:00
Byron.wang
0c5f2507d0 refactor(api): separate app MCP server service and persistence (#42850) 2026-09-28 06:03:58 +00:00
qielanci
2515cf5b48 fix(api): use plain Session in MCP controller to avoid closed transactionFix/mcp plain session (#43038) 2026-09-28 05:44:11 +00:00
Stephen Zhou
5283462286 chore(deps): update compatible workspace dependencies (#43031) 2026-09-28 04:37:37 +00:00
yyh
f95edc85f5 refactor(web): consume generated app detail contracts (#42982) 2026-09-28 04:35:06 +00:00
947 changed files with 29009 additions and 17850 deletions

View File

@@ -15,7 +15,7 @@ runs:
run-install: 'false'
- name: Setup UV and Python
uses: astral-sh/setup-uv@bec219d24cd3e171d82865faccec33120bb574f4 # v10.1.0
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
with:
enable-cache: true
python-version: '3.12'

View File

@@ -13,11 +13,11 @@ runs:
using: composite
steps:
- name: Setup pnpm and Node.js
uses: pnpm/setup@703c52620218391530e48b9e8870d5c0082e1b9b # v2.1.0
uses: pnpm/setup@fbda4c85fc2e1e08721cd8763afea8f48d60f024 # v3.0.0
with:
install: false
- name: Setup Vite+
uses: voidzero-dev/setup-vp@9fd26ff49f9b5e6276c2b493da4454ad4527f7f4 # v1.20.0
uses: voidzero-dev/setup-vp@3754dd7dbdb32bd8f6d28b6043de13ad3a75f21f # v1.21.1
with:
node-manager: false
package-manager: |

View File

@@ -0,0 +1,104 @@
const { execFile } = require('node:child_process')
const { appendFile, mkdtemp, rm } = require('node:fs/promises')
const os = require('node:os')
const path = require('node:path')
const { promisify } = require('node:util')
const waitForBuild = require('./wait.cjs')
const execFileAsync = promisify(execFile)
async function downloadWebBuild({
env = process.env,
execute = execFileAsync,
webDir = path.resolve(__dirname, '../../../web'),
} = {}) {
const repository = env.GITHUB_REPOSITORY
const runId = env.GITHUB_RUN_ID
const runAttempt = Number(env.GITHUB_RUN_ATTEMPT)
if (
!repository ||
!/^\d+$/.test(runId || '') ||
!Number.isInteger(runAttempt) ||
runAttempt < 1 ||
!env.GH_TOKEN
) {
throw new Error(
'Downloading the Web build requires GITHUB_REPOSITORY, GITHUB_RUN_ID, GITHUB_RUN_ATTEMPT, and GH_TOKEN.',
)
}
const run = (command, args, timeout = 60_000) =>
execute(command, args, {
env,
timeout,
maxBuffer: 32 * 1024 * 1024,
})
const api = async (endpoint, paginate = false) => {
const { stdout } = await run('gh', [
'api',
endpoint,
...(paginate ? ['--paginate', '--slurp'] : []),
])
return JSON.parse(stdout)
}
const prefix = `repos/${repository}/actions`
let summary = ''
const artifactId = await waitForBuild({
github: {
rest: {
actions: { listJobsForWorkflowRunAttempt: 'jobs', listWorkflowRunArtifacts: 'artifacts' },
},
paginate: async (kind) => {
const endpoint =
kind === 'jobs'
? `${prefix}/runs/${runId}/attempts/${runAttempt}/jobs?per_page=100`
: `${prefix}/runs/${runId}/artifacts?per_page=100`
const pages = await api(endpoint, true)
return pages.flatMap((page) => page[kind])
},
},
context: { repo: {}, runId },
core: {
info: console.log,
summary: {
addHeading(text) {
summary += `### ${text}\n\n`
return this
},
addRaw(text) {
summary += `${text}\n`
return this
},
async write() {
if (env.GITHUB_STEP_SUMMARY) await appendFile(env.GITHUB_STEP_SUMMARY, summary)
},
},
},
runAttempt,
})
const artifact = await api(`${prefix}/artifacts/${artifactId}`)
const directory = await mkdtemp(path.join(os.tmpdir(), 'dify-e2e-web-build-'))
try {
// Artifact names are immutable within a run. Use the name selected by wait.cjs,
// including its matching upload timestamp when only consumer jobs are rerun.
await run(
'gh',
['run', 'download', runId, '--repo', repository, '--name', artifact.name, '--dir', directory],
300_000,
)
await run('tar', ['-xf', path.join(directory, 'e2e-web-build.tar'), '-C', webDir])
console.log(`Web build artifact ${artifactId} extracted and ready.`)
} finally {
await rm(directory, { force: true, recursive: true })
}
}
module.exports = downloadWebBuild
if (require.main === module) {
downloadWebBuild().catch((error) => {
console.error(error)
process.exitCode = 1
})
}

View File

@@ -0,0 +1,112 @@
const assert = require('node:assert/strict')
const { access } = require('node:fs/promises')
const { test } = require('node:test')
const downloadWebBuild = require('./download.cjs')
function fixture({ downloadFails = false, producerFails = false } = {}) {
const calls = []
const env = {
GITHUB_REPOSITORY: 'owner/repo',
GITHUB_RUN_ID: '123',
GITHUB_RUN_ATTEMPT: '4',
GH_TOKEN: 'test-token',
}
const execute = async (command, args) => {
calls.push({ command, args })
if (command === 'tar') return { stdout: '' }
if (args[0] === 'run') {
if (downloadFails) throw new Error('artifact download failed')
return { stdout: '' }
}
if (args[1].includes('/jobs?')) {
return {
stdout: JSON.stringify([
{
jobs: [
{
name: 'Run Web Full-Stack E2E / Prepare Core E2E Web Build',
status: 'completed',
conclusion: producerFails ? 'failure' : 'success',
steps: [
{
name: 'Upload Web build',
conclusion: 'success',
started_at: '2026-09-22T04:08:08Z',
completed_at: '2026-09-22T04:08:12Z',
},
],
},
],
},
]),
}
}
if (args[1].includes('/artifacts?')) {
return {
stdout: JSON.stringify([
{ artifacts: [] },
{
artifacts: [
{
id: 42,
name: 'core-e2e-web-build-2',
created_at: '2026-09-22T04:08:11Z',
expired: false,
},
],
},
]),
}
}
assert.equal(args[1], 'repos/owner/repo/actions/artifacts/42')
return { stdout: JSON.stringify({ name: 'core-e2e-web-build-2' }) }
}
return { calls, options: { env, execute, webDir: '/test/web' } }
}
test('downloads the producer-selected artifact on consumer-only reruns, including paginated results', async () => {
const { calls, options } = fixture()
await downloadWebBuild(options)
assert.match(calls[0].args[1], /runs\/123\/attempts\/4\/jobs/)
const download = calls.find(({ args }) => args[0] === 'run')
assert.deepEqual(download.args.slice(0, 7), [
'run',
'download',
'123',
'--repo',
'owner/repo',
'--name',
'core-e2e-web-build-2',
])
const extract = calls.at(-1)
assert.equal(extract.command, 'tar')
assert.deepEqual(extract.args.slice(-2), ['-C', '/test/web'])
await assert.rejects(access(download.args.at(-1)), { code: 'ENOENT' })
})
test('does not extract a failed download and removes its temporary directory', async () => {
const { calls, options } = fixture({ downloadFails: true })
await assert.rejects(downloadWebBuild(options), /artifact download failed/)
assert.equal(
calls.some(({ command }) => command === 'tar'),
false,
)
await assert.rejects(access(calls.at(-1).args.at(-1)), { code: 'ENOENT' })
})
test('does not download or extract when the producer fails', async () => {
const { calls, options } = fixture({ producerFails: true })
await assert.rejects(downloadWebBuild(options), /Web build finished with failure/)
assert.equal(calls.length, 1)
})
test('requires CI metadata and credentials before starting preparation', async () => {
const { calls, options } = fixture()
for (const key of Object.keys(options.env)) {
await assert.rejects(
downloadWebBuild({ ...options, env: { ...options.env, [key]: '' } }),
/requires GITHUB/,
)
}
assert.equal(calls.length, 0)
})

View File

@@ -117,7 +117,7 @@ jobs:
-p no:benchmark \
--timeout "${PYTEST_TIMEOUT:-20}" \
-n auto \
-o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
--cov-report= \
--file-shard-plan unit-test-plan.json \
--shard-index "${{ matrix.shardIndex }}" \
@@ -181,7 +181,7 @@ jobs:
run: |
uv run --project api pytest \
--timeout "${PYTEST_TIMEOUT:-20}" \
-o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
--cov-report= \
--junitxml=test-results/controllers.xml \
api/tests/unit_tests/controllers
@@ -246,7 +246,7 @@ jobs:
--start-middleware \
--middleware-stop-timeout 2 \
-n auto \
-o 'addopts=--cov=./api --cov-branch --import-mode=importlib' \
-o 'addopts=-p no:opik -p no:langsmith_plugin --cov=./api --cov-branch --import-mode=importlib' \
--cov-report= \
--timeout "${PYTEST_TIMEOUT:-180}" \
api/tests/integration_tests/workflow \

View File

@@ -78,7 +78,7 @@ jobs:
ws2_app_id: ${{ steps.out.outputs.DIFY_E2E_WS2_APP_ID }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -121,7 +121,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -166,7 +166,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -227,7 +227,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -266,7 +266,7 @@ jobs:
- name: Upload results on failure
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-run-${{ matrix.name }}-${{ github.run_id }}
path: cli/test-results/
@@ -287,7 +287,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -341,7 +341,7 @@ jobs:
shell: bash
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v4
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ inputs.cli_ref || github.ref }}
persist-credentials: false
@@ -396,7 +396,7 @@ jobs:
- name: Upload results on failure
if: failure()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-last-${{ github.run_id }}
path: cli/test-results/

View File

@@ -21,9 +21,10 @@ concurrency:
cancel-in-progress: ${{ github.event_name == 'merge_group' }}
jobs:
# Keep orchestration independent of the Depot pool used for test execution.
pre_job:
name: Skip Duplicate Checks
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
outputs:
should_skip: ${{ steps.skip_check.outputs.should_skip || 'false' }}
steps:
@@ -37,7 +38,7 @@ jobs:
# Check which paths were changed to determine which tests to run
check-changes:
name: Check Changed Files
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
outputs:
api-changed: ${{ steps.changes.outputs.api }}
cli-changed: ${{ steps.changes.outputs.cli }}
@@ -66,6 +67,7 @@ jobs:
- 'scripts/lint_controller_sqlalchemy.py'
- 'scripts/ast_grep_rules/no_new_getattr.yml'
- 'scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml'
- 'scripts/ast_grep_rules/no_new_session_mock.yml'
- '.github/workflows/style.yml'
- '.github/workflows/main-ci.yml'
- '.github/workflows/api-tests.yml'
@@ -160,9 +162,11 @@ jobs:
- 'scripts/ast_grep_guard.py'
- 'scripts/check_no_new_getattr.py'
- 'scripts/check_no_new_controller_sqlalchemy.py'
- 'scripts/check_no_new_session_mock.py'
- 'scripts/lint_controller_sqlalchemy.py'
- 'scripts/ast_grep_rules/no_new_getattr.yml'
- 'scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml'
- 'scripts/ast_grep_rules/no_new_session_mock.yml'
- '.github/workflows/style.yml'
- '.github/workflows/main-ci.yml'
dify-agent-style:
@@ -212,7 +216,8 @@ jobs:
- '.editorconfig'
- '.vite-hooks/**'
# Run tests in parallel while always emitting stable required checks.
# Finalizers report unchanged paths directly, avoiding separate skip-only jobs.
# Keep each required check name stable and fail closed if classification fails.
api-tests-run:
name: Run API Tests
needs:
@@ -222,17 +227,6 @@ jobs:
uses: ./.github/workflows/api-tests.yml
secrets: inherit
api-tests-skip:
name: Skip API Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.api-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped API tests
run: echo "No API-related changes detected; skipping API tests."
api-tests:
name: API Tests
if: ${{ always() }}
@@ -240,21 +234,26 @@ jobs:
- pre_job
- check-changes
- api-tests-run
- api-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize API Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.api-changed }}
RUN_RESULT: ${{ needs.api-tests-run.result }}
SKIP_RESULT: ${{ needs.api-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "API tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "API tests ran successfully."
@@ -265,12 +264,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "API tests were skipped because no API-related files changed."
exit 0
fi
echo "API tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
cli-tests-run:
@@ -282,17 +281,6 @@ jobs:
uses: ./.github/workflows/cli-tests.yml
secrets: inherit
cli-tests-skip:
name: Skip CLI Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.cli-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped CLI tests
run: echo "No CLI-related changes detected; skipping CLI tests."
cli-tests:
name: CLI Tests
if: ${{ always() }}
@@ -300,21 +288,26 @@ jobs:
- pre_job
- check-changes
- cli-tests-run
- cli-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize CLI Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.cli-changed }}
RUN_RESULT: ${{ needs.cli-tests-run.result }}
SKIP_RESULT: ${{ needs.cli-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "CLI tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "CLI tests ran successfully."
@@ -325,12 +318,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "CLI tests were skipped because no CLI-related files changed."
exit 0
fi
echo "CLI tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
web-tests-run:
@@ -342,17 +335,6 @@ jobs:
uses: ./.github/workflows/web-tests.yml
secrets: inherit
web-tests-skip:
name: Skip Web Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.web-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped web tests
run: echo "No web-related changes detected; skipping web tests."
web-tests:
name: Web Tests
if: ${{ always() }}
@@ -360,21 +342,26 @@ jobs:
- pre_job
- check-changes
- web-tests-run
- web-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize Web Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.web-changed }}
RUN_RESULT: ${{ needs.web-tests-run.result }}
SKIP_RESULT: ${{ needs.web-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Web tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Web tests ran successfully."
@@ -385,12 +372,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "Web tests were skipped because no web-related files changed."
exit 0
fi
echo "Web tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
web-e2e-run:
@@ -404,17 +391,6 @@ jobs:
run-external-runtime: false
secrets: inherit
web-e2e-skip:
name: Skip Web Full-Stack E2E
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.e2e-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped web full-stack e2e
run: echo "No E2E-related changes detected; skipping web full-stack E2E."
web-e2e:
name: Web Full-Stack E2E
if: ${{ always() }}
@@ -422,21 +398,26 @@ jobs:
- pre_job
- check-changes
- web-e2e-run
- web-e2e-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize Web Full-Stack E2E status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.e2e-changed }}
RUN_RESULT: ${{ needs.web-e2e-run.result }}
SKIP_RESULT: ${{ needs.web-e2e-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Web full-stack E2E was skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Web full-stack E2E ran successfully."
@@ -447,12 +428,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "Web full-stack E2E was skipped because no E2E-related files changed."
exit 0
fi
echo "Web full-stack E2E was not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
style-check:
@@ -480,17 +461,6 @@ jobs:
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed == 'true'
uses: ./.github/workflows/vdb-tests.yml
vdb-tests-skip:
name: Skip VDB Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.vdb-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped VDB tests
run: echo "No VDB-related changes detected; skipping VDB tests."
vdb-tests:
name: VDB Tests
if: ${{ always() }}
@@ -498,21 +468,26 @@ jobs:
- pre_job
- check-changes
- vdb-tests-run
- vdb-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize VDB Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.vdb-changed }}
RUN_RESULT: ${{ needs.vdb-tests-run.result }}
SKIP_RESULT: ${{ needs.vdb-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "VDB tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "VDB tests ran successfully."
@@ -523,12 +498,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "VDB tests were skipped because no VDB-related files changed."
exit 0
fi
echo "VDB tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
db-migration-test-run:
@@ -539,17 +514,6 @@ jobs:
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed == 'true'
uses: ./.github/workflows/db-migration-test.yml
db-migration-test-skip:
name: Skip DB Migration Test
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.migration-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped DB migration tests
run: echo "No migration-related changes detected; skipping DB migration tests."
db-migration-test:
name: DB Migration Test
if: ${{ always() }}
@@ -557,21 +521,26 @@ jobs:
- pre_job
- check-changes
- db-migration-test-run
- db-migration-test-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize DB Migration Test status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.migration-changed }}
RUN_RESULT: ${{ needs.db-migration-test-run.result }}
SKIP_RESULT: ${{ needs.db-migration-test-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "DB migration tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "DB migration tests ran successfully."
@@ -582,12 +551,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "DB migration tests were skipped because no migration-related files changed."
exit 0
fi
echo "DB migration tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
sandbox-runtime-tests-run:
@@ -599,17 +568,6 @@ jobs:
uses: ./.github/workflows/sandbox-runtime-tests.yml
secrets: inherit
sandbox-runtime-tests-skip:
name: Skip Sandbox Runtime Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.sandbox-runtime-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped sandbox runtime tests
run: echo "No sandbox-runtime-related changes detected; skipping sandbox runtime tests."
sandbox-runtime-tests:
name: Sandbox Runtime Tests
if: ${{ always() }}
@@ -617,21 +575,26 @@ jobs:
- pre_job
- check-changes
- sandbox-runtime-tests-run
- sandbox-runtime-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize Sandbox Runtime Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.sandbox-runtime-changed }}
RUN_RESULT: ${{ needs.sandbox-runtime-tests-run.result }}
SKIP_RESULT: ${{ needs.sandbox-runtime-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Sandbox runtime tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Sandbox runtime tests ran successfully."
@@ -642,12 +605,12 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "Sandbox runtime tests were skipped because no sandbox-runtime-related files changed."
exit 0
fi
echo "Sandbox runtime tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1
dify-agent-tests-run:
@@ -659,17 +622,6 @@ jobs:
uses: ./.github/workflows/dify-agent-tests.yml
secrets: inherit
dify-agent-tests-skip:
name: Skip Dify Agent Tests
needs:
- pre_job
- check-changes
if: needs.pre_job.outputs.should_skip != 'true' && needs.check-changes.outputs.dify-agent-changed != 'true'
runs-on: depot-ubuntu-24.04
steps:
- name: Report skipped dify agent tests
run: echo "No dify-agent-related changes detected; skipping dify agent tests."
dify-agent-tests:
name: Dify Agent Tests
if: ${{ always() }}
@@ -677,21 +629,26 @@ jobs:
- pre_job
- check-changes
- dify-agent-tests-run
- dify-agent-tests-skip
runs-on: depot-ubuntu-24.04
runs-on: ubuntu-24.04
steps:
- name: Finalize Dify Agent Tests status
env:
SHOULD_SKIP_WORKFLOW: ${{ needs.pre_job.outputs.should_skip }}
TESTS_CHANGED: ${{ needs.check-changes.outputs.dify-agent-changed }}
RUN_RESULT: ${{ needs.dify-agent-tests-run.result }}
SKIP_RESULT: ${{ needs.dify-agent-tests-skip.result }}
PRE_JOB_RESULT: ${{ needs.pre_job.result }}
CHANGES_RESULT: ${{ needs.check-changes.result }}
run: |
if [[ "$SHOULD_SKIP_WORKFLOW" == 'true' ]]; then
echo "Dify agent tests were skipped because this workflow run duplicated a successful or newer run."
exit 0
fi
if [[ "$PRE_JOB_RESULT" != 'success' || "$CHANGES_RESULT" != 'success' ]]; then
echo "Cannot finalize tests: duplicate check=$PRE_JOB_RESULT, change detection=$CHANGES_RESULT" >&2
exit 1
fi
if [[ "$TESTS_CHANGED" == 'true' ]]; then
if [[ "$RUN_RESULT" == 'success' ]]; then
echo "Dify agent tests ran successfully."
@@ -702,10 +659,10 @@ jobs:
exit 1
fi
if [[ "$SKIP_RESULT" == 'success' ]]; then
if [[ "$TESTS_CHANGED" == 'false' && "$RUN_RESULT" == 'skipped' ]]; then
echo "Dify agent tests were skipped because no dify-agent-related files changed."
exit 0
fi
echo "Dify agent tests were not required, but the skip job finished with result: $SKIP_RESULT" >&2
echo "Unexpected test state: changed=$TESTS_CHANGED, result=$RUN_RESULT" >&2
exit 1

View File

@@ -89,6 +89,10 @@ jobs:
if: inputs.run-python-style
run: uv run --project api python scripts/check_no_new_controller_sqlalchemy.py --base-rev "${{ inputs.base-rev }}"
- name: Run No New Session Mock Guard
if: inputs.run-python-style
run: uv run --project api python scripts/check_no_new_session_mock.py --base-rev "${{ inputs.base-rev }}"
- name: Run Type Checks
if: inputs.run-python-style
env:

View File

@@ -26,7 +26,7 @@ jobs:
persist-credentials: false
- name: Test Web build synchronization
run: node --test .github/actions/wait-e2e-web-build/wait.test.cjs
run: node --test .github/actions/wait-e2e-web-build/*.test.cjs
- name: Compute build source hash
id: build-source
@@ -119,24 +119,12 @@ jobs:
with:
browser: chromium
# Install dependencies while core-build runs, then wait only for its artifact.
- name: Wait for Web build
id: web-build
timeout-minutes: 35
uses: ./.github/actions/wait-e2e-web-build
- name: Download Web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ steps.web-build.outputs.artifact-id }}
github-token: ${{ github.token }}
- name: Extract Web build
run: tar -xf e2e-web-build.tar -C web
# The runner prepares the backend while waiting for and downloading the build.
- name: Run Chromium full shard
working-directory: ./e2e
run: vp run e2e:full -- --shard ${{ matrix.shard }}/3
env:
GH_TOKEN: ${{ github.token }}
run: vp run e2e:full --download-web-build -- --shard ${{ matrix.shard }}/3
- name: Upload Cucumber report
if: ${{ !cancelled() }}
@@ -183,24 +171,12 @@ jobs:
with:
browser: webkit
# Install dependencies while core-build runs, then wait only for its artifact.
- name: Wait for Web build
id: web-build
timeout-minutes: 35
uses: ./.github/actions/wait-e2e-web-build
- name: Download Web build
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
artifact-ids: ${{ steps.web-build.outputs.artifact-id }}
github-token: ${{ github.token }}
- name: Extract Web build
run: tar -xf e2e-web-build.tar -C web
# Keep the same overlap and teardown ownership for the WebKit smoke lane.
- name: Run WebKit browser smoke
working-directory: ./e2e
run: vp run e2e:full -- --tags '@browser-smoke'
env:
GH_TOKEN: ${{ github.token }}
run: vp run e2e:full --download-web-build -- --tags '@browser-smoke'
- name: Upload Cucumber report
if: ${{ !cancelled() }}

View File

@@ -156,6 +156,7 @@ ARCHIVE_STORAGE_EXPORT_BUCKET=
ARCHIVE_STORAGE_ACCESS_KEY=
ARCHIVE_STORAGE_SECRET_KEY=
ARCHIVE_STORAGE_REGION=auto
ARCHIVE_STORAGE_ADDRESS_STYLE=path
# Azure Blob Storage configuration
AZURE_BLOB_ACCOUNT_NAME=your-account-name

View File

@@ -697,6 +697,20 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-package-service-boundary]
name = Recommended package application service is framework and persistence neutral
type = forbidden
source_modules =
services.recommended_app_package_service
forbidden_modules =
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:recommended-app-catalog-gateway-boundary]
name = Recommended app catalog gateway does not depend on Flask
type = forbidden
@@ -786,6 +800,46 @@ forbidden_modules =
tasks
werkzeug
[importlinter:contract:app-mcp-server-service-boundary]
name = App MCP server application service is framework and persistence neutral
type = forbidden
source_modules =
services.app.mcp_server_service
forbidden_modules =
configs
controllers
extensions
flask
models
repositories
sqlalchemy
werkzeug
[importlinter:contract:console-app-mcp-server-controller-boundary]
name = Console App MCP server controller reaches persistence only through the composition root
type = forbidden
source_modules =
controllers.console.app.mcp_server
forbidden_modules =
extensions.ext_database
models.model
repositories
sqlalchemy
# The composition root (extensions.ext_application_services) wires repositories,
# so only direct imports are checked here.
allow_indirect_imports = True
[importlinter:contract:console-app-wraps-boundary]
name = Console app decorators load App rows without reaching into repositories
type = forbidden
source_modules =
controllers.console.app.wraps
forbidden_modules =
repositories
# models.model / models.workflow lazily import repositories.factory (legacy debt),
# so only direct imports are checked here.
allow_indirect_imports = True
[importlinter:contract:app-audio-boundary]
name = App audio operations and values are framework and persistence neutral
type = forbidden

View File

@@ -1,3 +1,5 @@
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings
@@ -41,3 +43,8 @@ class ArchiveStorageConfig(BaseSettings):
description="Region for storage (use 'auto' if the provider supports it)",
default="auto",
)
ARCHIVE_STORAGE_ADDRESS_STYLE: Literal["path", "virtual", "auto"] = Field(
description="S3 addressing style for archive storage: 'path' (default), 'virtual' (virtual-hosted), or 'auto'",
default="path",
)

View File

@@ -116,7 +116,7 @@ class AgentBehindApp(_ParamLocator):
binding = agent_binding(tenant_id, _required(path_args, self.param))
if binding is None or binding.scope == AgentScope.WORKFLOW_ONLY:
return None
return ResourceIdentity(self.scope, str(binding.id))
return ResourceIdentity(self.scope, binding.id)
def owner_id(self, tenant_id: str, identity: ResourceIdentity) -> str | None:
return None

View File

@@ -7,14 +7,16 @@ import io
import json
from collections.abc import Callable, Mapping
from dataclasses import dataclass
from typing import Any, Literal
from functools import wraps
from typing import Any, Concatenate, Literal
from uuid import UUID
from flask import Response, request, send_file, url_for
from flask import Response, make_response, request, send_file, url_for
from flask_restx import Resource
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy.orm import Session
from controllers.common.fields import BinaryFileResponse
from controllers.common.rbac import AgentId, PlainApp, RBACCheck
from controllers.common.schema import (
query_params_from_model,
@@ -25,6 +27,8 @@ from controllers.common.schema import (
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.error import AppUnavailableError
from controllers.console.app.preview_admission import get_preview_app
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
@@ -36,7 +40,11 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_application_services import application_services
from fields.agent_fields import AgentAppComposerResponse
from fields.base import ResponseModel
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
from libs.helper import dump_response
from libs.login import login_required
from models.account import Account
from models.model import App, AppMode
@@ -50,6 +58,8 @@ from services.agent_config_service import (
ConfigPushPayload,
ConfigPushSkillItem,
)
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_query_service import AppPreviewRef
class AgentConfigQuery(BaseModel):
@@ -215,6 +225,7 @@ class AgentConfigDeleteResponse(ResponseModel):
register_schema_models(console_ns, AgentConfigFileUploadPayload)
register_response_schema_models(
console_ns,
BinaryFileResponse,
AgentConfigDeleteResponse,
AgentConfigDownloadResponse,
AgentConfigFileItemResponse,
@@ -239,7 +250,7 @@ register_response_schema_models(
class _ResolvedConsoleTarget:
tenant_id: str
agent_id: str
account_id: str
account_id: str | None
version_id: str
version_kind: AgentConfigVersionKind
@@ -247,6 +258,51 @@ class _ResolvedConsoleTarget:
_WORKFLOW_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
class TrialAgentConfigQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: UUID | None = Field(default=None, description="Must match the current published template snapshot")
class TrialAgentSkillFileQuery(TrialAgentConfigQuery):
path: str = Field(description="Normalized member path inside the published Skill package")
register_schema_models(console_ns, TrialAgentConfigQuery, TrialAgentSkillFileQuery)
def _with_trial_config[T, Q: TrialAgentConfigQuery, **P](
view: Callable[Concatenate[T, _ResolvedConsoleTarget, Q, P], Response | dict[str, object]],
) -> Callable[Concatenate[T, Q, AppPreviewRef, P], Response]:
@wraps(view)
def decorated(self: T, query: Q, app: AppPreviewRef, /, *args: P.args, **kwargs: P.kwargs) -> Response:
try:
state = AgentAppComposerResponse.model_validate(
application_services().app_previews.get_agent_composer(app=app)
)
snapshot = state.active_config_snapshot
if snapshot is None or (query.version_id is not None and str(query.version_id) != snapshot.id):
raise AgentConfigServiceError(
"config_version_not_found", "published template version is unavailable", status_code=404
)
target = _ResolvedConsoleTarget(
tenant_id=app.tenant_id,
agent_id=state.agent.id,
account_id=None,
version_id=snapshot.id,
version_kind=AgentConfigVersionKind.SNAPSHOT,
)
response = make_response(view(self, target, query, *args, **kwargs))
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
except AgentConfigServiceError as exc:
response = make_response(_handle(exc))
response.headers["Cache-Control"] = "no-store"
return response
return decorated
def _service() -> AgentConfigService:
return AgentConfigService()
@@ -481,6 +537,7 @@ def _read_single_upload() -> tuple[bytes, str]:
def _skill_upload_response(target: _ResolvedConsoleTarget) -> tuple[dict[str, object], int]:
assert target.account_id is not None
return _upload_skill_for_target(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -517,6 +574,7 @@ def _upload_skill_for_target(
def _file_upload_response(
target: _ResolvedConsoleTarget, payload: AgentConfigFileUploadPayload
) -> tuple[dict[str, object], int]:
assert target.account_id is not None
manifest = _service().push_file_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -616,6 +674,7 @@ def _skill_file_raw_download_response(target: _ResolvedConsoleTarget, name: str,
def _skill_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
assert target.account_id is not None
_service().push_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -654,6 +713,7 @@ def _file_download_response(target: _ResolvedConsoleTarget, name: str) -> Respon
def _file_delete_response(target: _ResolvedConsoleTarget, name: str) -> dict[str, object]:
assert target.account_id is not None
_service().push_for_console(
tenant_id=target.tenant_id,
agent_id=target.agent_id,
@@ -1360,5 +1420,124 @@ class AgentConfigFileApi(Resource):
)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills")
class TrialAgentConfigSkillsApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigSkillListResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
return dump_response(AgentConfigSkillListResponse, _skill_list_response(target))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files")
class TrialAgentConfigFilesApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFileListResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery) -> dict[str, object]:
return dump_response(AgentConfigFileListResponse, _file_list_response(target))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/inspect")
class TrialAgentConfigSkillInspectApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(
200, "Published template resource", console_ns.models[AgentConfigSkillInspectResponse.__name__]
)
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _skill_inspect_response(target, name)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/download")
class TrialAgentConfigSkillDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _skill_download_response(target, name)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/preview")
class TrialAgentConfigSkillFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.response(
200, "Published template resource", console_ns.models[AgentConfigSkillFilePreviewResponse.__name__]
)
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> dict[str, object]:
return dump_response(
AgentConfigSkillFilePreviewResponse, _skill_file_preview_response(target, name, query.path)
)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/download")
class TrialAgentConfigSkillFileDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
assert request.view_args is not None
return _skill_file_download_response(
target,
name=name,
path=query.path,
raw_endpoint="console.trial_agent_config_skill_file_content",
route_params={"app_id": str(request.view_args["app_id"])},
)
@console_ns.route(
"/trial-apps/<uuid:app_id>/agent/config/skills/<string:name>/files/content",
endpoint="trial_agent_config_skill_file_content",
)
class TrialAgentConfigSkillFileContentApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentSkillFileQuery))
@console_ns.doc(
produces=["application/octet-stream"],
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/octet-stream"]},
)
@console_ns.response(200, "Published template resource", console_ns.models[BinaryFileResponse.__name__])
@get_preview_app
@model_validate(TrialAgentSkillFileQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, query: TrialAgentSkillFileQuery, name: str) -> Response:
return _skill_file_raw_download_response(target, name, query.path)
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/preview")
class TrialAgentConfigFilePreviewApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigFilePreviewResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> dict[str, object]:
return dump_response(AgentConfigFilePreviewResponse, _file_preview_response(target, name))
@console_ns.route("/trial-apps/<uuid:app_id>/agent/config/files/<string:name>/download")
class TrialAgentConfigFileDownloadApi(Resource):
@console_ns.doc(params=query_params_from_model(TrialAgentConfigQuery))
@console_ns.response(200, "Published template resource", console_ns.models[AgentConfigDownloadResponse.__name__])
@get_preview_app
@model_validate(TrialAgentConfigQuery)
@_with_trial_config
def get(self, target: _ResolvedConsoleTarget, _query: TrialAgentConfigQuery, name: str) -> Response:
return _file_download_response(target, name)
# pyrefly: ignore [unresolvable-dunder-all]
__all__ = [name for name, value in globals().items() if inspect.isclass(value) and issubclass(value, Resource)]

View File

@@ -8,6 +8,7 @@ from werkzeug.exceptions import Forbidden
from controllers.common.rbac import PlainApp, RBACCheck, Workspace
from controllers.common.schema import register_enum_models, register_schema_models
from controllers.console.app.error import AppNotFoundError
from controllers.console.explore.error import RecommendedAppNotFoundError as RecommendedAppNotFoundHttpError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission, validate_request
from extensions.ext_application_services import application_services
@@ -17,6 +18,7 @@ from services.agent.errors import InvalidRosterAgentPackageError
from services.app.console_service import ConsoleAppNotFoundError
from services.entities.dsl_entities import AppImportParams, CheckDependenciesResult, Import, ImportStatus
from services.errors.account import NoPermissionError
from services.recommended_app_query_service import RecommendedAppNotFoundError
from .. import console_ns
@@ -86,6 +88,8 @@ class AppImportApi(Resource):
result = application_services().apps.console.import_app(context, payload, source=source)
except NoPermissionError as exc:
raise Forbidden(str(exc)) from exc
except RecommendedAppNotFoundError:
raise RecommendedAppNotFoundHttpError() from None
status_code = {ImportStatus.FAILED: HTTPStatus.BAD_REQUEST, ImportStatus.PENDING: HTTPStatus.ACCEPTED}.get(
result.status, HTTPStatus.OK
)

View File

@@ -401,6 +401,7 @@ class InstructionGenerateApi(Resource):
instruction=req_data.instruction,
model_config=req_data.model_config_data,
ideal_output=req_data.ideal_output,
session=session,
)
if req_data.node_id != "" and req_data.current != "": # For workflow node
return LLMGenerator.instruction_modify_workflow(

View File

@@ -1,32 +1,31 @@
import json
from datetime import datetime
from typing import Any
from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import select
from werkzeug.exceptions import NotFound
from werkzeug.exceptions import Conflict, NotFound
from controllers.common.rbac import PlainApp, RBACCheck
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.app.wraps import get_app_model
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
edit_permission_required,
model_validate,
rbac_permission_required,
setup_required,
with_current_tenant_id,
)
from extensions.ext_database import db
from controllers.console.app.error import AppNotFoundError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import RBACPermission, validate_request
from extensions.ext_application_services import application_services
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models.enums import AppMCPServerStatus
from models.model import App, AppMCPServer
from services.app_ref_service import AppRefService
from machinery.context import RequestContext
from models.account import TenantAccountRole
from services.app.mcp_server_service import (
AppMCPServerAlreadyExistsError,
AppMCPServerAppNotFoundError,
AppMCPServerNotFoundError,
AppMCPServerStatus,
)
_EDIT_ROLES = frozenset({TenantAccountRole.OWNER, TenantAccountRole.ADMIN, TenantAccountRole.EDITOR})
class MCPServerCreatePayload(BaseModel):
@@ -38,13 +37,13 @@ class MCPServerCreatePayload(BaseModel):
class MCPServerUpdatePayload(BaseModel):
id: str = Field(..., description="Server ID")
id: UUID = Field(..., description="Server ID")
description: str | None = Field(default=None, description="Server description")
parameters: dict[str, Any] = Field(
...,
description="Server parameters configuration",
)
status: str | None = Field(default=None, description="Server status")
status: AppMCPServerStatus | None = Field(default=None, description="Server status")
class AppMCPServerResponse(ResponseModel):
@@ -84,13 +83,12 @@ class AppMCPServerController(Resource):
@console_ns.response(
200, "MCP server configuration retrieved successfully", console_ns.models[AppMCPServerResponse.__name__]
)
@login_required
@account_initialization_required
@setup_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
@get_app_model
def get(self, app_model: App):
server = db.session.scalar(select(AppMCPServer).where(AppMCPServer.app_id == app_model.id).limit(1))
@console_account_admission(rbac_checks=[RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp())])
def get(self, request_context: RequestContext, app_id: UUID):
try:
server = application_services().app_mcp_servers.get(request_context, str(app_id))
except AppMCPServerAppNotFoundError as error:
raise AppNotFoundError() from error
if server is None:
return {}
return dump_response(AppMCPServerResponse, server)
@@ -103,30 +101,18 @@ class AppMCPServerController(Resource):
201, "MCP server configuration created successfully", console_ns.models[AppMCPServerResponse.__name__]
)
@console_ns.response(403, "Insufficient permissions")
@account_initialization_required
@login_required
@setup_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
@with_current_tenant_id
@get_app_model
@model_validate(MCPServerCreatePayload)
def post(self, req_data: MCPServerCreatePayload, current_tenant_id: str, app_model: App):
description = req_data.description
if not description:
description = app_model.description or ""
server = AppMCPServer(
name=app_model.name,
description=description,
parameters=json.dumps(req_data.parameters, ensure_ascii=False),
status=AppMCPServerStatus.ACTIVE,
app_id=app_model.id,
tenant_id=current_tenant_id,
server_code=AppMCPServer.generate_server_code(16, session=db.session()),
)
db.session.add(server)
db.session.commit()
@console_ns.response(409, "MCP server already exists for this app")
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
def post(self, request_context: RequestContext, app_id: UUID):
payload = validate_request(MCPServerCreatePayload)
try:
server = application_services().app_mcp_servers.create(
request_context, str(app_id), description=payload.description, parameters=payload.parameters
)
except AppMCPServerAppNotFoundError as error:
raise AppNotFoundError() from error
except AppMCPServerAlreadyExistsError as error:
raise Conflict(str(error)) from error
return dump_response(AppMCPServerResponse, server), 201
@console_ns.doc("update_app_mcp_server")
@@ -138,43 +124,22 @@ class AppMCPServerController(Resource):
)
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Server not found")
@login_required
@setup_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
@get_app_model
@model_validate(MCPServerUpdatePayload)
def put(self, req_data: MCPServerUpdatePayload, app_model: App):
app_ref = AppRefService.create_app_ref(app_model)
server_ref = AppRefService.create_mcp_server_ref(app_ref, req_data.id)
server = db.session.scalar(
select(AppMCPServer)
.where(
AppMCPServer.id == server_ref.server_id,
AppMCPServer.tenant_id == server_ref.app.tenant_id,
AppMCPServer.app_id == server_ref.app.app_id,
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
def put(self, request_context: RequestContext, app_id: UUID):
payload = validate_request(MCPServerUpdatePayload)
try:
server = application_services().app_mcp_servers.update(
request_context,
str(app_id),
server_id=str(payload.id),
description=payload.description,
parameters=payload.parameters,
status=payload.status,
)
.limit(1)
)
if not server:
raise NotFound()
description = req_data.description
if description is None or not description:
server.description = app_model.description or ""
else:
server.description = description
server.name = app_model.name
server.parameters = json.dumps(req_data.parameters, ensure_ascii=False)
if req_data.status:
try:
server.status = AppMCPServerStatus(req_data.status)
except ValueError:
raise ValueError("Invalid status")
db.session.commit()
except AppMCPServerAppNotFoundError as error:
raise AppNotFoundError() from error
except AppMCPServerNotFoundError as error:
raise NotFound from error
return dump_response(AppMCPServerResponse, server)
@@ -186,21 +151,12 @@ class AppMCPServerRefreshController(Resource):
@console_ns.response(200, "MCP server refreshed successfully", console_ns.models[AppMCPServerResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(404, "Server not found")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACCheck(RBACPermission.APP_EDIT, PlainApp()))
@with_current_tenant_id
@get_app_model
def post(self, current_tenant_id: str, app_model: App):
server = db.session.scalar(
select(AppMCPServer)
.where(AppMCPServer.app_id == app_model.id, AppMCPServer.tenant_id == current_tenant_id)
.limit(1)
)
if not server:
raise NotFound()
server.server_code = AppMCPServer.generate_server_code(16, session=db.session())
db.session.commit()
@console_account_admission(allowed_roles=_EDIT_ROLES, rbac_checks=[RBACCheck(RBACPermission.APP_EDIT, PlainApp())])
def post(self, request_context: RequestContext, app_id: UUID):
try:
server = application_services().app_mcp_servers.refresh(request_context, str(app_id))
except AppMCPServerAppNotFoundError as error:
raise AppNotFoundError() from error
except AppMCPServerNotFoundError as error:
raise NotFound from error
return dump_response(AppMCPServerResponse, server)

View File

@@ -1,4 +1,5 @@
import logging
from http import HTTPStatus
from typing import Literal
from uuid import UUID
@@ -6,7 +7,7 @@ from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError, NotFound
from werkzeug.exceptions import HTTPException, InternalServerError, NotFound, Unauthorized
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
from controllers.common.fields import SimpleResultResponse, TextFileResponse
@@ -16,6 +17,8 @@ from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_runtime_app_model
from controllers.console.app.error import (
AppNotFoundError,
AppUnavailableError,
CompletionRequestError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
@@ -23,6 +26,7 @@ from controllers.console.app.error import (
)
from controllers.console.app.wraps import get_app_model
from controllers.console.explore.error import AppSuggestedQuestionsAfterAnswerDisabledError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import (
RBACPermission,
account_initialization_required,
@@ -33,9 +37,9 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from core.app.entities.app_invoke_entities import InvokeFrom
from core.entities.execution_extra_content import ExecutionExtraContentDomainModel
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.conversation_fields import (
@@ -46,13 +50,19 @@ from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import dump_response, uuid_value
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from libs.login import login_required
from machinery.context import RequestContext
from models.account import Account
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
from services.agent.errors import AgentNotFoundError
from services.app.agent_app_contracts import AgentAppNotFoundError
from services.app.console_service import ConsoleAppNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.conversation_service import ConversationService
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
from services.message_service import MessageService, attach_message_extra_contents
from services.message_service import attach_message_extra_contents
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
logger = logging.getLogger(__name__)
@@ -296,21 +306,25 @@ class MessageSuggestedQuestionApi(Resource):
@console_ns.doc(description="Get suggested questions for a message")
@console_ns.doc(params={"app_id": "Application ID", "message_id": "Message ID"})
@console_ns.response(
200,
HTTPStatus.OK,
"Suggested questions retrieved successfully",
console_ns.models[SuggestedQuestionsResponse.__name__],
)
@console_ns.response(404, "Message or conversation not found")
@setup_required
@login_required
@account_initialization_required
@with_current_user
@rbac_permission_required(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()))
@with_session(write=False)
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
def get(self, session: Session, current_user: Account, app_model: App, message_id: UUID):
@console_ns.response(HTTPStatus.BAD_REQUEST, "App or model provider unavailable, or generation failed")
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Account authentication required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions or suggested questions disabled")
@console_ns.response(HTTPStatus.NOT_FOUND, "App, message, or conversation not found")
@console_ns.response(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected server error")
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.APP_VIEW_LAYOUT, PlainApp()),))
def get(self, context: RequestContext, app_id: UUID, message_id: UUID) -> dict[str, object]:
try:
app = application_services().apps.console.get_reference(context, str(app_id))
except ConsoleAppNotFoundError as exc:
raise AppNotFoundError() from exc
if app.mode not in (AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT):
raise AppNotFoundError("App mode does not support suggested questions")
return _get_message_suggested_questions(
session=session, current_user=current_user, app_model=app_model, message_id=message_id
context=context, app_id=app.id, app_mode=app.mode, message_id=message_id
)
@@ -320,26 +334,23 @@ class AgentMessageSuggestedQuestionApi(Resource):
@console_ns.doc(description="Get suggested questions for an Agent App message")
@console_ns.doc(params={"agent_id": "Agent ID", "message_id": "Message ID"})
@console_ns.response(
200,
HTTPStatus.OK,
"Suggested questions retrieved successfully",
console_ns.models[SuggestedQuestionsResponse.__name__],
)
@console_ns.response(404, "Agent, message, or conversation not found")
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_TEST_AND_RUN, AgentId()))
@with_current_user
@with_current_tenant_id
@with_session(write=False)
def get(self, session: Session, current_tenant_id: str, current_user: Account, agent_id: UUID, message_id: UUID):
app_model = resolve_agent_runtime_app_model(
session=session,
tenant_id=current_tenant_id,
agent_id=agent_id,
)
@console_ns.response(HTTPStatus.BAD_REQUEST, "App or model provider unavailable, or generation failed")
@console_ns.response(HTTPStatus.UNAUTHORIZED, "Account authentication required")
@console_ns.response(HTTPStatus.FORBIDDEN, "Insufficient permissions or suggested questions disabled")
@console_ns.response(HTTPStatus.NOT_FOUND, "Agent, message, or conversation not found")
@console_ns.response(HTTPStatus.INTERNAL_SERVER_ERROR, "Unexpected server error")
@console_account_admission(rbac_checks=(RBACCheck(RBACPermission.AGENT_TEST_AND_RUN, AgentId()),))
def get(self, context: RequestContext, agent_id: UUID, message_id: UUID) -> dict[str, object]:
try:
app_id = application_services().agent_apps.access.resolve_existing_runtime_app_id(context, str(agent_id))
except AgentAppNotFoundError as exc:
raise AgentNotFoundError() from exc
return _get_message_suggested_questions(
session=session, current_user=current_user, app_model=app_model, message_id=message_id
context=context, app_id=app_id, app_mode=AppMode.AGENT, message_id=message_id
)
@@ -552,17 +563,22 @@ def _update_message_feedback(
return SimpleResultResponse(result="success").model_dump(mode="json")
def _get_message_suggested_questions(*, session: Session, current_user: Account, app_model: App, message_id: UUID):
message_id_str = str(message_id)
def _get_message_suggested_questions(
*, context: RequestContext, app_id: str, app_mode: str, message_id: UUID
) -> dict[str, object]:
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model,
message_id=message_id_str,
user=current_user,
invoke_from=InvokeFrom.DEBUGGER,
session=session,
questions = application_services().message_suggested_questions.get_suggested_questions(
app_id=app_id,
app_owner_tenant_id=context.active_workspace_id,
expected_app_mode=app_mode,
actor=SuggestedQuestionsAccount(account_id=context.account_id, invoke_from="debugger"),
message_id=str(message_id),
)
except AppDefinitionUnavailableError as exc:
raise AppUnavailableError() from exc
except SuggestedQuestionsActorNotFoundError as exc:
raise Unauthorized("Account no longer exists") from exc
except MessageNotExistsError:
raise NotFound("Message not found")
except ConversationNotExistsError:
@@ -577,6 +593,8 @@ def _get_message_suggested_questions(*, session: Session, current_user: Account,
raise CompletionRequestError(e.description)
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError()
except HTTPException:
raise
except Exception:
logger.exception("internal server error.")
raise InternalServerError()

View File

@@ -18,6 +18,7 @@ from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models import App, AppMode
from models.agent import AgentScope
from models.enums import AppStatus
__all__ = [
"get_app_model",
@@ -25,31 +26,28 @@ __all__ = [
]
def _is_hidden_backing_app(app_model: App, session: Session | scoped_session) -> bool:
binding = app_model.agent_app_binding_with_session(session=session, include_archived=True)
return binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY
def _load_app_model(session: Session, app_id: str) -> App | None:
"""Load the tenant-scoped app row with the request session owned by `with_session`."""
def _load_app_model(session: Session | scoped_session, app_id: str) -> App | None:
"""Load the tenant-scoped Console app row through the given session."""
_, current_tenant_id = current_account_with_tenant()
app_model = session.scalar(
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
select(App)
.where(
App.id == app_id,
App.tenant_id == current_tenant_id,
App.status == AppStatus.NORMAL,
)
.limit(1)
)
if app_model is not None and _is_hidden_backing_app(app_model, session):
return None
if app_model is not None:
binding = app_model.agent_app_binding_with_session(session=session, include_archived=True)
if binding is not None and binding.scope == AgentScope.WORKFLOW_ONLY:
return None
return app_model
def _load_app_model_from_scoped_session(app_id: str) -> App | None:
"""Load the app row for legacy handlers that have not adopted request session injection yet."""
_, current_tenant_id = current_account_with_tenant()
app_model = db.session.scalar(
select(App).where(App.id == app_id, App.tenant_id == current_tenant_id, App.status == "normal").limit(1)
)
if app_model is not None and _is_hidden_backing_app(app_model, db.session):
return None
return app_model
return _load_app_model(db.session, app_id)
def _get_injected_session(args: tuple[object, ...]) -> Session | None:

View File

@@ -115,14 +115,14 @@ class ActivateApi(Resource):
console_ns.models[ActivationResponse.__name__],
)
@console_ns.response(400, "Already activated or invalid token")
def post(self):
@model_validate(ActivatePayload)
def post(self, args: ActivatePayload):
"""Accept an invitation without letting an existing session act for another account.
Token-only activation remains available for legacy clients. When the request already
carries a console session, that session must belong to the account encoded in the
invitation before the token is consumed or tenant membership is changed.
"""
args = ActivatePayload.model_validate(console_ns.payload or {})
authenticated_account_id: str | None = None
if extract_access_token(request) is not None:
authenticated_account_id = current_account_with_tenant().account.id

View File

@@ -1,9 +1,10 @@
from http import HTTPStatus
from typing import Any
from typing import Any, Literal
from uuid import UUID
from flask import make_response, send_file
from flask_restx import Resource
from pydantic import BaseModel, Field, computed_field, field_validator
from pydantic import BaseModel, ConfigDict, Field, computed_field, field_validator
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
@@ -11,7 +12,9 @@ from controllers.console.explore.error import RecommendedAppNotFoundError
from controllers.console.flask_admission import console_account_admission
from controllers.console.wraps import model_validate
from extensions.ext_application_services import application_services
from fields.app_export_fields import AppExportResponse
from fields.base import ResponseModel
from libs.flask_restx_compat import BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY
from libs.helper import build_icon_url, dump_response
from machinery.context import RequestContext
from services.recommended_app_query_service import RecommendedAppNotFoundError as RecommendedAppQueryNotFoundError
@@ -21,6 +24,13 @@ class RecommendedAppsQuery(BaseModel):
language: str = Field(default="en-US", description="Language code for recommended app localization")
class RecommendedAgentExportQuery(BaseModel):
model_config = ConfigDict(extra="forbid")
version_id: UUID = Field(description="Current published template snapshot ID")
format: Literal["yaml", "ifpkg"] = Field(default="ifpkg", description="Export format; defaults to ifpkg")
class RecommendedAppInfoResponse(ResponseModel):
id: str
name: str | None = None
@@ -78,11 +88,14 @@ class RecommendedAppDetailResponse(ResponseModel):
mode: str
export_data: str
can_trial: bool
package_url: str | None = Field(default=None, description="Download URL for a New Agent .ifpkg template")
version_id: str | None = Field(default=None, description="Published version for direct local template creation")
register_schema_models(
console_ns,
RecommendedAppsQuery,
RecommendedAgentExportQuery,
)
register_response_schema_models(
console_ns,
@@ -91,6 +104,7 @@ register_response_schema_models(
RecommendedAppListResponse,
LearnDifyAppListResponse,
RecommendedAppDetailResponse,
AppExportResponse,
)
@@ -135,3 +149,37 @@ class RecommendedAppApi(Resource):
except RecommendedAppQueryNotFoundError:
raise RecommendedAppNotFoundError() from None
return dump_response(RecommendedAppDetailResponse, result)
@console_ns.route("/trial-apps/<uuid:app_id>/export")
class RecommendedAgentExportApi(Resource):
@console_ns.doc(params=query_params_from_model(RecommendedAgentExportQuery))
@console_ns.doc(
produces=["application/json", "application/zip"],
vendor={BINARY_RESPONSE_MEDIA_TYPES_VENDOR_KEY: ["application/zip"]},
)
@console_ns.response(200, "Published Agent template export", console_ns.models[AppExportResponse.__name__])
@console_ns.response(404, "Published template unavailable")
@model_validate(RecommendedAgentExportQuery)
def get(self, query: RecommendedAgentExportQuery, app_id: UUID):
# Both formats require public catalog membership and the current published version.
try:
exported = application_services().recommended_app_packages.export(
app_id=str(app_id), version_id=query.version_id, format=query.format
)
except RecommendedAppQueryNotFoundError:
raise RecommendedAppNotFoundError() from None
if isinstance(exported, str):
response = make_response(dump_response(AppExportResponse, {"data": exported}))
response.headers["Cache-Control"] = "no-store"
return response
try:
response = send_file(
exported.archive, mimetype="application/zip", as_attachment=True, download_name=exported.filename
)
except Exception:
exported.close()
raise
response.headers["Cache-Control"] = "no-store"
response.call_on_close(exported.close)
return response

View File

@@ -57,12 +57,14 @@ from controllers.console.explore.error import (
NotCompletionAppError,
NotWorkflowAppError,
)
from controllers.console.explore.trial_app_admission import get_trial_app
from controllers.console.explore.trial_app_admission import get_trial_app, get_trial_app_for_stop
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request_context
from controllers.console.flask_admission import console_account_admission
from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file
from controllers.console.wraps import cloud_edition_billing_resource_check, model_validate
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
AppInvokeQuotaExceededError,
ModelCurrentlyNotSupportError,
@@ -72,6 +74,7 @@ from core.errors.error import (
from core.helper import encrypter
from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable
from extensions.ext_application_services import application_services
from fields.agent_fields import AgentAppComposerResponse
from fields.base import ResponseModel
from fields.conversation_variable_fields import WorkflowConversationVariableResponse
from fields.file_fields import FileResponse, FileWithSignedUrl
@@ -84,6 +87,7 @@ from libs.stream import close_stream
from libs.url_utils import normalize_api_base_url
from machinery.context import RequestContext
from models.enums import CreatorUserRole
from models.model import AppMode
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_query_service import (
@@ -153,7 +157,7 @@ class TrialDatasetListQuery(BaseModel):
ids: list[str] = Field(default_factory=list, description="Dataset IDs")
type TrialAppMode = Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
type TrialAppMode = Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
type TrialIconType = Literal["emoji", "image", "link"]
type JsonObject = dict[str, Any]
@@ -448,6 +452,7 @@ register_response_schema_models(
SiteResponse,
SuggestedQuestionsResponse,
TrialAppDetailResponse,
AgentAppComposerResponse,
TrialDatasetListResponse,
TrialWorkflowResponse,
)
@@ -603,6 +608,8 @@ class TrialChatApi(Resource):
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
raise AppUnavailableError()
except (AgentAppGeneratorError, AgentAppNotPublishedError):
raise AppUnavailableError() from None
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -620,6 +627,27 @@ class TrialChatApi(Resource):
raise InternalServerError()
@console_ns.route(
"/trial-apps/<uuid:app_id>/chat-messages/<string:task_id>/stop",
endpoint="trial_app_stop_chat_completion",
)
class TrialChatTaskStopApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_trial_app_for_stop
def post(self, request_context: RequestContext, trial_app: TrialAppRef, task_id: str):
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
raise NotChatAppError()
application_services().app_tasks.stop_task(
task_id=task_id,
invoke_from=InvokeFrom.EXPLORE,
user_id=request_context.account_id,
app_mode=AppMode.value_of(trial_app.app_mode),
)
return SimpleResultResponse(result="success").model_dump(mode="json"), 200
@console_ns.route(
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
endpoint="trial_app_suggested_question",
@@ -629,7 +657,7 @@ class TrialMessageSuggestedQuestionApi(Resource):
@console_account_admission()
@get_trial_app
def get(self, request_context: RequestContext, trial_app: TrialAppRef, message_id: UUID) -> dict[str, object]:
if trial_app.app_mode not in {"chat", "agent-chat", "advanced-chat"}:
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
raise NotChatAppError()
try:
@@ -837,7 +865,7 @@ class TrialAppParameterApi(Resource):
"""Retrieve app parameters."""
try:
parameters = application_services().app_definitions.get_parameters(app.app_id)
parameters = application_services().app_definitions.get_public_parameters(app.app_id)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
@@ -875,6 +903,18 @@ class AppApi(Resource):
return dump_response(TrialAppDetailResponse, source)
@console_ns.route("/trial-apps/<uuid:app_id>/agent-composer")
class TrialAgentComposerApi(Resource):
@console_ns.response(200, "Published Agent configuration", console_ns.models[AgentAppComposerResponse.__name__])
@get_preview_app
def get(self, app: AppPreviewRef):
try:
preview = application_services().app_previews.get_agent_composer(app=app)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
return dump_response(AgentAppComposerResponse, preview)
@console_ns.route(
"/trial-apps/<uuid:app_id>/workflows",
endpoint="trial_app_workflow",

View File

@@ -28,8 +28,10 @@ def trial_feature_enable[**P, R](view: Callable[P, R]) -> Callable[P, R]:
def get_trial_app[T, **P, R](
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
*,
check_usage: bool = True,
) -> Callable[Concatenate[T, RequestContext, P], R]:
"""Check trial availability and inject an admitted app after Console admission."""
"""Admit the trial app, optionally skipping generation quota for task cancellation."""
@wraps(view)
def decorated(self: T, request_context: RequestContext, /, *args: P.args, **kwargs: P.kwargs) -> R:
@@ -38,7 +40,7 @@ def get_trial_app[T, **P, R](
raise RuntimeError("The trial app admission route must provide app_id")
try:
trial_app = application_services().trial_apps.access.get_access(
app_id=str(app_id), account_id=request_context.account_id
app_id=str(app_id), account_id=request_context.account_id, check_usage=check_usage
)
except TrialAppUnavailableError as error:
raise TrialAppNotAllowed() from error
@@ -47,3 +49,10 @@ def get_trial_app[T, **P, R](
return view(self, request_context, trial_app, *args, **kwargs)
return trial_feature_enable(decorated)
def get_trial_app_for_stop[T, **P, R](
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
) -> Callable[Concatenate[T, RequestContext, P], R]:
"""Keep trial access checks without requiring remaining generation quota."""
return get_trial_app(view, check_usage=False)

View File

@@ -4,7 +4,7 @@ from flask import Response, request
from flask_restx import Resource
from pydantic import BaseModel, Field, RootModel, ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm import Session
from controllers.common.schema import register_response_schema_models, register_schema_model
from controllers.mcp import mcp_ns
@@ -13,8 +13,9 @@ from core.mcp.server.streamable_http import handle_mcp_request, negotiate_protoc
from extensions.ext_database import db
from graphon.variables.input_entities import VariableEntity, VariableEntityType
from libs import helper
from models.enums import AppMCPServerStatus, EndUserType
from models.enums import EndUserType
from models.model import App, AppMCPServer, AppMode, EndUser
from services.app.mcp_server_service import AppMCPServerStatus
class MCPRequestError(Exception):
@@ -82,7 +83,7 @@ class MCPAppApi(Resource):
else:
return self._protocol_version_error_response(request_id, header_value)
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
with Session(db.engine, expire_on_commit=False) as session:
# Get MCP server and app
mcp_server, app = self._get_mcp_server_and_app(server_code, session)
self._validate_server_status(mcp_server)
@@ -91,9 +92,11 @@ class MCPAppApi(Resource):
user_input_form = self._get_user_input_form(app, session)
# Handle notification vs request differently
return self._process_mcp_message(
response = self._process_mcp_message(
mcp_request, request_id, app, mcp_server, user_input_form, session, protocol_version
)
session.commit()
return response
def _protocol_version_error_response(
self, request_id: Union[int, str] | None, header_value: str | None
@@ -128,7 +131,7 @@ class MCPAppApi(Resource):
def _validate_server_status(self, mcp_server: AppMCPServer):
"""Validate MCP server status"""
if mcp_server.status != AppMCPServerStatus.ACTIVE:
if mcp_server.status != AppMCPServerStatus.ACTIVE.value:
raise MCPRequestError(mcp_types.INVALID_REQUEST, "Server is not active")
def _process_mcp_message(
@@ -192,8 +195,7 @@ class MCPAppApi(Resource):
app_model_config = app.app_model_config_with_session(session=session)
if not app_model_config:
raise MCPRequestError(mcp_types.INVALID_REQUEST, "App is unavailable")
features_dict = app_model_config.to_dict()
raw_user_input_form = features_dict.get("user_input_form", [])
raw_user_input_form = app_model_config.user_input_form_list
# Convert to VariableEntity objects
try:
@@ -237,16 +239,15 @@ class MCPAppApi(Resource):
except ValidationError as e:
raise MCPRequestError(mcp_types.INVALID_PARAMS, f"Invalid MCP request: {str(e)}")
def _retrieve_end_user(self, tenant_id: str, mcp_server_id: str) -> EndUser | None:
"""Get end user - manages its own database session"""
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
return session.scalar(
select(EndUser)
.where(
EndUser.tenant_id == tenant_id, EndUser.session_id == mcp_server_id, EndUser.type == EndUserType.MCP
)
.limit(1)
)
def _retrieve_end_user(self, tenant_id: str, mcp_server_id: str, session: Session) -> EndUser | None:
"""Get end user in the caller session"""
return session.scalar(
select(EndUser)
.where(EndUser.tenant_id == tenant_id)
.where(EndUser.session_id == mcp_server_id)
.where(EndUser.type == EndUserType.MCP)
.limit(1)
)
def _create_end_user(
self, client_name: str, tenant_id: str, app_id: str, mcp_server_id: str, session: Session
@@ -275,7 +276,7 @@ class MCPAppApi(Resource):
protocol_version: str,
) -> mcp_types.JSONRPCResponse | mcp_types.JSONRPCError | None:
"""Handle MCP request and return response"""
end_user = self._retrieve_end_user(mcp_server.tenant_id, mcp_server.id)
end_user = self._retrieve_end_user(mcp_server.tenant_id, mcp_server.id, session)
if not end_user and isinstance(mcp_request.root, mcp_types.InitializeRequest):
client_info = mcp_request.root.params.clientInfo

View File

@@ -67,7 +67,7 @@ def _mapping(part: FileStorage, caller: Any) -> FileMapping:
uploaded = upload(part, caller)
return {
"transfer_method": FileTransferMethod.LOCAL_FILE,
"upload_file_id": str(uploaded.id),
"upload_file_id": uploaded.id,
"type": standardize_file_type(extension="." + uploaded.extension, mime_type=uploaded.mime_type),
}

View File

@@ -62,6 +62,8 @@ class AgentChatAppConfigManager(BaseAppConfigManager):
config_from = EasyUIBasedAppModelConfigFrom.APP_LATEST_CONFIG
if config_from != EasyUIBasedAppModelConfigFrom.ARGS:
if annotation_reply is None:
raise ValueError("Annotation reply config is required")
app_model_config_dict = app_model_config.to_dict(annotation_reply=annotation_reply)
config_dict = app_model_config_dict.copy()
else:

View File

@@ -138,9 +138,12 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
# always enable retriever resource in debugger mode
override_model_config_dict["retriever_resource"] = {"enabled": True}
annotation_reply = (
None if override_model_config_dict else load_annotation_reply_config(session, app_model_config.app_id)
)
if override_model_config_dict:
annotation_reply = None
effective_model_config_dict = override_model_config_dict
else:
annotation_reply = load_annotation_reply_config(session, app_model_config.app_id)
effective_model_config_dict = app_model_config.to_dict(annotation_reply=annotation_reply)
# parse files
# TODO(QuantumGhost): Move file parsing logic to the API controller layer
@@ -150,9 +153,7 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
# `DraftWorkflowNodeRunApi` class which handle this properly.
with self._bind_file_access_scope(tenant_id=app_model.tenant_id, user=user, invoke_from=invoke_from):
files = args.get("files") or []
file_extra_config = FileUploadConfigManager.convert(
override_model_config_dict or app_model_config.to_dict(annotation_reply=annotation_reply)
)
file_extra_config = FileUploadConfigManager.convert(effective_model_config_dict)
if file_extra_config:
file_objs = file_factory.build_from_mappings(
mappings=files,

View File

@@ -55,6 +55,8 @@ class ChatAppConfigManager(BaseAppConfigManager):
config_from = EasyUIBasedAppModelConfigFrom.APP_LATEST_CONFIG
if config_from != EasyUIBasedAppModelConfigFrom.ARGS:
if annotation_reply is None:
raise ValueError("Annotation reply config is required")
app_model_config_dict = app_model_config.to_dict(annotation_reply=annotation_reply)
config_dict = app_model_config_dict.copy()
else:

View File

@@ -133,9 +133,12 @@ class ChatAppGenerator(MessageBasedAppGenerator):
# always enable retriever resource in debugger mode
override_model_config_dict["retriever_resource"] = {"enabled": True}
annotation_reply = (
None if override_model_config_dict else load_annotation_reply_config(session, app_model_config.app_id)
)
if override_model_config_dict:
annotation_reply = None
effective_model_config_dict = override_model_config_dict
else:
annotation_reply = load_annotation_reply_config(session, app_model_config.app_id)
effective_model_config_dict = app_model_config.to_dict(annotation_reply=annotation_reply)
# parse files
# TODO(QuantumGhost): Move file parsing logic to the API controller layer
@@ -145,9 +148,7 @@ class ChatAppGenerator(MessageBasedAppGenerator):
# `DraftWorkflowNodeRunApi` class which handle this properly.
with self._bind_file_access_scope(tenant_id=app_model.tenant_id, user=user, invoke_from=invoke_from):
files = args["files"] if args.get("files") else []
file_extra_config = FileUploadConfigManager.convert(
override_model_config_dict or app_model_config.to_dict(annotation_reply=annotation_reply)
)
file_extra_config = FileUploadConfigManager.convert(effective_model_config_dict)
if file_extra_config:
file_objs = file_factory.build_from_mappings(
mappings=files,

View File

@@ -46,6 +46,8 @@ class CompletionAppConfigManager(BaseAppConfigManager):
config_from = EasyUIBasedAppModelConfigFrom.APP_LATEST_CONFIG
if config_from != EasyUIBasedAppModelConfigFrom.ARGS:
if annotation_reply is None:
raise ValueError("Annotation reply config is required")
app_model_config_dict = app_model_config.to_dict(annotation_reply=annotation_reply)
config_dict = app_model_config_dict.copy()
else:

View File

@@ -119,9 +119,12 @@ class CompletionAppGenerator(MessageBasedAppGenerator):
tenant_id=app_model.tenant_id, config=args.get("model_config", {}), session=session
)
annotation_reply = (
None if override_model_config_dict else load_annotation_reply_config(session, app_model_config.app_id)
)
if override_model_config_dict:
annotation_reply = None
effective_model_config_dict = override_model_config_dict
else:
annotation_reply = load_annotation_reply_config(session, app_model_config.app_id)
effective_model_config_dict = app_model_config.to_dict(annotation_reply=annotation_reply)
# parse files
# TODO(QuantumGhost): Move file parsing logic to the API controller layer
@@ -131,9 +134,7 @@ class CompletionAppGenerator(MessageBasedAppGenerator):
# `DraftWorkflowNodeRunApi` class which handle this properly.
with self._bind_file_access_scope(tenant_id=app_model.tenant_id, user=user, invoke_from=invoke_from):
files = args["files"] if args.get("files") else []
file_extra_config = FileUploadConfigManager.convert(
override_model_config_dict or app_model_config.to_dict(annotation_reply=annotation_reply)
)
file_extra_config = FileUploadConfigManager.convert(effective_model_config_dict)
if file_extra_config:
file_objs = file_factory.build_from_mappings(
mappings=files,

View File

@@ -875,8 +875,9 @@ class LLMGenerator:
instruction: str,
model_config: ModelConfig,
ideal_output: str | None,
session: Session,
):
last_run: Message | None = db.session.scalar(
last_run: Message | None = session.scalar(
select(Message)
.join(App, App.id == Message.app_id)
.where(Message.app_id == flow_id, App.tenant_id == tenant_id)

View File

@@ -57,12 +57,13 @@ from core.workflow.generator.prompts.planner_prompts import (
from core.workflow.generator.prompts.tool_router_prompts import TOOL_ROUTER_SYSTEM_PROMPT, TOOL_ROUTER_USER_PROMPT
from core.workflow.generator.tool_catalogue import (
DIRECT_TOOL_INJECTION_LIMIT,
LegacyToolFallbackSelection,
ToolCapabilityQuery,
ToolCatalogueEntry,
find_tool_entry,
format_tool_builder_context,
format_tool_catalogue,
select_legacy_fallback_tools,
select_legacy_fallback_selection,
select_tool_candidates,
text_mentions_tool_identifier,
)
@@ -120,6 +121,7 @@ _DEFAULT_FILE_UPLOAD_METHODS = ("local_file", "remote_url")
# keep the caller's budget so complex node configs are not truncated.
_PLANNER_DEFAULT_MAX_TOKENS = 4096
_TOOL_ROUTER_MAX_TOKENS = 256
_MAX_OMITTED_TOOL_PREVIEW = 20
# Per-node calls trade a larger request count for a shorter critical path.
@@ -341,6 +343,41 @@ def _find_planned_tool_entry(node: dict[str, Any], entries: list[ToolCatalogueEn
return None
def _format_omitted_tool_preview(entries: list[ToolCatalogueEntry]) -> str:
"""Build a bounded identity preview for fallback diagnostics."""
identifiers = [f"{entry['provider_name']}/{entry['tool_name']}" for entry in entries]
preview = ",".join(identifiers[:_MAX_OMITTED_TOOL_PREVIEW])
if len(identifiers) > _MAX_OMITTED_TOOL_PREVIEW:
preview = f"{preview},...(+{len(identifiers) - _MAX_OMITTED_TOOL_PREVIEW})"
return preview
def _log_tool_catalogue_fallback(
*,
reason: str,
total: int,
query_count: int,
selection: LegacyToolFallbackSelection,
started_at: float,
) -> None:
"""Record selected and omitted identities for legacy catalogue fallback."""
logger.warning(
"Workflow generator: tool catalogue selection mode=fallback reason=%s total=%s "
"query_count=%s candidates=%s omitted=%s pinned=%s limit=%s overflow=%s "
"omitted_tools=%s elapsed_ms=%.1f",
reason,
total,
query_count,
len(selection.entries),
len(selection.omitted_entries),
selection.pinned_count,
selection.limit,
selection.overflow_count,
_format_omitted_tool_preview(selection.omitted_entries),
(time.monotonic() - started_at) * 1000,
)
def _stage_error_to_envelope_code(exc: Exception) -> str:
"""Map a stage-typed exception to the result envelope's error code."""
if isinstance(exc, _StageJSONError):
@@ -730,21 +767,19 @@ class WorkflowGenerator:
)
candidate_count = len(selection.entries)
if selection.unmatched_queries:
fallback_entries = select_legacy_fallback_tools(
fallback_selection = select_legacy_fallback_selection(
tool_catalogue_entries,
explicit_text=explicit_text,
current_graph=current_graph,
)
logger.warning(
"Workflow generator: tool catalogue selection mode=fallback reason=unmatched_query "
"total=%s query_count=%s candidates=%s pinned=%s elapsed_ms=%.1f",
tool_count,
query_count,
len(fallback_entries),
selection.pinned_count,
(time.monotonic() - started_at) * 1000,
_log_tool_catalogue_fallback(
reason="unmatched_query",
total=tool_count,
query_count=query_count,
selection=fallback_selection,
started_at=started_at,
)
return format_tool_catalogue(fallback_entries, max_tools=None)
return format_tool_catalogue(fallback_selection.entries, max_tools=None)
logger.info(
"Workflow generator: tool catalogue selection mode=routed total=%s needs_tools=%s "
@@ -758,21 +793,19 @@ class WorkflowGenerator:
)
return format_tool_catalogue(selection.entries, max_tools=None)
except Exception as e:
fallback_entries = select_legacy_fallback_tools(
fallback_selection = select_legacy_fallback_selection(
tool_catalogue_entries,
explicit_text=explicit_text,
current_graph=current_graph,
)
logger.warning(
"Workflow generator: tool catalogue selection mode=fallback reason=%s total=%s "
"query_count=%s candidates=%s elapsed_ms=%.1f",
type(e).__name__,
tool_count,
query_count,
len(fallback_entries),
(time.monotonic() - started_at) * 1000,
_log_tool_catalogue_fallback(
reason=type(e).__name__,
total=tool_count,
query_count=query_count,
selection=fallback_selection,
started_at=started_at,
)
return format_tool_catalogue(fallback_entries, max_tools=None)
return format_tool_catalogue(fallback_selection.entries, max_tools=None)
@classmethod
def _run_tool_router(

View File

@@ -92,6 +92,19 @@ class ToolCandidateSelection:
pinned_count: int
@dataclass(frozen=True)
class LegacyToolFallbackSelection:
entries: list[ToolCatalogueEntry]
omitted_entries: list[ToolCatalogueEntry]
pinned_count: int
limit: int
@property
def overflow_count(self) -> int:
"""Count intentional pinned overflow beyond the legacy prompt cap."""
return max(0, len(self.entries) - self.limit)
def build_tool_catalogue(tenant_id: str) -> list[ToolCatalogueEntry]:
"""
Enumerate installed tools for the given tenant.
@@ -292,13 +305,13 @@ def select_tool_candidates(
)
def select_legacy_fallback_tools(
def select_legacy_fallback_selection(
entries: list[ToolCatalogueEntry],
*,
explicit_text: str = "",
current_graph: dict[str, Any] | None = None,
) -> list[ToolCatalogueEntry]:
"""Keep pinned tools while reproducing the legacy bounded prompt fallback."""
) -> LegacyToolFallbackSelection:
"""Keep pinned tools and expose the tools omitted by the legacy fallback."""
pinned = select_tool_candidates(
entries,
[],
@@ -307,10 +320,27 @@ def select_legacy_fallback_tools(
).entries
pinned_keys = {(entry["provider_name"], entry["tool_name"]) for entry in pinned}
regular_limit = max(0, _MAX_PROMPT_TOOLS - len(pinned))
regular = [entry for entry in entries if (entry["provider_name"], entry["tool_name"]) not in pinned_keys][
:regular_limit
]
return [*pinned, *regular]
regular = [entry for entry in entries if (entry["provider_name"], entry["tool_name"]) not in pinned_keys]
return LegacyToolFallbackSelection(
entries=[*pinned, *regular[:regular_limit]],
omitted_entries=regular[regular_limit:],
pinned_count=len(pinned),
limit=_MAX_PROMPT_TOOLS,
)
def select_legacy_fallback_tools(
entries: list[ToolCatalogueEntry],
*,
explicit_text: str = "",
current_graph: dict[str, Any] | None = None,
) -> list[ToolCatalogueEntry]:
"""Keep pinned tools while reproducing the legacy bounded prompt fallback."""
return select_legacy_fallback_selection(
entries,
explicit_text=explicit_text,
current_graph=current_graph,
).entries
def _find_explicit_tool_keys(

View File

@@ -20,6 +20,7 @@ from services.app_dsl_service import AppDslService
from services.app_package_service import AppPackageService
from services.app_tracing_config_gateway import OpsTraceManagerGateway
from services.oauth_server_service import OAuthServerService
from services.recommended_app_package_service import RecommendedAppPackageService
@dataclass(frozen=True, slots=True)
@@ -33,6 +34,7 @@ def build_app_services(
*,
database_client: sessionmaker[Session],
oauth: OAuthServerService,
recommended_packages: RecommendedAppPackageService,
) -> AppServices:
repository = ConsoleAppRepository(session_factory=database_client)
transfers = AppTransferGateway(
@@ -41,6 +43,7 @@ def build_app_services(
packages=AppPackageService(),
agent_packages=RosterAgentPackageExporter(),
agent_importer=RosterAgentPackageImporter(),
recommended_packages=recommended_packages,
)
return AppServices(
imports=AppImportService(accounts=SQLAlchemyAccountRepository(database_client), definitions=transfers),

View File

@@ -45,12 +45,13 @@ from repositories.account_oauth_repository import (
RegisterServiceOAuthInvitationGateway,
)
from repositories.account_repository import SQLAlchemyAccountRepository
from repositories.app.mcp_server_repository import AppMCPServerRepository
from repositories.app.site_command_repository import AppSiteCommandRepository
from repositories.app.tracing_config_repository import SQLAlchemyAppTracingConfigRepository
from repositories.app_definition_query_repository import AppDefinitionQueryRepository
from repositories.app_preview_query_repository import AppPreviewQueryRepository
from repositories.app_scoped_end_user_repository import AppScopedEndUserRepo
from repositories.app_site_command_repository import AppSiteCommandRepository
from repositories.app_statistic_query_repository import AppStatisticQueryRepository
from repositories.app_tracing_config_repository import SQLAlchemyAppTracingConfigRepository
from repositories.data_source_api_key_auth_repository import SQLAlchemyDataSourceApiKeyAuthBindingRepository
from repositories.data_source_oauth_binding_repository import SQLAlchemyDataSourceOAuthBindingRepository
from repositories.explore_banner_query_repository import ExploreBannerQueryRepository
@@ -146,8 +147,10 @@ from services.account_oauth_service import AccountOAuthService, OAuthProviderGat
from services.account_password_hasher import DefaultAccountPasswordHasher
from services.account_password_service import AccountPasswordService
from services.account_profile_service import AccountProfileService
from services.agent.roster_package_exporter import RosterAgentPackageExporter
from services.app.advanced_prompt_template_service import AdvancedPromptTemplateService
from services.app.api_key_service import AppApiKeyService
from services.app.mcp_server_service import AppMCPServerService
from services.app_audio_adapters import AppAudioRuntime
from services.app_audio_service import AppAudio
from services.app_definition_query_service import AppDefinitionQueryService
@@ -211,6 +214,7 @@ from services.recommended_app_catalog_gateway import (
RecommendedAppCatalogRouter,
RemoteRecommendedAppCatalogGateway,
)
from services.recommended_app_package_service import RecommendedAppPackageService
from services.recommended_app_query_service import RecommendedAppQueryService
from services.remote_file_service import RemoteFileService
from services.retention.workflow_run.archive_download_adapters import (
@@ -339,6 +343,7 @@ class ApplicationServices:
account_activation: AccountActivationService
apps: AppServices
app_definitions: AppDefinitionQueryService
app_mcp_servers: AppMCPServerService
app_preview_details: AppPreviewDetails
app_previews: AppPreviewQueryService
app_sites: AppSiteService
@@ -371,6 +376,7 @@ class ApplicationServices:
step_by_step_tour: StepByStepTourService
partner_tenant_bindings: PartnerTenantBindingService
recommended_app_queries: RecommendedAppQueryService
recommended_app_packages: RecommendedAppPackageService
remote_files: RemoteFileService
saved_messages: SavedMessageService
app_tasks: AppTaskControlService
@@ -577,6 +583,9 @@ def build_application_services(
trial_apps=trial_apps,
trial_enabled=trial_app_enabled,
)
recommended_app_packages = RecommendedAppPackageService(
sources=database_catalog, exporter=RosterAgentPackageExporter()
)
workspace_query_repository = WorkspaceQueryRepository(session_factory=database_client)
app_scoped_end_user_repository = AppScopedEndUserRepo(session_factory=database_client)
file_service = FileService(session_factory=database_client)
@@ -748,10 +757,14 @@ def build_application_services(
apps=build_app_services(
database_client=database_client,
oauth=oauth_server,
recommended_packages=recommended_app_packages,
),
agent_apps=build_agent_app_services(database_client=database_client),
advanced_prompt_templates=AdvancedPromptTemplateService(),
app_definitions=app_definitions,
app_mcp_servers=AppMCPServerService(
servers=AppMCPServerRepository(session_factory=database_client),
),
app_preview_details=AppPreviewDetailsRuntime(details=app_preview_repository),
app_previews=AppPreviewQueryService(
apps=app_preview_repository,
@@ -869,6 +882,7 @@ def build_application_services(
sync_bindings=BillingService.sync_partner_tenants_bindings,
),
recommended_app_queries=recommended_app_queries,
recommended_app_packages=recommended_app_packages,
remote_files=remote_file_service,
saved_messages=SavedMessageService(
saved_messages=SQLAlchemySavedMessageRepository(session_factory=database_client),

View File

@@ -0,0 +1,5 @@
from fields.base import ResponseModel
class AppExportResponse(ResponseModel):
data: str

View File

@@ -77,7 +77,7 @@ class ArchiveStorage:
aws_secret_access_key=dify_config.ARCHIVE_STORAGE_SECRET_KEY,
region_name=dify_config.ARCHIVE_STORAGE_REGION,
config=Config(
s3={"addressing_style": "path"},
s3={"addressing_style": dify_config.ARCHIVE_STORAGE_ADDRESS_STYLE},
max_pool_connections=64,
),
)

View File

@@ -14,7 +14,6 @@ from typing_extensions import deprecated
from configs import dify_config
from .base import TypeBase
from .engine import db
from .types import EnumText, LongText, StringUUID
@@ -131,18 +130,6 @@ class Account(UserMixin, TypeBase):
def current_tenant(self):
return self._current_tenant
@current_tenant.setter
def current_tenant(self, tenant: "Tenant") -> None:
with Session(db.engine, expire_on_commit=False) as session:
# TODO: A workaround to reload the tenant with `expire_on_commit=False`, allowing
# access to it after the session has been closed.
# This prevents `DetachedInstanceError` when accessing the tenant outside
# the session's lifecycle.
# (The `tenant` argument is typically loaded by `db.session` without the
# `expire_on_commit=False` flag, meaning its lifetime is tied to the web
# request's lifecycle.)
self.set_current_tenant_with_session(tenant, session=session)
def set_current_tenant_with_session(self, tenant: "Tenant", *, session: Session) -> None:
"""Set the current tenant and role using the caller-owned session."""
tenant_join_query = select(TenantAccountJoin).where(
@@ -162,10 +149,6 @@ class Account(UserMixin, TypeBase):
def current_tenant_id(self) -> str | None:
return self._current_tenant.id if self._current_tenant else None
def set_tenant_id(self, tenant_id: str) -> None:
with Session(db.engine, expire_on_commit=False) as session:
self.set_tenant_id_with_session(tenant_id, session=session)
def set_tenant_id_with_session(self, tenant_id: str, *, session: Session) -> None:
"""Set the current tenant by id using the caller-owned session."""
query = select(Tenant, TenantAccountJoin).where(

View File

@@ -93,14 +93,6 @@ class AppStatus(StrEnum):
NORMAL = "normal"
class AppMCPServerStatus(StrEnum):
"""AppMCPServer Status Enum"""
NORMAL = "normal"
ACTIVE = "active"
INACTIVE = "inactive"
class ConversationStatus(StrEnum):
"""Conversation Status Enum"""

View File

@@ -34,7 +34,6 @@ from .base import Base, TypeBase, gen_uuidv4_string
from .engine import db
from .enums import (
ApiTokenType,
AppMCPServerStatus,
AppStatus,
BannerStatus,
ConversationFromSource,
@@ -807,10 +806,6 @@ class AppModelConfig(TypeBase):
def retriever_resource_dict(self) -> EnabledConfig:
return self._get_enabled_config(self.retriever_resource, default_enabled=True)
@property
def annotation_reply_dict(self) -> AnnotationReplyConfig:
return load_annotation_reply_config(db.session(), self.app_id)
@property
def more_like_this_dict(self) -> EnabledConfig:
return self._get_enabled_config(self.more_like_this)
@@ -880,7 +875,7 @@ class AppModelConfig(TypeBase):
},
)
def to_dict(self, *, annotation_reply: AnnotationReplyConfig | None = None) -> AppModelConfigDict:
def to_dict(self, *, annotation_reply: AnnotationReplyConfig) -> AppModelConfigDict:
return {
"opening_statement": self.opening_statement,
"suggested_questions": self.suggested_questions_list,
@@ -888,7 +883,7 @@ class AppModelConfig(TypeBase):
"speech_to_text": self.speech_to_text_dict,
"text_to_speech": self.text_to_speech_dict,
"retriever_resource": self.retriever_resource_dict,
"annotation_reply": annotation_reply if annotation_reply is not None else self.annotation_reply_dict,
"annotation_reply": annotation_reply,
"more_like_this": self.more_like_this_dict,
"sensitive_word_avoidance": self.sensitive_word_avoidance_dict,
"external_data_tools": self.external_data_tools_list,
@@ -2128,9 +2123,8 @@ class AppMCPServer(TypeBase):
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str] = mapped_column(String(255), nullable=False)
server_code: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[AppMCPServerStatus] = mapped_column(
EnumText(AppMCPServerStatus, length=255), nullable=False, server_default=sa.text("'normal'")
)
# The repository maps the stored value to the application-owned publication status.
status: Mapped[str] = mapped_column(String(255), nullable=False, server_default=sa.text("'normal'"))
parameters: Mapped[str] = mapped_column(LongText, nullable=False)
created_at: Mapped[datetime] = mapped_column(

View File

@@ -663,7 +663,11 @@ Get suggested questions for an Agent App message
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Suggested questions retrieved successfully | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)<br> |
| 400 | App or model provider unavailable, or generation failed | |
| 401 | Account authentication required | |
| 403 | Insufficient permissions or suggested questions disabled | |
| 404 | Agent, message, or conversation not found | |
| 500 | Unexpected server error | |
### [POST] /agent/{agent_id}/chat-messages/{task_id}/stop
Stop a running Agent App chat message generation
@@ -2510,7 +2514,11 @@ Get suggested questions for a message
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Suggested questions retrieved successfully | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)<br> |
| 404 | Message or conversation not found | |
| 400 | App or model provider unavailable, or generation failed | |
| 401 | Account authentication required | |
| 403 | Insufficient permissions or suggested questions disabled | |
| 404 | App, message, or conversation not found | |
| 500 | Unexpected server error | |
### [POST] /apps/{app_id}/chat-messages/{task_id}/stop
Stop a running chat message generation
@@ -2891,6 +2899,7 @@ Create MCP server configuration for an application
| ---- | ----------- | ------ |
| 201 | MCP server configuration created successfully | **application/json**: [AppMCPServerResponse](#appmcpserverresponse)<br> |
| 403 | Insufficient permissions | |
| 409 | MCP server already exists for this app | |
### [PUT] /apps/{app_id}/server
Update MCP server configuration for an application
@@ -9319,6 +9328,155 @@ Remove one or more tag bindings from a target.
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [TrialAppDetailResponse](#trialappdetailresponse)<br> |
### [GET] /trial-apps/{app_id}/agent-composer
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published Agent configuration | **application/json**: [AgentAppComposerResponse](#agentappcomposerresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/files
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigFileListResponse](#agentconfigfilelistresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/files/{name}/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/files/{name}/preview
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigFilePreviewResponse](#agentconfigfilepreviewresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigSkillListResponse](#agentconfigskilllistresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/files/content
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| path | query | Normalized member path inside the published Skill package | Yes | string |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/octet-stream**: binary<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/files/download
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| path | query | Normalized member path inside the published Skill package | Yes | string |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigDownloadResponse](#agentconfigdownloadresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/files/preview
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| path | query | Normalized member path inside the published Skill package | Yes | string |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigSkillFilePreviewResponse](#agentconfigskillfilepreviewresponse)<br> |
### [GET] /trial-apps/{app_id}/agent/config/skills/{name}/inspect
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| version_id | query | Must match the current published template snapshot | No | string (uuid) |
| app_id | path | | Yes | string (uuid) |
| name | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published template resource | **application/json**: [AgentConfigSkillInspectResponse](#agentconfigskillinspectresponse)<br> |
### [POST] /trial-apps/{app_id}/audio-to-text
#### Parameters
@@ -9351,6 +9509,20 @@ Remove one or more tag bindings from a target.
| ---- | ----------- |
| 200 | Success |
### [POST] /trial-apps/{app_id}/chat-messages/{task_id}/stop
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| app_id | path | | Yes | string (uuid) |
| task_id | path | | Yes | string |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SimpleResultResponse](#simpleresultresponse)<br> |
### [POST] /trial-apps/{app_id}/completion-messages
#### Parameters
@@ -9386,6 +9558,22 @@ Remove one or more tag bindings from a target.
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [TrialDatasetListResponse](#trialdatasetlistresponse)<br> |
### [GET] /trial-apps/{app_id}/export
#### Parameters
| Name | Located in | Description | Required | Schema |
| ---- | ---------- | ----------- | -------- | ------ |
| format | query | Export format; defaults to ifpkg | No | string, <br>**Available values:** "ifpkg", "yaml", <br>**Default:** ifpkg |
| version_id | query | Current published template snapshot ID | Yes | string (uuid) |
| app_id | path | | Yes | string (uuid) |
#### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Published Agent template export | **application/json**: [AppExportResponse](#appexportresponse)<br>**application/zip**: binary<br> |
| 404 | Published template unavailable | |
### [POST] /trial-apps/{app_id}/files/upload
**Upload a file into the tenant that owns the trial app**
@@ -16237,6 +16425,9 @@ This class is used to store the schema information of an api based tool.
| icon_type | string | | No |
| mode | string | Import mode | Yes |
| name | string | | No |
| package_url | string | | No |
| template_id | string | | No |
| version_id | string | | No |
| yaml_content | string | | No |
| yaml_url | string | | No |
@@ -16413,11 +16604,11 @@ This class is used to store the schema information of an api based tool.
#### AppMCPServerStatus
AppMCPServer Status Enum
Publication state of a Dify app exposed as an MCP server; only ACTIVE servers accept MCP calls.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AppMCPServerStatus | string | AppMCPServer Status Enum | |
| AppMCPServerStatus | string | Publication state of a Dify app exposed as an MCP server; only ACTIVE servers accept MCP calls. | |
#### AppMetadataConditionPayload
@@ -20292,9 +20483,9 @@ Enum class for large language model mode.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | Server description | No |
| id | string | Server ID | Yes |
| id | string (uuid) | Server ID | Yes |
| parameters | object | Server parameters configuration | Yes |
| status | string | Server status | No |
| status | [AppMCPServerStatus](#appmcpserverstatus) | Server status | No |
#### Marketplace
@@ -22476,6 +22667,13 @@ Resource types understood by access policies.
| permission_keys | [ string ] | | No |
| updated_at | integer | | Yes |
#### RecommendedAgentExportQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| format | string, <br>**Available values:** "ifpkg", "yaml", <br>**Default:** ifpkg | Export format; defaults to ifpkg<br>*Enum:* `"ifpkg"`, `"yaml"` | No |
| version_id | string (uuid) | Current published template snapshot ID | Yes |
#### RecommendedAppDetailResponse
| Name | Type | Description | Required |
@@ -22487,6 +22685,8 @@ Resource types understood by access policies.
| id | string | | Yes |
| mode | string | | Yes |
| name | string | | Yes |
| package_url | string | Download URL for a New Agent .ifpkg template | No |
| version_id | string | Published version for direct local template creation | No |
#### RecommendedAppInfoResponse
@@ -24361,6 +24561,19 @@ Enum class for tool provider
| ---- | ---- | ----------- | -------- |
| tracing_provider | string | Tracing provider name | Yes |
#### TrialAgentConfigQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| version_id | string | Must match the current published template snapshot | No |
#### TrialAgentSkillFileQuery
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| path | string | Normalized member path inside the published Skill package | Yes |
| version_id | string | Must match the current published template snapshot | No |
#### TrialAppAgentMode
| Name | Type | Description | Required |

View File

@@ -63,7 +63,7 @@ class TestTraceClient:
def test_export(self, mock_exporter_class: MagicMock, trace_client_factory: type[TraceClient]):
mock_exporter = mock_exporter_class.return_value
client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint")
spans = [MagicMock(spec=ReadableSpan)]
spans = [ReadableSpan(name="test-span")]
client.export(spans)
mock_exporter.export.assert_called_once_with(spans)
@@ -131,8 +131,8 @@ class TestTraceClient:
span_kind=SpanKind.INTERNAL,
)
mock_span = MagicMock(spec=ReadableSpan)
client.span_builder.build_span = MagicMock(return_value=mock_span)
span = ReadableSpan(name="test-span")
client.span_builder.build_span = MagicMock(return_value=span)
with patch.object(client.condition, "notify") as mock_notify:
client.add_span(span_data)
@@ -159,8 +159,8 @@ class TestTraceClient:
status=Status(StatusCode.OK),
span_kind=SpanKind.INTERNAL,
)
mock_span = MagicMock(spec=ReadableSpan)
client.span_builder.build_span = MagicMock(return_value=mock_span)
span = ReadableSpan(name="test-span")
client.span_builder.build_span = MagicMock(return_value=span)
client.add_span(span_data)
assert len(client.queue) == 1
@@ -178,8 +178,8 @@ class TestTraceClient:
mock_exporter.export.side_effect = Exception("Export failed")
client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint")
mock_span = MagicMock(spec=ReadableSpan)
client.queue.append(mock_span)
span = ReadableSpan(name="test-span")
client.queue.append(span)
with caplog.at_level(logging.WARNING):
client._export_batch()
@@ -207,8 +207,8 @@ class TestTraceClient:
mock_exporter = mock_exporter_class.return_value
client = trace_client_factory(service_name="test-service", endpoint="http://test-endpoint")
mock_span = MagicMock(spec=ReadableSpan)
client.queue.append(mock_span)
span = ReadableSpan(name="test-span")
client.queue.append(span)
client.shutdown()
# Should have called export twice (once in worker/export_batch, once in shutdown)

View File

@@ -55,8 +55,8 @@ from core.ops.entities.trace_entity import (
ToolTraceInfo,
WorkflowTraceInfo,
)
from graphon.entities import WorkflowNodeExecution
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from tests.unit_tests.core.ops.trace_fixtures import workflow_node_execution
class RecordingTraceClient:
@@ -464,7 +464,7 @@ def test_get_workflow_node_executions_builds_repo_and_fetches(
def test_build_workflow_node_span_routes_llm_type(trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch):
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
trace_info = _make_workflow_trace_info()
trace_metadata = _make_trace_metadata()
@@ -477,7 +477,7 @@ def test_build_workflow_node_span_routes_llm_type(trace_instance: AliyunDataTrac
def test_build_workflow_node_span_routes_knowledge_retrieval_type(
trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch
):
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
trace_info = _make_workflow_trace_info()
trace_metadata = _make_trace_metadata()
@@ -488,7 +488,7 @@ def test_build_workflow_node_span_routes_knowledge_retrieval_type(
def test_build_workflow_node_span_routes_tool_type(trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch):
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
trace_info = _make_workflow_trace_info()
trace_metadata = _make_trace_metadata()
@@ -499,7 +499,7 @@ def test_build_workflow_node_span_routes_tool_type(trace_instance: AliyunDataTra
def test_build_workflow_node_span_routes_code_type(trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch):
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
trace_info = _make_workflow_trace_info()
trace_metadata = _make_trace_metadata()
@@ -512,7 +512,7 @@ def test_build_workflow_node_span_routes_code_type(trace_instance: AliyunDataTra
def test_build_workflow_node_span_handles_errors(
trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
):
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
trace_info = _make_workflow_trace_info()
trace_metadata = _make_trace_metadata()
@@ -541,7 +541,7 @@ def test_build_workflow_task_span(trace_instance: AliyunDataTrace, monkeypatch:
monkeypatch.setattr(aliyun_trace_module, "get_workflow_node_status", lambda _: status)
trace_metadata = _make_trace_metadata(links=[_make_link()])
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node-id"
node_execution.title = "title"
node_execution.node_type = node_type
@@ -573,7 +573,7 @@ def test_http_task_span_uses_request_snapshot(trace_instance: AliyunDataTrace, e
"Authorization: Bearer ******\r\nContent-Type: application/json\r\n\r\n"
'{"message":"synthetic 测试"}'
)
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "00000000-0000-0000-0000-000000000006"
node_execution.node_type = BuiltinNodeTypes.HTTP_REQUEST
node_execution.title = "HTTP request"
@@ -625,7 +625,7 @@ def test_http_task_span_uses_request_snapshot(trace_instance: AliyunDataTrace, e
)
@pytest.mark.parametrize("inputs", [None, {}, {"fallback": "original input"}])
def test_http_task_span_falls_back_to_inputs(trace_instance: AliyunDataTrace, process_data, inputs):
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "00000000-0000-0000-0000-000000000006"
node_execution.node_type = BuiltinNodeTypes.HTTP_REQUEST
node_execution.title = "HTTP request"
@@ -645,7 +645,7 @@ def test_http_task_span_falls_back_to_inputs(trace_instance: AliyunDataTrace, pr
def test_non_http_task_span_ignores_request_snapshot(trace_instance: AliyunDataTrace):
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "00000000-0000-0000-0000-000000000007"
node_execution.node_type = BuiltinNodeTypes.CODE
node_execution.title = "Code"
@@ -671,7 +671,7 @@ def test_build_workflow_tool_span(trace_instance: AliyunDataTrace, monkeypatch:
monkeypatch.setattr(aliyun_trace_module, "get_workflow_node_status", lambda _: status)
trace_metadata = _make_trace_metadata(links=[_make_link()])
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node-id"
node_execution.title = "my-tool"
node_execution.inputs = {"a": 1}
@@ -714,7 +714,7 @@ def test_build_workflow_retrieval_span(trace_instance: AliyunDataTrace, monkeypa
)
trace_metadata = _make_trace_metadata()
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node-id"
node_execution.title = "retrieval"
node_execution.inputs = {"query": "q"}
@@ -743,7 +743,7 @@ def test_build_workflow_llm_span(trace_instance: AliyunDataTrace, monkeypatch: p
monkeypatch.setattr(aliyun_trace_module, "format_output_messages", lambda _: "out")
trace_metadata = _make_trace_metadata()
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node-id"
node_execution.title = "llm"
node_execution.inputs = {}
@@ -783,7 +783,7 @@ def test_build_workflow_llm_span_falls_back_to_inputs_on_invoke_failure(
monkeypatch.setattr(aliyun_trace_module, "get_workflow_node_status", lambda _: Status(StatusCode.ERROR, "boom"))
trace_metadata = _make_trace_metadata()
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node-id"
node_execution.title = "llm"
node_execution.process_data = {}
@@ -873,7 +873,7 @@ def _make_agent_outputs() -> dict:
def test_build_workflow_node_span_routes_agent_type(trace_instance: AliyunDataTrace, monkeypatch: pytest.MonkeyPatch):
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
trace_info = _make_workflow_trace_info()
trace_metadata = _make_trace_metadata()
@@ -890,7 +890,7 @@ def test_build_workflow_agent_span(trace_instance: AliyunDataTrace, monkeypatch:
monkeypatch.setattr(aliyun_trace_module, "get_workflow_node_status", lambda _: status)
trace_metadata = _make_trace_metadata()
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node-id"
node_execution.title = "my-agent"
node_execution.inputs = {"query": "ping"}
@@ -926,7 +926,7 @@ def test_build_agent_react_spans(trace_instance: AliyunDataTrace, monkeypatch: p
monkeypatch.setattr(aliyun_trace_module, "generate_span_id", lambda: next(span_ids))
trace_metadata = _make_trace_metadata()
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node-id"
node_execution.outputs = _make_agent_outputs()
node_execution.created_at = _dt()
@@ -972,7 +972,7 @@ def test_build_agent_react_spans(trace_instance: AliyunDataTrace, monkeypatch: p
def test_build_agent_react_spans_returns_empty_without_log(trace_instance: AliyunDataTrace):
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node-id"
node_execution.outputs = {"text": "t"}
node_execution.created_at = _dt()
@@ -988,9 +988,9 @@ def test_workflow_trace_adds_react_spans_for_agent_nodes(
monkeypatch.setattr(aliyun_trace_module, "convert_to_span_id", lambda _, __: 222)
monkeypatch.setattr(aliyun_trace_module, "create_links_from_trace_id", lambda _: [])
agent_node = MagicMock(spec=WorkflowNodeExecution)
agent_node = workflow_node_execution()
agent_node.node_type = BuiltinNodeTypes.AGENT
code_node = MagicMock(spec=WorkflowNodeExecution)
code_node = workflow_node_execution()
code_node.node_type = BuiltinNodeTypes.CODE
monkeypatch.setattr(trace_instance, "add_workflow_span", MagicMock())
@@ -1018,7 +1018,7 @@ def test_build_workflow_llm_span_records_time_to_first_token(
monkeypatch.setattr(aliyun_trace_module, "get_workflow_node_status", lambda _: Status(StatusCode.OK))
trace_metadata = _make_trace_metadata()
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node-id"
node_execution.title = "llm"
node_execution.inputs = {}

View File

@@ -37,14 +37,14 @@ from dify_trace_aliyun.utils import (
map_gen_ai_tool_type,
serialize_json_data,
)
from opentelemetry.trace import Link, StatusCode
from opentelemetry.trace import Link, SpanContext, StatusCode
from sqlalchemy.orm import Session
from core.rag.models.document import Document
from graphon.entities import WorkflowNodeExecution
from graphon.enums import WorkflowNodeExecutionStatus
from models import EndUser
from models.enums import EndUserType
from tests.unit_tests.core.ops.trace_fixtures import workflow_node_execution
def test_get_user_id_from_message_data_no_end_user(monkeypatch: pytest.MonkeyPatch):
@@ -105,7 +105,7 @@ def test_create_status_from_error():
def test_get_workflow_node_status():
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
# SUCCEEDED
node_execution.status = WorkflowNodeExecutionStatus.SUCCEEDED
@@ -134,10 +134,10 @@ def test_get_workflow_node_status():
def test_create_links_from_trace_id(monkeypatch: pytest.MonkeyPatch):
# Mock create_link
mock_link = MagicMock(spec=Link)
link = Link(SpanContext(trace_id=1, span_id=2, is_remote=False))
import dify_trace_aliyun.data_exporter.traceclient
monkeypatch.setattr(dify_trace_aliyun.data_exporter.traceclient, "create_link", lambda trace_id_str: mock_link)
monkeypatch.setattr(dify_trace_aliyun.data_exporter.traceclient, "create_link", lambda trace_id_str: link)
# Trace ID None
assert create_links_from_trace_id(None) == []
@@ -145,17 +145,15 @@ def test_create_links_from_trace_id(monkeypatch: pytest.MonkeyPatch):
# Trace ID Present
links = create_links_from_trace_id("trace_id")
assert len(links) == 1
assert links[0] == mock_link
assert links[0] == link
def test_extract_retrieval_documents():
doc1 = MagicMock(spec=Document)
doc1.page_content = "content1"
doc1.metadata = {"dataset_id": "ds1", "doc_id": "di1", "document_id": "dd1", "score": 0.9}
doc2 = MagicMock(spec=Document)
doc2.page_content = "content2"
doc2.metadata = {"dataset_id": "ds2"} # Missing some keys
doc1 = Document(
page_content="content1",
metadata={"dataset_id": "ds1", "doc_id": "di1", "document_id": "dd1", "score": 0.9},
)
doc2 = Document(page_content="content2", metadata={"dataset_id": "ds2"}) # Missing some keys
documents = [doc1, doc2]
extracted = extract_retrieval_documents(documents)

View File

@@ -728,18 +728,28 @@ def test_setup_tracer_exception():
@pytest.fixture
def trace_instance():
with (
patch("dify_trace_arize_phoenix.arize_phoenix_trace.setup_tracer") as mock_setup,
patch("dify_trace_arize_phoenix.arize_phoenix_trace.redis_client", new=MagicMock()) as mock_redis,
):
mock_tracer = MagicMock(spec=Tracer)
mock_processor = MagicMock()
mock_setup.return_value = (mock_tracer, mock_processor)
config = ArizeConfig(endpoint="http://a.com", api_key="k", space_id="s", project="p")
instance = ArizePhoenixDataTrace(config)
cast(Any, instance)._mock_redis_client = mock_redis
yield instance
def span_exporter():
return _CollectingSpanExporter()
@pytest.fixture
def trace_instance(span_exporter):
provider = trace_sdk.TracerProvider()
processor = SimpleSpanProcessor(span_exporter)
provider.add_span_processor(processor)
tracer = provider.get_tracer("test-phoenix")
try:
with (
patch.object(tracer, "start_span", wraps=tracer.start_span),
patch("dify_trace_arize_phoenix.arize_phoenix_trace.setup_tracer", return_value=(tracer, processor)),
patch("dify_trace_arize_phoenix.arize_phoenix_trace.redis_client", new=MagicMock()) as mock_redis,
):
config = ArizeConfig(endpoint="http://a.com", api_key="k", space_id="s", project="p")
instance = ArizePhoenixDataTrace(config)
cast(Any, instance)._mock_redis_client = mock_redis
yield instance
finally:
provider.shutdown()
def test_trace_dispatch(trace_instance):
@@ -864,7 +874,7 @@ def test_workflow_trace_uses_canonical_root_context_for_top_level_workflow(mock_
mock_repo_factory.create_workflow_node_execution_repository.return_value = repo
root_carrier = {}
root_context = object()
root_context = Context()
with (
patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()),
@@ -984,7 +994,7 @@ def test_workflow_trace_reuses_upstream_parent_workflow_context_when_no_parent_n
mock_repo_factory.create_workflow_node_execution_repository.return_value = repo
parent_carrier = {}
parent_context = object()
parent_context = Context()
with (
patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()),
@@ -1030,7 +1040,7 @@ def test_workflow_trace_uses_published_parent_node_context_for_nested_workflow(
mock_repo_factory.create_workflow_node_execution_repository.return_value = repo
stored_carrier = '{"traceparent":"00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-01"}'
trace_instance._mock_redis_client.get.return_value = stored_carrier
parent_context = object()
parent_context = Context()
with (
patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()),
@@ -1111,7 +1121,7 @@ def test_workflow_trace_falls_back_when_parent_app_tracing_cannot_publish_parent
trace_instance._mock_redis_client.get.return_value = None
parent_carrier = {}
parent_context = object()
parent_context = Context()
with (
patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()),
@@ -1297,7 +1307,7 @@ def test_workflow_trace_keeps_nested_conversation_session_while_reusing_parent_r
mock_repo_factory.create_workflow_node_execution_repository.return_value = repo
parent_carrier = {}
parent_context = object()
parent_context = Context()
with (
patch.object(trace_instance, "get_service_account_with_tenant", return_value=MagicMock()),
@@ -2101,17 +2111,15 @@ def test_ensure_root_span_uses_custom_name_and_attributes(trace_instance):
)
def test_ensure_root_span_records_error_status_and_exception_event(trace_instance):
def test_ensure_root_span_records_error_status_and_exception_event(trace_instance, span_exporter):
trace_instance.ensure_root_span("tid", root_span_error="workflow failed")
root_span = trace_instance.tracer.start_span.return_value
root_span.set_status.assert_called_once()
assert root_span.set_status.call_args.args[0].status_code == StatusCode.ERROR
root_span.add_event.assert_called_once()
assert root_span.add_event.call_args.kwargs["name"] == "exception"
assert root_span.add_event.call_args.kwargs["attributes"][OTELSpanAttributes.EXCEPTION_MESSAGE] == (
"workflow failed"
)
assert len(span_exporter.spans) == 1
root_span = span_exporter.spans[0]
assert root_span.status.status_code == StatusCode.ERROR
assert len(root_span.events) == 1
assert root_span.events[0].name == "exception"
assert root_span.events[0].attributes[OTELSpanAttributes.EXCEPTION_MESSAGE] == "workflow failed"
def test_ensure_root_span_falls_back_to_dify_name_when_custom_name_is_blank(trace_instance):

View File

@@ -38,6 +38,9 @@ def _dt() -> datetime:
return datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
from tests.unit_tests.core.ops.trace_fixtures import message_trace_info, tool_trace_info, workflow_trace_info
@pytest.fixture
def langfuse_config():
return LangfuseConfig(public_key="pk-123", secret_key="sk-123", host="https://cloud.langfuse.com")
@@ -118,37 +121,37 @@ def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(trace_instance, method, m)
# WorkflowTraceInfo
info = MagicMock(spec=WorkflowTraceInfo)
info = workflow_trace_info()
trace_instance.trace(info)
mocks["workflow_trace"].assert_called_once_with(info)
# MessageTraceInfo
info = MagicMock(spec=MessageTraceInfo)
info = message_trace_info()
trace_instance.trace(info)
mocks["message_trace"].assert_called_once_with(info)
# ModerationTraceInfo
info = MagicMock(spec=ModerationTraceInfo)
info = ModerationTraceInfo(metadata={}, flagged=False, action="allow", preset_response="", query="hello")
trace_instance.trace(info)
mocks["moderation_trace"].assert_called_once_with(info)
# SuggestedQuestionTraceInfo
info = MagicMock(spec=SuggestedQuestionTraceInfo)
info = SuggestedQuestionTraceInfo(metadata={}, total_tokens=0, suggested_question=[], level="info")
trace_instance.trace(info)
mocks["suggested_question_trace"].assert_called_once_with(info)
# DatasetRetrievalTraceInfo
info = MagicMock(spec=DatasetRetrievalTraceInfo)
info = DatasetRetrievalTraceInfo(metadata={})
trace_instance.trace(info)
mocks["dataset_retrieval_trace"].assert_called_once_with(info)
# ToolTraceInfo
info = MagicMock(spec=ToolTraceInfo)
info = tool_trace_info()
trace_instance.trace(info)
mocks["tool_trace"].assert_called_once_with(info)
# GenerateNameTraceInfo
info = MagicMock(spec=GenerateNameTraceInfo)
info = GenerateNameTraceInfo(tenant_id="tenant-1", metadata={})
trace_instance.trace(info)
mocks["generate_name_trace"].assert_called_once_with(info)

View File

@@ -29,6 +29,7 @@ from core.ops.entities.trace_entity import (
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey
from models import EndUser
from models.enums import EndUserType
from tests.unit_tests.core.ops.trace_fixtures import message_trace_info, tool_trace_info, workflow_trace_info
def _dt() -> datetime:
@@ -88,37 +89,37 @@ def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(trace_instance, method, m)
# WorkflowTraceInfo
info = MagicMock(spec=WorkflowTraceInfo)
info = workflow_trace_info()
trace_instance.trace(info)
mocks["workflow_trace"].assert_called_once_with(info)
# MessageTraceInfo
info = MagicMock(spec=MessageTraceInfo)
info = message_trace_info()
trace_instance.trace(info)
mocks["message_trace"].assert_called_once_with(info)
# ModerationTraceInfo
info = MagicMock(spec=ModerationTraceInfo)
info = ModerationTraceInfo(metadata={}, flagged=False, action="allow", preset_response="", query="hello")
trace_instance.trace(info)
mocks["moderation_trace"].assert_called_once_with(info)
# SuggestedQuestionTraceInfo
info = MagicMock(spec=SuggestedQuestionTraceInfo)
info = SuggestedQuestionTraceInfo(metadata={}, total_tokens=0, suggested_question=[], level="info")
trace_instance.trace(info)
mocks["suggested_question_trace"].assert_called_once_with(info)
# DatasetRetrievalTraceInfo
info = MagicMock(spec=DatasetRetrievalTraceInfo)
info = DatasetRetrievalTraceInfo(metadata={})
trace_instance.trace(info)
mocks["dataset_retrieval_trace"].assert_called_once_with(info)
# ToolTraceInfo
info = MagicMock(spec=ToolTraceInfo)
info = tool_trace_info()
trace_instance.trace(info)
mocks["tool_trace"].assert_called_once_with(info)
# GenerateNameTraceInfo
info = MagicMock(spec=GenerateNameTraceInfo)
info = GenerateNameTraceInfo(tenant_id="tenant-1", metadata={})
trace_instance.trace(info)
mocks["generate_name_trace"].assert_called_once_with(info)
@@ -295,7 +296,7 @@ def test_workflow_trace_no_start_time(
def test_workflow_trace_missing_app_id(
trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_session: Session
) -> None:
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.trace_id = "trace-1"
trace_info.message_id = None
trace_info.workflow_run_id = "run-1"
@@ -376,7 +377,7 @@ def test_message_trace(trace_instance, monkeypatch: pytest.MonkeyPatch, sqlite3_
def test_message_trace_no_data(trace_instance):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info = message_trace_info()
trace_info.message_data = None
trace_info.file_list = []
trace_info.message_file_data = None
@@ -387,7 +388,7 @@ def test_message_trace_no_data(trace_instance):
def test_moderation_trace_no_data(trace_instance):
trace_info = MagicMock(spec=ModerationTraceInfo)
trace_info = ModerationTraceInfo(metadata={}, flagged=False, action="allow", preset_response="", query="hello")
trace_info.message_data = None
trace_instance.add_run = MagicMock()
trace_instance.moderation_trace(trace_info)
@@ -395,7 +396,7 @@ def test_moderation_trace_no_data(trace_instance):
def test_suggested_question_trace_no_data(trace_instance):
trace_info = MagicMock(spec=SuggestedQuestionTraceInfo)
trace_info = SuggestedQuestionTraceInfo(metadata={}, total_tokens=0, suggested_question=[], level="info")
trace_info.message_data = None
trace_instance.add_run = MagicMock()
trace_instance.suggested_question_trace(trace_info)
@@ -403,7 +404,7 @@ def test_suggested_question_trace_no_data(trace_instance):
def test_dataset_retrieval_trace_no_data(trace_instance):
trace_info = MagicMock(spec=DatasetRetrievalTraceInfo)
trace_info = DatasetRetrievalTraceInfo(metadata={})
trace_info.message_data = None
trace_instance.add_run = MagicMock()
trace_instance.dataset_retrieval_trace(trace_info)

View File

@@ -31,6 +31,9 @@ def _dt() -> datetime:
return datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC)
from tests.unit_tests.core.ops.trace_fixtures import message_trace_info, tool_trace_info, workflow_trace_info
@pytest.fixture
def opik_config():
return OpikConfig(
@@ -101,37 +104,37 @@ def test_trace_dispatch(trace_instance, monkeypatch: pytest.MonkeyPatch):
monkeypatch.setattr(trace_instance, method, m)
# WorkflowTraceInfo
info = MagicMock(spec=WorkflowTraceInfo)
info = workflow_trace_info()
trace_instance.trace(info)
mocks["workflow_trace"].assert_called_once_with(info)
# MessageTraceInfo
info = MagicMock(spec=MessageTraceInfo)
info = message_trace_info()
trace_instance.trace(info)
mocks["message_trace"].assert_called_once_with(info)
# ModerationTraceInfo
info = MagicMock(spec=ModerationTraceInfo)
info = ModerationTraceInfo(metadata={}, flagged=False, action="allow", preset_response="", query="hello")
trace_instance.trace(info)
mocks["moderation_trace"].assert_called_once_with(info)
# SuggestedQuestionTraceInfo
info = MagicMock(spec=SuggestedQuestionTraceInfo)
info = SuggestedQuestionTraceInfo(metadata={}, total_tokens=0, suggested_question=[], level="info")
trace_instance.trace(info)
mocks["suggested_question_trace"].assert_called_once_with(info)
# DatasetRetrievalTraceInfo
info = MagicMock(spec=DatasetRetrievalTraceInfo)
info = DatasetRetrievalTraceInfo(metadata={})
trace_instance.trace(info)
mocks["dataset_retrieval_trace"].assert_called_once_with(info)
# ToolTraceInfo
info = MagicMock(spec=ToolTraceInfo)
info = tool_trace_info()
trace_instance.trace(info)
mocks["tool_trace"].assert_called_once_with(info)
# GenerateNameTraceInfo
info = MagicMock(spec=GenerateNameTraceInfo)
info = GenerateNameTraceInfo(tenant_id="tenant-1", metadata={})
trace_instance.trace(info)
mocks["generate_name_trace"].assert_called_once_with(info)

View File

@@ -1,5 +1,5 @@
from datetime import datetime
from unittest.mock import MagicMock, patch
from unittest.mock import patch
from dify_trace_tencent.entities.semconv import (
GEN_AI_IS_ENTRY,
@@ -20,13 +20,15 @@ from opentelemetry.trace import StatusCode
from core.ops.entities.trace_entity import (
DatasetRetrievalTraceInfo,
MessageTraceInfo,
ToolTraceInfo,
WorkflowTraceInfo,
)
from core.rag.models.document import Document
from graphon.entities import WorkflowNodeExecution
from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
from tests.unit_tests.core.ops.trace_fixtures import (
message_trace_info,
tool_trace_info,
workflow_node_execution,
workflow_trace_info,
)
class TestTencentSpanBuilder:
@@ -39,7 +41,7 @@ class TestTencentSpanBuilder:
mock_convert.assert_called_once_with(dt)
def test_build_workflow_spans(self):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.workflow_run_id = "run_id"
trace_info.error = None
trace_info.start_time = datetime.now()
@@ -61,7 +63,7 @@ class TestTencentSpanBuilder:
assert spans[1].parent_span_id == 2
def test_build_workflow_spans_no_message(self):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.workflow_run_id = "run_id"
trace_info.error = "some error"
trace_info.start_time = datetime.now()
@@ -82,10 +84,10 @@ class TestTencentSpanBuilder:
assert spans[0].attributes[GEN_AI_IS_ENTRY] == "true"
def test_build_workflow_llm_span(self):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.metadata = {"conversation_id": "conv_id"}
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node_id"
node_execution.created_at = datetime.now()
node_execution.finished_at = datetime.now()
@@ -109,10 +111,10 @@ class TestTencentSpanBuilder:
assert span.attributes[GEN_AI_USAGE_INPUT_TOKENS] == "10"
def test_build_workflow_llm_span_usage_in_outputs(self):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.metadata = {}
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node_id"
node_execution.created_at = datetime.now()
node_execution.finished_at = datetime.now()
@@ -132,7 +134,7 @@ class TestTencentSpanBuilder:
assert GEN_AI_IS_STREAMING_REQUEST not in span.attributes
def test_build_message_span_standalone(self):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info = message_trace_info()
trace_info.message_id = "msg_id"
trace_info.error = None
trace_info.start_time = datetime.now()
@@ -152,7 +154,7 @@ class TestTencentSpanBuilder:
assert span.attributes[INPUT_VALUE] == str(trace_info.inputs)
def test_build_message_span_standalone_with_error(self):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info = message_trace_info()
trace_info.message_id = "msg_id"
trace_info.error = "some error"
trace_info.start_time = datetime.now()
@@ -172,7 +174,7 @@ class TestTencentSpanBuilder:
assert span.attributes[INPUT_VALUE] == ""
def test_build_tool_span(self):
trace_info = MagicMock(spec=ToolTraceInfo)
trace_info = tool_trace_info()
trace_info.message_id = "msg_id"
trace_info.tool_name = "search"
trace_info.error = "tool error"
@@ -192,7 +194,7 @@ class TestTencentSpanBuilder:
assert span.attributes[TOOL_NAME] == "search"
def test_build_retrieval_span(self):
trace_info = MagicMock(spec=DatasetRetrievalTraceInfo)
trace_info = DatasetRetrievalTraceInfo(metadata={})
trace_info.message_id = "msg_id"
trace_info.inputs = "query"
trace_info.error = None
@@ -214,7 +216,7 @@ class TestTencentSpanBuilder:
assert "content" in span.attributes[RETRIEVAL_DOCUMENT]
def test_build_retrieval_span_with_error(self):
trace_info = MagicMock(spec=DatasetRetrievalTraceInfo)
trace_info = DatasetRetrievalTraceInfo(metadata={})
trace_info.message_id = "msg_id"
trace_info.inputs = ""
trace_info.error = "retrieval failed"
@@ -231,7 +233,7 @@ class TestTencentSpanBuilder:
assert span.status.description == "retrieval failed"
def test_get_workflow_node_status(self):
node = MagicMock(spec=WorkflowNodeExecution)
node = workflow_node_execution()
node.status = WorkflowNodeExecutionStatus.SUCCEEDED
assert TencentSpanBuilder._get_workflow_node_status(node).status_code == StatusCode.OK
@@ -252,10 +254,10 @@ class TestTencentSpanBuilder:
assert TencentSpanBuilder._get_workflow_node_status(node).status_code == StatusCode.UNSET
def test_build_workflow_retrieval_span(self):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.metadata = {"conversation_id": "conv_id"}
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node_id"
node_execution.title = "my retrieval"
node_execution.status = WorkflowNodeExecutionStatus.SUCCEEDED
@@ -274,10 +276,10 @@ class TestTencentSpanBuilder:
assert "c1" in span.attributes[RETRIEVAL_DOCUMENT]
def test_build_workflow_retrieval_span_empty(self):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.metadata = {}
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node_id"
node_execution.title = "my retrieval"
node_execution.status = WorkflowNodeExecutionStatus.SUCCEEDED
@@ -295,9 +297,9 @@ class TestTencentSpanBuilder:
assert span.attributes[RETRIEVAL_DOCUMENT] == ""
def test_build_workflow_tool_span(self):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node_id"
node_execution.title = "my tool"
node_execution.status = WorkflowNodeExecutionStatus.SUCCEEDED
@@ -317,9 +319,9 @@ class TestTencentSpanBuilder:
assert "some" in span.attributes[TOOL_DESCRIPTION]
def test_build_workflow_tool_span_no_metadata(self):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node_id"
node_execution.title = "my tool"
node_execution.status = WorkflowNodeExecutionStatus.SUCCEEDED
@@ -338,10 +340,10 @@ class TestTencentSpanBuilder:
assert span.attributes[TOOL_PARAMETERS] == "{}"
def test_build_workflow_task_span(self):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.metadata = {"conversation_id": "conv_id"}
node_execution = MagicMock(spec=WorkflowNodeExecution)
node_execution = workflow_node_execution()
node_execution.id = "node_id"
node_execution.title = "my task"
node_execution.status = WorkflowNodeExecutionStatus.SUCCEEDED

View File

@@ -20,11 +20,16 @@ from core.ops.entities.trace_entity import (
ToolTraceInfo,
WorkflowTraceInfo,
)
from graphon.entities import WorkflowNodeExecution
from graphon.enums import BuiltinNodeTypes
from models import Account, App, Tenant, TenantAccountJoin
from models.account import TenantAccountRole
from models.model import AppMode, IconType
from tests.unit_tests.core.ops.trace_fixtures import (
message_trace_info,
tool_trace_info,
workflow_node_execution,
workflow_trace_info,
)
logger = logging.getLogger(__name__)
@@ -180,7 +185,7 @@ class TestTencentDataTrace:
tencent_data_trace.trace_client.get_project_url.assert_called_once()
def test_workflow_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.workflow_run_id = "run-id"
trace_info.trace_id = "parent-trace-id"
@@ -204,7 +209,7 @@ class TestTencentDataTrace:
mock_dur.assert_called_once_with(trace_info)
def test_workflow_trace_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.workflow_run_id = "run-id"
with (
@@ -217,7 +222,7 @@ class TestTencentDataTrace:
assert "[Tencent APM] Failed to process workflow trace" in caplog.text
def test_message_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info = message_trace_info()
trace_info.message_id = "msg-id"
trace_info.trace_id = "parent-trace-id"
@@ -241,7 +246,7 @@ class TestTencentDataTrace:
mock_dur.assert_called_once_with(trace_info)
def test_message_trace_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info = message_trace_info()
with (
patch(
@@ -253,7 +258,7 @@ class TestTencentDataTrace:
assert "[Tencent APM] Failed to process message trace" in caplog.text
def test_tool_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
trace_info = MagicMock(spec=ToolTraceInfo)
trace_info = tool_trace_info()
trace_info.message_id = "msg-id"
mock_trace_utils.convert_to_span_id.return_value = 456
@@ -267,14 +272,14 @@ class TestTencentDataTrace:
tencent_data_trace.trace_client.add_span.assert_called_once()
def test_tool_trace_no_msg_id(self, tencent_data_trace):
trace_info = MagicMock(spec=ToolTraceInfo)
trace_info = tool_trace_info()
trace_info.message_id = None
tencent_data_trace.tool_trace(trace_info)
tencent_data_trace.trace_client.add_span.assert_not_called()
def test_tool_trace_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=ToolTraceInfo)
trace_info = tool_trace_info()
trace_info.message_id = "msg-id"
with (
@@ -287,7 +292,7 @@ class TestTencentDataTrace:
assert "[Tencent APM] Failed to process tool trace" in caplog.text
def test_dataset_retrieval_trace(self, tencent_data_trace, mock_trace_utils, mock_span_builder):
trace_info = MagicMock(spec=DatasetRetrievalTraceInfo)
trace_info = DatasetRetrievalTraceInfo(metadata={})
trace_info.message_id = "msg-id"
mock_trace_utils.convert_to_span_id.return_value = 456
@@ -301,14 +306,14 @@ class TestTencentDataTrace:
tencent_data_trace.trace_client.add_span.assert_called_once()
def test_dataset_retrieval_trace_no_msg_id(self, tencent_data_trace):
trace_info = MagicMock(spec=DatasetRetrievalTraceInfo)
trace_info = DatasetRetrievalTraceInfo(metadata={})
trace_info.message_id = None
tencent_data_trace.dataset_retrieval_trace(trace_info)
tencent_data_trace.trace_client.add_span.assert_not_called()
def test_dataset_retrieval_trace_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=DatasetRetrievalTraceInfo)
trace_info = DatasetRetrievalTraceInfo(metadata={})
trace_info.message_id = "msg-id"
with (
@@ -321,7 +326,7 @@ class TestTencentDataTrace:
assert "[Tencent APM] Failed to process dataset retrieval trace" in caplog.text
def test_suggested_question_trace(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=SuggestedQuestionTraceInfo)
trace_info = SuggestedQuestionTraceInfo(metadata={}, total_tokens=0, suggested_question=[], level="info")
with caplog.at_level(logging.INFO):
tencent_data_trace.suggested_question_trace(trace_info)
assert "[Tencent APM] Processing suggested question trace" in caplog.text
@@ -329,7 +334,7 @@ class TestTencentDataTrace:
def test_suggested_question_trace_exception(
self, tencent_data_trace, monkeypatch, caplog: pytest.LogCaptureFixture
):
trace_info = MagicMock(spec=SuggestedQuestionTraceInfo)
trace_info = SuggestedQuestionTraceInfo(metadata={}, total_tokens=0, suggested_question=[], level="info")
target_logger = logging.getLogger("dify_trace_tencent.tencent_trace")
monkeypatch.setattr(target_logger, "info", MagicMock(side_effect=Exception("error")))
with caplog.at_level(logging.ERROR):
@@ -337,14 +342,14 @@ class TestTencentDataTrace:
assert "[Tencent APM] Failed to process suggested question trace" in caplog.text
def test_process_workflow_nodes(self, tencent_data_trace, mock_trace_utils):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.workflow_run_id = "run-id"
mock_trace_utils.convert_to_span_id.return_value = 111
node1 = MagicMock(spec=WorkflowNodeExecution)
node1 = workflow_node_execution()
node1.id = "n1"
node1.node_type = BuiltinNodeTypes.LLM
node2 = MagicMock(spec=WorkflowNodeExecution)
node2 = workflow_node_execution()
node2.id = "n2"
node2.node_type = BuiltinNodeTypes.TOOL
@@ -361,24 +366,29 @@ class TestTencentDataTrace:
def test_process_workflow_nodes_node_exception(
self, tencent_data_trace, mock_trace_utils, caplog: pytest.LogCaptureFixture
):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
mock_trace_utils.convert_to_span_id.return_value = 111
node = MagicMock(spec=WorkflowNodeExecution)
node = workflow_node_execution()
node.id = "n1"
next_node = workflow_node_execution()
next_node.id = "n2"
with (
patch.object(tencent_data_trace, "_get_workflow_node_executions", return_value=[node]),
patch.object(tencent_data_trace, "_build_workflow_node_span", side_effect=Exception("node error")),
patch.object(tencent_data_trace, "_get_workflow_node_executions", return_value=[node, next_node]),
patch.object(
tencent_data_trace, "_build_workflow_node_span", side_effect=[Exception("node error"), "next-span"]
),
caplog.at_level(logging.ERROR),
):
tencent_data_trace._process_workflow_nodes(trace_info, 123)
assert "[Tencent APM] Failed to process workflow nodes" in caplog.text
assert "[Tencent APM] Failed to process node execution: n1" in caplog.text
tencent_data_trace.trace_client.add_span.assert_called_once_with("next-span")
def test_process_workflow_nodes_exception(
self, tencent_data_trace, mock_trace_utils, caplog: pytest.LogCaptureFixture
):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
mock_trace_utils.convert_to_span_id.side_effect = Exception("outer error")
with caplog.at_level(logging.ERROR):
@@ -386,7 +396,7 @@ class TestTencentDataTrace:
assert "[Tencent APM] Failed to process workflow nodes" in caplog.text
def test_build_workflow_node_span(self, tencent_data_trace, mock_span_builder):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
nodes = [
(BuiltinNodeTypes.LLM, mock_span_builder.build_workflow_llm_span),
@@ -396,7 +406,7 @@ class TestTencentDataTrace:
]
for node_type, builder_method in nodes:
node = MagicMock(spec=WorkflowNodeExecution)
node = workflow_node_execution()
node.node_type = node_type
builder_method.return_value = "span"
@@ -408,7 +418,7 @@ class TestTencentDataTrace:
def test_build_workflow_node_span_exception(
self, tencent_data_trace, mock_span_builder, caplog: pytest.LogCaptureFixture
):
node = MagicMock(spec=WorkflowNodeExecution)
node = workflow_node_execution()
node.node_type = BuiltinNodeTypes.LLM
node.id = "n1"
mock_span_builder.build_workflow_llm_span.side_effect = Exception("error")
@@ -456,12 +466,11 @@ class TestTencentDataTrace:
sqlite3_session.add_all([app, tenant_join])
sqlite3_session.commit()
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.metadata = {"app_id": app.id}
trace_info.workflow_run_id = "run-1"
database = SimpleNamespace(engine=sqlite3_session.get_bind())
monkeypatch.setattr("dify_trace_tencent.tencent_trace.db", database)
monkeypatch.setattr("models.account.db", database)
with patch("dify_trace_tencent.tencent_trace.SQLAlchemyWorkflowNodeExecutionRepository") as mock_repo:
mock_repo.return_value.get_by_workflow_execution.return_value = []
@@ -481,7 +490,7 @@ class TestTencentDataTrace:
monkeypatch: pytest.MonkeyPatch,
sqlite3_session: Session,
) -> None:
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.metadata = {}
monkeypatch.setattr(
"dify_trace_tencent.tencent_trace.db",
@@ -501,7 +510,7 @@ class TestTencentDataTrace:
monkeypatch: pytest.MonkeyPatch,
sqlite3_session: Session,
) -> None:
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.metadata = {"app_id": "app-1"}
monkeypatch.setattr(
"dify_trace_tencent.tencent_trace.db",
@@ -514,7 +523,7 @@ class TestTencentDataTrace:
assert len([r for r in caplog.records if r.levelno == logging.ERROR]) >= 1
def test_get_user_id_workflow(self, tencent_data_trace):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.tenant_id = "tenant-1"
trace_info.metadata = {"user_id": "user-1"}
@@ -529,21 +538,21 @@ class TestTencentDataTrace:
assert user_id == "unknown"
def test_get_user_id_only_user_id(self, tencent_data_trace):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info = message_trace_info()
trace_info.metadata = {"user_id": "user-1"}
user_id = tencent_data_trace._get_user_id(trace_info)
assert user_id == "user-1"
def test_get_user_id_anonymous(self, tencent_data_trace):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info = message_trace_info()
trace_info.metadata = {}
user_id = tencent_data_trace._get_user_id(trace_info)
assert user_id == "anonymous"
def test_get_user_id_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.tenant_id = "t"
trace_info.metadata = {"user_id": "u"}
@@ -556,7 +565,7 @@ class TestTencentDataTrace:
assert "[Tencent APM] Failed to get user ID" in caplog.text
def test_record_llm_metrics_usage_in_process_data(self, tencent_data_trace):
node = MagicMock(spec=WorkflowNodeExecution)
node = workflow_node_execution()
node.process_data = {
"usage": {
"latency": 2.5,
@@ -579,7 +588,7 @@ class TestTencentDataTrace:
assert tencent_data_trace.trace_client.record_token_usage.call_count == 2
def test_record_llm_metrics_usage_in_outputs(self, tencent_data_trace):
node = MagicMock(spec=WorkflowNodeExecution)
node = workflow_node_execution()
node.process_data = {}
node.outputs = {"usage": {"latency": 1.0, "prompt_tokens": 5}}
@@ -588,7 +597,7 @@ class TestTencentDataTrace:
tencent_data_trace.trace_client.record_token_usage.assert_called_once()
def test_record_llm_metrics_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
node = MagicMock(spec=WorkflowNodeExecution)
node = workflow_node_execution()
node.process_data = None
node.outputs = None
@@ -597,7 +606,7 @@ class TestTencentDataTrace:
# Should not crash
def test_record_message_llm_metrics(self, tencent_data_trace):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info = message_trace_info()
trace_info.metadata = {"ls_provider": "openai", "ls_model_name": "gpt-4"}
trace_info.message_data = {"provider_response_latency": 1.1}
trace_info.is_streaming_request = True
@@ -614,7 +623,7 @@ class TestTencentDataTrace:
assert tencent_data_trace.trace_client.record_token_usage.call_count == 2
def test_record_message_llm_metrics_object_data(self, tencent_data_trace):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info = message_trace_info()
trace_info.metadata = {}
msg_data = MagicMock()
msg_data.provider_response_latency = 1.1
@@ -627,15 +636,16 @@ class TestTencentDataTrace:
tencent_data_trace.trace_client.record_llm_duration.assert_called_once()
def test_record_message_llm_metrics_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info.metadata = None
trace_info = message_trace_info()
# Inject malformed metadata to exercise the metrics error handler.
trace_info = trace_info.model_copy(update={"metadata": None})
with caplog.at_level(logging.DEBUG):
tencent_data_trace._record_message_llm_metrics(trace_info)
# Should not crash
def test_record_workflow_trace_duration(self, tencent_data_trace):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
from datetime import datetime, timedelta
now = datetime.now()
@@ -663,7 +673,7 @@ class TestTencentDataTrace:
assert attributes["has_conversation"] == "true"
def test_record_workflow_trace_duration_fallback(self, tencent_data_trace):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.start_time = None
trace_info.workflow_run_elapsed_time = 4.5
trace_info.workflow_run_status = "failed"
@@ -679,14 +689,14 @@ class TestTencentDataTrace:
assert attributes["has_conversation"] == "false"
def test_record_workflow_trace_duration_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=WorkflowTraceInfo)
trace_info = workflow_trace_info()
trace_info.start_time = MagicMock() # This might cause total_seconds() to fail if not mocked right
with caplog.at_level(logging.DEBUG):
tencent_data_trace._record_workflow_trace_duration(trace_info)
def test_record_message_trace_duration(self, tencent_data_trace):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info = message_trace_info()
from datetime import datetime, timedelta
now = datetime.now()
@@ -701,7 +711,7 @@ class TestTencentDataTrace:
)
def test_record_message_trace_duration_exception(self, tencent_data_trace, caplog: pytest.LogCaptureFixture):
trace_info = MagicMock(spec=MessageTraceInfo)
trace_info = message_trace_info()
trace_info.start_time = None
with caplog.at_level(logging.DEBUG):

View File

@@ -1,6 +1,8 @@
[pytest]
pythonpath = .
addopts = --cov=./api --cov-report=json --import-mode=importlib --cov-branch --cov-report=xml
# These optional SDK plugins are not used by the backend suite. In particular,
# Opik eagerly imports LiteLLM, which also loads the developer's .env into os.environ.
addopts = -p no:opik -p no:langsmith_plugin --cov=./api --cov-report=json --import-mode=importlib --cov-branch --cov-report=xml
env =
ANTHROPIC_API_KEY = sk-ant-api11-IamNotARealKeyJustForMockTestKawaiiiiiiiiii-NotBaka-ASkksz
AZURE_OPENAI_API_BASE = https://difyai-openai.openai.azure.com

View File

@@ -1 +1 @@
"""Persistence adapters for App services."""
"""Persistence adapters for App services. New or reworked app_* repositories move here; keep this file export-free."""

View File

@@ -27,6 +27,18 @@ class AgentAppRepository:
except AgentNotFoundError as exc:
raise AgentAppNotFoundError from exc
def resolve_existing_runtime_app_id(self, *, tenant_id: str, agent_id: str) -> str:
"""Read persisted runtime state without creating a hidden app during a GET."""
with self._session_factory() as session:
try:
return (
AgentRosterService(session)
.get_existing_agent_runtime_app_model(tenant_id=tenant_id, agent_id=agent_id)
.id
)
except AgentNotFoundError as exc:
raise AgentAppNotFoundError from exc
def list_referencing_workflows(self, *, tenant_id: str, agent_id: str) -> list[AgentReferencingWorkflow]:
with self._session_factory() as session:
roster = AgentRosterService(session)

View File

@@ -0,0 +1,19 @@
"""SQL visibility predicate shared by Console app persistence adapters."""
from sqlalchemy import ColumnElement, exists, or_
from models.agent import Agent, AgentScope
from models.model import App, AppMode
def console_visible_condition() -> ColumnElement[bool]:
"""Exclude workflow-only Agents' runtime backing Apps, including archived Agents.
Console must address these Apps through their owning workflow.
"""
backs_workflow_only_agent = exists().where(
Agent.tenant_id == App.tenant_id,
Agent.backing_app_id == App.id,
Agent.scope == AgentScope.WORKFLOW_ONLY,
)
return or_(App.mode != AppMode.AGENT, ~backs_workflow_only_agent)

View File

@@ -0,0 +1,127 @@
"""SQLAlchemy persistence adapter for Console app MCP servers."""
import json
from typing import override
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session, sessionmaker
from models.enums import AppStatus
from models.model import App, AppMCPServer
from repositories.app.console_visibility import console_visible_condition
from services.app.mcp_server_service import (
AppMCPServerAlreadyExistsError,
AppMCPServerDraft,
AppMCPServerNotFoundError,
AppMCPServerRecord,
AppMCPServerStatus,
AppMCPServerStore,
AppMCPServerUpdate,
PublishedApp,
)
_SERVER_CODE_LENGTH = 16
class AppMCPServerRepository(AppMCPServerStore):
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def find_app(self, *, workspace_id: str, app_id: str) -> PublishedApp | None:
with self._session_factory() as session:
row = session.execute(
select(App.name, App.description)
.where(
App.id == app_id,
App.tenant_id == workspace_id,
App.status == AppStatus.NORMAL,
console_visible_condition(),
)
.limit(1)
).one_or_none()
return PublishedApp(name=row.name, description=row.description or "") if row is not None else None
@override
def find_server(self, *, workspace_id: str, app_id: str) -> AppMCPServerRecord | None:
with self._session_factory() as session:
server = session.scalar(
select(AppMCPServer)
.where(AppMCPServer.tenant_id == workspace_id, AppMCPServer.app_id == app_id)
.limit(1)
)
return self._to_record(server) if server is not None else None
@override
def insert_server(self, *, workspace_id: str, app_id: str, draft: AppMCPServerDraft) -> AppMCPServerRecord:
try:
with self._session_factory.begin() as session:
server = AppMCPServer(
tenant_id=workspace_id,
app_id=app_id,
name=draft.name,
description=draft.description,
parameters=json.dumps(draft.parameters, ensure_ascii=False),
status=draft.status.value,
server_code=AppMCPServer.generate_server_code(_SERVER_CODE_LENGTH, session=session),
)
session.add(server)
session.flush()
return self._to_record(server)
except IntegrityError as error:
# A concurrent create can pass the caller's pre-check and win the (tenant_id, app_id) unique constraint.
# Re-check in a fresh transaction so the committed winner is visible under any isolation level;
# other violations (e.g. server_code) are not duplicates and must stay visible as failures.
if self.find_server(workspace_id=workspace_id, app_id=app_id) is None:
raise
raise AppMCPServerAlreadyExistsError from error
@override
def update_server(
self, *, workspace_id: str, app_id: str, server_id: str, update: AppMCPServerUpdate
) -> AppMCPServerRecord:
with self._session_factory.begin() as session:
server = self._require_server(session, workspace_id=workspace_id, app_id=app_id, server_id=server_id)
server.name = update.name
server.description = update.description
server.parameters = json.dumps(update.parameters, ensure_ascii=False)
if update.status is not None:
server.status = update.status.value
session.flush()
return self._to_record(server)
@override
def rotate_server_code(self, *, workspace_id: str, app_id: str) -> AppMCPServerRecord:
with self._session_factory.begin() as session:
server = self._require_server(session, workspace_id=workspace_id, app_id=app_id, server_id=None)
server.server_code = AppMCPServer.generate_server_code(_SERVER_CODE_LENGTH, session=session)
session.flush()
return self._to_record(server)
@staticmethod
def _require_server(session: Session, *, workspace_id: str, app_id: str, server_id: str | None) -> AppMCPServer:
"""Require the scoped ID, or the app's unique server when server_id is None for code rotation."""
statement = select(AppMCPServer).where(
AppMCPServer.tenant_id == workspace_id,
AppMCPServer.app_id == app_id,
)
if server_id is not None:
statement = statement.where(AppMCPServer.id == server_id)
server = session.scalar(statement.limit(1))
if server is None:
raise AppMCPServerNotFoundError
return server
@staticmethod
def _to_record(server: AppMCPServer) -> AppMCPServerRecord:
return AppMCPServerRecord(
id=server.id,
name=server.name,
server_code=server.server_code,
description=server.description,
status=AppMCPServerStatus(server.status),
parameters=server.parameters,
created_at=server.created_at,
updated_at=server.updated_at,
)

View File

@@ -3,12 +3,13 @@
from dataclasses import asdict
from typing import override
from sqlalchemy import select
from sqlalchemy import ColumnElement, select
from sqlalchemy.orm import Session, sessionmaker
from libs.datetime_utils import naive_utc_now
from models.enums import AppStatus
from models.model import App, Site
from repositories.app.console_visibility import console_visible_condition
from services.app_site_service import (
AppSiteAppNotFoundError,
AppSiteChanges,
@@ -18,6 +19,15 @@ from services.app_site_service import (
)
def _console_app_conditions(workspace_id: str, app_id: str) -> tuple[ColumnElement[bool], ...]:
return (
App.id == app_id,
App.tenant_id == workspace_id,
App.status == AppStatus.NORMAL,
console_visible_condition(),
)
class AppSiteCommandRepository(AppSiteStore):
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@@ -61,27 +71,12 @@ class AppSiteCommandRepository(AppSiteStore):
@staticmethod
def _get_site(session: Session, workspace_id: str, app_id: str) -> Site:
site = session.scalar(
select(Site)
.join(App, App.id == Site.app_id)
.where(
App.id == app_id,
App.tenant_id == workspace_id,
App.status == AppStatus.NORMAL,
)
.limit(1)
select(Site).join(App, App.id == Site.app_id).where(*_console_app_conditions(workspace_id, app_id)).limit(1)
)
if site is not None:
return site
app_exists = session.scalar(
select(App.id)
.where(
App.id == app_id,
App.tenant_id == workspace_id,
App.status == AppStatus.NORMAL,
)
.limit(1)
)
app_exists = session.scalar(select(App.id).where(*_console_app_conditions(workspace_id, app_id)).limit(1))
if app_exists is None:
raise AppSiteAppNotFoundError
raise AppSiteNotFoundError

View File

@@ -7,6 +7,7 @@ from sqlalchemy.orm import Session, sessionmaker
from models.enums import AppStatus
from models.model import App, TraceAppConfig
from repositories.app.console_visibility import console_visible_condition
from services.app_tracing_config_service import (
AppTracingConfigAppNotFoundError,
AppTracingConfigRecord,
@@ -97,6 +98,7 @@ class SQLAlchemyAppTracingConfigRepository(AppTracingConfigStore):
App.id == app_id,
App.tenant_id == workspace_id,
App.status == AppStatus.NORMAL,
console_visible_condition(),
)
.limit(1)
)

View File

@@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence
from datetime import datetime
from typing import cast, override
from pydantic import JsonValue
from pydantic import JsonValue, ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
@@ -12,11 +12,13 @@ from core.tools.entities.tool_entities import ToolProviderType
from core.tools.utils.uuid_utils import is_valid_uuid
from models.account import Account, Tenant
from models.dataset import Dataset
from models.model import App, AppModelConfig, Site, load_annotation_reply_config
from models.model import App, AppMode, AppModelConfig, Site, load_annotation_reply_config
from models.tools import ApiToolProvider
from models.workflow import Workflow
from repositories.app_definition_query_repository import map_site_configuration
from services.account_errors import AccountNotFoundError
from services.agent.composer_service import AgentComposerService
from services.agent.errors import AgentNotFoundError, AgentVersionNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_preview_details_service import (
AppPreviewAccount,
@@ -41,6 +43,24 @@ from services.app_preview_query_service import (
class AppPreviewQueryRepository(AppPreviewQuery, AppPreviewDetailsQuery):
def get_agent_composer(self, *, app: AppPreviewRef) -> Mapping[str, object] | None:
with self._session_factory() as session:
try:
model = self._get_app(session=session, app=app)
except AppPreviewUnavailableError:
return None
if model.mode != AppMode.AGENT:
return None
agent = model.agent_app_binding_with_session(session=session)
if agent is None:
return None
try:
return AgentComposerService.load_published_agent_composer(
session=session, tenant_id=app.tenant_id, agent_id=agent.id
)
except (AgentNotFoundError, AgentVersionNotFoundError, ValidationError, ValueError):
return None
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory: sessionmaker[Session] = session_factory
@@ -267,6 +287,8 @@ class AppPreviewQueryRepository(AppPreviewQuery, AppPreviewDetailsQuery):
annotation_reply=load_annotation_reply_config(session, model_config.app_id)
)
result: dict[str, JsonValue | datetime] = dict(cast(Mapping[str, JsonValue], configuration))
if not result.get("model"):
result["model"] = None
result.update(
created_by=model_config.created_by,
created_at=model_config.created_at,

View File

@@ -21,17 +21,16 @@ from models.model import ApiToken, DatasetApiTokenBinding
def get_bound_dataset_ids(session: Session, api_token_id: str) -> set[str]:
"""Return the dataset ids a key is scoped to (empty set means unrestricted)."""
return {
str(dataset_id)
for dataset_id in session.scalars(
return set(
session.scalars(
select(DatasetApiTokenBinding.dataset_id).where(DatasetApiTokenBinding.api_token_id == api_token_id)
).all()
}
)
def list_bindings_by_token(session: Session, token_ids: Iterable[str]) -> dict[str, list[str]]:
"""Group bound dataset ids by api token id for the given tokens."""
ids = [str(token_id) for token_id in token_ids]
ids = list(token_ids)
bindings_by_token: dict[str, list[str]] = {}
if not ids:
return bindings_by_token
@@ -49,12 +48,9 @@ def find_unknown_dataset_ids(session: Session, dataset_ids: list[str], tenant_id
"""Return the dataset ids that do not belong to the tenant (preserving order)."""
if not dataset_ids:
return []
existing = {
str(dataset_id)
for dataset_id in session.scalars(
select(Dataset.id).where(Dataset.id.in_(dataset_ids), Dataset.tenant_id == tenant_id)
).all()
}
existing = set(
session.scalars(select(Dataset.id).where(Dataset.id.in_(dataset_ids), Dataset.tenant_id == tenant_id)).all()
)
return [dataset_id for dataset_id in dataset_ids if dataset_id not in existing]
@@ -76,7 +72,7 @@ def delete_keys_scoped_only_to(session: Session, dataset_id: str) -> list[str]:
Must run before the dataset row is deleted (so the bindings still exist).
Returns the deleted api_token ids; the caller controls the transaction.
"""
orphan_ids = [str(token_id) for token_id in session.scalars(token_ids_scoped_only_to(dataset_id)).all()]
orphan_ids = list(session.scalars(token_ids_scoped_only_to(dataset_id)).all())
if orphan_ids:
session.execute(delete(ApiToken).where(ApiToken.id.in_(orphan_ids)))
return orphan_ids

View File

@@ -59,7 +59,7 @@ class SQLAlchemyOAuthDeviceTokenRepository:
)
session.add(record)
session.flush()
token_id = str(record.id)
token_id = record.id
session.commit()
rotation = OAuthDeviceTokenRotation(
@@ -150,7 +150,7 @@ class SQLAlchemyOAuthDeviceTokenRepository:
total=total,
items=tuple(
OAuthDeviceSession(
id=str(record.id),
id=record.id,
prefix=record.prefix,
client_id=record.client_id,
device_label=record.device_label,

View File

@@ -4,14 +4,19 @@ import json
import logging
from collections.abc import Sequence
from typing import cast, override
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from constants.languages import languages
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
from extensions.ext_redis import RedisClientWrapper
from models.model import App, RecommendedApp, Site
from models.account import Tenant, TenantStatus
from models.agent import APP_BACKED_AGENT_SOURCES, Agent, AgentConfigSnapshot, AgentScope, AgentStatus
from models.model import App, AppMode, RecommendedApp, Site
from services.app_dsl_service import AppDslService
from services.recommended_app_package_service import RecommendedAgentPackageSource
from services.recommended_app_query_service import (
RecommendedAppCatalogPage,
RecommendedAppCatalogQuery,
@@ -57,6 +62,43 @@ class DatabaseRecommendedAppCatalogRepository(RecommendedAppCatalogQuery):
with self._session_factory() as session:
return self._get_detail(app_id, session=session)
def get_package_source(self, app_id: str, version_id: UUID) -> RecommendedAgentPackageSource | None:
with self._session_factory() as session:
source = self._get_agent_package_source(app_id, session=session)
return source if source is not None and source.version_id == version_id else None
@staticmethod
def _get_agent_package_source(app_id: str, *, session: Session) -> RecommendedAgentPackageSource | None:
row = session.execute(
select(Agent.tenant_id, Agent.id, Agent.active_config_snapshot_id)
.join(App, (App.id == Agent.app_id) & (App.tenant_id == Agent.tenant_id))
.join(Tenant, Tenant.id == App.tenant_id)
.join(RecommendedApp, RecommendedApp.app_id == App.id)
.join(
AgentConfigSnapshot,
(AgentConfigSnapshot.id == Agent.active_config_snapshot_id)
& (AgentConfigSnapshot.agent_id == Agent.id)
& (AgentConfigSnapshot.tenant_id == Agent.tenant_id),
)
.where(
App.id == app_id,
App.mode == AppMode.AGENT,
App.status == "normal",
App.is_public.is_(True),
RecommendedApp.is_listed.is_(True),
Tenant.status != TenantStatus.ARCHIVE,
Agent.scope == AgentScope.ROSTER,
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
Agent.status == AgentStatus.ACTIVE,
workflow_callable_active_snapshot_filter(),
(Agent.backing_app_id == App.id) | Agent.backing_app_id.is_(None),
)
.limit(1)
).one_or_none()
if row is None:
return None
return RecommendedAgentPackageSource(tenant_id=row[0], agent_id=row[1], version_id=UUID(row[2]))
@override
def contains(self, app_id: str) -> bool:
with self._session_factory() as session:
@@ -179,13 +221,19 @@ class DatabaseRecommendedAppCatalogRepository(RecommendedAppCatalogQuery):
if app is None or not app.is_public:
return None
version_id = None
if app.mode == AppMode.AGENT:
source = DatabaseRecommendedAppCatalogRepository._get_agent_package_source(app.id, session=session)
if source is not None:
version_id = str(source.version_id)
return RecommendedAppDetailRecord(
id=app.id,
name=app.name,
icon=cast(str | None, app.icon),
icon_background=app.icon_background,
mode=app.mode.value,
export_data=AppDslService.export_dsl(app_model=app, session=session),
export_data="" if app.mode == AppMode.AGENT else AppDslService.export_dsl(app_model=app, session=session),
version_id=version_id,
)
@staticmethod

View File

@@ -3,10 +3,12 @@
from collections.abc import Sequence, Set
from typing import override
from sqlalchemy import and_, select
from sqlalchemy import and_, or_, select
from sqlalchemy.orm import Session, sessionmaker
from models.model import AccountTrialAppRecord, App, TrialApp
from core.agent.publish_visibility import workflow_callable_active_snapshot_filter
from models.agent import APP_BACKED_AGENT_SOURCES, Agent, AgentConfigSnapshot, AgentScope, AgentStatus
from models.model import AccountTrialAppRecord, App, AppMode, TrialApp
from services.recommended_app_query_service import TrialAppQuery
from services.trial_app_access_service import TrialAppAccessQuery, TrialAppAccessSnapshot, TrialAppRef
from services.trial_app_usage import TrialAppUsageRecorder
@@ -21,8 +23,42 @@ class TrialAppRepository(TrialAppQuery, TrialAppAccessQuery, TrialAppUsageRecord
if not app_ids:
return frozenset()
runnable_agent_exists = (
select(Agent.id)
.join(
AgentConfigSnapshot,
and_(
AgentConfigSnapshot.tenant_id == Agent.tenant_id,
AgentConfigSnapshot.agent_id == Agent.id,
AgentConfigSnapshot.id == Agent.active_config_snapshot_id,
),
)
.where(
Agent.tenant_id == App.tenant_id,
Agent.status == AgentStatus.ACTIVE,
or_(
and_(
Agent.app_id == App.id,
Agent.scope == AgentScope.ROSTER,
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
),
Agent.backing_app_id == App.id,
),
workflow_callable_active_snapshot_filter(),
)
.correlate(App)
.exists()
)
query = (
select(TrialApp.app_id)
.outerjoin(App, App.id == TrialApp.app_id)
.where(
TrialApp.app_id.in_(app_ids),
or_(App.id.is_(None), App.mode != AppMode.AGENT, runnable_agent_exists),
)
)
with self._session_factory() as session:
return frozenset(session.scalars(select(TrialApp.app_id).where(TrialApp.app_id.in_(app_ids))).all())
return frozenset(session.scalars(query).all())
@override
def resolve(self, *, app_id: str, account_id: str) -> TrialAppAccessSnapshot | None:

View File

@@ -35,6 +35,7 @@ from models.agent_config_entities import (
from models.workflow import Workflow
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.dsl_entities import make_portable_agent_soul
from services.agent.errors import (
AgentBuildSandboxNotFoundError,
AgentModelNotConfiguredError,
@@ -388,6 +389,32 @@ class AgentComposerService:
agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=agent_id)
return cls._load_agent_composer_for_agent(session=session, tenant_id=tenant_id, agent=agent)
@classmethod
def load_published_agent_composer(cls, *, session: Session, tenant_id: str, agent_id: str) -> dict[str, Any]:
"""Read public Composer state after the caller admits template access."""
agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=agent_id)
if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent):
raise AgentVersionNotFoundError()
state = cls._load_agent_composer_for_agent(
session=session, tenant_id=tenant_id, agent=agent, published_only=True
)
soul = make_portable_agent_soul(AgentSoulConfig.model_validate(state["agent_soul"]))
# Export portability alone does not remove plaintext runtime values.
soul.env = type(soul.env)()
soul.human = type(soul.human)()
soul.sandbox = type(soul.sandbox)()
soul.memory = type(soul.memory)()
for tool in soul.tools.dify_tools:
tool.runtime_parameters = {}
for cli_tool in soul.tools.cli_tools:
cli_tool.env = type(cli_tool.env)()
cli_tool.invoke_metadata = {}
state["agent_soul"] = soul.model_dump(mode="json")
state["chat_endpoint"] = None
if state["active_config_snapshot"] is not None:
state["active_config_snapshot"]["created_by"] = None
return state
@classmethod
def prepare_agent_composer_draft(
cls, *, session: Session, tenant_id: str, agent_id: str, account_id: str
@@ -425,18 +452,25 @@ class AgentComposerService:
return AgentSoulConfig.model_validate(state["agent_soul"])
@classmethod
def _load_agent_composer_for_agent(cls, *, session: Session, tenant_id: str, agent: Agent) -> dict[str, Any]:
def _load_agent_composer_for_agent(
cls, *, session: Session, tenant_id: str, agent: Agent, published_only: bool = False
) -> dict[str, Any]:
"""Read effective configuration without creating or rebasing persisted drafts.
Stale workflow-only drafts read from the current snapshot. Their draft
metadata still describes the persisted row; write paths own rebasing.
Published-only reads never query drafts or advertise editing capabilities.
"""
draft = cls._get_agent_draft(
session=session,
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
draft = (
None
if published_only
else cls._get_agent_draft(
session=session,
tenant_id=tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
)
)
version = cls._get_version_if_present(
session=session, tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
@@ -455,10 +489,11 @@ class AgentComposerService:
"variant": ComposerVariant.AGENT_APP.value,
"agent": cls._serialize_agent(agent),
"active_config_snapshot": cls._serialize_version(version),
"active_config_is_published": bool(agent.active_config_snapshot_id and agent.active_config_is_published),
"active_config_is_published": published_only
or bool(agent.active_config_snapshot_id and agent.active_config_is_published),
"draft": cls._serialize_draft(draft),
"agent_soul": agent_soul,
"save_options": [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value],
"save_options": [] if published_only else [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value],
"app_id": agent.app_id,
"backing_app_id": agent.backing_app_id or agent.app_id,
"hidden_app_backed": bool(agent.scope == AgentScope.WORKFLOW_ONLY and agent.backing_app_id),

View File

@@ -12,6 +12,7 @@ from models.agent import Agent
from models.agent_config_entities import AgentSoulConfig
from models.model import App
from services.entities.dsl_entities import make_app_dsl
from services.entities.site_dsl import SiteDsl
AGENT_PACKAGE_SCHEMA_VERSION = 1
AGENT_PACKAGE_REF_KEY = "package_ref"
@@ -76,6 +77,7 @@ class AgentAppDsl(BaseModel):
version: str = Field(min_length=1)
kind: Literal["app"]
app: dict[str, Any]
site: SiteDsl | None = None
agent: AgentAppReference
agent_packages: dict[str, AgentPackage]
dependencies: list[PluginDependency] = Field(default_factory=list)

View File

@@ -244,9 +244,9 @@ class AgentDslService:
name=self.unique_roster_name(tenant_id=app.tenant_id, requested=app.name or metadata.name),
description=app.description or metadata.description,
role=metadata.role,
icon_type=self._agent_icon_type(metadata.icon_type),
icon=metadata.icon,
icon_background=metadata.icon_background,
icon_type=self._agent_icon_type(app.icon_type),
icon=app.icon,
icon_background=app.icon_background,
source=AgentSource.IMPORTED,
initial_soul=soul,
revision_operation=AgentConfigRevisionOperation.IMPORT_PACKAGE,

View File

@@ -163,6 +163,80 @@ class AgentPackageResourceExporter:
)
self._workspace_sources.clear()
def read_local_resources(
self, package_ref: str
) -> tuple[dict[str, bytes], AgentPackageResources, list[PackageIcon]]:
"""Read bounded local assets without building an outer archive."""
skill_sources, file_sources = self.sources[package_ref]
if len(skill_sources) + len(file_sources) + len(self.icon_sources) + 2 > dify_config.AGENT_PACKAGE_MAX_ENTRIES:
raise RosterAgentPackageTooLargeError("Agent template has too many resources")
payloads: dict[str, bytes] = {}
total_size = 0
def read(path: str, storage_key: str, limit: int) -> tuple[int, str]:
nonlocal total_size
content = bytearray()
try:
for chunk in self._storage.load_stream(storage_key):
if len(content) + len(chunk) > min(limit, dify_config.AGENT_PACKAGE_MAX_BYTES - total_size):
raise RosterAgentPackageTooLargeError("Agent template resources exceed the size limit")
content.extend(chunk)
except RosterAgentPackageTooLargeError:
raise
except Exception as exc:
raise RosterAgentPackageExportFailedError(f"Unable to read template resource {path!r}") from exc
payload = bytes(content)
payloads[path] = payload
total_size += len(payload)
return len(payload), hashlib.sha256(payload).hexdigest()
skills: list[RosterAgentPackageSkill] = []
files: list[RosterAgentPackageFile] = []
nested_uncompressed_size = 0
skill_packages = SkillPackageService()
for item in skill_sources:
size, digest = read(item.path, item.storage_key, dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024)
try:
inspection = skill_packages.inspect(content=payloads[item.path], filename=item.path)
except SkillPackageError as exc:
raise RosterAgentPackageExportFailedError(
f"Agent template contains unusable Skill {item.name!r}"
) from exc
if inspection.name != item.name:
raise RosterAgentPackageExportFailedError(f"Agent template Skill {item.name!r} has a name mismatch")
nested_uncompressed_size += inspection.uncompressed_size
if nested_uncompressed_size > dify_config.AGENT_PACKAGE_MAX_BYTES:
raise RosterAgentPackageTooLargeError("Agent template nested Skill contents exceed the size limit")
skills.append(
RosterAgentPackageSkill(
id=item.id,
scope=item.scope,
name=item.name,
path=item.path,
size=size,
sha256=digest,
audit=RosterAgentPackageAudit(ref=item.audit_ref),
)
)
for file_source in file_sources:
size, digest = read(file_source.path, file_source.storage_key, dify_config.AGENT_PACKAGE_MAX_BYTES)
files.append(
RosterAgentPackageFile(
id=file_source.id,
path=file_source.path,
size=size,
sha256=digest,
audit=RosterAgentPackageAudit(ref=file_source.audit_ref),
)
)
icons = []
for path, key in self.icon_sources.values():
size, digest = read(path, key, dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024)
icons.append(PackageIcon(id=path.split(".")[0], path=path, size=size, sha256=digest))
resources = AgentPackageResources(skills=skills, files=files)
resources.validate_packages([self.packages[package_ref]])
return payloads, resources, icons
def write_resources(self, archive: zipfile.ZipFile) -> tuple[dict[str, AgentPackageResources], int]:
entry_count = sum(len(skills) + len(files) for skills, files in self.sources.values()) + 2
if entry_count > dify_config.AGENT_PACKAGE_MAX_ENTRIES:

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import hashlib
import mimetypes
import os
from collections.abc import Callable
from typing import Protocol
from uuid import uuid4
@@ -82,7 +83,26 @@ class AgentPackageResourceImporter:
tenant_id: str,
account_id: str,
) -> tuple[AgentPackage, list[DslImportWarning]]:
"""Upload outside database transactions, then commit all file records together."""
return self.materialize_resources(
read_member=lambda path, limit: self._reader.read_member_bytes(archive, path, max_bytes=limit),
invalid_skills=archive.invalid_skills,
resources=resources,
agent_package=agent_package,
tenant_id=tenant_id,
account_id=account_id,
)
def materialize_resources(
self,
*,
read_member: Callable[[str, int], bytes],
invalid_skills: dict[str, str],
resources: AgentPackageResources,
agent_package: AgentPackage,
tenant_id: str,
account_id: str,
) -> tuple[AgentPackage, list[DslImportWarning]]:
"""Materialize portable references from archive members or local resource bytes."""
files: list[ToolFile | UploadFile] = []
warnings: list[DslImportWarning] = []
soul_data = agent_package.soul.model_dump(mode="json")
@@ -112,15 +132,11 @@ class AgentPackageResourceImporter:
"mime_type": "application/zip",
}
)
reason = archive.invalid_skills.get(skill_resource.id)
reason = invalid_skills.get(skill_resource.id)
normalized = None
try:
if reason is None:
payload = self._reader.read_member_bytes(
archive,
skill_resource.path,
max_bytes=dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024,
)
payload = read_member(skill_resource.path, dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024)
normalized = self._skill_packages.validate_and_normalize(
content=payload, filename=skill_resource.path
)
@@ -165,7 +181,7 @@ class AgentPackageResourceImporter:
file_kind = package_ref["file_kind"]
mime_type = package_ref["mime_type"] or "application/octet-stream"
extension = self._extension(package_ref["name"])
payload = self._reader.read_member_bytes(archive, file_resource.path, max_bytes=file_resource.size)
payload = read_member(file_resource.path, file_resource.size)
if file_kind == "tool_file":
storage_key = f"tools/{tenant_id}/{uuid4().hex}.{extension or 'bin'}"
row: ToolFile | UploadFile = ToolFile(
@@ -216,13 +232,26 @@ class AgentPackageResourceImporter:
def materialize_icons(
self, *, archive: PreparedPackageArchive, icons: list[PackageIcon], tenant_id: str, account_id: str
) -> dict[str, str]:
return self.materialize_icon_resources(
read_member=lambda path, limit: self._reader.read_member_bytes(archive, path, max_bytes=limit),
icons=icons,
tenant_id=tenant_id,
account_id=account_id,
)
def materialize_icon_resources(
self,
*,
read_member: Callable[[str, int], bytes],
icons: list[PackageIcon],
tenant_id: str,
account_id: str,
) -> dict[str, str]:
files: list[ToolFile | UploadFile] = []
mapping: dict[str, str] = {}
for icon in icons:
payload = self._reader.read_member_bytes(
archive, icon.path, max_bytes=dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024
)
payload = read_member(icon.path, dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024)
extension = self._extension(icon.path)
file_id = str(uuid4())
key = f"upload_files/{tenant_id}/{file_id}.{extension}"

View File

@@ -179,9 +179,9 @@ class RosterAgentPackageManifest(AgentPackageResources):
if set(apps) != {item.path for item in self.apps}:
raise ValueError("app members must match the package app index")
self.validate_packages(app.package for app in apps.values())
validate_icon_references(
self.icons, [metadata for app in apps.values() for metadata in (app.app, app.package.metadata.model_dump())]
)
icon_metadata = [item for app in apps.values() for item in (app.app, app.package.metadata.model_dump())]
icon_metadata.extend(app.site.model_dump() for app in apps.values() if app.site is not None)
validate_icon_references(self.icons, icon_metadata)
@dataclass(kw_only=True)

View File

@@ -1,4 +1,4 @@
"""Export active Roster Agents as portable ``.ifpkg`` archives."""
"""Export active Roster Agents as portable YAML definitions or ``.ifpkg`` archives."""
from __future__ import annotations
@@ -12,6 +12,7 @@ from uuid import UUID
import yaml
from sqlalchemy import or_, select
from sqlalchemy.orm import Session
from configs import dify_config
from core.db.session_factory import session_factory
@@ -49,6 +50,8 @@ from services.agent.roster_package_entities import (
RosterAgentPackageManifest,
)
from services.agent.roster_service import AgentRosterService
from services.app_dsl_service import AppDslService
from services.entities.site_dsl import SiteDsl
from services.plugin.dependencies_analysis import DependenciesAnalysisService
@@ -65,31 +68,46 @@ class RosterAgentPackageExporter:
self._dependency_provider = dependency_provider or DependenciesAnalysisService.generate_dependencies
def export(self, *, tenant_id: str, agent_id: str, version_id: UUID | None) -> RosterAgentPackageExport:
"""Export a visible version, or the shared draft with an active snapshot fallback."""
app, resources, audit = self.collect(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id)
return self._build_archive(app=app, audit=audit, resources=resources)
def export_yaml(self, *, tenant_id: str, agent_id: str, version_id: UUID) -> str:
"""Reuse the standard YAML export semantics, including omitted asset references."""
with session_factory.create_session() as session:
_, app_model = self._load_source(session=session, tenant_id=tenant_id, agent_id=agent_id)
return AppDslService.export_dsl(app_model=app_model, session=session, version_id=version_id)
@staticmethod
def _load_source(*, session: Session, tenant_id: str, agent_id: str) -> tuple[Agent, App]:
row = session.execute(
select(Agent, App)
.join(
App,
(App.id == Agent.app_id) & (App.tenant_id == Agent.tenant_id),
)
.where(
Agent.id == agent_id,
Agent.tenant_id == tenant_id,
Agent.scope == AgentScope.ROSTER,
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
Agent.status == AgentStatus.ACTIVE,
or_(Agent.backing_app_id == Agent.app_id, Agent.backing_app_id.is_(None)),
App.mode == AppMode.AGENT,
App.status == AppStatus.NORMAL,
)
.limit(1)
).one_or_none()
if row is None:
raise AgentNotFoundError()
return row[0], row[1]
def collect(
self, *, tenant_id: str, agent_id: str, version_id: UUID | None
) -> tuple[AgentAppDsl, AgentPackageResourceExporter, RosterAgentPackageAudit]:
"""Collect configuration and resource references without serializing a package."""
resources = AgentPackageResourceExporter(storage_backend=self._storage)
with session_factory.create_session() as session:
row = session.execute(
select(Agent, App)
.join(
App,
(App.id == Agent.app_id) & (App.tenant_id == Agent.tenant_id),
)
.where(
Agent.id == agent_id,
Agent.tenant_id == tenant_id,
Agent.scope == AgentScope.ROSTER,
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
Agent.status == AgentStatus.ACTIVE,
Agent.app_id.is_not(None),
or_(Agent.backing_app_id == Agent.app_id, Agent.backing_app_id.is_(None)),
App.mode == AppMode.AGENT,
App.status == AppStatus.NORMAL,
)
.limit(1)
).one_or_none()
if row is None:
raise AgentNotFoundError()
agent, app_model = row
agent, app_model = self._load_source(session=session, tenant_id=tenant_id, agent_id=agent_id)
draft = None
snapshot_id = agent.active_config_snapshot_id
@@ -136,16 +154,16 @@ class RosterAgentPackageExporter:
)
app = make_agent_app_dsl(app_model, package_ref="agent_1", packages={"agent_1": package}, dependencies=[])
resources.collect_icon(session=session, tenant_id=tenant_id, metadata=app.app)
if (site := app_model.site_with_session(session=session)) is not None:
site_data = SiteDsl.from_site(site).model_dump(mode="json")
resources.collect_icon(session=session, tenant_id=tenant_id, metadata=site_data)
app.site = SiteDsl.model_validate(site_data)
audit = RosterAgentPackageAudit(ref=agent.id)
dependency_ids = extract_agent_soul_dependencies(package.soul)
resources.collect_workspace_skills()
app.dependencies = self._dependency_provider(tenant_id, dependency_ids)
return self._build_archive(
app=app,
audit=audit,
resources=resources,
)
return app, resources, audit
def _build_archive(
self,

View File

@@ -3,13 +3,17 @@
from __future__ import annotations
import logging
from collections.abc import Callable
from dataclasses import dataclass
from typing import BinaryIO
from urllib.parse import urlsplit
from uuid import uuid4
from pydantic import ValidationError
from sqlalchemy import select, update
from sqlalchemy.exc import IntegrityError
from configs import dify_config
from constants.model_template import default_app_templates
from core.db.session_factory import session_factory
from extensions.ext_storage import storage
@@ -25,7 +29,7 @@ from models.agent import (
)
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode, AppModelConfig, IconType, UploadFile
from services.agent.dsl_entities import AgentPackage, AgentPackageMetadata
from services.agent.dsl_entities import AgentAppDsl, AgentPackage, AgentPackageMetadata
from services.agent.dsl_service import AgentDslService
from services.agent.errors import (
AgentNameConflictError,
@@ -35,13 +39,19 @@ from services.agent.errors import (
RosterAgentPackageTooLargeError,
)
from services.agent.package_resource_importer import AgentPackageResourceImporter, _Storage
from services.agent.roster_package_entities import AgentPackageResources, PackageIcon
from services.agent.roster_package_exporter import RosterAgentPackageExporter
from services.agent.roster_package_reader import RosterAgentPackageReader
from services.agent.roster_service import AgentRosterService
from services.app_creation_records import create_installed_app_record, create_site_record
from services.app_dsl_service import AppDslService
from services.app_import_source import download_app_import_source
from services.app_service import AppService
from services.entities.dsl_entities import DslImportWarning
from services.entities.site_dsl import SiteDsl, apply_site_dsl
from services.feature_service import FeatureService
from services.icon_configuration import DEFAULT_ICON, DEFAULT_ICON_BACKGROUND, DEFAULT_ICON_TYPE, is_valid_image_icon
from services.recommended_app_package_service import RecommendedAgentPackageSource
logger = logging.getLogger(__name__)
@@ -59,90 +69,229 @@ class RosterAgentPackageImporter:
def __init__(self, *, storage_backend: _Storage = storage) -> None:
self._resources = AgentPackageResourceImporter(storage_backend=storage_backend)
def import_template(
self,
*,
source: RecommendedAgentPackageSource,
tenant_id: str,
account: Account,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> RosterAgentPackageImportResult:
"""Import a catalog-admitted published source without an outer archive."""
app, resources, _ = RosterAgentPackageExporter().collect(
tenant_id=source.tenant_id, agent_id=source.agent_id, version_id=source.version_id
)
payloads, resource_index, icons = resources.read_local_resources(app.agent.package_ref)
return self.import_collected(
app_dsl=app,
resources=resource_index,
icons=icons,
# Local payloads already passed per-resource and aggregate limits during collection.
read_member=lambda path, _limit: payloads[path],
invalid_skills={},
tenant_id=tenant_id,
account=account,
name=name,
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
)
def import_package_url(
self,
*,
url: str,
tenant_id: str,
account: Account,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> RosterAgentPackageImportResult:
try:
parsed = urlsplit(url)
credential_free = not parsed.username and not parsed.password
except ValueError:
credential_free = False
if not credential_free:
raise InvalidRosterAgentPackageError("Package URL must be an HTTP(S) URL without credentials")
with download_app_import_source(url, max_bytes=dify_config.AGENT_PACKAGE_MAX_BYTES) as source:
return self.import_package(
source=source,
tenant_id=tenant_id,
account=account,
name=name,
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
)
def import_package(
self,
*,
source: BinaryIO,
tenant_id: str,
account: Account,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> RosterAgentPackageImportResult:
with RosterAgentPackageReader().read(source) as package:
if len(package.apps) != 1:
raise InvalidRosterAgentPackageError("Import requires exactly one Agent App")
app_dsl = next(iter(package.apps.values()))
agent_package = app_dsl.package
self._resources.validate(resources=package.manifest, agent_package=agent_package)
return self.import_collected(
app_dsl=next(iter(package.apps.values())),
resources=package.manifest,
icons=package.manifest.icons,
read_member=lambda path, limit: RosterAgentPackageReader().read_member_bytes(
package, path, max_bytes=limit
),
invalid_skills=package.invalid_skills,
tenant_id=tenant_id,
account=account,
name=name,
description=description,
icon_type=icon_type,
icon=icon,
icon_background=icon_background,
)
def import_collected(
self,
*,
app_dsl: AgentAppDsl,
resources: AgentPackageResources,
icons: list[PackageIcon],
read_member: Callable[[str, int], bytes],
invalid_skills: dict[str, str],
tenant_id: str,
account: Account,
name: str | None = None,
description: str | None = None,
icon_type: str | None = None,
icon: str | None = None,
icon_background: str | None = None,
) -> RosterAgentPackageImportResult:
if icon_type is not None:
try:
icons = self._resources.materialize_icons(
archive=package, icons=package.manifest.icons, tenant_id=tenant_id, account_id=account.id
)
if agent_package.metadata.icon_type == "image" and agent_package.metadata.icon in icons:
agent_package.metadata.icon = icons[agent_package.metadata.icon]
app_metadata = AgentPackageMetadata(
name=agent_package.metadata.name,
icon_type=app_dsl.app.get("icon_type"),
icon=app_dsl.app.get("icon"),
icon_background=app_dsl.app.get("icon_background"),
)
if app_metadata.icon_type == "image" and app_metadata.icon in icons:
app_metadata.icon = icons[app_metadata.icon]
materialized, skill_warnings = self._resources.materialize(
archive=package,
resources=package.manifest,
agent_package=agent_package,
tenant_id=tenant_id,
account_id=account.id,
)
with session_factory.create_session() as session:
resolved_soul, warnings = AgentDslService(session).resolve_package_soul(
tenant_id=tenant_id,
package=AgentPackage(metadata=agent_package.metadata, soul=materialized.soul),
package_path="agent",
)
warnings = [*skill_warnings, *warnings]
app_id, agent_id = self._persist_import(
tenant_id=tenant_id,
account=account,
metadata=agent_package.metadata,
app_metadata=app_metadata,
soul=resolved_soul,
)
except Exception as exc:
if isinstance(exc, IntegrityError) and "roster_unique_name" in str(exc):
raise AgentNameConflictError() from exc
if isinstance(
exc,
(
AgentNameConflictError,
InvalidRosterAgentPackageError,
RosterAgentPackageTooLargeError,
RosterAgentPackageResourceUnavailableError,
),
):
raise
raise RosterAgentPackageImportFailedError() from exc
IconType(icon_type)
except ValueError as exc:
raise InvalidRosterAgentPackageError("Invalid icon type") from exc
with session_factory.create_session() as session:
if not is_valid_image_icon(session=session, tenant_id=tenant_id, icon_type=icon_type, icon=icon):
raise InvalidRosterAgentPackageError("Image icon must belong to the current workspace")
agent_package = app_dsl.package
self._resources.validate(resources=resources, agent_package=agent_package)
overrides = {
key: value for key, value in {"name": name, "description": description}.items() if value is not None
}
try:
agent_package.metadata = AgentPackageMetadata.model_validate(
{**agent_package.metadata.model_dump(), **overrides}
)
except ValidationError as exc:
raise InvalidRosterAgentPackageError("Invalid Agent metadata overrides") from exc
try:
# Resolve billing before uploading package members or opening the write transaction.
allow_premium_site_settings = app_dsl.site is None or FeatureService.can_import_premium_site_settings(
tenant_id
)
materialized_icons = self._resources.materialize_icon_resources(
read_member=read_member, icons=icons, tenant_id=tenant_id, account_id=account.id
)
if agent_package.metadata.icon_type == "image" and agent_package.metadata.icon in materialized_icons:
agent_package.metadata.icon = materialized_icons[agent_package.metadata.icon]
app_metadata = AgentPackageMetadata(
name=agent_package.metadata.name,
icon_type=app_dsl.app.get("icon_type"),
icon=app_dsl.app.get("icon"),
icon_background=app_dsl.app.get("icon_background"),
)
if app_metadata.icon_type == "image" and app_metadata.icon in materialized_icons:
app_metadata.icon = materialized_icons[app_metadata.icon]
site_data = app_dsl.site.model_copy(deep=True) if app_dsl.site is not None else None
if site_data is not None and site_data.icon_type == "image":
site_icon = site_data.icon
if site_icon is not None and site_icon in materialized_icons:
site_data = site_data.model_copy(update={"icon": materialized_icons[site_icon]})
for field_name, value in {
"icon_type": icon_type,
"icon": icon,
"icon_background": icon_background,
}.items():
if value is not None:
setattr(agent_package.metadata, field_name, value)
setattr(app_metadata, field_name, value)
materialized, skill_warnings = self._resources.materialize_resources(
read_member=read_member,
invalid_skills=invalid_skills,
resources=resources,
agent_package=agent_package,
tenant_id=tenant_id,
account_id=account.id,
)
with session_factory.create_session() as session:
resolved_soul, warnings = AgentDslService(session).resolve_package_soul(
tenant_id=tenant_id,
package=AgentPackage(metadata=agent_package.metadata, soul=materialized.soul),
package_path="agent",
)
warnings = [*skill_warnings, *warnings]
app_id, agent_id = self._persist_import(
tenant_id=tenant_id,
account=account,
metadata=agent_package.metadata,
app_metadata=app_metadata,
site_data=site_data,
allow_premium_site_settings=allow_premium_site_settings,
soul=resolved_soul,
)
except Exception as exc:
if isinstance(exc, IntegrityError) and "roster_unique_name" in str(exc):
raise AgentNameConflictError() from exc
if isinstance(
exc,
(
AgentNameConflictError,
InvalidRosterAgentPackageError,
RosterAgentPackageTooLargeError,
RosterAgentPackageResourceUnavailableError,
),
):
raise
raise RosterAgentPackageImportFailedError() from exc
try:
self._finalize_app(app_id=app_id, agent_id=agent_id, tenant_id=tenant_id, account=account)
except Exception:
logger.warning(
"Imported Agent App post-commit initialization failed: tenant_id=%s app_id=%s",
tenant_id,
app_id,
exc_info=True,
)
if app_dsl.dependencies:
try:
self._finalize_app(app_id=app_id, agent_id=agent_id, tenant_id=tenant_id, account=account)
AppDslService.cache_import_dependencies(app_id=app_id, dependencies=app_dsl.dependencies)
except Exception:
logger.warning(
"Imported Agent App post-commit initialization failed: tenant_id=%s app_id=%s",
"Imported Agent App dependency check could not be cached: tenant_id=%s app_id=%s",
tenant_id,
app_id,
exc_info=True,
)
if app_dsl.dependencies:
try:
AppDslService.cache_import_dependencies(app_id=app_id, dependencies=app_dsl.dependencies)
except Exception:
logger.warning(
"Imported Agent App dependency check could not be cached: tenant_id=%s app_id=%s",
tenant_id,
app_id,
exc_info=True,
)
return RosterAgentPackageImportResult(app_id=app_id, agent_id=agent_id, warnings=warnings)
return RosterAgentPackageImportResult(app_id=app_id, agent_id=agent_id, warnings=warnings)
@staticmethod
def _persist_import(
@@ -151,7 +300,9 @@ class RosterAgentPackageImporter:
account: Account,
metadata: AgentPackageMetadata,
soul: AgentSoulConfig,
allow_premium_site_settings: bool,
app_metadata: AgentPackageMetadata | None = None,
site_data: SiteDsl | None = None,
) -> tuple[str, str]:
with session_factory.create_session() as session, session.begin():
name = AgentDslService(session).unique_roster_name(tenant_id=tenant_id, requested=metadata.name)
@@ -230,6 +381,17 @@ class RosterAgentPackageImporter:
.values(used=True, used_by=account.id, used_at=naive_utc_now())
)
create_site_record(app=app, account=account, session=session)
if site_data is not None:
site = app.site_with_session(session=session)
if site is None:
raise RuntimeError("Imported App Site is unavailable")
apply_site_dsl(
site=site,
app=app,
data=site_data,
session=session,
allow_premium_settings=allow_premium_site_settings,
)
create_installed_app_record(app=app, session=session)
return app.id, agent.id

View File

@@ -1 +1,4 @@
"""Application services and adapters owned by the App domain."""
"""Application services and adapters owned by the App domain.
New or reworked app_* modules move here without the prefix; keep this file export-free.
"""

View File

@@ -7,6 +7,8 @@ from services.app.agent_app_contracts import AgentReferencingWorkflow
class AgentAppReferences(Protocol):
def resolve_existing_runtime_app_id(self, *, tenant_id: str, agent_id: str) -> str: ...
def list_referencing_workflows(self, *, tenant_id: str, agent_id: str) -> list[AgentReferencingWorkflow]: ...
@@ -24,6 +26,11 @@ class AgentAppAccessService:
def __init__(self, *, references: AgentAppReferences) -> None:
self._references = references
def resolve_existing_runtime_app_id(self, context: RequestContext, agent_id: str) -> str:
return self._references.resolve_existing_runtime_app_id(
tenant_id=context.active_workspace_id, agent_id=agent_id
)
def list_referencing_workflows(self, context: RequestContext, agent_id: str) -> list[AgentReferencingWorkflow]:
return self._references.list_referencing_workflows(tenant_id=context.active_workspace_id, agent_id=agent_id)

View File

@@ -63,6 +63,7 @@ from services.entities.dsl_entities import (
)
from services.errors.account import NoPermissionError
from services.feature_service import FeatureService
from services.recommended_app_package_service import RecommendedAppPackageService
from services.system_feature_service import SystemFeatureService
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
@@ -207,12 +208,14 @@ class AppTransferGateway(AppTransfers, AppDefinitionImports):
packages: AppPackageService,
agent_packages: RosterAgentPackageExporter,
agent_importer: RosterAgentPackageImporter,
recommended_packages: RecommendedAppPackageService,
) -> None:
self._session_factory = session_factory
self._dsl_factory = dsl_factory
self._packages = packages
self._agent_packages = agent_packages
self._agent_importer = agent_importer
self._recommended_packages = recommended_packages
@override
def download_import(self, url: str) -> AbstractContextManager[BinaryIO]:
@@ -290,6 +293,38 @@ class AppTransferGateway(AppTransfers, AppDefinitionImports):
)
return ImportedAppPackage(result.app_id, result.agent_id, result.warnings)
@override
def import_agent_template(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage:
assert params.template_id is not None
assert params.version_id is not None
source = self._recommended_packages.get_source(app_id=str(params.template_id), version_id=params.version_id)
result = self._agent_importer.import_template(
source=source,
tenant_id=context.active_workspace_id,
account=self._import_actor(context),
name=params.name,
description=params.description,
icon_type=params.icon_type,
icon=params.icon,
icon_background=params.icon_background,
)
return ImportedAppPackage(result.app_id, result.agent_id, result.warnings)
@override
def import_agent_package_url(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage:
assert params.package_url is not None
result = self._agent_importer.import_package_url(
url=params.package_url,
tenant_id=context.active_workspace_id,
account=self._import_actor(context),
name=params.name,
description=params.description,
icon_type=params.icon_type,
icon=params.icon,
icon_background=params.icon_background,
)
return ImportedAppPackage(result.app_id, result.agent_id, result.warnings)
@override
def check_dependencies(self, context: RequestContext, app_id: str) -> CheckDependenciesResult:
return AppDslService.check_app_dependencies(tenant_id=context.active_workspace_id, app_id=app_id)

View File

@@ -137,6 +137,10 @@ class AppTransfers(Protocol):
def import_agent_package(self, context: RequestContext, source: BinaryIO) -> ImportedAppPackage: ...
def import_agent_template(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage: ...
def import_agent_package_url(self, context: RequestContext, params: AppImportParams) -> ImportedAppPackage: ...
def check_dependencies(self, context: RequestContext, app_id: str) -> CheckDependenciesResult: ...
def export_dsl(self, context: RequestContext, app_id: str, options: AppExportOptions) -> str: ...
@@ -203,6 +207,30 @@ class ConsoleAppService:
def import_app(self, context: RequestContext, params: AppImportParams, *, source: BinaryIO | None = None) -> Import:
if source is not None:
return self._import_package(context, params, source)
if params.mode == "template":
if (
params.template_id is None
or params.version_id is None
or params.app_id
or any(value is not None for value in (params.package_url, params.yaml_content, params.yaml_url))
):
raise InvalidRosterAgentPackageError(
"template mode requires template_id and version_id without other sources"
)
self._access.require_import(context, "agent")
return self._import_agent_result(self._transfers.import_agent_template(context, params))
if params.mode == "ifpkg-url":
if (
not params.package_url
or params.app_id
or params.yaml_content is not None
or params.yaml_url is not None
):
raise InvalidRosterAgentPackageError(
"ifpkg-url requires package_url and does not accept YAML or app_id"
)
self._access.require_import(context, "agent")
return self._import_agent_result(self._transfers.import_agent_package_url(context, params))
if params.mode == "yaml-url" and params.yaml_url:
self._access.require_import(context, "url")
with self._transfers.download_import(params.yaml_url) as downloaded:
@@ -225,6 +253,9 @@ class ConsoleAppService:
if params.app_id:
raise InvalidRosterAgentPackageError("Roster Agent package import does not support overwriting an App")
result = self._transfers.import_agent_package(context, source)
return self._import_agent_result(result)
def _import_agent_result(self, result: ImportedAppPackage) -> Import:
return Import(
id=str(uuid4()),
status=ImportStatus.COMPLETED_WITH_WARNINGS if result.warnings else ImportStatus.COMPLETED,
@@ -256,6 +287,10 @@ class ConsoleAppService:
):
result.permission_keys = self._access.imported_permissions(context, result.app_id)
def get_reference(self, context: RequestContext, app_id: str) -> AppReference:
"""Admit an existing console app without retaining its database session."""
return self._apps.get_reference(context, app_id)
def check_import_dependencies(self, context: RequestContext, app_id: str) -> CheckDependenciesResult:
self._apps.get_reference(context, app_id)
return self._transfers.check_dependencies(context, app_id)

View File

@@ -0,0 +1,158 @@
"""Application boundary for publishing a Console app as an MCP server."""
from dataclasses import dataclass
from datetime import datetime
from enum import StrEnum
from typing import Any, Protocol
from machinery.context import RequestContext
class AppMCPServerStatus(StrEnum):
"""Publication state of a Dify app exposed as an MCP server; only ACTIVE servers accept MCP calls."""
# Column server default; like INACTIVE, it does not serve MCP calls.
NORMAL = "normal"
ACTIVE = "active"
INACTIVE = "inactive"
@dataclass(frozen=True, slots=True)
class AppMCPServerRecord:
id: str
name: str
server_code: str
description: str
status: AppMCPServerStatus
parameters: str
created_at: datetime
updated_at: datetime
@dataclass(frozen=True, slots=True)
class PublishedApp:
"""The app fields an MCP server mirrors."""
name: str
description: str
@dataclass(frozen=True, slots=True)
class AppMCPServerDraft:
"""Complete field values for creating a server; the store writes them verbatim."""
name: str
description: str
parameters: dict[str, Any]
status: AppMCPServerStatus
@dataclass(frozen=True, slots=True)
class AppMCPServerUpdate:
"""Server edits; a None status leaves the persisted status untouched."""
name: str
description: str
parameters: dict[str, Any]
status: AppMCPServerStatus | None
class AppMCPServerStore(Protocol):
"""Persistence for MCP servers, always scoped by workspace and app."""
def find_app(self, *, workspace_id: str, app_id: str) -> PublishedApp | None:
"""Return the app only when Console may address it (normal status, not a hidden backing app)."""
...
def find_server(self, *, workspace_id: str, app_id: str) -> AppMCPServerRecord | None: ...
def insert_server(self, *, workspace_id: str, app_id: str, draft: AppMCPServerDraft) -> AppMCPServerRecord:
"""Insert with a fresh server code; raise `AppMCPServerAlreadyExistsError` if the app already has one."""
...
def update_server(
self, *, workspace_id: str, app_id: str, server_id: str, update: AppMCPServerUpdate
) -> AppMCPServerRecord:
"""Apply edits, skipping status when None.
Raise `AppMCPServerNotFoundError` if the ID is absent from this scope.
"""
...
def rotate_server_code(self, *, workspace_id: str, app_id: str) -> AppMCPServerRecord:
"""Find this app's server and rotate its code atomically; raise `AppMCPServerNotFoundError` if absent."""
...
class AppMCPServerError(Exception):
"""Base class for framework-neutral app MCP server failures."""
class AppMCPServerAppNotFoundError(AppMCPServerError):
def __init__(self) -> None:
super().__init__("App not found")
class AppMCPServerNotFoundError(AppMCPServerError):
def __init__(self) -> None:
super().__init__("MCP server not found")
class AppMCPServerAlreadyExistsError(AppMCPServerError):
def __init__(self) -> None:
super().__init__("MCP server already exists for this app")
class AppMCPServerService:
"""Owns the publishing rules: a server mirrors its app's name, inherits the app description
unless given one, starts ACTIVE, and keeps its status unless a new one is requested."""
def __init__(self, *, servers: AppMCPServerStore) -> None:
self._servers = servers
def get(self, context: RequestContext, app_id: str) -> AppMCPServerRecord | None:
self._require_app(context.active_workspace_id, app_id)
return self._servers.find_server(workspace_id=context.active_workspace_id, app_id=app_id)
def create(
self, context: RequestContext, app_id: str, *, description: str | None, parameters: dict[str, Any]
) -> AppMCPServerRecord:
workspace_id = context.active_workspace_id
app = self._require_app(workspace_id, app_id)
if self._servers.find_server(workspace_id=workspace_id, app_id=app_id) is not None:
raise AppMCPServerAlreadyExistsError
draft = AppMCPServerDraft(
name=app.name,
description=description or app.description,
parameters=parameters,
status=AppMCPServerStatus.ACTIVE,
)
return self._servers.insert_server(workspace_id=workspace_id, app_id=app_id, draft=draft)
def update(
self,
context: RequestContext,
app_id: str,
*,
server_id: str,
description: str | None,
parameters: dict[str, Any],
status: AppMCPServerStatus | None,
) -> AppMCPServerRecord:
workspace_id = context.active_workspace_id
app = self._require_app(workspace_id, app_id)
update = AppMCPServerUpdate(
name=app.name, description=description or app.description, parameters=parameters, status=status
)
return self._servers.update_server(workspace_id=workspace_id, app_id=app_id, server_id=server_id, update=update)
def refresh(self, context: RequestContext, app_id: str) -> AppMCPServerRecord:
workspace_id = context.active_workspace_id
self._require_app(workspace_id, app_id)
return self._servers.rotate_server_code(workspace_id=workspace_id, app_id=app_id)
def _require_app(self, workspace_id: str, app_id: str) -> PublishedApp:
app = self._servers.find_app(workspace_id=workspace_id, app_id=app_id)
if app is None:
raise AppMCPServerAppNotFoundError
return app

View File

@@ -70,8 +70,10 @@ from services.entities.dsl_entities import (
PendingImportOwner,
make_app_dsl,
)
from services.entities.site_dsl import SiteDsl, apply_site_dsl
from services.errors.account import NoPermissionError
from services.errors.app import WorkflowNotFoundError
from services.feature_service import FeatureService
from services.icon_configuration import (
DEFAULT_ICON,
DEFAULT_ICON_TYPE,
@@ -145,7 +147,7 @@ class AppDslService:
import_app_id: str | None = None,
package: AppImportPackage | None = None,
) -> Import:
"""Import an App DSL, materializing validated archive resources before database writes."""
"""Import an App DSL after checking Site entitlements and staging archive resources."""
self._warnings = []
import_id = str(uuid.uuid4())
@@ -247,6 +249,14 @@ class AppDslService:
error=missing_app_section_error(original_top_level_keys),
)
allow_premium_site_settings = True
if status != ImportStatus.PENDING and data.get("site") is not None:
# Resolve billing before archive uploads or database writes.
tenant_id = account.current_tenant_id
if tenant_id is None:
raise ValueError("Current tenant is not set")
allow_premium_site_settings = FeatureService.can_import_premium_site_settings(tenant_id)
if package is not None and package.has_resources:
tenant_id = account.current_tenant_id
if tenant_id is None:
@@ -344,6 +354,7 @@ class AppDslService:
icon_background=icon_background,
dependencies=check_dependencies_pending_data,
import_app_id=import_app_id,
allow_premium_site_settings=allow_premium_site_settings,
)
draft_var_srv = WorkflowDraftVariableService(session=self._session)
@@ -411,6 +422,13 @@ class AppDslService:
data = yaml.safe_load(pending_data.yaml_content)
self._warnings = list(pending_data.warnings)
allow_premium_site_settings = True
if data.get("site") is not None:
tenant_id = account.current_tenant_id
if tenant_id is None:
raise ValueError("Current tenant is not set")
allow_premium_site_settings = FeatureService.can_import_premium_site_settings(tenant_id)
app = None
if pending_data.app_id:
app = self._load_app_for_overwrite(account, pending_data.app_id)
@@ -434,6 +452,7 @@ class AppDslService:
icon_type=pending_data.icon_type,
icon=pending_data.icon,
icon_background=pending_data.icon_background,
allow_premium_site_settings=allow_premium_site_settings,
)
# Delete import info from Redis
@@ -537,7 +556,7 @@ class AppDslService:
account.id,
scene=RBACPermission.AGENT_IMPORT_EXPORT_DSL,
resource_type=RBACResourceScope.AGENT if binding is not None else None,
resource_id=str(binding.id) if binding is not None else None,
resource_id=binding.id if binding is not None else None,
)
if not allowed:
raise NoPermissionError("Agent DSL import permission is required to import an Agent App")
@@ -574,6 +593,7 @@ class AppDslService:
icon_background: str | None = None,
dependencies: list[PluginDependency] | None = None,
import_app_id: str | None = None,
allow_premium_site_settings: bool = True,
) -> App:
"""Create a new app or update an existing one."""
app_data = data.get("app", {})
@@ -581,6 +601,7 @@ class AppDslService:
if not app_mode:
raise ValueError("loss app mode")
app_mode = AppMode(app_mode)
site_data = SiteDsl.model_validate(data["site"]) if data.get("site") is not None else None
if app_mode == AppMode.AGENT:
self._ensure_agent_import_permission(account, app=app)
@@ -636,6 +657,18 @@ class AppDslService:
self._session.flush()
app_was_created.send(app, account=account, session=self._session)
if site_data is not None:
site = app.site_with_session(session=self._session)
if site is None:
raise ValueError("App Site is unavailable")
apply_site_dsl(
site=site,
app=app,
data=site_data,
session=self._session,
allow_premium_settings=allow_premium_site_settings,
)
# save dependencies
if dependencies:
self.cache_import_dependencies(app_id=app.id, dependencies=dependencies)
@@ -805,7 +838,7 @@ class AppDslService:
version_id: uuid.UUID | None = None,
resource_exporter: AgentPackageResourceExporter | None = None,
) -> AppDslExportData:
"""Load portable data using the caller's transaction, without requesting plugin dependencies."""
"""Load portable App and Site data without requesting plugin dependencies."""
app_mode = AppMode.value_of(app_model.mode)
if app_mode == AppMode.AGENT:
@@ -831,6 +864,11 @@ class AppDslService:
else:
dependencies = cls._append_model_config_export_data(export_data, app_model, session=session)
if (site := app_model.site_with_session(session=session)) is not None:
export_data["site"] = SiteDsl.from_site(site).model_dump(mode="json")
else:
export_data.pop("site", None)
return AppDslExportData(app_model.tenant_id, export_data, dependencies)
@staticmethod

View File

@@ -17,8 +17,11 @@ from services.dsl_content import DSL_MAX_SIZE
@contextmanager
def download_app_import_source(url: str) -> Generator[BinaryIO]:
def download_app_import_source(url: str, *, max_bytes: int | None = None) -> Generator[BinaryIO]:
"""Fetch a bounded source without opening import transactions or creating resources."""
size_limit = max(dify_config.AGENT_PACKAGE_MAX_BYTES, DSL_MAX_SIZE) if max_bytes is None else max_bytes
if size_limit <= 0:
raise ValueError("Import download size limit must be positive")
url = url.strip()
try:
parsed = urlsplit(url)
@@ -49,7 +52,7 @@ def download_app_import_source(url: str) -> Generator[BinaryIO]:
size = 0
for chunk in response.iter_bytes(chunk_size=1024 * 1024):
size += len(chunk)
if size > max(dify_config.AGENT_PACKAGE_MAX_BYTES, DSL_MAX_SIZE):
if size > size_limit:
raise RosterAgentPackageTooLargeError("App import download exceeds the size limit")
source.write(chunk)
finally:

View File

@@ -1,4 +1,4 @@
"""Package App DSLs and their node-bound Agent resources."""
"""Package App DSLs with Site icons and node-bound Agent resources."""
from __future__ import annotations
@@ -37,6 +37,7 @@ from services.agent.roster_package_entities import (
from services.agent.roster_package_reader import RosterAgentPackageReader
from services.dsl_content import DSL_MAX_SIZE
from services.entities.dsl_entities import DslImportWarning
from services.entities.site_dsl import SiteDsl
class AppPackageManifest(BaseModel):
@@ -83,6 +84,8 @@ class PreparedAppPackage(PreparedPackageArchive):
importer.validate(resources=resources, agent_package=self.agents[ref])
mapping = importer.materialize_icons(archive=self, icons=self.icons, tenant_id=tenant_id, account_id=account_id)
metadata = [data["app"], *(agent.metadata for agent in self.agents.values())]
if isinstance(data.get("site"), dict):
metadata.append(data["site"])
for item in metadata:
if isinstance(item, dict):
if item.get("icon_type") == "image" and item.get("icon") in mapping:
@@ -143,9 +146,20 @@ class AppPackageService(RosterAgentPackageReader):
):
raise InvalidRosterAgentPackageError("App package DSL is invalid")
agents = self._validate_agents(app_data, manifest)
site_data = app_data.get("site")
if site_data is not None:
try:
SiteDsl.model_validate(site_data)
except ValidationError as exc:
raise InvalidRosterAgentPackageError("App package Site DSL is invalid") from exc
try:
validate_icon_references(
manifest.icons, [app_data["app"], *(agent.metadata.model_dump() for agent in agents.values())]
manifest.icons,
[
app_data["app"],
*(agent.metadata.model_dump() for agent in agents.values()),
*([site_data] if site_data else []),
],
)
except ValueError as exc:
raise InvalidRosterAgentPackageError("App package icon references are invalid") from exc
@@ -223,6 +237,8 @@ class AppPackageService(RosterAgentPackageReader):
resource_exporter=resources,
)
resources.collect_icon(session=session, tenant_id=app_model.tenant_id, metadata=prepared.data["app"])
if isinstance(prepared.data.get("site"), dict):
resources.collect_icon(session=session, tenant_id=app_model.tenant_id, metadata=prepared.data["site"])
resources.collect_workspace_skills()
if resources.packages:
prepared.data["agent_packages"] = {

View File

@@ -1,11 +1,11 @@
"""Read-only app previews, independent of trial execution and account quotas."""
from collections.abc import Callable, Sequence
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import Protocol
from services.app_definition_query_service import AppSiteConfiguration
from services.app_definition_query_service import AppDefinitionUnavailableError, AppSiteConfiguration
@dataclass(frozen=True, slots=True)
@@ -33,6 +33,8 @@ class AppPreviewDataset:
class AppPreviewQuery(Protocol):
def get_agent_composer(self, *, app: AppPreviewRef) -> Mapping[str, object] | None: ...
def get_app(self, *, app_id: str) -> AppPreviewRef | None: ...
def get_site(self, *, app: AppPreviewRef) -> AppPreviewSite | None: ...
@@ -53,6 +55,12 @@ class AppPreviewOwnerUnavailableError(LookupError):
class AppPreviewQueryService:
def get_agent_composer(self, *, app: AppPreviewRef) -> Mapping[str, object]:
preview = self._apps.get_agent_composer(app=app)
if preview is None:
raise AppDefinitionUnavailableError("Agent preview is unavailable")
return preview
def __init__(self, *, apps: AppPreviewQuery, is_previewable: Callable[[str], bool]) -> None:
self._apps: AppPreviewQuery = apps
self._is_previewable: Callable[[str], bool] = is_previewable

View File

@@ -28,13 +28,6 @@ class AnnotationRef(NamedTuple):
annotation_id: str
class AppMCPServerRef(NamedTuple):
"""MCP server identifiers used to scope downstream resource lookups."""
app: AppRef
server_id: str
class AppRefService:
"""Factory helpers for app and child resource refs."""
@@ -61,8 +54,3 @@ class AppRefService:
def create_annotation_ref(app_ref: AppRef, annotation_id: str) -> AnnotationRef:
"""Bind a candidate annotation ID; ownership is enforced when the ref is consumed."""
return AnnotationRef(app=app_ref, annotation_id=annotation_id)
@staticmethod
def create_mcp_server_ref(app_ref: AppRef, server_id: str) -> AppMCPServerRef:
"""Bind a candidate MCP server ID; ownership is enforced when the ref is consumed."""
return AppMCPServerRef(app=app_ref, server_id=server_id)

View File

@@ -1,6 +1,7 @@
from dataclasses import dataclass
from enum import StrEnum
from typing import Any, Protocol
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
@@ -23,6 +24,9 @@ class ImportStatus(StrEnum):
class AppImportParams(BaseModel):
mode: str = Field(..., description="Import mode")
template_id: UUID | None = None
version_id: UUID | None = None
package_url: str | None = None
yaml_content: str | None = None
yaml_url: str | None = None
name: str | None = None

View File

@@ -0,0 +1,85 @@
"""Portable, presentation-only Site settings for App DSL exports."""
from pydantic import BaseModel, ConfigDict, Field, ValidationInfo, field_validator, model_validator
from sqlalchemy.orm import Session
from constants.languages import get_valid_language, supported_language
from models.model import App, IconType, Site
from services.icon_configuration import is_valid_image_icon
_SITE_EXPORT_CONTEXT = object()
class SiteDsl(BaseModel):
"""Keep deployment identity and access policy owned by the destination Site.
The allowlist deliberately excludes database and owner ids, the site code,
custom domain, token strategy, public prompt setting, and status. The App's
enable_site flag is also outside this schema. These values identify or
expose the destination deployment, so an import must not replace them.
"""
model_config = ConfigDict(extra="forbid")
title: str | None = Field(default=None, max_length=255)
description: str | None = None
icon_type: IconType | None = None
icon: str | None = None
icon_background: str | None = None
default_language: str | None = Field(default=None, min_length=1, max_length=255)
chat_color_theme: str | None = None
chat_color_theme_inverted: bool | None = None
copyright: str | None = None
privacy_policy: str | None = None
input_placeholder: str | None = None
custom_disclaimer: str | None = Field(default=None, max_length=512)
show_workflow_steps: bool | None = None
use_icon_as_answer_icon: bool | None = None
@classmethod
def from_site(cls, site: Site) -> "SiteDsl":
"""Export persisted Site values as an importable DSL, including legacy locales."""
return cls.model_validate(site, from_attributes=True, context=_SITE_EXPORT_CONTEXT)
@field_validator("default_language")
@classmethod
def validate_language(cls, value: str | None, info: ValidationInfo) -> str | None:
if value is None:
return None
if info.context is _SITE_EXPORT_CONTEXT:
return get_valid_language(value)
return supported_language(value)
@model_validator(mode="after")
def validate_required_values(self) -> "SiteDsl":
required = {
"title",
"default_language",
"chat_color_theme_inverted",
"custom_disclaimer",
"show_workflow_steps",
"use_icon_as_answer_icon",
}
values = self.model_dump(exclude_unset=True)
if any(values[name] is None for name in required & values.keys()):
raise ValueError("Required Site settings cannot be null")
return self
def apply_site_dsl(*, site: Site, app: App, data: SiteDsl, session: Session, allow_premium_settings: bool) -> None:
"""Apply portable Site fields, omitting unavailable paid settings and image ids."""
values = data.model_dump(exclude_unset=True)
if not allow_premium_settings:
# Cloud free workspaces must not retain paid Site settings after import.
values.pop("copyright", None)
values.pop("input_placeholder", None)
icon_type = values.get("icon_type", site.icon_type)
icon = values.get("icon", site.icon)
if {"icon_type", "icon"} & values.keys() and not is_valid_image_icon(
session=session, tenant_id=app.tenant_id, icon_type=icon_type, icon=icon
):
# Standalone YAML cannot carry uploaded image bytes across workspaces.
# Match App DSL imports: discard a foreign image id without a warning.
values.update(icon_type=app.icon_type, icon=app.icon, icon_background=app.icon_background)
for name, value in values.items():
setattr(site, name, value)

View File

@@ -7,6 +7,13 @@ from services.entities import feature_entities
class FeatureService:
@classmethod
def can_import_premium_site_settings(cls, tenant_id: str) -> bool:
"""Keep Cloud-only Web app customization out of free workspace imports."""
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:
return True
return cls.get_workspace_plan(tenant_id).is_paid
@classmethod
def get_workspace_plan(cls, tenant_id: str) -> CloudPlan:
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.CLOUD:

View File

@@ -1,5 +1,7 @@
import logging
from collections.abc import Sequence
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import cast
from sqlalchemy import select
@@ -11,7 +13,7 @@ from core.app.entities.app_invoke_entities import InvokeFrom, get_credit_usage_a
from core.llm_generator.llm_generator import LLMGenerator
from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_context import use_credit_usage_metadata
from core.model_manager import ModelManager
from core.model_manager import ModelInstance, ModelManager
from core.ops.entities.trace_entity import TraceTaskName
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
from core.ops.utils import measure_time
@@ -65,6 +67,23 @@ def attach_message_extra_contents(messages: Sequence[Message]) -> None:
message.set_extra_contents([content.model_dump(mode="json", exclude_none=True) for content in contents])
@dataclass(frozen=True, slots=True)
class SuggestedQuestionsContext:
"""Validated configuration and loaded conversation for the legacy history reader.
The conversation's scalar fields remain usable after the preparation session
closes. History loading must use an explicit session, never ORM properties.
"""
app_id: str
tenant_id: str
app_mode: str
message_id: str
conversation: Conversation
instruction_prompt: str | None
model_config: object | None
class MessageService:
@classmethod
def _get_agent_suggested_questions_config(
@@ -284,13 +303,11 @@ class MessageService:
FeedbackCreatedEvent(
context=TelemetryContext(tenant_id=app_model.tenant_id),
payload={
"message_id": str(message.id),
"app_id": str(app_model.id) if app_model.id is not None else None,
"conversation_id": (
str(message.conversation_id) if message.conversation_id is not None else None
),
"from_end_user_id": str(user.id) if isinstance(user, EndUser) and user.id is not None else None,
"from_account_id": str(user.id) if isinstance(user, Account) and user.id is not None else None,
"message_id": message.id,
"app_id": app_model.id,
"conversation_id": message.conversation_id,
"from_end_user_id": user.id if isinstance(user, EndUser) else None,
"from_account_id": user.id if isinstance(user, Account) else None,
"rating": rating.value if rating else None,
"from_source": (
FeedbackFromSource.USER if isinstance(user, EndUser) else FeedbackFromSource.ADMIN
@@ -336,7 +353,7 @@ class MessageService:
return message
@classmethod
def get_suggested_questions_after_answer(
def prepare_suggested_questions_after_answer(
cls,
app_model: App,
user: Account | EndUser | None,
@@ -344,7 +361,8 @@ class MessageService:
invoke_from: InvokeFrom,
*,
session: Session,
) -> list[str]:
) -> SuggestedQuestionsContext | None:
"""Read authorization and feature configuration; None means no published workflow."""
if not user:
raise ValueError("user cannot be None")
@@ -354,7 +372,6 @@ class MessageService:
app_model=app_model, conversation_id=message.conversation_id, user=user, session=session
)
model_manager = ModelManager.for_tenant(tenant_id=app_model.tenant_id)
suggested_questions_after_answer_config: SuggestedQuestionsAfterAnswerConfig = {"enabled": False}
if app_model.mode == AppMode.ADVANCED_CHAT:
@@ -365,7 +382,7 @@ class MessageService:
workflow = workflow_service.get_published_workflow(app_model=app_model, session=session)
if workflow is None:
return []
return None
app_config = AdvancedChatAppConfigManager.get_app_config(app_model=app_model, workflow=workflow)
@@ -411,46 +428,80 @@ class MessageService:
if suggested_questions_after_answer_config.get("enabled", False) is False:
raise SuggestedQuestionsAfterAnswerDisabledError()
try:
model_instance = model_manager.get_default_model_instance(
tenant_id=app_model.tenant_id,
model_type=ModelType.LLM,
)
except Exception:
logger.exception("Failed to resolve the history model for suggested questions")
return []
# get memory of conversation (read-only)
memory = TokenBufferMemory(conversation=conversation, model_instance=model_instance)
histories = memory.get_history_prompt_text(
max_token_limit=3000,
message_limit=3,
)
instruction_prompt = suggested_questions_after_answer_config.get("prompt")
if not isinstance(instruction_prompt, str) or not instruction_prompt.strip():
instruction_prompt = None
configured_model = suggested_questions_after_answer_config.get("model")
with (
measure_time() as timer,
use_credit_usage_metadata({"app_type": get_credit_usage_app_type(app_model.mode)}),
):
questions_sequence = LLMGenerator.generate_suggested_questions_after_answer(
tenant_id=app_model.tenant_id,
histories=histories,
instruction_prompt=instruction_prompt,
model_config=configured_model,
)
questions: list[str] = list(questions_sequence)
# get tracing instance
trace_manager = TraceQueueManager(app_id=app_model.id)
trace_manager.add_trace_task(
TraceTask(
TraceTaskName.SUGGESTED_QUESTION_TRACE, message_id=message_id, suggested_question=questions, timer=timer
)
return SuggestedQuestionsContext(
app_id=app_model.id,
tenant_id=app_model.tenant_id,
app_mode=app_model.mode,
message_id=message_id,
conversation=conversation,
instruction_prompt=instruction_prompt,
model_config=suggested_questions_after_answer_config.get("model"),
)
@staticmethod
def get_suggested_questions_history_model(*, tenant_id: str) -> ModelInstance | None:
"""Resolve the history token counter; None preserves the no-model fallback."""
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
try:
return model_manager.get_default_model_instance(tenant_id=tenant_id, model_type=ModelType.LLM)
except Exception:
logger.exception("Failed to resolve the history model for suggested questions")
return None
@classmethod
def get_suggested_questions_after_answer(
cls,
app_model: App,
user: Account | EndUser | None,
message_id: str,
invoke_from: InvokeFrom,
*,
session: Session,
) -> list[str]:
"""Compatibility entry point; the caller still owns its request session.
Admitted callers use MessageSuggestedQuestionsRuntime to release query
and model-resolution sessions before attachment and provider I/O.
"""
context = cls.prepare_suggested_questions_after_answer(
app_model=app_model, user=user, message_id=message_id, invoke_from=invoke_from, session=session
)
if context is None:
return []
model_instance = cls.get_suggested_questions_history_model(tenant_id=context.tenant_id)
if model_instance is None:
return []
memory = TokenBufferMemory(conversation=context.conversation, model_instance=model_instance)
histories = memory.get_history_prompt_text(max_token_limit=3000, message_limit=3)
with (
measure_time() as timer,
use_credit_usage_metadata({"app_type": get_credit_usage_app_type(context.app_mode)}),
):
questions = list(
LLMGenerator.generate_suggested_questions_after_answer(
tenant_id=context.tenant_id,
histories=histories,
instruction_prompt=context.instruction_prompt,
model_config=context.model_config,
)
)
cls.trace_suggested_questions(context=context, questions=questions, timer=timer)
return questions
@staticmethod
def trace_suggested_questions(
*, context: SuggestedQuestionsContext, questions: list[str], timer: Mapping[str, datetime | None]
) -> None:
trace_manager = TraceQueueManager(app_id=context.app_id)
trace_manager.add_trace_task(
TraceTask(
TraceTaskName.SUGGESTED_QUESTION_TRACE,
message_id=context.message_id,
suggested_question=questions,
timer=timer,
)
)

View File

@@ -1,7 +1,8 @@
"""Bridge admitted apps and actors to the existing suggested-question runtime.
"""Run suggested questions with bounded query and model-resolution sessions.
TODO: Remove this bridge when message configuration, history and model lookups
share a framework-neutral application boundary. Reuse their current policy here.
Keep legacy configuration, history and provider policy with their existing
owners. This bridge controls their lifetimes until those owners accept only
framework-neutral inputs; it never closes the caller's scoped session.
"""
from typing import override
@@ -10,7 +11,11 @@ from flask import current_app
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.app_invoke_entities import InvokeFrom, get_credit_usage_app_type
from core.llm_generator.llm_generator import LLMGenerator
from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_context import use_credit_usage_metadata
from core.ops.utils import measure_time
from extensions.ext_database import db
from models import Account, App
from models.model import EndUser
@@ -65,14 +70,60 @@ class MessageSuggestedQuestionsRuntime(MessageSuggestedQuestions):
f"End user {actor.end_user_id} does not exist for app {app_id} in tenant {app_owner_tenant_id}"
)
# Legacy history/model lookups still use db.session internally. Give them
# one scoped session, released on success or failure without removing the
# caller's session. They may still hold it during provider I/O.
with current_app.app_context():
return MessageService.get_suggested_questions_after_answer(
context = MessageService.prepare_suggested_questions_after_answer(
app_model=app_model,
user=user,
message_id=message_id,
invoke_from=InvokeFrom(actor.invoke_from),
session=db.session(),
session=session,
)
if context is None:
return []
# Legacy model resolution, quota and tracing use db.session and may
# commit. Own one isolated scope so none can commit or close the caller's
# session. Release its sessions explicitly between phases; teardown also
# handles early returns and exceptions.
with current_app.app_context():
history_model = MessageService.get_suggested_questions_history_model(tenant_id=context.tenant_id)
db.session.remove()
if history_model is None:
return []
# Resolve the history model first to preserve the no-model fallback
# even when old history contains invalid file configuration.
with self._session_factory(expire_on_commit=False) as session:
history = TokenBufferMemory.load_history(
conversation=context.conversation,
app_record=app_model,
session=session,
message_limit=3,
)
histories = history.get_prompt_text(model_instance=history_model, max_token_limit=3000)
with (
measure_time() as timer,
use_credit_usage_metadata({"app_type": get_credit_usage_app_type(context.app_mode)}),
):
model = LLMGenerator.prepare_suggested_questions_model(
tenant_id=context.tenant_id, model_config=context.model_config
)
db.session.remove()
questions = (
list(
LLMGenerator.invoke_suggested_questions_after_answer(
prepared_model=model,
histories=histories,
instruction_prompt=context.instruction_prompt,
)
)
if model is not None
else []
)
# Generation can return [] after catching an error. Tracing must not
# inherit an open or failed transaction from that invocation.
db.session.remove()
MessageService.trace_suggested_questions(context=context, questions=questions, timer=timer)
return questions

View File

@@ -37,4 +37,4 @@ class RBACResourceService:
dataset_id = session.scalar(
select(Dataset.id).where(Dataset.pipeline_id == pipeline_id, Dataset.tenant_id == tenant_id)
)
return None if dataset_id is None else str(dataset_id)
return dataset_id

View File

@@ -328,7 +328,11 @@ def _map_app_info(source: Mapping[str, object] | None) -> RecommendedAppInfoReco
def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord:
app_id = source["id"]
name = source["name"]
export_data = source["export_data"]
mode = _enum_string(source["mode"], field="mode")
package_url = source.get("package_url")
if package_url is not None and (not isinstance(package_url, str) or not package_url.strip()):
raise TypeError("package_url must be a non-empty string")
export_data = source.get("export_data", "") if mode == "agent" and package_url else source["export_data"]
if not isinstance(app_id, str):
raise TypeError("id must be a string")
if not isinstance(name, str):
@@ -336,7 +340,6 @@ def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord:
if not isinstance(export_data, str):
raise TypeError("export_data must be a string")
mode = _enum_string(source["mode"], field="mode")
if mode is None:
raise TypeError("mode must be a string or string enum")
return RecommendedAppDetailRecord(
@@ -346,6 +349,7 @@ def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord:
icon_background=cast(str | None, source.get("icon_background")),
mode=mode,
export_data=export_data,
package_url=package_url,
)

View File

@@ -0,0 +1,53 @@
"""Resolve publicly listed Agent templates for direct creation and published exports."""
from typing import BinaryIO, Literal, NamedTuple, Protocol
from uuid import UUID
from services.recommended_app_query_service import RecommendedAppNotFoundError
class RecommendedAgentPackageSource(NamedTuple):
tenant_id: str
agent_id: str
version_id: UUID
class RecommendedAgentPackageQuery(Protocol):
def get_package_source(self, app_id: str, version_id: UUID) -> RecommendedAgentPackageSource | None: ...
class AgentPackageDownload(Protocol):
@property
def archive(self) -> BinaryIO: ...
@property
def filename(self) -> str: ...
def close(self) -> None: ...
class AgentPackageExporter(Protocol):
def export(self, *, tenant_id: str, agent_id: str, version_id: UUID | None) -> AgentPackageDownload: ...
def export_yaml(self, *, tenant_id: str, agent_id: str, version_id: UUID) -> str: ...
class RecommendedAppPackageService:
def __init__(self, *, sources: RecommendedAgentPackageQuery, exporter: AgentPackageExporter) -> None:
self._sources = sources
self._exporter = exporter
def get_source(self, *, app_id: str, version_id: UUID) -> RecommendedAgentPackageSource:
source = self._sources.get_package_source(app_id, version_id)
if source is None:
raise RecommendedAppNotFoundError
return source
def export(self, *, app_id: str, version_id: UUID, format: Literal["yaml", "ifpkg"]) -> str | AgentPackageDownload:
source = self.get_source(app_id=app_id, version_id=version_id)
# Resolve template admission and close the read session before export performs storage I/O.
if format == "yaml":
return self._exporter.export_yaml(
tenant_id=source.tenant_id, agent_id=source.agent_id, version_id=source.version_id
)
return self._exporter.export(tenant_id=source.tenant_id, agent_id=source.agent_id, version_id=source.version_id)

View File

@@ -41,6 +41,8 @@ class RecommendedAppDetailRecord(NamedTuple):
icon_background: str | None
mode: str
export_data: str
package_url: str | None = None
version_id: str | None = None
class RecommendedAppCatalogQuery(Protocol):
@@ -89,6 +91,8 @@ class RecommendedAppDetailSummary(NamedTuple):
mode: str
export_data: str
can_trial: bool
package_url: str | None = None
version_id: str | None = None
class RecommendedAppNotFoundError(Exception):
@@ -154,6 +158,8 @@ class RecommendedAppQueryService:
mode=detail.mode,
export_data=detail.export_data,
can_trial=can_trial,
package_url=detail.package_url,
version_id=detail.version_id,
)
def _with_trial_status(self, apps: Sequence[RecommendedAppRecord]) -> tuple[RecommendedAppSummary, ...]:

View File

@@ -34,13 +34,13 @@ class TrialAppAccessService:
def __init__(self, *, apps: TrialAppAccessQuery) -> None:
self._apps: TrialAppAccessQuery = apps
def get_access(self, *, app_id: str, account_id: str) -> TrialAppRef:
def get_access(self, *, app_id: str, account_id: str, check_usage: bool = True) -> TrialAppRef:
access = self._apps.resolve(app_id=app_id, account_id=account_id)
if access is None:
raise TrialAppUnavailableError(f"App {app_id} is unavailable for trial execution")
# Preserve the first-use allowance, including when the configured limit is zero.
if access.used_count is not None and access.used_count >= access.trial_limit:
if check_usage and access.used_count is not None and access.used_count >= access.trial_limit:
raise TrialAppUsageLimitExceededError(
f"Account {account_id} has used {access.used_count} trials of app {app_id} "
f"with a limit of {access.trial_limit}"

View File

@@ -52,7 +52,7 @@ class TrialAppGenerationService:
def generate_chat(
self, *, trial_app: TrialAppRef, account_id: str, args: Mapping[str, object]
) -> GenerationResponse:
if trial_app.app_mode not in {"chat", "agent-chat", "advanced-chat"}:
if trial_app.app_mode not in {"chat", "agent-chat", "agent", "advanced-chat"}:
raise TrialAppNotChatError(f"App {trial_app.app_id} is not a chat app")
return self._generate(
trial_app=trial_app, account_id=account_id, args={**args, "auto_generate_name": False}, streaming=True

View File

@@ -53,7 +53,7 @@ class _WhitelistResourceKind[ItemT]:
)
if last_id:
stmt = stmt.where(self.model.id > last_id)
ids = [str(resource_id) for resource_id in db.session().scalars(stmt).all()]
ids = list(db.session().scalars(stmt).all())
if not ids:
return
yield ids

View File

@@ -57,11 +57,7 @@ class TestChatMessageApiPermissions:
mock_scalars_result.one.return_value = tenant
monkeypatch.setattr(mock_session_instance, "scalars", mock.Mock(return_value=mock_scalars_result))
mock_session_context = mock.Mock()
mock_session_context.__enter__.return_value = mock_session_instance
monkeypatch.setattr("models.account.Session", lambda _, expire_on_commit: mock_session_context)
account.current_tenant = tenant
account.set_current_tenant_with_session(tenant, session=mock_session_instance)
return account
@pytest.mark.parametrize(

View File

@@ -58,11 +58,7 @@ class TestFeedbackExportApi:
mock_scalars_result.one.return_value = tenant
monkeypatch.setattr(mock_session_instance, "scalars", mock.Mock(return_value=mock_scalars_result))
mock_session_context = mock.Mock()
mock_session_context.__enter__.return_value = mock_session_instance
monkeypatch.setattr("models.account.Session", lambda _, expire_on_commit: mock_session_context)
account.current_tenant = tenant
account.set_current_tenant_with_session(tenant, session=mock_session_instance)
return account
@pytest.fixture

View File

@@ -53,11 +53,7 @@ class TestModelConfigResourcePermissions:
mock_scalars_result.one.return_value = tenant
monkeypatch.setattr(mock_session_instance, "scalars", mock.Mock(return_value=mock_scalars_result))
mock_session_context = mock.Mock()
mock_session_context.__enter__.return_value = mock_session_instance
monkeypatch.setattr("models.account.Session", lambda _, expire_on_commit: mock_session_context)
account.current_tenant = tenant
account.set_current_tenant_with_session(tenant, session=mock_session_instance)
return account
@pytest.mark.parametrize(

View File

@@ -43,7 +43,7 @@ def _create_account_and_tenant(db_session: Session) -> tuple[Account, Tenant]:
db_session.add(join)
db_session.commit()
account.set_tenant_id(tenant.id)
account.set_tenant_id_with_session(tenant.id, session=db_session)
account.timezone = "UTC"
db_session.commit()

View File

@@ -255,7 +255,7 @@ def test_message_suggested_questions_success(
message_id = str(uuid4())
with patch(
"controllers.console.app.message.MessageService.get_suggested_questions_after_answer",
"services.message_suggested_questions_adapters.MessageSuggestedQuestionsRuntime.get_suggested_questions",
return_value=["q1", "q2"],
):
response = test_client_with_containers.get(
@@ -291,7 +291,7 @@ def test_message_suggested_questions_errors(
message_id = str(uuid4())
with patch(
"controllers.console.app.message.MessageService.get_suggested_questions_after_answer",
"services.message_suggested_questions_adapters.MessageSuggestedQuestionsRuntime.get_suggested_questions",
side_effect=exc,
):
response = test_client_with_containers.get(

View File

@@ -53,7 +53,7 @@ def create_console_account_and_tenant(db_session: Session) -> tuple[Account, Ten
)
db_session.commit()
account.set_tenant_id(tenant.id)
account.set_tenant_id_with_session(tenant.id, session=db_session)
account.timezone = "UTC"
db_session.commit()

View File

@@ -78,7 +78,7 @@ class WorkspaceMembersIntegrationFactory:
)
db_session_with_containers.add(join)
db_session_with_containers.commit()
account.current_tenant = tenant
account.set_current_tenant_with_session(tenant, session=db_session_with_containers)
return account
@staticmethod

Some files were not shown because too many files have changed in this diff Show More