Compare commits
57 Commits
deploy/ope
...
perf/contr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d15ac951aa | ||
|
|
0d1bd869ad | ||
|
|
af7f6abb95 | ||
|
|
2a1211baa5 | ||
|
|
3813c4d552 | ||
|
|
8fc8178ff7 | ||
|
|
fb0e55f5e7 | ||
|
|
fb9b97b26d | ||
|
|
80b34b5ab6 | ||
|
|
8db1712ccd | ||
|
|
6dc9de24d8 | ||
|
|
9c11e36cc5 | ||
|
|
1d02154638 | ||
|
|
b4bc25cab6 | ||
|
|
948fd166dd | ||
|
|
38f42f357e | ||
|
|
f24586323b | ||
|
|
bc0279572e | ||
|
|
20d9f15933 | ||
|
|
d42276a5c0 | ||
|
|
5c4489911c | ||
|
|
657624154d | ||
|
|
be8744264f | ||
|
|
0fec636da5 | ||
|
|
ce27ef9dc3 | ||
|
|
f7e5946d81 | ||
|
|
90f7fbd468 | ||
|
|
1545517fe8 | ||
|
|
1b9ddf9ae5 | ||
|
|
9bb9935f44 | ||
|
|
1ff3d3db68 | ||
|
|
18fea7ff0d | ||
|
|
357f697b16 | ||
|
|
e6f1d77ed5 | ||
|
|
4f8bf32b72 | ||
|
|
d8187b6e51 | ||
|
|
51940eac9c | ||
|
|
005f019a80 | ||
|
|
5f6bb5e6d3 | ||
|
|
7020120197 | ||
|
|
fe5dfd756f | ||
|
|
4808976d0c | ||
|
|
1b21b8220d | ||
|
|
bf55d1f6a6 | ||
|
|
855842410a | ||
|
|
33b2f09325 | ||
|
|
4713c3853b | ||
|
|
89e6b71b2d | ||
|
|
27cdf50c7d | ||
|
|
2b88685fbd | ||
|
|
52dcf83de6 | ||
|
|
a4a18e0652 | ||
|
|
6441436b0c | ||
|
|
0c5f2507d0 | ||
|
|
2515cf5b48 | ||
|
|
5283462286 | ||
|
|
f95edc85f5 |
2
.github/actions/setup-core-e2e/action.yml
vendored
2
.github/actions/setup-core-e2e/action.yml
vendored
@@ -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'
|
||||
|
||||
4
.github/actions/setup-web/action.yml
vendored
4
.github/actions/setup-web/action.yml
vendored
@@ -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: |
|
||||
|
||||
104
.github/actions/wait-e2e-web-build/download.cjs
vendored
Normal file
104
.github/actions/wait-e2e-web-build/download.cjs
vendored
Normal 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
|
||||
})
|
||||
}
|
||||
112
.github/actions/wait-e2e-web-build/download.test.cjs
vendored
Normal file
112
.github/actions/wait-e2e-web-build/download.test.cjs
vendored
Normal 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)
|
||||
})
|
||||
6
.github/workflows/api-tests.yml
vendored
6
.github/workflows/api-tests.yml
vendored
@@ -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 \
|
||||
|
||||
16
.github/workflows/cli-e2e.yml
vendored
16
.github/workflows/cli-e2e.yml
vendored
@@ -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/
|
||||
|
||||
219
.github/workflows/main-ci.yml
vendored
219
.github/workflows/main-ci.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/style.yml
vendored
4
.github/workflows/style.yml
vendored
@@ -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:
|
||||
|
||||
42
.github/workflows/web-e2e.yml
vendored
42
.github/workflows/web-e2e.yml
vendored
@@ -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() }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
5
api/fields/app_export_fields.py
Normal file
5
api/fields/app_export_fields.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from fields.base import ResponseModel
|
||||
|
||||
|
||||
class AppExportResponse(ResponseModel):
|
||||
data: str
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"""
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1 +1 @@
|
||||
"""Persistence adapters for App services."""
|
||||
"""Persistence adapters for App services. New or reworked app_* repositories move here; keep this file export-free."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
19
api/repositories/app/console_visibility.py
Normal file
19
api/repositories/app/console_visibility.py
Normal 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)
|
||||
127
api/repositories/app/mcp_server_repository.py
Normal file
127
api/repositories/app/mcp_server_repository.py
Normal 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,
|
||||
)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
158
api/services/app/mcp_server_service.py
Normal file
158
api/services/app/mcp_server_service.py
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"] = {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
85
api/services/entities/site_dsl.py
Normal file
85
api/services/entities/site_dsl.py
Normal 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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
53
api/services/recommended_app_package_service.py
Normal file
53
api/services/recommended_app_package_service.py
Normal 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)
|
||||
@@ -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, ...]:
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user