Compare commits

...

39 Commits

Author SHA1 Message Date
yyh
6e34e4b790 fix(web): preserve requests during session recovery 2026-09-22 11:11:14 +08:00
非法操作
206cf8fb94 refactor(api): isolate trial file upload admission and actors (#42625) 2026-09-22 02:43:22 +00:00
非法操作
a6b41ac6d1 refactor(api): isolate trial generation admission and execution (#42130) 2026-09-22 01:55:33 +00:00
Hao Sun
151680eef9 ci: add deterministic pull request size labels (#42615)
Co-authored-by: surbadest <surbadest@users.noreply.github.com>
2026-09-22 01:40:46 +00:00
chenshiyang
d6491af103 fix(web): resolve tilde-alias SVG URLs at build time (#42654)
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
2026-09-21 13:33:07 +00:00
Stephen Zhou
11be5332bb refactor(i18n): align analysis with Vite environment graphs (#42656) 2026-09-21 13:17:54 +00:00
林玮 (Jade Lin)
a625c62a1d fix(web): skip parameter rules for unavailable models (#42660) 2026-09-21 12:33:26 +00:00
yyh
fb2dba0cf7 chore(web): enforce tooltip styling with shadcn lint (#42667) 2026-09-21 11:45:05 +00:00
yyh
e811c23d37 fix: normalize field label spacing (#42670) 2026-09-21 11:43:01 +00:00
yyh
4ee8095161 fix: correct schedule trigger labels and theme colors (#42665) 2026-09-21 10:47:05 +00:00
Joel
8fb2741963 fix(web): preserve model warnings when reset is unavailable (#42663) 2026-09-21 10:32:56 +00:00
yyh
905afb5be1 fix(ui): align tooltip surfaces with design tokens (#42661) 2026-09-21 10:31:07 +00:00
Joel
f7b594bf79 fix: keep unset agent web app descriptions no fallback (#42662) 2026-09-21 10:22:11 +00:00
zxhlyh
863ce1d70d fix(skills): fix import, tags, duplication, model selection, and TXT icons (#42653) 2026-09-21 09:57:39 +00:00
Ultronen
9089ed40b9 refactor(dataset): replace available_segment_count property with get_available_segment_count(session) (#42523)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-09-21 09:57:27 +00:00
Stream
ad7849a82b chore(api): add union-type and default-param rules to AGENTS.md (#42652) 2026-09-21 09:49:30 +00:00
Wu Tianwei
9a0961a4a9 fix(web): align access point permissions (#42648) 2026-09-21 08:33:38 +00:00
yyh
1dfa8733a5 fix(i18n): avoid hiding the app during hydration (#42647) 2026-09-21 08:15:40 +00:00
Joel
05df951373 fix: rag pipeline accessibility (#42589) 2026-09-21 08:15:03 +00:00
Stephen Zhou
bbaaaf980f fix(web): align icon picker with updated design (#42636) 2026-09-21 07:59:11 +00:00
zyssyz123
af4f19840c feat(agent): record E2B sandbox runtime usage (#42554) 2026-09-21 07:11:12 +00:00
林玮 (Jade Lin)
1fa3245e83 fix(app): include image icons in app packages (#42626) 2026-09-21 06:32:21 +00:00
dependabot[bot]
6ba3d29f07 chore(deps): bump google-cloud-aiplatform from 2.1.0 to 2.1.3 in /api in the google group (#42610)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 06:18:59 +00:00
yyh
bf100beb97 refactor(web): let primitives own local interaction state (#42633) 2026-09-21 06:17:07 +00:00
yyh
22aff66cc0 fix(dify-ui): preserve state-aware styling callbacks (#42631) 2026-09-21 05:56:21 +00:00
林玮 (Jade Lin)
11038c5936 fix(agent): auto-rename duplicate package imports (#42623) 2026-09-21 05:54:22 +00:00
林玮 (Jade Lin)
99df0ca07f fix(api): mark unavailable legacy DSL agent assets as missing (#42622) 2026-09-21 05:54:14 +00:00
Sourav Rajvi
f9129bc500 fix(api): grant in-run tool files to later workflow nodes (#41300) 2026-09-21 05:39:04 +00:00
盐粒 Yanli
2a66bf6f09 refactor(workflow): prepare inputs by entry node type 🤖🤖🤖 (#42595) 2026-09-21 05:26:23 +00:00
Baki Burak Öğün
ee0efe0bdb fix(i18n): check placeholder and tag parity in i18n:check (#42532)
Signed-off-by: Baki Burak Öğün <63836730+bakiburakogun@users.noreply.github.com>
2026-09-21 05:17:54 +00:00
yyh
679785b67e fix(web): align app creation and pricing separators (#42629) 2026-09-21 05:12:47 +00:00
Stephen Zhou
57c140144a ci(web): check image compression savings and support local fixes (#42618) 2026-09-21 05:10:06 +00:00
zyssyz123
d2c2e39ba7 fix(agent): preserve sandbox file errors in stdout 🤖🤖🤖 (#42624) 2026-09-21 04:34:28 +00:00
yyh
3e787d11cc feat(ui): replace legacy Divider with semantic Separator (#42627) 2026-09-21 04:28:29 +00:00
林玮 (Jade Lin)
e78ce4db64 feat(app): package inline agents in workflow and chatflow exports (#42579) 2026-09-21 02:54:43 +00:00
yyh
efd1fb0967 fix(web): migrate legacy plain tabs to Dify UI (#42620) 2026-09-21 02:51:58 +00:00
dependabot[bot]
08a9d40096 chore(deps): bump flask-compress from 1.24 to 1.25 in /api in the flask group (#42609)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 02:28:09 +00:00
Sourav Rajvi
126dee3da6 chore(web): optimize in-site notification header background asset (#42479)
Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com>
2026-09-21 02:13:42 +00:00
dependabot[bot]
bfa243aecf chore(deps): bump the github-actions-dependencies group with 4 updates (#42612)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 01:51:00 +00:00
611 changed files with 19960 additions and 5754 deletions

View File

@@ -26,15 +26,17 @@ Prefer:
## Styling
For Dify UI/Base UI state styling and callback composition, apply the package's [Styling] contract,
including its documented reasons for direct state access.
Flag:
- New CSS modules or ad hoc CSS when Tailwind utilities and Dify tokens cover the need.
- Component-level plain `.css` files or component CSS imported through `globals.css`; use scoped `*.module.css` only when Tailwind and component variants cannot express the style.
- Generic color utilities where Dify semantic tokens exist.
- Hardcoded magic class values for colors, spacing, radius, shadow, z-index, or typography when Dify tokens, component variants, or documented radius mappings exist.
- Hardcoded magic class values for colors, spacing, radius, shadow, z-index, or typography when Dify tokens, component variants, or standard Tailwind radius classes match the required design value. Apply [Styling] when choosing radius classes.
- `!` important modifiers or important CSS overrides without a narrow, documented reason.
- Manual class-list assembly through string concatenation, template strings, array `.join(' ')`, or a custom conditional combiner instead of `cn(...)`. Conditional values passed to `cn(...)` remain valid.
- JS conditional class branches for primitive visual states already exposed by Dify UI/Base UI `data-*` selectors.
- Incoming `className` placed before default classes in `cn(...)`, preventing call-site overrides.
- Arbitrary z-index or one-off layering fixes on overlays.
@@ -43,7 +45,6 @@ Use:
- `cn(...)` from the local package or utility already used by the file.
- Dify semantic tokens and Tailwind v4 utilities.
- Existing component variants before one-off class forks.
- Primitive selectors such as `data-disabled:*`, `data-checked:*`, `data-highlighted:*`, `group-data-*`, `peer-data-*`, and `has-[:focus-visible]` before adding React state or boolean props solely for styling.
- Component-level variants, semantic tokens, and normal cascade/order before `!` overrides. Use `!` only for a contained compatibility override that cannot be expressed through the component API or local selector structure.
## Imports
@@ -66,3 +67,5 @@ Flag:
- Error messages that state only the failure and not the next step.
Use feature-local translation keys by default. Alias only when crossing namespaces. `pnpm i18n:check --file <name>` should pass for any touched translation namespace.
[Styling]: ../../../../packages/dify-ui/docs/styling.md

View File

@@ -16,7 +16,7 @@ owner:
| Forms and field semantics | `packages/dify-ui/docs/forms.md` |
| Selection and typed values | `packages/dify-ui/docs/selection.md` |
| Portals, layers, and floating surfaces | `packages/dify-ui/docs/overlays.md` |
| Tailwind and radius tokens | `packages/dify-ui/docs/styling.md` |
| State styling, callbacks, and Tailwind | `packages/dify-ui/docs/styling.md` |
| Package tests and Storybook | `packages/dify-ui/docs/testing.md` |
For consumer code under `web/`, also read `web/AGENTS.md` for application-owned reuse policy and

View File

@@ -10,6 +10,7 @@ Use this skill to route component architecture decisions to its bundled referenc
## Topic Routing
- Component moves, module boundaries, props, types, or owner placement: read [`references/ownership.md`][ownership].
- Dify UI wrappers or primitive state styling: read the package's [Public API authoring] and [Styling] contracts.
- Jotai, form drafts, route identity, URL state, or persistence: read [`references/state.md`][state].
- Generated contracts, nullable API data, Query, mutations, SSR, auth, or workspace state: read [`references/data.md`][data].
- Hotkeys, focus, dialogs, menus, popovers, or other secondary surfaces: read [`references/interactions.md`][interactions] and the overlay guide it references when applicable. Also read [`references/state.md`][state] when the surface owns a draft or other local session state.
@@ -24,6 +25,8 @@ Identify the behavior owner, state lifetime, and public contract from the nearby
- Write canonical Tailwind v4 classes; prefer canonical utilities over equivalent arbitrary values.
- Common forms include `w-105` instead of `w-[420px]`, `px-2.25` instead of `px-[9px]`, `bg-linear-to-b` instead of `bg-gradient-to-b`, `wrap-break-word` instead of `break-words`, and `field-sizing-content` instead of `[field-sizing:content]`.
[Public API authoring]: ../../../packages/dify-ui/docs/authoring.md
[Styling]: ../../../packages/dify-ui/docs/styling.md
[data]: references/data.md
[interactions]: references/interactions.md
[ownership]: references/ownership.md

94
.github/scripts/size-labeler.test.cjs vendored Normal file
View File

@@ -0,0 +1,94 @@
const fs = require('node:fs')
const assert = require('node:assert/strict')
const test = require('node:test')
const source = fs.readFileSync(
require('node:path').join(__dirname, '../workflows/size-labeler.yml'),
'utf8',
)
const script = source
.replace(/\r\n/g, '\n')
.split(' script: |\n')[1]
.split('\n')
.map((l) => l.replace(/^ /, ''))
.join('\n')
const run = new (Object.getPrototypeOf(async function () {}).constructor)(
'github',
'context',
'core',
script,
)
async function scenario(files, existing = [], changed = files.length) {
const calls = []
const warnings = []
const api = {
pulls: {
get: async () => ({ data: { changed_files: changed } }),
listFiles: 'files',
},
issues: {
listLabelsOnIssue: 'labels',
addLabels: async (p) => calls.push(['add', p.labels[0]]),
removeLabel: async (p) => calls.push(['remove', p.name]),
},
}
await run(
{
rest: api,
paginate: async (endpoint) =>
endpoint === 'files' ? files : existing.map((name) => ({ name })),
},
{
repo: { owner: 'test', repo: 'repo' },
payload: { pull_request: { number: 1 } },
},
{ warning: (s) => warnings.push(s) },
)
return { calls, warnings }
}
for (const [n, label] of [
[0, 'XS'],
[9, 'XS'],
[10, 'S'],
[29, 'S'],
[30, 'M'],
[99, 'M'],
[100, 'L'],
[499, 'L'],
[500, 'XL'],
[999, 'XL'],
[1000, 'XXL'],
]) {
test(`threshold ${n}`, async () =>
assert.deepEqual((await scenario([{ filename: 'a.py', additions: n, deletions: 0 }])).calls, [
['add', `size:${label}`],
]))
}
test('count deletions', async () =>
assert.deepEqual((await scenario([{ filename: 'a.py', additions: 0, deletions: 30 }])).calls, [
['add', 'size:M'],
]))
test('ignore nested lockfiles', async () =>
assert.deepEqual(
(
await scenario([
{ filename: 'api/uv.lock', additions: 9000, deletions: 5 },
{ filename: 'web/pnpm-lock.yaml', additions: 5000, deletions: 0 },
])
).calls,
[['add', 'size:XS']],
))
test('replace stale size only', async () =>
assert.deepEqual(
(await scenario([{ filename: 'a.py', additions: 10, deletions: 0 }], ['size:L', 'bug', 'web']))
.calls,
[
['add', 'size:S'],
['remove', 'size:L'],
],
))
test('idempotent', async () => assert.deepEqual((await scenario([], ['size:XS', 'bug'])).calls, []))
test('incomplete file list', async () => {
const r = await scenario([], ['size:L'], 3001)
assert.deepEqual(r.calls, [])
assert.equal(r.warnings.length, 1)
})

View File

@@ -40,7 +40,7 @@ jobs:
cp envs/middleware.env.example middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0
uses: hoverkraft-tech/compose-action@b659c744ee9bed25aea2307c017ad849bd104433 # v3.2.0
with:
compose-file: |
docker/docker-compose.middleware.yaml
@@ -94,7 +94,7 @@ jobs:
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=mysql/' middleware.env
- name: Set up Middlewares
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0
uses: hoverkraft-tech/compose-action@b659c744ee9bed25aea2307c017ad849bd104433 # v3.2.0
with:
compose-file: |
docker/docker-compose.middleware.yaml

39
.github/workflows/image-resources.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Image optimization
on:
pull_request:
permissions:
contents: read
jobs:
image-resources:
name: Image optimization
runs-on: depot-ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
persist-credentials: false
fetch-depth: 0
- name: Set up workspace toolchain
uses: ./.github/actions/setup-web
with:
run-install: 'false'
- name: Install image checker dependencies
run: pnpm --filter @dify/image-resources... install --frozen-lockfile --ignore-scripts
- name: Type-check image checker
run: pnpm --filter @dify/image-resources type-check
- name: Test image checker
run: pnpm --filter @dify/image-resources test
- name: Check image optimization
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
pnpm --filter @dify/image-resources check:images --base "$BASE_SHA"

55
.github/workflows/size-labeler.yml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: Pull Request Size Labeler
on:
pull_request_target:
types: [opened, reopened, synchronize, ready_for_review, edited]
permissions:
pull-requests: write
concurrency:
group: size-labeler-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
label:
runs-on: depot-ubuntu-24.04
steps:
# Only read PR metadata. Never check out or execute the PR's code here.
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const { owner, repo } = context.repo
const issue_number = context.payload.pull_request.number
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: issue_number })
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: issue_number, per_page: 100,
})
// GitHub caps this endpoint at 3,000 files; avoid labeling a partial count.
if (files.length !== pr.changed_files) {
core.warning('Skipping size label because the changed-file list is incomplete')
return
}
const ignoredNames = new Set(['uv.lock', 'pnpm-lock.yaml', 'package-lock.json', 'yarn.lock'])
const changes = files
.filter(file => !ignoredNames.has(file.filename.split('/').pop()))
.reduce((total, file) => total + file.additions + file.deletions, 0)
// Match the descriptions of the repository's existing size labels.
const buckets = [
[10, 'size:XS'], [30, 'size:S'], [100, 'size:M'],
[500, 'size:L'], [1000, 'size:XL'], [Infinity, 'size:XXL'],
]
const label = buckets.find(([limit]) => changes < limit)[1]
const sizeLabels = new Set(buckets.map(([, name]) => name))
const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner, repo, issue_number, per_page: 100,
})
// Add first so an API error cannot leave the PR without a size label.
if (!labels.some(item => item.name === label)) {
await github.rest.issues.addLabels({ owner, repo, issue_number, labels: [label] })
}
for (const item of labels) {
if (sizeLabels.has(item.name) && item.name !== label) {
await github.rest.issues.removeLabel({ owner, repo, issue_number, name: item.name })
}
}

View File

@@ -162,7 +162,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@3b8197d3d486006dd4af54613517f21ac6ac625e # v1.0.227
uses: anthropics/claude-code-action@4036a180cf690f49529f5d8c79c998855287f590 # v1.0.230
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -37,7 +37,7 @@ jobs:
- name: Restore Next.js build cache
id: next-cache
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: web/.next/cache
key: e2e-next-v1-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml') }}-${{ steps.build-source.outputs.hash }}
@@ -81,7 +81,7 @@ jobs:
- name: Save Next.js build cache
if: github.event_name != 'merge_group' && steps.next-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: web/.next/cache
key: ${{ steps.next-cache.outputs.cache-primary-key }}

View File

@@ -77,9 +77,6 @@ How we prioritize:
AI tools are welcome, but you are responsible for understanding and verifying every change you submit. Describe the problem, your solution, and actual test results in your own words. Unreviewed or repetitive low-quality submissions may be closed.
> [!NOTE]
> If you are an automated agent, append `🤖🤖🤖` to the PR or issue title to opt into expedited review. Your submission will be prioritized.
### Setup the project
#### Frontend

View File

@@ -568,6 +568,22 @@ source_modules =
forbidden_modules =
flask
[importlinter:contract:trial-app-application-boundary]
name = Trial app access and generation services are framework and persistence neutral
type = forbidden
source_modules =
services.trial_app_access_service
services.trial_app_generation_service
forbidden_modules =
controllers
extensions
flask
models
repositories
services.app_generate_service
sqlalchemy
werkzeug
[importlinter:contract:billing-application-boundary]
name = Billing application services and ports are framework and infrastructure neutral
type = forbidden

View File

@@ -23,3 +23,8 @@ Run direct Python commands through `uv run --project api`. Docker-backed integra
- Use Pydantic v2 for request and response models. Reuse domain-specific exceptions and translate them at the controller boundary.
- Use existing Celery task and queue owners for asynchronous work; do not route unrelated jobs through workflow-specific services.
- Celery tasks that may be retried or redelivered must keep side effects idempotent and log affected resource identifiers.
## Typing Best Practises
- Treat nullable types (`T | None`) strictly as union types; document the semantic meaning for each branch.
- Treat default parameter values as introducing distinct function overloads; verify that every added default has demonstrable practical utility and avoids masking unsafe runtime states.

View File

@@ -7,6 +7,18 @@ class AgentBackendConfig(BaseSettings):
Configuration settings for the Agent backend runtime integration.
"""
AGENT_SANDBOX_METERING_ENABLED: bool = Field(default=False, description="Persist independent E2B runtime usage.")
AGENT_SANDBOX_METERING_PROJECT_ID: str = Field(
default="", description="Allowed E2B project/team for this database."
)
AGENT_SANDBOX_METERING_START_AT: str = Field(
default="", description="Immutable UTC activation instant, aligned to a whole second; required when enabled."
)
AGENT_SANDBOX_METERING_INTERVAL_SECONDS: int | str = Field(
default=60,
description="Celery Beat interval in seconds; validated only when registering optional usage collection.",
)
AGENT_BACKEND_BASE_URL: str | None = Field(
description="Base URL for the Dify Agent backend service.",
default=None,

View File

@@ -1086,14 +1086,10 @@ class AppExportApi(Resource):
tenant_id=app_model.tenant_id, agent_id=agent_id, version_id=req_data.version_id
)
else:
exported = AppPackageService().export(
dsl=AppDslService.export_dsl(
app_model=app_model,
session=db.session(),
include_secret=req_data.include_secret,
workflow_id=req_data.workflow_id,
),
name=app_model.name,
exported = AppPackageService().export_app(
app_model=app_model,
include_secret=req_data.include_secret,
workflow_id=req_data.workflow_id,
)
try:
archive_response = send_file(

View File

@@ -37,7 +37,7 @@ from services.app_dsl_service import (
PendingData,
)
from services.app_import_source import download_app_import_source, try_read_yaml
from services.app_package_service import AppPackageService
from services.app_package_service import AppPackageService, PreparedAppPackage
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.errors.account import NoPermissionError
@@ -165,14 +165,16 @@ class AppImportApi(Resource):
if not uploaded.filename.lower().endswith(".ifpkg"):
raise InvalidRosterAgentPackageError("App package file must use the .ifpkg extension")
source = cast(BinaryIO, uploaded.stream)
dsl = AppPackageService().read_dsl(source)
if dsl is not None:
if payload is None:
payload = AppImportPayload.model_validate({**request.form.to_dict(), "mode": "yaml-content"})
return self._import_dsl(
payload.model_copy(update={"mode": "yaml-content", "yaml_content": dsl, "yaml_url": None}),
current_user,
)
package = AppPackageService().read_package(source)
if package is not None:
with package:
if payload is None:
payload = AppImportPayload.model_validate({**request.form.to_dict(), "mode": "yaml-content"})
return self._import_dsl(
payload.model_copy(update={"mode": "yaml-content", "yaml_content": package.dsl, "yaml_url": None}),
current_user,
package=package,
)
return self._import_agent_package(current_user, source, payload)
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_CREATE, Workspace()))
@@ -196,7 +198,13 @@ class AppImportApi(Resource):
@cloud_edition_billing_resource_check("apps")
@rbac_permission_required(RBACCheck(RBACPermission.APP_IMPORT_EXPORT_DSL, Workspace()))
def _import_dsl(self, req_data: AppImportPayload, current_user: Account | None = None):
def _import_dsl(
self,
req_data: AppImportPayload,
current_user: Account | None = None,
*,
package: PreparedAppPackage | None = None,
):
current_user = current_user if current_user is not None else _current_user_and_tenant_id(None)[0]
# AppDslService performs internal commits for some creation paths, so use a plain
@@ -217,6 +225,7 @@ class AppImportApi(Resource):
icon=req_data.icon,
icon_background=req_data.icon_background,
app_id=req_data.app_id,
package=package,
)
except NoPermissionError as e:
raise Forbidden(str(e))

View File

@@ -53,7 +53,6 @@ from controllers.console.wraps import (
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.app_config.features.file_upload.manager import FileUploadConfigManager
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.file_access import DatabaseFileAccessController
from core.db.session_factory import session_factory
@@ -1737,15 +1736,12 @@ class DraftWorkflowTriggerRunApi(Resource):
event = poller.poll()
if not event:
return jsonable_encoder({"status": "waiting", "retry_in": LISTENING_RETRY_IN})
workflow_args = dict(event.workflow_args)
workflow_args[SKIP_PREPARE_USER_INPUTS_KEY] = True
return helper.compact_generate_response(
AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=workflow_args,
args=event.workflow_args,
invoke_from=InvokeFrom.DEBUGGER,
streaming=True,
root_node_id=node_id,
@@ -1894,14 +1890,11 @@ class DraftWorkflowTriggerRunAllApi(Resource):
return jsonable_encoder({"status": "waiting", "retry_in": LISTENING_RETRY_IN})
try:
workflow_args = dict(trigger_debug_event.workflow_args)
workflow_args[SKIP_PREPARE_USER_INPUTS_KEY] = True
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=workflow_args,
args=trigger_debug_event.workflow_args,
invoke_from=InvokeFrom.DEBUGGER,
streaming=True,
root_node_id=trigger_debug_event.node_id,

View File

@@ -3,12 +3,12 @@ from dataclasses import dataclass
from datetime import datetime
from typing import Any, Literal
from flask import request
from flask import Response, request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, field_validator
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized
import services
from controllers.common.fields import (
@@ -19,6 +19,7 @@ from controllers.common.fields import (
from controllers.common.fields import Parameters as ParametersResponse
from controllers.common.fields import Site as SiteResponse
from controllers.common.schema import (
JsonResponseWithStatus,
query_params_from_model,
register_response_schema_models,
register_schema_models,
@@ -45,14 +46,16 @@ from controllers.console.explore.error import (
NotCompletionAppError,
NotWorkflowAppError,
)
from controllers.console.explore.trial_app_admission import get_trial_app
from controllers.console.explore.wraps import TrialAppResource
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request
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, with_current_user
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import (
AppInvokeQuotaExceededError,
ModelCurrentlyNotSupportError,
ProviderTokenNotInitError,
QuotaExceededError,
@@ -61,23 +64,23 @@ 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 extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
from fields.conversation_variable_fields import WorkflowConversationVariableResponse
from fields.file_fields import FileResponse, FileWithSignedUrl
from fields.message_fields import SuggestedQuestionsResponse
from graphon.graph_engine.manager import GraphEngineManager
from graphon.model_runtime.errors.invoke import InvokeError
from graphon.variables import SecretVariable, VariableBase
from libs import helper
from libs.helper import dump_response, to_timestamp, uuid_value
from models import Account, App
from machinery.context import RequestContext
from models import Account
from models.account import TenantStatus
from models.enums import CreatorUserRole
from models.model import AppMode, Site
from models.workflow import Workflow
from services.account_errors import AccountNotFoundError
from services.account_service import TenantService
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_generate_service import AppGenerateService
from services.app_ref_service import AppRefService
from services.app_service import AppResponseView, AppService
from services.audio_service import AudioService
@@ -95,7 +98,14 @@ from services.errors.message import (
MessageNotExistsError,
SuggestedQuestionsAfterAnswerDisabledError,
)
from services.file_service import FileUploadActor
from services.message_service import MessageService
from services.trial_app_access_service import TrialAppRef
from services.trial_app_generation_service import (
TrialAppNotChatError,
TrialAppNotCompletionError,
TrialAppNotWorkflowError,
)
logger = logging.getLogger(__name__)
@@ -453,68 +463,66 @@ register_response_schema_models(
simple_account_model = console_ns.models[TrialSimpleAccount.__name__]
class TrialAppFileUploadApi(TrialAppResource):
@cloud_edition_billing_resource_check("documents")
class TrialAppFileUploadApi(Resource):
@console_ns.doc(consumes=["multipart/form-data"], params=FILE_UPLOAD_PARAMS)
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileResponse.__name__])
@with_current_user
def post(self, current_user: Account, app_model: App):
@console_account_admission()
@get_trial_app
@cloud_edition_billing_resource_check("documents")
def post(self, request_context: RequestContext, trial_app: TrialAppRef) -> JsonResponseWithStatus:
"""Upload a file into the tenant that owns the trial app."""
upload_file = upload_file_from_request(
current_user=current_user,
resource_tenant_id=app_model.tenant_id,
upload_file = upload_file_from_request_context(
request_context=request_context,
resource_tenant_id=trial_app.tenant_id,
)
return dump_response(FileResponse, upload_file), 201
class TrialAppRemoteFileUploadApi(TrialAppResource):
@cloud_edition_billing_resource_check("documents")
class TrialAppRemoteFileUploadApi(Resource):
@console_ns.expect(console_ns.models[RemoteFileUploadPayload.__name__])
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__])
@with_current_user
@console_account_admission()
@get_trial_app
@cloud_edition_billing_resource_check("documents")
@model_validate(RemoteFileUploadPayload)
def post(self, payload: RemoteFileUploadPayload, current_user: Account, app_model: App):
def post(
self, payload: RemoteFileUploadPayload, request_context: RequestContext, trial_app: TrialAppRef
) -> JsonResponseWithStatus:
"""Upload a remote file into the tenant that owns the trial app."""
remote_file = upload_remote_file(
url=payload.url,
current_user=current_user,
resource_tenant_id=app_model.tenant_id,
current_user=FileUploadActor(id=request_context.account_id, creator_role=CreatorUserRole.ACCOUNT),
resource_tenant_id=trial_app.tenant_id,
)
return dump_response(FileWithSignedUrl, remote_file), 201
class TrialAppWorkflowRunApi(TrialAppResource):
class TrialAppWorkflowRunApi(Resource):
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
@console_ns.response(200, "Success")
@with_current_user
@with_session
@console_account_admission()
@get_trial_app
@model_validate(WorkflowRunRequest)
def post(self, req_data: WorkflowRunRequest, session: Session, current_user: Account, trial_app):
def post(self, req_data: WorkflowRunRequest, request_context: RequestContext, trial_app: TrialAppRef) -> Response:
"""
Run workflow
"""
app_model = trial_app
if not app_model:
raise NotWorkflowAppError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode != AppMode.WORKFLOW:
raise NotWorkflowAppError()
args = req_data.model_dump()
try:
app_id = app_model.id
user_id = current_user.id
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=args,
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
response = application_services().trial_app_generation.generate_workflow(
trial_app=trial_app,
account_id=request_context.account_id,
args=req_data.model_dump(),
)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except TrialAppNotWorkflowError as error:
raise NotWorkflowAppError() from error
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AccountNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
except services.errors.app_model_config.AppModelConfigBrokenError as error:
raise AppUnavailableError() from error
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description)
except QuotaExceededError:
@@ -523,7 +531,7 @@ class TrialAppWorkflowRunApi(TrialAppResource):
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except InvokeRateLimitError as ex:
except (AppInvokeQuotaExceededError, InvokeRateLimitError) as ex:
raise InvokeRateLimitHttpError(ex.description)
except ValueError as e:
raise e
@@ -532,41 +540,29 @@ class TrialAppWorkflowRunApi(TrialAppResource):
raise InternalServerError()
class TrialAppWorkflowTaskStopApi(TrialAppResource):
class TrialAppWorkflowTaskStopApi(Resource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
def post(self, trial_app, task_id: str):
@console_account_admission()
@get_trial_app
def post(self, request_context: RequestContext, trial_app: TrialAppRef, task_id: str) -> dict[str, object]:
"""
Stop workflow task
"""
app_model = trial_app
if not app_model:
raise NotWorkflowAppError()
app_mode = AppMode.value_of(app_model.mode)
if app_mode != AppMode.WORKFLOW:
if trial_app.app_mode != "workflow":
raise NotWorkflowAppError()
# Stop using both mechanisms for backward compatibility
# Legacy stop flag mechanism (without user check)
AppQueueManager.set_stop_flag_no_user_check(task_id)
application_services().app_tasks.stop_workflow_task_no_user_check(task_id=task_id)
# New graph engine command channel mechanism
GraphEngineManager(redis_client).send_stop_command(task_id)
return {"result": "success"}
return dump_response(SimpleResultResponse, {"result": "success"})
class TrialChatApi(TrialAppResource):
class TrialChatApi(Resource):
@console_ns.expect(console_ns.models[ChatRequest.__name__])
@console_ns.response(200, "Success")
@with_current_user
@with_session
@console_account_admission()
@get_trial_app
@model_validate(ChatRequest)
def post(self, req_data: ChatRequest, session: Session, current_user: Account, trial_app):
app_model = trial_app
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT}:
raise NotChatAppError()
def post(self, req_data: ChatRequest, request_context: RequestContext, trial_app: TrialAppRef) -> Response:
args = req_data.model_dump()
# Validate UUID values if provided
@@ -575,24 +571,20 @@ class TrialChatApi(TrialAppResource):
if args.get("parent_message_id"):
args["parent_message_id"] = uuid_value(args["parent_message_id"])
args["auto_generate_name"] = False
try:
# Get IDs before they might be detached from session
app_id = app_model.id
user_id = current_user.id
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
response = application_services().trial_app_generation.generate_chat(
trial_app=trial_app,
account_id=request_context.account_id,
args=args,
invoke_from=InvokeFrom.EXPLORE,
streaming=True,
)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except TrialAppNotChatError as error:
raise NotChatAppError() from error
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AccountNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
@@ -608,7 +600,7 @@ class TrialChatApi(TrialAppResource):
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except InvokeRateLimitError as ex:
except (AppInvokeQuotaExceededError, InvokeRateLimitError) as ex:
raise InvokeRateLimitHttpError(ex.description)
except ValueError as e:
raise e
@@ -765,39 +757,27 @@ class TrialChatTextApi(TrialAppResource):
raise InternalServerError()
class TrialCompletionApi(TrialAppResource):
class TrialCompletionApi(Resource):
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
@console_ns.response(200, "Success")
@with_current_user
@with_session
@console_account_admission()
@get_trial_app
@model_validate(CompletionRequest)
def post(self, req_data: CompletionRequest, session: Session, current_user: Account, trial_app):
app_model = trial_app
if app_model.mode != "completion":
raise NotCompletionAppError()
args = req_data.model_dump()
streaming = args["response_mode"] == "streaming"
args["auto_generate_name"] = False
def post(self, req_data: CompletionRequest, request_context: RequestContext, trial_app: TrialAppRef) -> Response:
try:
# Get IDs before they might be detached from session
app_id = app_model.id
user_id = current_user.id
response = AppGenerateService.generate(
session=session,
app_model=app_model,
user=current_user,
args=args,
invoke_from=InvokeFrom.EXPLORE,
streaming=streaming,
response = application_services().trial_app_generation.generate_completion(
trial_app=trial_app,
account_id=request_context.account_id,
args=req_data.model_dump(),
)
application_services().trial_app_usage.record(app_id=app_id, account_id=user_id)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except TrialAppNotCompletionError as error:
raise NotCompletionAppError() from error
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except AccountNotFoundError as error:
raise Unauthorized("Account no longer exists.") from error
except services.errors.conversation.ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
except services.errors.conversation.ConversationCompletedError:
@@ -813,6 +793,8 @@ class TrialCompletionApi(TrialAppResource):
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except (AppInvokeQuotaExceededError, InvokeRateLimitError) as ex:
raise InvokeRateLimitHttpError(ex.description)
except ValueError as e:
raise e
except Exception:

View File

@@ -0,0 +1,49 @@
"""Trial execution admission for handlers using a detached app reference."""
from collections.abc import Callable
from functools import wraps
from typing import Concatenate
from controllers.console.explore.error import TrialAppFeatureDisabledError, TrialAppLimitExceeded, TrialAppNotAllowed
from extensions.ext_application_services import application_services
from machinery.context import RequestContext
from services.trial_app_access_service import (
TrialAppRef,
TrialAppUnavailableError,
TrialAppUsageLimitExceededError,
)
def trial_feature_enable[**P, R](view: Callable[P, R]) -> Callable[P, R]:
"""Gate trial execution before resolving app membership or account usage."""
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
if not application_services().recommended_app_queries.is_trial_enabled():
raise TrialAppFeatureDisabledError()
return view(*args, **kwargs)
return decorated
def get_trial_app[T, **P, R](
view: Callable[Concatenate[T, RequestContext, TrialAppRef, P], R],
) -> Callable[Concatenate[T, RequestContext, P], R]:
"""Check trial availability and inject an admitted app after Console admission."""
@wraps(view)
def decorated(self: T, request_context: RequestContext, /, *args: P.args, **kwargs: P.kwargs) -> R:
app_id = kwargs.pop("app_id", None)
if app_id is None:
raise RuntimeError("The trial app admission route must provide app_id")
try:
trial_app = application_services().trial_app_access.get_access(
app_id=str(app_id), account_id=request_context.account_id
)
except TrialAppUnavailableError as error:
raise TrialAppNotAllowed() from error
except TrialAppUsageLimitExceededError as error:
raise TrialAppLimitExceeded() from error
return view(self, request_context, trial_app, *args, **kwargs)
return trial_feature_enable(decorated)

View File

@@ -8,12 +8,11 @@ from werkzeug.exceptions import NotFound
from controllers.console.explore.error import (
AppAccessDeniedError,
TrialAppFeatureDisabledError,
TrialAppLimitExceeded,
TrialAppNotAllowed,
)
from controllers.console.explore.trial_app_admission import trial_feature_enable
from controllers.console.wraps import account_initialization_required
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from libs.login import current_account_with_tenant, login_required
from models import AccountTrialAppRecord, App, InstalledApp, TrialApp
@@ -107,16 +106,6 @@ def trial_app_required[**P, R](view: Callable[Concatenate[App, P], R] | None = N
return decorator
def trial_feature_enable[**P, R](view: Callable[P, R]):
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs):
if not application_services().recommended_app_queries.is_trial_enabled():
raise TrialAppFeatureDisabledError()
return view(*args, **kwargs)
return decorated
class InstalledAppResource(Resource):
# must be reversed if there are multiple decorators

View File

@@ -1,8 +1,11 @@
from collections.abc import Generator
from contextlib import contextmanager
from typing import Literal
from uuid import UUID
from flask import request
from flask_restx import Resource
from werkzeug.datastructures import FileStorage
from werkzeug.exceptions import Forbidden
import services
@@ -29,10 +32,12 @@ from controllers.console.wraps import (
from extensions.ext_application_services import application_services
from fields.file_fields import FileResponse, UploadConfig
from libs.helper import dump_response
from libs.login import login_required
from libs.login import current_account_with_tenant, login_required
from machinery.context import RequestContext
from models import Account, UploadFile
from models.enums import CreatorUserRole
from services.feature_service import FeatureService
from services.file_service import FileUploadActor, FileUploadResult
register_response_schema_models(
console_ns,
@@ -61,8 +66,8 @@ FILE_UPLOAD_PARAMS = {
}
def upload_file_from_request(*, current_user: Account, resource_tenant_id: str | None = None) -> UploadFile:
"""Validate the multipart request and persist the file under the requested resource tenant."""
def _parse_file_upload() -> tuple[FileStorage, str, Literal["datasets"] | None]:
"""Validate multipart shape before checking source-specific permissions."""
source_str = request.args.get("source") or request.form.get("source")
source: Literal["datasets"] | None = "datasets" if source_str == "datasets" else None
@@ -75,21 +80,37 @@ def upload_file_from_request(*, current_user: Account, resource_tenant_id: str |
if not file.filename:
raise FilenameNotExistsError()
return file, file.filename, source
@contextmanager
def _file_upload_errors() -> Generator[None, None, None]:
try:
yield
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description or "File size exceeded.") from file_too_large_error
except services.errors.file.UnsupportedFileTypeError as unsupported_file_type_error:
raise UnsupportedFileTypeError() from unsupported_file_type_error
except services.errors.file.BlockedFileExtensionError as blocked_extension_error:
raise BlockedFileExtensionError(blocked_extension_error.description) from blocked_extension_error
def upload_file_from_request(*, current_user: Account, resource_tenant_id: str | None = None) -> UploadFile:
"""Validate the multipart request and persist the file under the requested resource tenant."""
file, filename, source = _parse_file_upload()
if source == "datasets" and not current_user.is_dataset_editor:
raise Forbidden()
if source not in ("datasets", None):
source = None
default_file_size_limit = (
FeatureService.get_knowledge_file_size_limit(resource_tenant_id or current_user.current_tenant_id)
if source == "datasets"
else None
)
try:
with _file_upload_errors():
return application_services().files.upload_file(
filename=file.filename,
filename=filename,
content=file.stream.read(),
mimetype=file.mimetype,
user=current_user,
@@ -97,12 +118,28 @@ def upload_file_from_request(*, current_user: Account, resource_tenant_id: str |
source=source,
default_file_size_limit=default_file_size_limit,
)
except services.errors.file.FileTooLargeError as file_too_large_error:
raise FileTooLargeError(file_too_large_error.description) from file_too_large_error
except services.errors.file.UnsupportedFileTypeError as unsupported_file_type_error:
raise UnsupportedFileTypeError() from unsupported_file_type_error
except services.errors.file.BlockedFileExtensionError as blocked_extension_error:
raise BlockedFileExtensionError(blocked_extension_error.description) from blocked_extension_error
def upload_file_from_request_context(*, request_context: RequestContext, resource_tenant_id: str) -> FileUploadResult:
"""Admit a Console upload and pass only its creator and resource owner to storage."""
file, filename, source = _parse_file_upload()
if source == "datasets" and not current_account_with_tenant().account.is_dataset_editor:
raise Forbidden()
default_file_size_limit = (
FeatureService.get_knowledge_file_size_limit(resource_tenant_id) if source == "datasets" else None
)
actor = FileUploadActor(id=request_context.account_id, creator_role=CreatorUserRole.ACCOUNT)
with _file_upload_errors():
return application_services().files.upload_file_for_actor(
actor=actor,
resource_tenant_id=resource_tenant_id,
filename=filename,
content=file.stream.read(),
mimetype=file.mimetype,
source=source,
default_file_size_limit=default_file_size_limit,
)
@console_ns.route("/files/upload")

View File

@@ -1,3 +1,5 @@
from typing import overload
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
@@ -25,6 +27,7 @@ from libs.helper import dump_response
from libs.login import login_required
from machinery.context import RequestContext
from models import Account
from services.file_service import FileUploadActor
from services.remote_file_service import (
RemoteFileAccessDeniedError as RemoteFileAccessDeniedServiceError,
)
@@ -84,13 +87,37 @@ class GetRemoteFileInfo(Resource):
)
@overload
def upload_remote_file(
*,
url: str,
current_user: Account,
resource_tenant_id: str | None = None,
) -> RemoteFileUploadResult: ...
@overload
def upload_remote_file(
*,
url: str,
current_user: FileUploadActor,
resource_tenant_id: str,
) -> RemoteFileUploadResult: ...
def upload_remote_file(
*,
url: str,
current_user: Account | FileUploadActor,
resource_tenant_id: str | None = None,
) -> RemoteFileUploadResult:
"""Fetch a remote file and persist it under the requested tenant."""
"""Fetch a remote file and persist it under the requested tenant.
For Account, resource_tenant_id=None (including when omitted) uses the
current workspace; an explicit value takes precedence. FileUploadActor
carries no tenant, so resource_tenant_id must be provided explicitly.
RemoteFileService enforces this before fetching the remote file.
"""
try:
return application_services().remote_files.upload_from_url(
url=url,

View File

@@ -19,6 +19,7 @@ from . import mail as _mail
from . import runtime_credentials as _runtime_credentials
from .agent import files as _agent_files
from .agent import llm as _agent_llm
from .agent import runtime_usage as _agent_runtime_usage
from .agent import tools as _agent_tools
from .app import dsl as _app_dsl
from .app import file_grants as _app_file_grants
@@ -34,6 +35,7 @@ __all__ = [
"_agent_config",
"_agent_files",
"_agent_llm",
"_agent_runtime_usage",
"_agent_tools",
"_app_dsl",
"_app_file_grants",

View File

@@ -0,0 +1,100 @@
"""Trusted ingestion for independently persisted E2B execution usage."""
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import agent_inner_api_only
from fields.base import ResponseModel
from libs.exception import BaseHTTPException
from libs.helper import dump_response
from services.agent.runtime_usage_service import SandboxUsageError, SandboxUsageEvent, SandboxUsageService
class SandboxUsageHttpError(BaseHTTPException):
error_code = "sandbox_usage_invalid_request"
description = "Invalid sandbox usage request."
code = 400
def __init__(self, error: SandboxUsageError | None = None) -> None:
if error is not None:
self.error_code = error.code
self.description = error.code
self.code = error.status_code
super().__init__(self.description)
class SandboxUsagePayload(BaseModel):
project_id: str = Field(min_length=1, max_length=128)
events: list[SandboxUsageEvent] = Field(min_length=1, max_length=100)
model_config = ConfigDict(extra="forbid")
class SandboxUsageQuery(BaseModel):
project_id: str = Field(min_length=1, max_length=128)
model_config = ConfigDict(extra="forbid")
class SandboxUsageResponse(ResponseModel):
accepted: int
duplicates: int
conflicts: int
ignored: int
class SandboxUsageDiagnosticsResponse(ResponseModel):
unresolved_events: int = 0
conflict_events: int = 0
open_executions: int = 0
unattributed_executions: int = 0
class SandboxUsageStateResponse(ResponseModel):
enabled: bool
project_id: str
started_at: str | None
checkpoint_at: str | None
full_scan_at: str | None
diagnostics: SandboxUsageDiagnosticsResponse
register_schema_models(inner_api_ns, SandboxUsagePayload, SandboxUsageQuery)
register_response_schema_models(inner_api_ns, SandboxUsageResponse, SandboxUsageStateResponse)
@inner_api_ns.route("/agent/sandbox-usage/events")
class SandboxUsageEventsApi(Resource):
@agent_inner_api_only
@inner_api_ns.doc("inner_agent_sandbox_usage_events")
@inner_api_ns.expect(inner_api_ns.models[SandboxUsagePayload.__name__])
@inner_api_ns.response(200, "Durably committed", inner_api_ns.models[SandboxUsageResponse.__name__])
def post(self):
request.max_content_length = 1024 * 1024
try:
payload = SandboxUsagePayload.model_validate(inner_api_ns.payload or {})
result = SandboxUsageService.ingest(project_id=payload.project_id, events=payload.events)
except ValidationError as exc:
raise SandboxUsageHttpError() from exc
except SandboxUsageError as exc:
raise SandboxUsageHttpError(exc) from exc
return dump_response(SandboxUsageResponse, result)
@inner_api_ns.route("/agent/sandbox-usage/state")
class SandboxUsageStateApi(Resource):
@agent_inner_api_only
@inner_api_ns.doc("inner_agent_sandbox_usage_state", params=query_params_from_model(SandboxUsageQuery))
@inner_api_ns.response(
200, "Persisted activation and completed scans", inner_api_ns.models[SandboxUsageStateResponse.__name__]
)
def get(self):
try:
query = SandboxUsageQuery.model_validate(request.args.to_dict(flat=True))
result = SandboxUsageService.get_state(project_id=query.project_id)
except ValidationError as exc:
raise SandboxUsageHttpError() from exc
except SandboxUsageError as exc:
raise SandboxUsageHttpError(exc) from exc
return dump_response(SandboxUsageStateResponse, result)

View File

@@ -41,6 +41,8 @@ from core.helper.trace_id_helper import (
from core.ops.ops_trace_manager import TraceQueueManager
from core.repositories import DifyCoreRepositoryFactory
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from core.trigger.constants import is_trigger_node_type
from core.workflow.node_factory import get_default_root_node_id
from extensions.ext_database import db
from factories import file_factory
from graphon.filters import ResponseStreamFilter
@@ -58,8 +60,6 @@ from services.workflow_draft_variable_service import DraftVarLoader, WorkflowDra
if TYPE_CHECKING:
from controllers.console.app.workflow import LoopNodeRunPayload
SKIP_PREPARE_USER_INPUTS_KEY = "_skip_prepare_user_inputs"
logger = logging.getLogger(__name__)
@@ -89,10 +89,6 @@ class WorkflowAppGenerator(BaseAppGenerator):
return workflow
return SnippetGenerateService.ensure_start_node_for_worker(workflow, snippet)
@staticmethod
def _should_prepare_user_inputs(args: Mapping[str, Any]) -> bool:
return not bool(args.get(SKIP_PREPARE_USER_INPUTS_KEY))
@overload
def generate(
self,
@@ -201,9 +197,11 @@ class WorkflowAppGenerator(BaseAppGenerator):
**extract_trace_session_id_from_args(args),
}
workflow_run_id = str(workflow_run_id or uuid.uuid4())
# FIXME (Yeuoly): we need to remove the SKIP_PREPARE_USER_INPUTS_KEY from the args
# trigger shouldn't prepare user inputs
if self._should_prepare_user_inputs(args):
root_node_id = root_node_id or get_default_root_node_id(workflow.graph_dict)
root_node_config = workflow.get_node_config_by_id(root_node_id)
root_node_type = workflow.get_node_type_from_node_config(root_node_config)
# Trigger inputs are already adapted event data, not Start form fields.
if not is_trigger_node_type(root_node_type):
inputs = self._prepare_user_inputs(
user_inputs=inputs,
variables=app_config.variables,

View File

@@ -5,6 +5,7 @@ from .scope import (
bind_file_access_scope,
get_current_file_access_scope,
grant_retriever_segment_access,
grant_tool_file_access,
grant_upload_file_access,
is_retriever_segment_access_granted,
)
@@ -16,6 +17,7 @@ __all__ = [
"bind_file_access_scope",
"get_current_file_access_scope",
"grant_retriever_segment_access",
"grant_tool_file_access",
"grant_upload_file_access",
"is_retriever_segment_access_granted",
]

View File

@@ -19,8 +19,8 @@ class DatabaseFileAccessController(FileAccessControllerProtocol):
Tenant scoping remains mandatory. When the current execution belongs to an
end user, the lookup is additionally constrained to that end user's file
ownership markers, plus upload files explicitly granted by the current
execution context.
ownership markers, plus upload and tool files explicitly granted by the
current execution context.
"""
_scope_getter: Callable[[], FileAccessScope | None]
@@ -80,7 +80,16 @@ class DatabaseFileAccessController(FileAccessControllerProtocol):
if not resolved_scope.requires_user_ownership:
return scoped_stmt
return scoped_stmt.where(ToolFile.user_id == resolved_scope.user_id)
user_owned_filter = ToolFile.user_id == resolved_scope.user_id
if not resolved_scope.granted_tool_file_ids:
return scoped_stmt.where(user_owned_filter)
return scoped_stmt.where(
or_(
user_owned_filter,
ToolFile.id.in_(resolved_scope.granted_tool_file_ids),
)
)
@override
def get_upload_file(

View File

@@ -21,6 +21,11 @@ class FileAccessScope:
that were returned by trusted retrieval paths without changing persistent
ownership markers.
``granted_tool_file_ids`` is the same pattern for ToolFile records created
during this run (plugin downloads, workflow-as-tool outputs). Those files
may be owned by a different user_id than the chatting end user, which is
why later LLM nodes otherwise drop them.
``granted_retriever_segment_ids`` gates lazy attachment loading by segment
ID, so user-provided context cannot make a later LLM node load arbitrary
same-tenant knowledge attachments.
@@ -32,6 +37,7 @@ class FileAccessScope:
invoke_from: InvokeFrom
granted_upload_file_ids: frozenset[str] = field(default_factory=frozenset)
granted_retriever_segment_ids: frozenset[str] = field(default_factory=frozenset)
granted_tool_file_ids: frozenset[str] = field(default_factory=frozenset)
@property
def requires_user_ownership(self) -> bool:
@@ -59,6 +65,24 @@ def grant_upload_file_access(upload_file_ids: Iterable[str]) -> None:
)
def grant_tool_file_access(tool_file_ids: Iterable[str]) -> None:
"""Allow this execution to read ToolFile records it just produced."""
scope = _current_file_access_scope.get()
if scope is None:
return
granted_tool_file_ids = frozenset(str(file_id) for file_id in tool_file_ids if file_id)
if not granted_tool_file_ids:
return
_current_file_access_scope.set(
replace(
scope,
granted_tool_file_ids=scope.granted_tool_file_ids | granted_tool_file_ids,
)
)
def grant_retriever_segment_access(segment_ids: Iterable[str]) -> None:
scope = _current_file_access_scope.get()
if scope is None:

View File

@@ -9,6 +9,7 @@ from uuid import UUID
import numpy as np
import pytz
from core.app.file_access import grant_tool_file_access
from core.tools.entities.tool_entities import ToolInvokeMessage
from core.tools.tool_file_manager import ToolFileManager, resolve_extension
from core.workflow.file_reference import parse_file_reference
@@ -214,6 +215,11 @@ class ToolFileMessageTransformer:
resolved_tool_file_id = tool_file_id or ToolFileMessageTransformer._extract_tool_file_id(url)
if resolved_tool_file_id and "tool_file_id" not in normalized_meta:
normalized_meta["tool_file_id"] = resolved_tool_file_id
if resolved_tool_file_id:
# Plugin/tool files may be owned by a different user than the chatting
# end user (workflow-as-tool from an agent). Grant this execution so
# later LLM nodes can attach the file instead of dropping it (#41169).
grant_tool_file_access([resolved_tool_file_id])
return normalized_meta
@staticmethod

View File

@@ -57,8 +57,7 @@ from repositories.recommended_app_catalog_repository import DatabaseRecommendedA
from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository
from repositories.step_by_step_tour_repository import SQLAlchemyStepByStepTourStateRepository
from repositories.tag_repository import TagRepository
from repositories.trial_app_query_repository import TrialAppQueryRepository
from repositories.trial_app_usage_repository import TrialAppUsageRepository
from repositories.trial_app_repository import TrialAppRepository
from repositories.upload_file_delivery_repository import UploadFileDeliveryQueryRepository
from repositories.web_passport_repository import WebPassportRepository
from repositories.webapp_access_query_repository import WebAppAccessQueryRepository
@@ -139,6 +138,7 @@ from services.account_profile_service import AccountProfileService
from services.app_definition_query_service import AppDefinitionQueryService
from services.app_site_service import AppSiteService
from services.app_statistic_query import AppStatisticQuery
from services.app_task_service import AppTaskControlService
from services.app_tracing_config_gateway import OpsTraceManagerGateway
from services.app_tracing_config_service import AppTracingConfigService
from services.auth.data_source_api_key_auth_gateways import (
@@ -190,6 +190,9 @@ from services.step_by_step_tour_service import StepByStepTourService
from services.system_feature_service import SystemFeatureService
from services.tag_application_service import TagApplicationService
from services.tool_file_download_service import ToolFileDownloadService
from services.trial_app_access_service import TrialAppAccessService
from services.trial_app_generation_adapters import AppGenerateServiceRuntime
from services.trial_app_generation_service import TrialAppGenerationService
from services.trial_app_usage import TrialAppUsageRecorder
from services.upload_file_delivery_service import UploadFileDeliveryService
from services.web_app_runtime_query_service import WebAppRuntimeQueryService
@@ -282,6 +285,9 @@ class ApplicationServices:
partner_tenant_bindings: PartnerTenantBindingService
recommended_app_queries: RecommendedAppQueryService
remote_files: RemoteFileService
app_tasks: AppTaskControlService
trial_app_access: TrialAppAccessService
trial_app_generation: TrialAppGenerationService
trial_app_usage: TrialAppUsageRecorder
workflow_run_archives: WorkflowRunArchiveService
workflow_runs: WorkflowRunService
@@ -428,6 +434,7 @@ def build_application_services(
accounts = SQLAlchemyAccountRepository(session_factory=database_client)
integrations = SQLAlchemyAccountIntegrationRepository(session_factory=database_client)
trial_app_enabled = SystemFeatureService.is_trial_app_enabled()
trial_apps = TrialAppRepository(session_factory=database_client)
database_catalog = DatabaseRecommendedAppCatalogRepository(session_factory=database_client, redis=redis)
builtin_catalog = BuiltinRecommendedAppCatalogGateway()
remote_catalog = RemoteRecommendedAppCatalogGateway()
@@ -707,11 +714,16 @@ def build_application_services(
),
recommended_app_queries=RecommendedAppQueryService(
catalog=recommended_app_catalog,
trial_apps=TrialAppQueryRepository(session_factory=database_client),
trial_apps=trial_apps,
trial_enabled=trial_app_enabled,
),
remote_files=remote_file_service,
trial_app_usage=TrialAppUsageRepository(session_factory=database_client),
app_tasks=AppTaskControlService(redis_client=redis),
trial_app_access=TrialAppAccessService(apps=trial_apps),
trial_app_generation=TrialAppGenerationService(
runtime=AppGenerateServiceRuntime(session_factory=database_client), usage=trial_apps
),
trial_app_usage=trial_apps,
workflow_run_archives=WorkflowRunArchiveService(
bundles=WorkflowRunArchiveBundleQueryRepository(session_factory=database_client),
tasks=WorkflowRunArchiveDownloadTaskCache(redis=redis),

View File

@@ -1,6 +1,7 @@
import logging
import ssl
from datetime import timedelta
from typing import Any
from typing import Any, NotRequired
import pytz # type: ignore[import-untyped]
from celery import Celery, Task
@@ -14,6 +15,8 @@ from enums import DeploymentEdition
from extensions.redis_names import normalize_redis_key_prefix
from extensions.workflow_warm_shutdown import setup_workflow_warm_shutdown_handler
logger = logging.getLogger(__name__)
class _CelerySentinelKwargsDict(TypedDict):
socket_timeout: float | None
@@ -36,6 +39,7 @@ class CelerySSLOptionsDict(TypedDict):
class CeleryBeatScheduleEntry(TypedDict):
task: str
schedule: crontab | timedelta
options: NotRequired[dict[str, Any]]
def _enqueue_initial_community_telemetry_heartbeat(sender: Any, **_: Any) -> None:
@@ -166,6 +170,7 @@ def init_app(app: DifyApp) -> Celery:
setup_workflow_warm_shutdown_handler()
imports = [
"schedule.collect_agent_sandbox_usage", # optional background provider accounting
"tasks.async_workflow_tasks", # trigger workers
"tasks.collect_agent_resources_task", # retired Agent resource collection
"tasks.trigger_processing_tasks", # async trigger processing
@@ -182,6 +187,20 @@ def init_app(app: DifyApp) -> Celery:
# if you add a new task, please add the switch to CeleryScheduleTasksConfig
beat_schedule: dict[str, CeleryBeatScheduleEntry] = {}
if dify_config.AGENT_SANDBOX_METERING_ENABLED:
try:
interval = int(dify_config.AGENT_SANDBOX_METERING_INTERVAL_SECONDS)
if interval < 1:
raise ValueError("collection interval must be positive")
collection_schedule = timedelta(seconds=interval)
except (ValueError, OverflowError):
logger.warning("Skipping sandbox usage schedule: interval must be a valid positive number of seconds")
else:
beat_schedule["collect_agent_sandbox_usage"] = {
"task": "schedule.collect_agent_sandbox_usage.collect_agent_sandbox_usage",
"schedule": collection_schedule,
"options": {"expires": interval},
}
if dify_config.ENABLE_CONVERSATION_CLEANUP_TASK:
imports.append("tasks.delete_conversation_task")
beat_schedule["conversation_cleanup_sweeper"] = {

View File

@@ -7,7 +7,7 @@ import struct
import subprocess
import time
import uuid
from collections.abc import Callable, Generator, Mapping
from collections.abc import Callable, Generator, Iterable, Mapping
from datetime import datetime
from hashlib import sha256
from typing import TYPE_CHECKING, Annotated, Any, Protocol, cast, overload, override
@@ -414,7 +414,7 @@ def generate_text_hash(text: str) -> str:
def compact_generate_response(
response: Mapping[str, Any] | Generator[str, None, None] | RateLimitGenerator,
response: Mapping[str, Any] | Iterable[str],
) -> Response:
if isinstance(response, Mapping):
return Response(

View File

@@ -0,0 +1,123 @@
"""Add independent sandbox execution accounting.
Revision ID: e7b2a9c4d601
Revises: d8e4a6b1c902
Create Date: 2026-09-20 12:00:00
"""
import sqlalchemy as sa
from alembic import op
from models.types import StringUUID
revision = "e7b2a9c4d601"
down_revision = "d8e4a6b1c902"
branch_labels = None
depends_on = None
def _identity_columns():
# UTC timestamps, explicit application values; not runtime measurements.
return [
sa.Column("id", StringUUID(), primary_key=True, nullable=False),
sa.Column("provider", sa.String(32), nullable=False),
sa.Column("provider_project_id", sa.String(128), nullable=False),
sa.Column("allocation_id", StringUUID()),
sa.Column("tenant_id", StringUUID()),
sa.Column("app_id", StringUUID()),
sa.Column("agent_id", StringUUID()),
sa.Column("binding_id", StringUUID()),
sa.Column("workspace_id", StringUUID()),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.current_timestamp()),
]
def upgrade():
op.create_table(
"agent_sandbox_usage_events",
*_identity_columns(),
sa.Column("source", sa.String(32), nullable=False),
sa.Column("source_event_id", sa.String(255), nullable=False),
sa.Column("event_type", sa.String(96), nullable=False),
sa.Column("sandbox_id", sa.String(128)),
sa.Column("provider_execution_id", sa.String(128)),
sa.Column("operation_id", StringUUID()),
sa.Column("lease_id", StringUUID()),
sa.Column("attempt", sa.Integer()),
sa.Column("purpose", sa.String(32)),
sa.Column("correlation", sa.JSON(), nullable=False),
sa.Column("occurred_at", sa.DateTime()),
sa.Column("received_at", sa.DateTime(), nullable=False),
sa.Column("payload", sa.JSON(), nullable=False),
sa.Column("canonical_hash", sa.String(64), nullable=False),
sa.Column("payload_versions", sa.JSON(), nullable=False),
sa.Column("resolution", sa.JSON(), nullable=False),
sa.Column("projection_status", sa.String(32), nullable=False),
sa.Column("projection_error_code", sa.String(96)),
sa.Column("processed_at", sa.DateTime()),
sa.UniqueConstraint(
"provider", "provider_project_id", "source", "source_event_id", name="sandbox_usage_event_unique"
),
sa.CheckConstraint("source IN ('application', 'provider')", name="sandbox_usage_event_source_check"),
)
for name, columns in (
("sandbox_usage_event_sandbox_idx", ["provider_project_id", "sandbox_id", "occurred_at"]),
("sandbox_usage_event_allocation_idx", ["allocation_id", "received_at"]),
("sandbox_usage_event_owner_idx", ["provider_project_id", "binding_id", "event_type"]),
("sandbox_usage_event_pending_idx", ["projection_status", "received_at"]),
("sandbox_usage_checkpoint_idx", ["provider_project_id", "event_type", "purpose", "occurred_at"]),
("sandbox_usage_diagnostics_idx", ["provider_project_id", "projection_status"]),
("sandbox_usage_execution_idx", ["provider_project_id", "provider_execution_id", "projection_error_code"]),
):
op.create_index(name, "agent_sandbox_usage_events", columns)
op.create_table(
"agent_sandbox_executions",
*_identity_columns(),
sa.Column("sandbox_id", sa.String(128), nullable=False),
sa.Column("provider_execution_id", sa.String(128), nullable=False),
sa.Column("attribution_status", sa.String(32), nullable=False),
sa.Column("template_id", sa.String(128)),
sa.Column("template_build_id", sa.String(128)),
sa.Column("vcpu_count", sa.Integer()),
sa.Column("memory_mib", sa.BigInteger()),
sa.Column("started_at", sa.DateTime()),
sa.Column("started_at_source", sa.String(32)),
sa.Column("started_at_precision_ms", sa.Integer()),
sa.Column("terminal_event_at", sa.DateTime()),
sa.Column("metered_duration_ms", sa.BigInteger()),
sa.Column("measurement_source", sa.String(32), nullable=False),
sa.Column("state", sa.String(16), nullable=False),
sa.Column("quality", sa.String(16), nullable=False),
sa.Column("close_reason", sa.String(64)),
sa.Column("terminal_event_id", StringUUID()),
sa.Column("last_reconciled_at", sa.DateTime()),
sa.Column("revision", sa.Integer(), nullable=False),
sa.UniqueConstraint(
"provider", "provider_project_id", "provider_execution_id", name="sandbox_execution_unique"
),
sa.CheckConstraint("metered_duration_ms IS NULL OR metered_duration_ms >= 0", name="sandbox_duration_check"),
sa.CheckConstraint("vcpu_count IS NULL OR vcpu_count > 0", name="sandbox_vcpu_check"),
sa.CheckConstraint("memory_mib IS NULL OR memory_mib > 0", name="sandbox_memory_check"),
sa.CheckConstraint(
"quality <> 'metered' OR (state = 'closed' AND measurement_source = 'provider_execution' "
"AND metered_duration_ms IS NOT NULL AND started_at IS NOT NULL "
"AND vcpu_count IS NOT NULL AND memory_mib IS NOT NULL)",
name="sandbox_metered_check",
),
)
for name, columns in (
("sandbox_execution_project_time_idx", ["provider_project_id", "started_at"]),
("sandbox_execution_tenant_time_idx", ["tenant_id", "started_at"]),
("sandbox_execution_sandbox_idx", ["provider_project_id", "sandbox_id", "started_at"]),
("sandbox_execution_allocation_idx", ["allocation_id"]),
("sandbox_execution_reconcile_idx", ["quality", "state", "last_reconciled_at"]),
("sandbox_execution_diagnostics_idx", ["provider_project_id", "state", "attribution_status"]),
):
op.create_index(name, "agent_sandbox_executions", columns)
def downgrade():
op.drop_table("agent_sandbox_executions")
op.drop_table("agent_sandbox_usage_events")

View File

@@ -30,6 +30,7 @@ from .agent import (
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from .agent_sandbox_usage import AgentSandboxExecution, AgentSandboxUsageEvent
from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint
from .comment import (
WorkflowComment,
@@ -171,6 +172,8 @@ __all__ = [
"AgentHomeSnapshot",
"AgentIconType",
"AgentKind",
"AgentSandboxExecution",
"AgentSandboxUsageEvent",
"AgentScope",
"AgentSkillBinding",
"AgentSource",

View File

@@ -0,0 +1,117 @@
"""Independent E2B accounting records, retained after business resource deletion.
All timestamps are explicit naive UTC values, following the API's database
convention. These records deliberately have no business-object foreign keys.
"""
from datetime import datetime
from typing import Any
import sqlalchemy as sa
from sqlalchemy.orm import Mapped, mapped_column
from libs.datetime_utils import naive_utc_now
from .base import Base, DefaultFieldsMixin
from .types import StringUUID
class AgentSandboxUsageEvent(DefaultFieldsMixin, Base):
__tablename__ = "agent_sandbox_usage_events"
__table_args__ = (
sa.UniqueConstraint(
"provider", "provider_project_id", "source", "source_event_id", name="sandbox_usage_event_unique"
),
sa.Index("sandbox_usage_event_sandbox_idx", "provider_project_id", "sandbox_id", "occurred_at"),
sa.Index("sandbox_usage_event_allocation_idx", "allocation_id", "received_at"),
sa.Index("sandbox_usage_event_owner_idx", "provider_project_id", "binding_id", "event_type"),
sa.Index("sandbox_usage_event_pending_idx", "projection_status", "received_at"),
sa.Index("sandbox_usage_checkpoint_idx", "provider_project_id", "event_type", "purpose", "occurred_at"),
sa.Index("sandbox_usage_diagnostics_idx", "provider_project_id", "projection_status"),
sa.Index(
"sandbox_usage_execution_idx", "provider_project_id", "provider_execution_id", "projection_error_code"
),
sa.CheckConstraint("source IN ('application', 'provider')", name="sandbox_usage_event_source_check"),
)
provider: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="e2b")
provider_project_id: Mapped[str] = mapped_column(sa.String(128), nullable=False)
source: Mapped[str] = mapped_column(sa.String(32), nullable=False)
source_event_id: Mapped[str] = mapped_column(sa.String(255), nullable=False)
event_type: Mapped[str] = mapped_column(sa.String(96), nullable=False)
sandbox_id: Mapped[str | None] = mapped_column(sa.String(128))
provider_execution_id: Mapped[str | None] = mapped_column(sa.String(128))
allocation_id: Mapped[str | None] = mapped_column(StringUUID)
operation_id: Mapped[str | None] = mapped_column(StringUUID)
lease_id: Mapped[str | None] = mapped_column(StringUUID)
attempt: Mapped[int | None] = mapped_column(sa.Integer)
tenant_id: Mapped[str | None] = mapped_column(StringUUID)
app_id: Mapped[str | None] = mapped_column(StringUUID)
agent_id: Mapped[str | None] = mapped_column(StringUUID)
binding_id: Mapped[str | None] = mapped_column(StringUUID)
workspace_id: Mapped[str | None] = mapped_column(StringUUID)
purpose: Mapped[str | None] = mapped_column(sa.String(32))
correlation: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False, default=dict)
occurred_at: Mapped[datetime | None] = mapped_column(sa.DateTime)
received_at: Mapped[datetime] = mapped_column(sa.DateTime, nullable=False, default=naive_utc_now)
payload: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False)
canonical_hash: Mapped[str] = mapped_column(sa.String(64), nullable=False)
payload_versions: Mapped[list[dict[str, Any]]] = mapped_column(sa.JSON, nullable=False, default=list)
resolution: Mapped[dict[str, Any]] = mapped_column(sa.JSON, nullable=False, default=dict)
projection_status: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="pending")
projection_error_code: Mapped[str | None] = mapped_column(sa.String(96))
processed_at: Mapped[datetime | None] = mapped_column(sa.DateTime)
class AgentSandboxExecution(DefaultFieldsMixin, Base):
"""One provider execution, not one request, lease, or connect call."""
__tablename__ = "agent_sandbox_executions"
__table_args__ = (
sa.UniqueConstraint(
"provider", "provider_project_id", "provider_execution_id", name="sandbox_execution_unique"
),
sa.Index("sandbox_execution_project_time_idx", "provider_project_id", "started_at"),
sa.Index("sandbox_execution_tenant_time_idx", "tenant_id", "started_at"),
sa.Index("sandbox_execution_sandbox_idx", "provider_project_id", "sandbox_id", "started_at"),
sa.Index("sandbox_execution_allocation_idx", "allocation_id"),
sa.Index("sandbox_execution_reconcile_idx", "quality", "state", "last_reconciled_at"),
sa.Index("sandbox_execution_diagnostics_idx", "provider_project_id", "state", "attribution_status"),
sa.CheckConstraint("metered_duration_ms IS NULL OR metered_duration_ms >= 0", name="sandbox_duration_check"),
sa.CheckConstraint("vcpu_count IS NULL OR vcpu_count > 0", name="sandbox_vcpu_check"),
sa.CheckConstraint("memory_mib IS NULL OR memory_mib > 0", name="sandbox_memory_check"),
sa.CheckConstraint(
"quality <> 'metered' OR (state = 'closed' AND measurement_source = 'provider_execution' "
"AND metered_duration_ms IS NOT NULL AND started_at IS NOT NULL "
"AND vcpu_count IS NOT NULL AND memory_mib IS NOT NULL)",
name="sandbox_metered_check",
),
)
provider: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="e2b")
provider_project_id: Mapped[str] = mapped_column(sa.String(128), nullable=False)
sandbox_id: Mapped[str] = mapped_column(sa.String(128), nullable=False)
provider_execution_id: Mapped[str] = mapped_column(sa.String(128), nullable=False)
allocation_id: Mapped[str | None] = mapped_column(StringUUID)
tenant_id: Mapped[str | None] = mapped_column(StringUUID)
app_id: Mapped[str | None] = mapped_column(StringUUID)
agent_id: Mapped[str | None] = mapped_column(StringUUID)
binding_id: Mapped[str | None] = mapped_column(StringUUID)
workspace_id: Mapped[str | None] = mapped_column(StringUUID)
attribution_status: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="unresolved")
template_id: Mapped[str | None] = mapped_column(sa.String(128))
template_build_id: Mapped[str | None] = mapped_column(sa.String(128))
vcpu_count: Mapped[int | None] = mapped_column(sa.Integer)
memory_mib: Mapped[int | None] = mapped_column(sa.BigInteger)
started_at: Mapped[datetime | None] = mapped_column(sa.DateTime)
started_at_source: Mapped[str | None] = mapped_column(sa.String(32))
started_at_precision_ms: Mapped[int | None] = mapped_column(sa.Integer)
terminal_event_at: Mapped[datetime | None] = mapped_column(sa.DateTime)
metered_duration_ms: Mapped[int | None] = mapped_column(sa.BigInteger)
measurement_source: Mapped[str] = mapped_column(sa.String(32), nullable=False, default="unknown")
state: Mapped[str] = mapped_column(sa.String(16), nullable=False, default="unknown")
quality: Mapped[str] = mapped_column(sa.String(16), nullable=False, default="pending")
close_reason: Mapped[str | None] = mapped_column(sa.String(64))
terminal_event_id: Mapped[str | None] = mapped_column(StringUUID)
last_reconciled_at: Mapped[datetime | None] = mapped_column(sa.DateTime)
revision: Mapped[int] = mapped_column(sa.Integer, nullable=False, default=1)

View File

@@ -29,7 +29,6 @@ from libs.uuid_utils import uuidv7
from .account import Account
from .base import Base, TypeBase
from .engine import db
from .enums import (
CollectionBindingType,
CreatorUserRole,
@@ -224,10 +223,9 @@ class Dataset(Base):
or 0
)
@property
def available_segment_count(self):
def get_available_segment_count(self, *, session: Session) -> int:
return (
db.session.scalar(
session.scalar(
select(func.count(DocumentSegment.id)).where(
DocumentSegment.dataset_id == self.id,
DocumentSegment.status == "completed",

View File

@@ -26,12 +26,12 @@ dependencies = [
# Stable: production-proven, cap below the next major
"aliyun-log-python-sdk==0.9.44",
"azure-identity>=1.25.3,<2.0.0",
"flask-compress>=1.24,<2.0.0",
"flask-compress>=1.25,<2.0.0",
"flask-login==0.6.3",
"flask-migrate>=4.1.0,<5.0.0",
"flask-orjson>=2.0.0,<3.0.0",
"flask-restx>=1.3.2,<2.0.0",
"google-cloud-aiplatform>=2.1.0,<3.0.0",
"google-cloud-aiplatform>=2.1.3,<3.0.0",
"httpx[socks]==0.28.1",
"opentelemetry-distro==0.65b0",
"opentelemetry-instrumentation-celery==0.65b0",

View File

@@ -1,23 +0,0 @@
"""Database repository for recommended app trial eligibility."""
from collections.abc import Sequence, Set
from typing import override
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from models.model import TrialApp
from services.recommended_app_query_service import TrialAppQuery
class TrialAppQueryRepository(TrialAppQuery):
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def existing_ids(self, app_ids: Sequence[str]) -> Set[str]:
if not app_ids:
return frozenset()
with self._session_factory() as session:
return frozenset(session.scalars(select(TrialApp.app_id).where(TrialApp.app_id.in_(app_ids))).all())

View File

@@ -0,0 +1,65 @@
"""Query trial app eligibility and record account usage in independent sessions."""
from collections.abc import Sequence, Set
from typing import override
from sqlalchemy import and_, select
from sqlalchemy.orm import Session, sessionmaker
from models.model import AccountTrialAppRecord, App, 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
class TrialAppRepository(TrialAppQuery, TrialAppAccessQuery, TrialAppUsageRecorder):
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory: sessionmaker[Session] = session_factory
@override
def existing_ids(self, app_ids: Sequence[str]) -> Set[str]:
if not app_ids:
return frozenset()
with self._session_factory() as session:
return frozenset(session.scalars(select(TrialApp.app_id).where(TrialApp.app_id.in_(app_ids))).all())
@override
def resolve(self, *, app_id: str, account_id: str) -> TrialAppAccessSnapshot | None:
with self._session_factory() as session:
row = session.execute(
select(App.id, App.tenant_id, App.mode, TrialApp.trial_limit, AccountTrialAppRecord.count)
.select_from(TrialApp)
.join(App, App.id == TrialApp.app_id)
.outerjoin(
AccountTrialAppRecord,
and_(
AccountTrialAppRecord.app_id == App.id,
AccountTrialAppRecord.account_id == account_id,
),
)
.where(TrialApp.app_id == app_id)
).one_or_none()
if row is None:
return None
resolved_app_id, tenant_id, app_mode, trial_limit, used_count = row
return TrialAppAccessSnapshot(
app=TrialAppRef(app_id=resolved_app_id, tenant_id=tenant_id, app_mode=app_mode),
trial_limit=trial_limit,
used_count=used_count,
)
@override
def record(self, *, app_id: str, account_id: str) -> None:
"""Increment usage without committing the caller's request transaction."""
with self._session_factory() as session, session.begin():
record = session.scalar(
select(AccountTrialAppRecord)
.where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id)
.limit(1)
)
if record is None:
session.add(AccountTrialAppRecord(app_id=app_id, account_id=account_id, count=1))
else:
record.count += 1

View File

@@ -1,28 +0,0 @@
"""Database repository for recommended trial app usage."""
from typing import override
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from models.model import AccountTrialAppRecord
from services.trial_app_usage import TrialAppUsageRecorder
class TrialAppUsageRepository(TrialAppUsageRecorder):
def __init__(self, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def record(self, *, app_id: str, account_id: str) -> None:
"""Increment usage without committing the caller's request transaction."""
with self._session_factory() as session, session.begin():
record = session.scalar(
select(AccountTrialAppRecord)
.where(AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id)
.limit(1)
)
if record is None:
session.add(AccountTrialAppRecord(app_id=app_id, account_id=account_id, count=1))
else:
record.count += 1

View File

@@ -0,0 +1,70 @@
"""Use existing Celery infrastructure to request one bounded E2B usage scan."""
import logging
from urllib.parse import urlsplit
from celery import shared_task
from pydantic import BaseModel, ConfigDict, StrictBool
from configs import dify_config
from core.helper import ssrf_proxy
logger = logging.getLogger(__name__)
class _CollectionResponse(BaseModel):
completed: StrictBool
model_config = ConfigDict(extra="ignore")
@shared_task(
name="schedule.collect_agent_sandbox_usage.collect_agent_sandbox_usage",
queue="ops_trace",
soft_time_limit=270,
time_limit=300,
ignore_result=True,
)
def collect_agent_sandbox_usage() -> bool:
"""Accounting failures fail this job only; the next scheduled scan retries.
No provider credential is copied to the API. The Agent's authenticated
control plane owns its E2B client and executes one request-scoped scan.
"""
if not dify_config.AGENT_SANDBOX_METERING_ENABLED:
return False
try:
base_url = (dify_config.AGENT_BACKEND_BASE_URL or "").rstrip("/")
token = (dify_config.AGENT_BACKEND_API_TOKEN or "").strip()
project_id = dify_config.AGENT_SANDBOX_METERING_PROJECT_ID.strip()
parsed = urlsplit(base_url)
if (
parsed.scheme not in {"http", "https"}
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
or not token
or not project_id
):
raise ValueError("Sandbox usage collection requires backend URL, token, and project configuration")
response = ssrf_proxy.post(
f"{base_url}/internal/e2b/usage/collect",
headers={"Authorization": f"Bearer {token}"},
json={"project_id": project_id},
max_retries=0,
timeout=260,
)
try:
response.raise_for_status()
completed = _CollectionResponse.model_validate(response.json()).completed
finally:
response.close()
if not completed:
raise RuntimeError("Sandbox usage collection did not complete its bounded scan")
except Exception as exc:
# Do not include credentials, response bodies, or provider metadata.
logger.warning("Background sandbox usage collection failed", extra={"error_type": type(exc).__name__})
raise
logger.info("Background sandbox usage collection completed")
return True

View File

@@ -15,11 +15,12 @@ from typing import Any, cast
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import func, select
from sqlalchemy import func, select, update
from sqlalchemy.orm import Session
from core.workflow.nodes.agent_v2.discriminator import is_dify_agent_node_data
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
from libs.datetime_utils import naive_utc_now
from models import Account
from models.agent import (
APP_BACKED_AGENT_SOURCES,
@@ -38,8 +39,9 @@ from models.agent import (
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
from models.model import App, AppModelConfig
from models.model import App, AppModelConfig, UploadFile
from models.skill import AgentSkillBinding, AgentSkillBindingSnapshot, Skill
from models.tools import ToolFile
from models.workflow import Workflow
from services.agent.agent_soul_state import agent_soul_has_model
from services.agent.dependency_service import extract_agent_soul_dependencies
@@ -53,6 +55,7 @@ from services.agent.dsl_entities import (
portable_ref,
)
from services.agent.knowledge_datasets import get_tenant_knowledge_dataset_rows
from services.agent.package_resource_exporter import AgentPackageResourceExporter
from services.agent.roster_service import AgentRosterService
from services.entities.dsl_entities import DslImportWarning
@@ -129,9 +132,13 @@ class AgentDslService:
return package_ref, {package_ref: make_portable_agent_package(agent, soul, workspace_skills=workspace_skills)}
def export_workflow_packages(
self, *, workflow: Workflow, graph: Mapping[str, Any]
self,
*,
workflow: Workflow,
graph: Mapping[str, Any],
resource_exporter: AgentPackageResourceExporter | None = None,
) -> tuple[dict[str, Any], dict[str, AgentPackage]]:
"""Replace persisted Agent binding ids with portable package references."""
"""Replace persisted bindings with portable packages, optionally collecting their assets."""
portable_graph = copy.deepcopy(dict(graph))
agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(portable_graph))
@@ -141,6 +148,7 @@ class AgentDslService:
bindings = self.session.scalars(
select(WorkflowAgentNodeBinding).where(
WorkflowAgentNodeBinding.tenant_id == workflow.tenant_id,
WorkflowAgentNodeBinding.app_id == workflow.app_id,
WorkflowAgentNodeBinding.workflow_id == workflow.id,
WorkflowAgentNodeBinding.workflow_version == workflow.version,
WorkflowAgentNodeBinding.node_id.in_(list(agent_nodes)),
@@ -166,16 +174,25 @@ class AgentDslService:
if package_ref is None:
package_ref = f"agent_{len(packages) + 1}"
package_refs_by_source[source_key] = package_ref
packages[package_ref] = make_portable_agent_package(
agent,
AgentSoulConfig.model_validate(snapshot.config_snapshot_dict),
workspace_skills=self._workspace_skills_for_export(
tenant_id=workflow.tenant_id,
agent_id=agent.id,
if resource_exporter is not None:
packages[package_ref] = resource_exporter.collect_package(
session=self.session,
agent=agent,
soul=AgentSoulConfig.model_validate(snapshot.config_snapshot_dict),
snapshot_id=snapshot.id,
include_draft=False,
),
)
package_ref=package_ref,
)
else:
packages[package_ref] = make_portable_agent_package(
agent,
AgentSoulConfig.model_validate(snapshot.config_snapshot_dict),
workspace_skills=self._workspace_skills_for_export(
tenant_id=workflow.tenant_id,
agent_id=agent.id,
snapshot_id=snapshot.id,
include_draft=False,
),
)
node_data["agent_binding"] = {
"binding_type": binding.binding_type.value,
AGENT_PACKAGE_REF_KEY: package_ref,
@@ -224,7 +241,7 @@ class AgentDslService:
tenant_id=app.tenant_id,
account_id=account.id,
app_id=app.id,
name=self._unique_roster_name(tenant_id=app.tenant_id, requested=app.name or metadata.name),
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),
@@ -515,6 +532,15 @@ class AgentDslService:
warnings=warnings,
account_id=account.id,
)
upload_file_ids = [
item.file_id for item in soul.config_files if item.file_kind == "upload_file" and not item.is_missing
]
if upload_file_ids:
self.session.execute(
update(UploadFile)
.where(UploadFile.tenant_id == workflow.tenant_id, UploadFile.id.in_(upload_file_ids))
.values(used=True, used_by=account.id, used_at=naive_utc_now())
)
return AgentPackageImportResult(agent=agent, snapshot=snapshot, warnings=warnings)
def _create_workflow_only_agent(
@@ -599,6 +625,9 @@ class AgentDslService:
)
for asset in package.omitted_assets
]
warnings.extend(
self._mark_missing_package_assets(tenant_id=tenant_id, soul_data=soul_data, package_path=package_path)
)
for tool_index, tool in enumerate(package.soul.tools.dify_tools):
tool_label = tool.tool_name or tool.provider or tool.provider_id
warnings.append(
@@ -651,6 +680,51 @@ class AgentDslService:
)
return AgentSoulConfig.model_validate(soul_data), warnings
def _mark_missing_package_assets(
self,
*,
tenant_id: str,
soul_data: dict[str, Any],
package_path: str,
) -> list[DslImportWarning]:
"""Mark unavailable asset references in place and return import warnings."""
warnings: list[DslImportWarning] = []
# Legacy DSLs can retain source-workspace ids without an is_missing flag.
# Check local records so resources materialized from archives remain usable.
asset_refs = [
(kind, index, item)
for kind, field in (("skill", "config_skills"), ("file", "config_files"))
for index, item in enumerate(soul_data[field])
if not item["is_missing"]
]
for file_kind, model in (("tool_file", ToolFile), ("upload_file", UploadFile)):
refs = [(kind, index, item) for kind, index, item in asset_refs if item["file_kind"] == file_kind]
valid_ids: set[str] = set()
for _, _, item in refs:
try:
valid_ids.add(str(UUID(item["file_id"])))
except ValueError:
continue
existing_ids = (
set(self.session.scalars(select(model.id).where(model.tenant_id == tenant_id, model.id.in_(valid_ids))))
if valid_ids
else set()
)
for kind, index, item in refs:
if item["file_id"] in existing_ids:
continue
item["file_id"] = ""
item["is_missing"] = True
warnings.append(
DslImportWarning(
code=f"agent_{kind}_missing",
path=f"{package_path}.soul.config_{'skills' if kind == 'skill' else 'files'}.{index}",
message=f"Agent {kind} {item['name']!r} is unavailable in the target workspace.",
details={"kind": kind, "name": item["name"]},
)
)
return warnings
def _create_snapshot(
self,
*,
@@ -691,7 +765,7 @@ class AgentDslService:
self.session.flush()
return snapshot
def _unique_roster_name(self, *, tenant_id: str, requested: str) -> str:
def unique_roster_name(self, *, tenant_id: str, requested: str) -> str:
candidates = [requested]
for index in range(1, 100):
suffix = " import" if index == 1 else f" import {index}"

View File

@@ -0,0 +1,356 @@
"""Collect and stream Agent assets for roster and workflow package exports."""
from __future__ import annotations
import hashlib
import os
import re
import zipfile
from collections.abc import Generator, Sequence
from dataclasses import dataclass
from typing import Any, Literal, Protocol
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from constants import IMAGE_EXTENSIONS
from extensions.ext_storage import storage
from models.agent import Agent
from models.agent_config_entities import AgentSoulConfig
from models.model import UploadFile
from models.tools import ToolFile
from services.agent.dsl_entities import AgentPackage, AgentPackageWorkspaceSkill, make_portable_agent_package
from services.agent.errors import RosterAgentPackageExportFailedError, RosterAgentPackageTooLargeError
from services.agent.roster_package_entities import (
AgentPackageResources,
PackageIcon,
RosterAgentPackageAudit,
RosterAgentPackageFile,
RosterAgentPackageSkill,
)
from services.agent.skill_package_service import SkillPackageError, SkillPackageService
from services.skill_management_service import SkillManagementService
class _Storage(Protocol):
def load_stream(self, filename: str) -> Generator[bytes, None, None]: ...
@dataclass(frozen=True)
class _SkillSource:
path: str
storage_key: str
id: str
scope: Literal["agent_config", "workspace"]
name: str
audit_ref: str
@dataclass(frozen=True)
class _FileSource:
path: str
storage_key: str
id: str
audit_ref: str
class AgentPackageResourceExporter:
"""Collect database references, resolve workspace Skills, then stream the archive."""
def __init__(self, *, storage_backend: _Storage = storage) -> None:
self._storage = storage_backend
self.sources: dict[str, tuple[list[_SkillSource], list[_FileSource]]] = {}
self.icon_sources: dict[str, tuple[str, str]] = {}
self.packages: dict[str, AgentPackage] = {}
self._workspace_sources: dict[str, tuple[str, str, str | None, bool]] = {}
def collect_package(
self,
*,
session: Session,
agent: Agent,
soul: AgentSoulConfig,
snapshot_id: str | None,
package_ref: str,
include_draft: bool = False,
) -> AgentPackage:
soul, skills, files = self._collect_payloads(
session=session,
tenant_id=agent.tenant_id,
soul=soul,
skill_offset=sum(len(skills) for skills, _ in self.sources.values()),
file_offset=sum(len(files) for _, files in self.sources.values()),
)
self.sources[package_ref] = skills, files
package = make_portable_agent_package(agent, soul, include_assets=True)
metadata = package.metadata.model_dump()
self.collect_icon(session=session, tenant_id=agent.tenant_id, metadata=metadata)
package.metadata.icon = metadata["icon"]
self.packages[package_ref] = package
self._workspace_sources[package_ref] = agent.tenant_id, agent.id, snapshot_id, include_draft
return package
def collect_icon(self, *, session: Session, tenant_id: str, metadata: dict[str, Any]) -> None:
if metadata.get("icon_type") != "image" or not metadata.get("icon"):
return
file_id = metadata["icon"]
if file_id not in self.icon_sources:
upload = self._upload_files(session=session, tenant_id=tenant_id, file_ids=[file_id]).get(file_id)
if upload is None:
raise RosterAgentPackageExportFailedError("App icon payload is unavailable")
extension = upload.extension.lower()
if extension not in IMAGE_EXTENSIONS:
raise RosterAgentPackageExportFailedError("App icon must be an image")
resource_id = f"i_{len(self.icon_sources) + 1:06d}"
self.icon_sources[file_id] = f"{resource_id}.{extension}", upload.key
metadata["icon"] = self.icon_sources[file_id][0].split(".")[0]
def write_icons(self, archive: zipfile.ZipFile, *, total_size: int) -> tuple[list[PackageIcon], int]:
if len(archive.infolist()) + len(self.icon_sources) + 2 > dify_config.AGENT_PACKAGE_MAX_ENTRIES:
raise RosterAgentPackageTooLargeError("Agent package has too many members")
icons = []
for path, key in self.icon_sources.values():
size, digest = self._write_storage_member(
archive,
path=path,
storage_key=key,
max_bytes=min(
dify_config.AGENT_PACKAGE_MAX_BYTES - total_size,
dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024,
),
)
total_size += size
icons.append(PackageIcon(id=path.split(".")[0], path=path, size=size, sha256=digest))
return icons, total_size
def collect_workspace_skills(self) -> None:
"""Resolve legacy Skill metadata after the caller closes its database read session."""
for ref, (tenant_id, agent_id, snapshot_id, include_draft) in self._workspace_sources.items():
package = self.packages[ref]
archives = SkillManagementService().list_runtime_agent_skill_archives(
tenant_id=tenant_id,
agent_id=agent_id,
config_snapshot_id=snapshot_id,
include_draft=include_draft,
)
skill_sources = self.sources[ref][0]
skill_index = sum(len(skills) for skills, _ in self.sources.values())
names = {item.name for item in package.soul.config_skills if not item.is_missing}
for archive in sorted(archives, key=lambda item: item.priority):
if archive.name in names:
continue
names.add(archive.name)
skill_index += 1
resource_id = f"s_{skill_index:06d}"
skill_sources.append(
_SkillSource(
path=f"{resource_id}.zip",
storage_key=archive.storage_key,
id=resource_id,
scope="workspace",
name=archive.name,
audit_ref=archive.version_id,
)
)
package.workspace_skills.append(
AgentPackageWorkspaceSkill(
name=archive.name,
display_name=archive.display_name,
description=archive.description,
priority=archive.priority,
)
)
self._workspace_sources.clear()
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:
raise RosterAgentPackageTooLargeError("Agent package has too many members")
total_size = 0
nested_uncompressed_size = 0
skill_packages = SkillPackageService()
resources: dict[str, AgentPackageResources] = {}
for ref, (skill_sources, file_sources) in self.sources.items():
skills: list[RosterAgentPackageSkill] = []
files: list[RosterAgentPackageFile] = []
sources: list[_SkillSource | _FileSource] = [*skill_sources, *file_sources]
for source in sources:
remaining_bytes = dify_config.AGENT_PACKAGE_MAX_BYTES - total_size
if isinstance(source, _SkillSource):
remaining_bytes = min(remaining_bytes, dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024)
member_size, member_digest = self._write_storage_member(
archive, path=source.path, storage_key=source.storage_key, max_bytes=remaining_bytes
)
total_size += member_size
if isinstance(source, _SkillSource):
# Inspect only the embedded Skill; our writer already owns
# the outer container, resource sizes, and digests.
try:
inspection = skill_packages.inspect(content=archive.read(source.path), filename=source.path)
except SkillPackageError as exc:
raise RosterAgentPackageExportFailedError(
f"Roster Agent package contains unusable Skill {source.name!r}"
) from exc
if inspection.name != source.name:
raise RosterAgentPackageExportFailedError(
f"Roster Agent package contains unusable Skill {source.name!r}: name mismatch"
)
nested_uncompressed_size += inspection.uncompressed_size
if nested_uncompressed_size > dify_config.AGENT_PACKAGE_MAX_BYTES:
raise RosterAgentPackageTooLargeError(
"Roster Agent package nested Skill contents exceed the size limit"
)
skills.append(
RosterAgentPackageSkill(
id=source.id,
scope=source.scope,
name=source.name,
path=source.path,
size=member_size,
sha256=member_digest,
audit=RosterAgentPackageAudit(ref=source.audit_ref),
)
)
else:
files.append(
RosterAgentPackageFile(
id=source.id,
path=source.path,
size=member_size,
sha256=member_digest,
audit=RosterAgentPackageAudit(ref=source.audit_ref),
)
)
resources[ref] = AgentPackageResources(skills=skills, files=files)
return resources, total_size
def _collect_payloads(
self,
*,
session: Session,
tenant_id: str,
soul: AgentSoulConfig,
skill_offset: int = 0,
file_offset: int = 0,
) -> tuple[AgentSoulConfig, list[_SkillSource], list[_FileSource]]:
resource_data = soul.model_dump(mode="json")
skill_sources: list[_SkillSource] = []
file_sources: list[_FileSource] = []
skill_file_ids = [item.file_id for item in soul.config_skills if not item.is_missing]
tool_files = self._tool_files(session=session, tenant_id=tenant_id, file_ids=skill_file_ids)
for skill_ref, portable_skill_ref in zip(soul.config_skills, resource_data["config_skills"]):
if skill_ref.is_missing:
continue
resource_id = f"s_{skill_offset + len(skill_sources) + 1:06d}"
tool_file = tool_files.get(skill_ref.file_id)
if tool_file is None:
raise RosterAgentPackageExportFailedError(f"Config skill {skill_ref.name!r} payload is unavailable")
path = f"{resource_id}.zip"
portable_skill_ref["file_id"] = resource_id
portable_skill_ref["is_missing"] = False
skill_sources.append(
_SkillSource(
path=path,
storage_key=tool_file.file_key,
id=resource_id,
scope="agent_config",
name=skill_ref.name,
audit_ref=tool_file.id,
)
)
tool_file_refs = [
item.file_id for item in soul.config_files if not item.is_missing and item.file_kind == "tool_file"
]
upload_file_refs = [
item.file_id for item in soul.config_files if not item.is_missing and item.file_kind == "upload_file"
]
file_tool_files = self._tool_files(session=session, tenant_id=tenant_id, file_ids=tool_file_refs)
upload_files = self._upload_files(session=session, tenant_id=tenant_id, file_ids=upload_file_refs)
for file_ref, portable_file_ref in zip(soul.config_files, resource_data["config_files"]):
if file_ref.is_missing:
continue
resource_id = f"f_{file_offset + len(file_sources) + 1:06d}"
extension = self._safe_extension(file_ref.name)
path = f"{resource_id}{extension}"
if file_ref.file_kind == "tool_file":
tool_file = file_tool_files.get(file_ref.file_id)
if tool_file is None:
raise RosterAgentPackageExportFailedError(f"Config file {file_ref.name!r} payload is unavailable")
storage_key = tool_file.file_key
mime_type = file_ref.mime_type or tool_file.mimetype
audit_ref = tool_file.id
else:
upload_file = upload_files.get(file_ref.file_id)
if upload_file is None:
raise RosterAgentPackageExportFailedError(f"Config file {file_ref.name!r} payload is unavailable")
storage_key = upload_file.key
mime_type = file_ref.mime_type or upload_file.mime_type
audit_ref = upload_file.id
portable_file_ref["file_id"] = resource_id
portable_file_ref["is_missing"] = False
portable_file_ref["mime_type"] = mime_type or "application/octet-stream"
file_sources.append(
_FileSource(
path=path,
storage_key=storage_key,
id=resource_id,
audit_ref=audit_ref,
)
)
return AgentSoulConfig.model_validate(resource_data), skill_sources, file_sources
@staticmethod
def _tool_files(*, session: Session, tenant_id: str, file_ids: Sequence[str]) -> dict[str, ToolFile]:
if not file_ids:
return {}
rows = session.scalars(
select(ToolFile).where(ToolFile.tenant_id == tenant_id, ToolFile.id.in_(set(file_ids)))
).all()
return {item.id: item for item in rows}
@staticmethod
def _upload_files(*, session: Session, tenant_id: str, file_ids: Sequence[str]) -> dict[str, UploadFile]:
if not file_ids:
return {}
rows = session.scalars(
select(UploadFile).where(UploadFile.tenant_id == tenant_id, UploadFile.id.in_(set(file_ids)))
).all()
return {item.id: item for item in rows}
def _write_storage_member(
self,
archive: zipfile.ZipFile,
*,
path: str,
storage_key: str,
max_bytes: int,
) -> tuple[int, str]:
digest = hashlib.sha256()
size = 0
try:
with archive.open(path, "w", force_zip64=True) as target:
for chunk in self._storage.load_stream(storage_key):
if not isinstance(chunk, bytes):
raise TypeError("storage stream returned a non-bytes chunk")
if size + len(chunk) > max_bytes:
raise RosterAgentPackageTooLargeError("Roster Agent package payload exceeds the size limit")
size += len(chunk)
digest.update(chunk)
target.write(chunk)
except RosterAgentPackageTooLargeError:
raise
except Exception as exc:
raise RosterAgentPackageExportFailedError(f"Unable to read package resource {path!r}") from exc
return size, digest.hexdigest()
@staticmethod
def _safe_extension(filename: str) -> str:
extension = os.path.splitext(filename)[1].lower()
if not extension or not re.fullmatch(r"\.[a-z0-9]{1,16}", extension):
return ".bin"
return extension

View File

@@ -0,0 +1,267 @@
"""Restore packaged Agent assets before persisting their owning application."""
from __future__ import annotations
import hashlib
import mimetypes
import os
from typing import Protocol
from uuid import uuid4
from configs import dify_config
from core.db.session_factory import session_factory
from extensions.ext_storage import storage
from extensions.storage.storage_type import StorageType
from libs.datetime_utils import naive_utc_now
from models.agent_config_entities import AgentSoulConfig
from models.enums import CreatorUserRole
from models.model import UploadFile
from models.tools import ToolFile
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.dsl_entities import AgentPackage, make_portable_agent_soul
from services.agent.errors import (
InvalidComposerConfigError,
InvalidRosterAgentPackageError,
PlaintextSecretNotAllowedError,
RosterAgentPackageResourceUnavailableError,
)
from services.agent.roster_package_entities import AgentPackageResources, PackageIcon, PreparedPackageArchive
from services.agent.roster_package_reader import RosterAgentPackageReader
from services.agent.skill_package_service import SkillPackageError, SkillPackageService
from services.entities.dsl_entities import DslImportWarning
from services.file_service import FileService
class _Storage(Protocol):
def save(self, filename: str, data: bytes) -> None: ...
class AgentPackageResourceImporter:
def __init__(self, *, storage_backend: _Storage = storage) -> None:
self._reader = RosterAgentPackageReader()
self._skill_packages = SkillPackageService()
self._storage = storage_backend
def validate(self, *, resources: AgentPackageResources, agent_package: AgentPackage) -> None:
agent_package.soul = make_portable_agent_soul(agent_package.soul)
self._validate_supported_resources(resources, soul=agent_package.soul)
try:
ComposerConfigValidator.validate_importable_agent_soul(agent_package.soul)
except (InvalidComposerConfigError, PlaintextSecretNotAllowedError) as exc:
raise InvalidRosterAgentPackageError("Agent package Soul is invalid") from exc
@classmethod
def _validate_supported_resources(cls, resources: AgentPackageResources, *, soul: AgentSoulConfig) -> None:
skill_names = [item.name for item in soul.config_skills]
skill_names.extend(item.name for item in resources.skills if item.scope == "workspace")
if len(skill_names) != len(set(skill_names)):
raise InvalidRosterAgentPackageError("Roster Agent package contains duplicate effective Skill names")
file_names = [item.name for item in soul.config_files]
if len(file_names) != len(set(file_names)):
raise InvalidRosterAgentPackageError("Roster Agent package contains duplicate config file names")
file_refs = {item.file_id: item for item in soul.config_files if not item.is_missing}
for resource in resources.files:
filename = file_refs[resource.id].name
extension = cls._extension(filename)
limit = FileService.file_size_limit(extension=extension)
if resource.size > limit:
raise InvalidRosterAgentPackageError(
f"Roster Agent package file {filename!r} exceeds its file size limit"
)
if extension and extension in dify_config.UPLOAD_FILE_EXTENSION_BLACKLIST:
raise InvalidRosterAgentPackageError(
f"Roster Agent package file extension '.{extension}' is not allowed"
)
def materialize(
self,
*,
archive: PreparedPackageArchive,
resources: AgentPackageResources,
agent_package: AgentPackage,
tenant_id: str,
account_id: str,
) -> tuple[AgentPackage, list[DslImportWarning]]:
"""Upload outside database transactions, then commit all file records together."""
files: list[ToolFile | UploadFile] = []
warnings: list[DslImportWarning] = []
soul_data = agent_package.soul.model_dump(mode="json")
skill_refs_by_package_id = {
item["file_id"]: item for item in soul_data["config_skills"] if not item["is_missing"]
}
file_refs_by_package_id = {
item["file_id"]: item for item in soul_data["config_files"] if not item["is_missing"]
}
skill_descriptions = {item.name: item.description for item in agent_package.workspace_skills}
skill_descriptions.update({item.name: item.description for item in agent_package.soul.config_skills})
for skill_resource in resources.skills:
target_ref = skill_refs_by_package_id.get(skill_resource.id)
if target_ref is None:
target_ref = {}
soul_data["config_skills"].append(target_ref)
target_ref.update(
{
"name": skill_resource.name,
"description": skill_descriptions.get(skill_resource.name, ""),
"file_kind": "tool_file",
"file_id": "",
"is_missing": True,
"size": skill_resource.size,
"hash": skill_resource.sha256,
"mime_type": "application/zip",
}
)
reason = archive.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,
)
normalized = self._skill_packages.validate_and_normalize(
content=payload, filename=skill_resource.path
)
except SkillPackageError as exc:
reason = exc.code
except InvalidRosterAgentPackageError:
reason = "archive_integrity_failed"
if normalized is None:
warnings.append(
DslImportWarning(
code="agent_skill_missing",
path=f"skills.{skill_resource.id}",
message=f"Skill {skill_resource.name!r} is damaged and must be uploaded again.",
details={"name": skill_resource.name, "reason": reason, "path": skill_resource.path},
)
)
continue
storage_key = f"tools/{tenant_id}/{uuid4().hex}.zip"
tool_file = ToolFile(
user_id=account_id,
tenant_id=tenant_id,
conversation_id=None,
file_key=storage_key,
mimetype="application/zip",
name=f"{skill_resource.name}.zip",
size=len(normalized.archive_bytes),
original_url=None,
)
self._save_resource(storage_key=storage_key, payload=normalized.archive_bytes, files=files, row=tool_file)
target_ref.update(
{
"description": normalized.manifest.description,
"file_id": tool_file.id,
"is_missing": False,
"size": tool_file.size,
"hash": normalized.manifest.hash,
}
)
for file_resource in resources.files:
package_ref = file_refs_by_package_id[file_resource.id]
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)
if file_kind == "tool_file":
storage_key = f"tools/{tenant_id}/{uuid4().hex}.{extension or 'bin'}"
row: ToolFile | UploadFile = ToolFile(
user_id=account_id,
tenant_id=tenant_id,
conversation_id=None,
file_key=storage_key,
mimetype=mime_type,
name=package_ref["name"],
size=len(payload),
original_url=None,
)
hash_value = None
else:
storage_key = f"upload_files/{tenant_id}/{uuid4()}.{extension or 'bin'}"
now = naive_utc_now()
row = UploadFile(
tenant_id=tenant_id,
storage_type=StorageType(dify_config.STORAGE_TYPE),
key=storage_key,
name=package_ref["name"],
size=len(payload),
extension=extension,
mime_type=mime_type,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=account_id,
created_at=now,
used=False,
hash=hashlib.sha3_256(payload).hexdigest(),
)
hash_value = row.hash
self._save_resource(storage_key=storage_key, payload=payload, files=files, row=row)
package_ref.update(
{
"file_id": row.id,
"is_missing": False,
"size": row.size,
"hash": hash_value,
"mime_type": mime_type,
}
)
soul = AgentSoulConfig.model_validate(soul_data)
with session_factory.create_session() as session, session.begin():
session.add_all(files)
# Localized Skills must not bind to same-named Skills in the target workspace.
return agent_package.model_copy(update={"soul": soul, "workspace_skills": []}), warnings
def materialize_icons(
self, *, archive: PreparedPackageArchive, 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
)
extension = self._extension(icon.path)
file_id = str(uuid4())
key = f"upload_files/{tenant_id}/{file_id}.{extension}"
row = UploadFile(
tenant_id=tenant_id,
storage_type=StorageType(dify_config.STORAGE_TYPE),
key=key,
name=icon.path,
size=len(payload),
extension=extension,
mime_type=mimetypes.guess_type(icon.path)[0] or "application/octet-stream",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=account_id,
created_at=naive_utc_now(),
used=False,
hash=hashlib.sha3_256(payload).hexdigest(),
)
row.id = file_id
self._save_resource(storage_key=key, payload=payload, files=files, row=row)
mapping[icon.id] = file_id
if files:
with session_factory.create_session() as session, session.begin():
session.add_all(files)
return mapping
def _save_resource(
self,
*,
storage_key: str,
payload: bytes,
files: list[ToolFile | UploadFile],
row: ToolFile | UploadFile,
) -> None:
try:
self._storage.save(storage_key, payload)
except Exception as exc:
raise RosterAgentPackageResourceUnavailableError() from exc
files.append(row)
@staticmethod
def _extension(filename: str) -> str:
return os.path.splitext(filename)[1].lstrip(".").lower()

View File

@@ -3,13 +3,14 @@
from __future__ import annotations
import re
from collections.abc import Mapping
from collections.abc import Iterable, Mapping
from dataclasses import dataclass, field
from typing import BinaryIO, Final, Literal, Self
from typing import Any, BinaryIO, Final, Literal, Self
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from services.agent.dsl_entities import AgentAppDsl
from constants import IMAGE_EXTENSIONS
from services.agent.dsl_entities import AgentAppDsl, AgentPackage
ROSTER_AGENT_PACKAGE_FORMAT: Final[Literal["dify.roster-agent"]] = "dify.roster-agent"
ROSTER_AGENT_PACKAGE_FORMAT_VERSION: Final[Literal[1]] = 1
@@ -81,15 +82,21 @@ class RosterAgentPackageFile(_RosterAgentPackageResource):
return self
class RosterAgentPackageManifest(BaseModel):
"""Package metadata and resource index stored in ``manifest.yaml``."""
class PackageIcon(_RosterAgentPackageResource):
id: str = Field(pattern=r"^i_[0-9]{6}$")
@model_validator(mode="after")
def validate_icon_path(self) -> Self:
if self.path not in {f"{self.id}.{extension}" for extension in IMAGE_EXTENSIONS}:
raise ValueError("icon path must use its resource id and an image extension")
return self
class AgentPackageResources(BaseModel):
"""Resources belonging to one portable Agent, independent of its container."""
model_config = ConfigDict(extra="forbid")
format: Literal["dify.roster-agent"]
format_version: Literal[1]
audit: RosterAgentPackageAudit | None = None
apps: list[RosterAgentPackageApp] = Field(min_length=1)
skills: list[RosterAgentPackageSkill] = Field(default_factory=list)
files: list[RosterAgentPackageFile] = Field(default_factory=list)
@@ -98,7 +105,7 @@ class RosterAgentPackageManifest(BaseModel):
ids = [item.id for item in self.skills] + [item.id for item in self.files]
if len(ids) != len(set(ids)):
raise ValueError("resource ids must be unique")
paths = [item.path.casefold() for item in [*self.apps, *self.skills, *self.files]]
paths = [item.path.casefold() for item in [*self.skills, *self.files]]
if len(paths) != len(set(paths)):
raise ValueError("resource paths must be unique")
skill_names = [item.name for item in self.skills]
@@ -107,20 +114,23 @@ class RosterAgentPackageManifest(BaseModel):
return self
def validate_apps(self, apps: Mapping[str, AgentAppDsl]) -> None:
"""Resolve all application references against the shared resource index."""
if set(apps) != {item.path for item in self.apps}:
raise ValueError("app members must match the package app index")
def validate_packages(self, packages: Iterable[AgentPackage]) -> None:
"""Resolve Agent references against this resource index."""
skill_by_id = {item.id: item for item in self.skills}
file_by_id = {item.id: item for item in self.files}
workspace_names = {item.name for item in self.skills if item.scope == "workspace"}
referenced_skill_ids: set[str] = set()
referenced_file_ids: set[str] = set()
referenced_workspace_names: set[str] = set()
for app in apps.values():
if app.package.soul.schema_version != 1:
for package in packages:
if package.soul.schema_version != 1:
raise ValueError("unsupported Agent Soul schema version")
for skill_ref in app.package.soul.config_skills:
resource_ids = [item.file_id for item in package.soul.config_skills if not item.is_missing] + [
item.file_id for item in package.soul.config_files if not item.is_missing
]
if len(resource_ids) != len(set(resource_ids)):
raise ValueError("Agent config resources must have unique references")
for skill_ref in package.soul.config_skills:
if skill_ref.is_missing:
continue
skill_resource = skill_by_id.get(skill_ref.file_id)
@@ -129,13 +139,13 @@ class RosterAgentPackageManifest(BaseModel):
if skill_resource.name != skill_ref.name:
raise ValueError("config skill name must match its resource metadata")
referenced_skill_ids.add(skill_resource.id)
app_workspace_names = [item.name for item in app.package.workspace_skills]
app_workspace_names = [item.name for item in package.workspace_skills]
if len(app_workspace_names) != len(set(app_workspace_names)):
raise ValueError("workspace skill names must be unique in the Agent DSL")
if set(app_workspace_names) - workspace_names:
raise ValueError("workspace skill references must match the package resource index")
referenced_workspace_names.update(app_workspace_names)
for file_ref in app.package.soul.config_files:
for file_ref in package.soul.config_files:
if file_ref.is_missing:
continue
if file_ref.file_id not in file_by_id:
@@ -149,26 +159,55 @@ class RosterAgentPackageManifest(BaseModel):
raise ValueError("file resources must be referenced by the Agent Soul")
@dataclass
class PreparedRosterAgentPackage:
"""Validated archive retained in a bounded spool for later materialization."""
class RosterAgentPackageManifest(AgentPackageResources):
"""Package metadata and resource index stored in ``manifest.yaml``."""
format: Literal["dify.roster-agent"]
format_version: Literal[1]
audit: RosterAgentPackageAudit | None = None
icons: list[PackageIcon] = Field(default_factory=list)
apps: list[RosterAgentPackageApp] = Field(min_length=1)
@model_validator(mode="after")
def validate_app_paths(self) -> Self:
paths = [item.path.casefold() for item in [*self.apps, *self.skills, *self.files, *self.icons]]
if len(paths) != len(set(paths)):
raise ValueError("resource paths must be unique")
return self
def validate_apps(self, apps: Mapping[str, AgentAppDsl]) -> None:
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())]
)
@dataclass(kw_only=True)
class PreparedPackageArchive:
"""Own one validated archive and its member index until import finishes."""
archive: BinaryIO
manifest: RosterAgentPackageManifest
apps: dict[str, AgentAppDsl]
members: dict[str, RosterAgentPackageMember]
invalid_skills: dict[str, str] = field(default_factory=dict)
def close(self) -> None:
self.archive.close()
def __enter__(self) -> PreparedRosterAgentPackage:
def __enter__(self) -> Self:
return self
def __exit__(self, *_args: object) -> None:
self.close()
@dataclass(kw_only=True)
class PreparedRosterAgentPackage(PreparedPackageArchive):
manifest: RosterAgentPackageManifest
apps: dict[str, AgentAppDsl]
@dataclass
class RosterAgentPackageExport:
"""Download artifact owning its archive until the response closes."""
@@ -187,10 +226,29 @@ class RosterAgentPackageExport:
self.close()
def validate_icon_references(icons: list[PackageIcon], metadata: list[dict[str, Any]]) -> None:
references: set[str] = set()
for item in metadata:
if item.get("icon_type") == "image":
reference = item.get("icon")
if reference is not None and not isinstance(reference, str):
raise ValueError("Image icon references must be strings")
if reference:
references.add(reference)
ids = {item.id for item in icons}
if len(ids) != len(icons):
raise ValueError("Packaged icons must have unique ids")
if ids - references or any(ref.startswith("i_") and ref not in ids for ref in references):
raise ValueError("Packaged icons must match image references")
__all__ = [
"ROSTER_AGENT_PACKAGE_FORMAT",
"ROSTER_AGENT_PACKAGE_FORMAT_VERSION",
"ROSTER_AGENT_PACKAGE_MAX_SIGNATURE_BYTES",
"AgentPackageResources",
"PackageIcon",
"PreparedPackageArchive",
"PreparedRosterAgentPackage",
"RosterAgentPackageApp",
"RosterAgentPackageAudit",
@@ -199,4 +257,5 @@ __all__ = [
"RosterAgentPackageManifest",
"RosterAgentPackageMember",
"RosterAgentPackageSkill",
"validate_icon_references",
]

View File

@@ -3,18 +3,15 @@
from __future__ import annotations
import hashlib
import os
import re
import tempfile
import zipfile
from collections.abc import Callable, Generator, Sequence
from dataclasses import dataclass
from typing import BinaryIO, Literal, Protocol, cast
from collections.abc import Callable
from typing import BinaryIO, cast
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
@@ -31,60 +28,28 @@ from models.agent import (
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import AppStatus
from models.model import App, AppMode, UploadFile
from models.tools import ToolFile
from models.model import App, AppMode
from services.agent.dependency_service import extract_agent_soul_dependencies
from services.agent.dsl_entities import (
AgentAppDsl,
AgentPackageWorkspaceSkill,
make_agent_app_dsl,
make_portable_agent_package,
)
from services.agent.errors import (
AgentNotFoundError,
AgentVersionNotFoundError,
RosterAgentPackageExportFailedError,
RosterAgentPackageTooLargeError,
)
from services.agent.package_resource_exporter import AgentPackageResourceExporter, _Storage
from services.agent.roster_package_entities import (
ROSTER_AGENT_PACKAGE_FORMAT,
ROSTER_AGENT_PACKAGE_FORMAT_VERSION,
RosterAgentPackageApp,
RosterAgentPackageAudit,
RosterAgentPackageExport,
RosterAgentPackageFile,
RosterAgentPackageManifest,
RosterAgentPackageSkill,
)
from services.agent.roster_service import AgentRosterService
from services.agent.skill_package_service import SkillPackageError, SkillPackageService
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.skill_management_service import RuntimeAgentSkillArchive, SkillManagementService
class _Storage(Protocol):
def load_stream(self, filename: str) -> Generator[bytes, None, None]: ...
@dataclass(frozen=True)
class _SkillSource:
path: str
storage_key: str
id: str
scope: Literal["agent_config", "workspace"]
name: str
display_name: str | None
description: str
priority: int | None
audit_ref: str
@dataclass(frozen=True)
class _FileSource:
path: str
storage_key: str
id: str
audit_ref: str
class RosterAgentPackageExporter:
@@ -101,6 +66,7 @@ class RosterAgentPackageExporter:
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."""
resources = AgentPackageResourceExporter(storage_backend=self._storage)
with session_factory.create_session() as session:
row = session.execute(
select(Agent, App)
@@ -160,121 +126,44 @@ class RosterAgentPackageExporter:
raise AgentVersionNotFoundError()
soul = AgentSoulConfig.model_validate(active_snapshot.config_snapshot_dict)
resource_soul, skill_sources, file_sources = self._collect_payloads(
package = resources.collect_package(
session=session,
tenant_id=tenant_id,
agent=agent,
soul=soul,
snapshot_id=snapshot_id,
package_ref="agent_1",
include_draft=draft is not None,
)
package = make_portable_agent_package(agent, resource_soul, include_assets=True)
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)
audit = RosterAgentPackageAudit(ref=agent.id)
dependency_ids = extract_agent_soul_dependencies(package.soul)
workspace_skills = SkillManagementService().list_runtime_agent_skill_archives(
tenant_id=tenant_id,
agent_id=agent_id,
include_draft=draft is not None,
config_snapshot_id=snapshot_id,
)
skill_sources.extend(
self._workspace_skill_sources(
soul=package.soul,
archives=workspace_skills,
start_index=len(skill_sources),
)
)
package.workspace_skills = [
AgentPackageWorkspaceSkill(
name=item.name,
display_name=item.display_name or "",
description=item.description,
priority=item.priority,
)
for item in skill_sources
if item.scope == "workspace" and item.priority is not None
]
resources.collect_workspace_skills()
app.dependencies = self._dependency_provider(tenant_id, dependency_ids)
return self._build_archive(
app=app,
audit=audit,
skill_sources=skill_sources,
file_sources=file_sources,
resources=resources,
)
def _build_archive(
self,
*,
app: AgentAppDsl,
skill_sources: Sequence[_SkillSource],
file_sources: Sequence[_FileSource],
resources: AgentPackageResourceExporter,
audit: RosterAgentPackageAudit | None = None,
) -> RosterAgentPackageExport:
required_entries = len(skill_sources) + len(file_sources) + 2
if required_entries > dify_config.AGENT_PACKAGE_MAX_ENTRIES:
raise RosterAgentPackageTooLargeError("Roster Agent package has too many members")
# Ownership is transferred to RosterAgentPackageExport.
output = cast(
BinaryIO,
tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024, mode="w+b"), # noqa: SIM115
)
try:
skills: list[RosterAgentPackageSkill] = []
files: list[RosterAgentPackageFile] = []
with zipfile.ZipFile(output, "w", compression=zipfile.ZIP_STORED, allowZip64=True) as archive:
total_size = 0
nested_uncompressed_size = 0
skill_packages = SkillPackageService()
sources: list[_SkillSource | _FileSource] = [*skill_sources, *file_sources]
for source in sources:
remaining_bytes = dify_config.AGENT_PACKAGE_MAX_BYTES - total_size
if isinstance(source, _SkillSource):
remaining_bytes = min(remaining_bytes, dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024)
member_size, member_digest = self._write_storage_member(
archive, path=source.path, storage_key=source.storage_key, max_bytes=remaining_bytes
)
total_size += member_size
if isinstance(source, _SkillSource):
# Inspect only the embedded Skill; our writer already owns
# the outer container, resource sizes, and digests.
try:
inspection = skill_packages.inspect(content=archive.read(source.path), filename=source.path)
except SkillPackageError as exc:
raise RosterAgentPackageExportFailedError(
f"Roster Agent package contains unusable Skill {source.name!r}"
) from exc
if inspection.name != source.name:
raise RosterAgentPackageExportFailedError(
f"Roster Agent package contains unusable Skill {source.name!r}: name mismatch"
)
nested_uncompressed_size += inspection.uncompressed_size
if nested_uncompressed_size > dify_config.AGENT_PACKAGE_MAX_BYTES:
raise RosterAgentPackageTooLargeError(
"Roster Agent package nested Skill contents exceed the size limit"
)
skills.append(
RosterAgentPackageSkill(
id=source.id,
scope=source.scope,
name=source.name,
path=source.path,
size=member_size,
sha256=member_digest,
audit=RosterAgentPackageAudit(ref=source.audit_ref),
)
)
else:
files.append(
RosterAgentPackageFile(
id=source.id,
path=source.path,
size=member_size,
sha256=member_digest,
audit=RosterAgentPackageAudit(ref=source.audit_ref),
)
)
groups, total_size = resources.write_resources(archive)
group = groups[app.agent.package_ref]
icons, total_size = resources.write_icons(archive, total_size=total_size)
app_bytes = yaml.safe_dump(
app.model_dump(mode="json", exclude_none=True), allow_unicode=True, sort_keys=False
).encode("utf-8")
@@ -287,12 +176,15 @@ class RosterAgentPackageExporter:
path="app.yaml", size=len(app_bytes), sha256=hashlib.sha256(app_bytes).hexdigest()
)
],
skills=skills,
files=files,
icons=icons,
skills=group.skills,
files=group.files,
)
manifest.validate_apps({"app.yaml": app})
manifest_bytes = yaml.safe_dump(
manifest.model_dump(mode="json", exclude_none=True), allow_unicode=True, sort_keys=False
manifest.model_dump(mode="json", exclude_none=True, exclude={"icons"} if not icons else set()),
allow_unicode=True,
sort_keys=False,
).encode("utf-8")
for path, document_bytes in (("app.yaml", app_bytes), ("manifest.yaml", manifest_bytes)):
if len(document_bytes) > dify_config.AGENT_PACKAGE_MAX_MANIFEST_BYTES:
@@ -315,163 +207,6 @@ class RosterAgentPackageExporter:
output.close()
raise
def _collect_payloads(
self,
*,
session: Session,
tenant_id: str,
soul: AgentSoulConfig,
) -> tuple[AgentSoulConfig, list[_SkillSource], list[_FileSource]]:
resource_data = soul.model_dump(mode="json")
skill_sources: list[_SkillSource] = []
file_sources: list[_FileSource] = []
skill_file_ids = [item.file_id for item in soul.config_skills if not item.is_missing]
tool_files = self._tool_files(session=session, tenant_id=tenant_id, file_ids=skill_file_ids)
for skill_ref, portable_skill_ref in zip(soul.config_skills, resource_data["config_skills"]):
if skill_ref.is_missing:
continue
resource_id = f"s_{len(skill_sources) + 1:06d}"
tool_file = tool_files.get(skill_ref.file_id)
if tool_file is None:
raise RosterAgentPackageExportFailedError(f"Config skill {skill_ref.name!r} payload is unavailable")
path = f"{resource_id}.zip"
portable_skill_ref["file_id"] = resource_id
portable_skill_ref["is_missing"] = False
skill_sources.append(
_SkillSource(
path=path,
storage_key=tool_file.file_key,
id=resource_id,
scope="agent_config",
name=skill_ref.name,
display_name=None,
description=skill_ref.description,
priority=None,
audit_ref=tool_file.id,
)
)
tool_file_refs = [
item.file_id for item in soul.config_files if not item.is_missing and item.file_kind == "tool_file"
]
upload_file_refs = [
item.file_id for item in soul.config_files if not item.is_missing and item.file_kind == "upload_file"
]
file_tool_files = self._tool_files(session=session, tenant_id=tenant_id, file_ids=tool_file_refs)
upload_files = self._upload_files(session=session, tenant_id=tenant_id, file_ids=upload_file_refs)
for file_ref, portable_file_ref in zip(soul.config_files, resource_data["config_files"]):
if file_ref.is_missing:
continue
resource_id = f"f_{len(file_sources) + 1:06d}"
extension = self._safe_extension(file_ref.name)
path = f"{resource_id}{extension}"
if file_ref.file_kind == "tool_file":
tool_file = file_tool_files.get(file_ref.file_id)
if tool_file is None:
raise RosterAgentPackageExportFailedError(f"Config file {file_ref.name!r} payload is unavailable")
storage_key = tool_file.file_key
mime_type = file_ref.mime_type or tool_file.mimetype
audit_ref = tool_file.id
else:
upload_file = upload_files.get(file_ref.file_id)
if upload_file is None:
raise RosterAgentPackageExportFailedError(f"Config file {file_ref.name!r} payload is unavailable")
storage_key = upload_file.key
mime_type = file_ref.mime_type or upload_file.mime_type
audit_ref = upload_file.id
portable_file_ref["file_id"] = resource_id
portable_file_ref["is_missing"] = False
portable_file_ref["mime_type"] = mime_type or "application/octet-stream"
file_sources.append(
_FileSource(
path=path,
storage_key=storage_key,
id=resource_id,
audit_ref=audit_ref,
)
)
return AgentSoulConfig.model_validate(resource_data), skill_sources, file_sources
@staticmethod
def _workspace_skill_sources(
*, soul: AgentSoulConfig, archives: Sequence[RuntimeAgentSkillArchive], start_index: int
) -> list[_SkillSource]:
sources: list[_SkillSource] = []
effective_skill_names = {item.name for item in soul.config_skills if not item.is_missing}
for archive in sorted(archives, key=lambda item: item.priority):
if archive.name in effective_skill_names:
continue
effective_skill_names.add(archive.name)
resource_id = f"s_{start_index + len(sources) + 1:06d}"
path = f"{resource_id}.zip"
sources.append(
_SkillSource(
path=path,
storage_key=archive.storage_key,
id=resource_id,
scope="workspace",
name=archive.name,
display_name=archive.display_name,
description=archive.description,
priority=archive.priority,
audit_ref=archive.version_id,
)
)
return sources
@staticmethod
def _tool_files(*, session: Session, tenant_id: str, file_ids: Sequence[str]) -> dict[str, ToolFile]:
if not file_ids:
return {}
rows = session.scalars(
select(ToolFile).where(ToolFile.tenant_id == tenant_id, ToolFile.id.in_(set(file_ids)))
).all()
return {item.id: item for item in rows}
@staticmethod
def _upload_files(*, session: Session, tenant_id: str, file_ids: Sequence[str]) -> dict[str, UploadFile]:
if not file_ids:
return {}
rows = session.scalars(
select(UploadFile).where(UploadFile.tenant_id == tenant_id, UploadFile.id.in_(set(file_ids)))
).all()
return {item.id: item for item in rows}
def _write_storage_member(
self,
archive: zipfile.ZipFile,
*,
path: str,
storage_key: str,
max_bytes: int,
) -> tuple[int, str]:
digest = hashlib.sha256()
size = 0
try:
with archive.open(path, "w", force_zip64=True) as target:
for chunk in self._storage.load_stream(storage_key):
if not isinstance(chunk, bytes):
raise TypeError("storage stream returned a non-bytes chunk")
if size + len(chunk) > max_bytes:
raise RosterAgentPackageTooLargeError("Roster Agent package payload exceeds the size limit")
size += len(chunk)
digest.update(chunk)
target.write(chunk)
except RosterAgentPackageTooLargeError:
raise
except Exception as exc:
raise RosterAgentPackageExportFailedError(f"Unable to read package resource {path!r}") from exc
return size, digest.hexdigest()
@staticmethod
def _safe_extension(filename: str) -> str:
extension = os.path.splitext(filename)[1].lower()
if not extension or not re.fullmatch(r"\.[a-z0-9]{1,16}", extension):
return ".bin"
return extension
@staticmethod
def _safe_slug(name: str) -> str:
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")

View File

@@ -2,67 +2,50 @@
from __future__ import annotations
import hashlib
import logging
import os
from dataclasses import dataclass
from typing import BinaryIO, Protocol
from typing import BinaryIO
from uuid import uuid4
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
from extensions.storage.storage_type import StorageType
from libs.datetime_utils import naive_utc_now
from models import Account
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
AgentScope,
AgentIconType,
AgentSource,
AgentStatus,
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import CreatorUserRole
from models.model import App, AppMode, AppModelConfig, UploadFile
from models.tools import ToolFile
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.dsl_entities import AgentPackage, AgentPackageMetadata, make_portable_agent_soul
from models.model import App, AppMode, AppModelConfig, IconType, UploadFile
from services.agent.dsl_entities import AgentPackage, AgentPackageMetadata
from services.agent.dsl_service import AgentDslService
from services.agent.errors import (
AgentNameConflictError,
InvalidComposerConfigError,
InvalidRosterAgentPackageError,
PlaintextSecretNotAllowedError,
RosterAgentPackageImportFailedError,
RosterAgentPackageResourceUnavailableError,
RosterAgentPackageTooLargeError,
)
from services.agent.package_resource_importer import AgentPackageResourceImporter, _Storage
from services.agent.roster_package_dependencies import check_package_dependencies
from services.agent.roster_package_entities import PreparedRosterAgentPackage
from services.agent.roster_package_reader import RosterAgentPackageReader
from services.agent.roster_service import AgentRosterService
from services.agent.skill_package_service import SkillPackageError, SkillPackageService
from services.app_creation_records import create_installed_app_record, create_site_record
from services.app_service import AppService
from services.entities.dsl_entities import DslImportWarning
from services.file_service import FileService
from services.icon_configuration import DEFAULT_ICON, DEFAULT_ICON_BACKGROUND, DEFAULT_ICON_TYPE
from services.icon_configuration import DEFAULT_ICON, DEFAULT_ICON_BACKGROUND, DEFAULT_ICON_TYPE, is_valid_image_icon
logger = logging.getLogger(__name__)
class _Storage(Protocol):
def save(self, filename: str, data: bytes) -> None: ...
@dataclass(frozen=True)
class RosterAgentPackageImportResult:
app_id: str
@@ -73,14 +56,8 @@ class RosterAgentPackageImportResult:
class RosterAgentPackageImporter:
"""Commit uploaded file records before creating the Agent in a separate transaction."""
def __init__(
self,
*,
storage_backend: _Storage = storage,
) -> None:
self._reader = RosterAgentPackageReader()
self._skill_packages = SkillPackageService()
self._storage = storage_backend
def __init__(self, *, storage_backend: _Storage = storage) -> None:
self._resources = AgentPackageResourceImporter(storage_backend=storage_backend)
def import_package(
self,
@@ -89,23 +66,31 @@ class RosterAgentPackageImporter:
tenant_id: str,
account: Account,
) -> RosterAgentPackageImportResult:
with self._reader.read(source) as package:
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
agent_package.soul = make_portable_agent_soul(agent_package.soul)
self._validate_supported_resources(package, soul=agent_package.soul)
try:
ComposerConfigValidator.validate_importable_agent_soul(agent_package.soul)
except (InvalidComposerConfigError, PlaintextSecretNotAllowedError) as exc:
raise InvalidRosterAgentPackageError("Roster Agent package Soul is invalid") from exc
self._resources.validate(resources=package.manifest, agent_package=agent_package)
check_package_dependencies(tenant_id=tenant_id, account=account, dependencies=app_dsl.dependencies)
try:
self._ensure_name_available(tenant_id=tenant_id, name=agent_package.metadata.name)
soul, skill_warnings = self._upload_resources(
package=package,
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,
@@ -113,7 +98,7 @@ class RosterAgentPackageImporter:
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=soul),
package=AgentPackage(metadata=agent_package.metadata, soul=materialized.soul),
package_path="agent",
)
warnings = [*skill_warnings, *warnings]
@@ -121,6 +106,7 @@ class RosterAgentPackageImporter:
tenant_id=tenant_id,
account=account,
metadata=agent_package.metadata,
app_metadata=app_metadata,
soul=resolved_soul,
)
except Exception as exc:
@@ -149,198 +135,6 @@ class RosterAgentPackageImporter:
)
return RosterAgentPackageImportResult(app_id=app_id, agent_id=agent_id, warnings=warnings)
@classmethod
def _validate_supported_resources(cls, package: PreparedRosterAgentPackage, *, soul: AgentSoulConfig) -> None:
skill_names = [item.name for item in soul.config_skills]
skill_names.extend(item.name for item in package.manifest.skills if item.scope == "workspace")
if len(skill_names) != len(set(skill_names)):
raise InvalidRosterAgentPackageError("Roster Agent package contains duplicate effective Skill names")
file_names = [item.name for item in soul.config_files]
if len(file_names) != len(set(file_names)):
raise InvalidRosterAgentPackageError("Roster Agent package contains duplicate config file names")
file_refs = {item.file_id: item for item in soul.config_files if not item.is_missing}
for resource in package.manifest.files:
filename = file_refs[resource.id].name
extension = cls._extension(filename)
limit = FileService.file_size_limit(extension=extension)
if resource.size > limit:
raise InvalidRosterAgentPackageError(
f"Roster Agent package file {filename!r} exceeds its file size limit"
)
if extension and extension in dify_config.UPLOAD_FILE_EXTENSION_BLACKLIST:
raise InvalidRosterAgentPackageError(
f"Roster Agent package file extension '.{extension}' is not allowed"
)
@staticmethod
def _ensure_name_available(*, tenant_id: str, name: str) -> None:
with session_factory.create_session() as session:
exists = session.scalar(
select(Agent.id)
.where(
Agent.tenant_id == tenant_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
Agent.name == name,
)
.limit(1)
)
if exists is not None:
raise AgentNameConflictError()
def _upload_resources(
self,
*,
package: PreparedRosterAgentPackage,
agent_package: AgentPackage,
tenant_id: str,
account_id: str,
) -> tuple[AgentSoulConfig, list[DslImportWarning]]:
"""Upload outside database transactions, then commit all file records together."""
files: list[ToolFile | UploadFile] = []
warnings: list[DslImportWarning] = []
soul_data = agent_package.soul.model_dump(mode="json")
skill_refs_by_package_id = {
item["file_id"]: item for item in soul_data["config_skills"] if not item["is_missing"]
}
file_refs_by_package_id = {
item["file_id"]: item for item in soul_data["config_files"] if not item["is_missing"]
}
skill_descriptions = {item.name: item.description for item in agent_package.workspace_skills}
skill_descriptions.update({item.name: item.description for item in agent_package.soul.config_skills})
for skill_resource in package.manifest.skills:
target_ref = skill_refs_by_package_id.get(skill_resource.id)
if target_ref is None:
target_ref = {}
soul_data["config_skills"].append(target_ref)
target_ref.update(
{
"name": skill_resource.name,
"description": skill_descriptions.get(skill_resource.name, ""),
"file_kind": "tool_file",
"file_id": "",
"is_missing": True,
"size": skill_resource.size,
"hash": skill_resource.sha256,
"mime_type": "application/zip",
}
)
reason = package.invalid_skills.get(skill_resource.id)
normalized = None
try:
if reason is None:
payload = self._reader.read_member_bytes(
package,
skill_resource.path,
max_bytes=dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024,
)
normalized = self._skill_packages.validate_and_normalize(
content=payload, filename=skill_resource.path
)
except SkillPackageError as exc:
reason = exc.code
except InvalidRosterAgentPackageError:
reason = "archive_integrity_failed"
if normalized is None:
warnings.append(
DslImportWarning(
code="agent_skill_missing",
path=f"skills.{skill_resource.id}",
message=f"Skill {skill_resource.name!r} is damaged and must be uploaded again.",
details={"name": skill_resource.name, "reason": reason, "path": skill_resource.path},
)
)
continue
storage_key = f"tools/{tenant_id}/{uuid4().hex}.zip"
tool_file = ToolFile(
user_id=account_id,
tenant_id=tenant_id,
conversation_id=None,
file_key=storage_key,
mimetype="application/zip",
name=f"{skill_resource.name}.zip",
size=len(normalized.archive_bytes),
original_url=None,
)
self._save_resource(storage_key=storage_key, payload=normalized.archive_bytes, files=files, row=tool_file)
target_ref.update(
{
"description": normalized.manifest.description,
"file_id": tool_file.id,
"is_missing": False,
"size": tool_file.size,
"hash": normalized.manifest.hash,
}
)
for file_resource in package.manifest.files:
package_ref = file_refs_by_package_id[file_resource.id]
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(package, file_resource.path, max_bytes=file_resource.size)
if file_kind == "tool_file":
storage_key = f"tools/{tenant_id}/{uuid4().hex}.{extension or 'bin'}"
row: ToolFile | UploadFile = ToolFile(
user_id=account_id,
tenant_id=tenant_id,
conversation_id=None,
file_key=storage_key,
mimetype=mime_type,
name=package_ref["name"],
size=len(payload),
original_url=None,
)
hash_value = None
else:
storage_key = f"upload_files/{tenant_id}/{uuid4()}.{extension or 'bin'}"
now = naive_utc_now()
row = UploadFile(
tenant_id=tenant_id,
storage_type=StorageType(dify_config.STORAGE_TYPE),
key=storage_key,
name=package_ref["name"],
size=len(payload),
extension=extension,
mime_type=mime_type,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=account_id,
created_at=now,
used=False,
hash=hashlib.sha3_256(payload).hexdigest(),
)
hash_value = row.hash
self._save_resource(storage_key=storage_key, payload=payload, files=files, row=row)
package_ref.update(
{
"file_id": row.id,
"is_missing": False,
"size": row.size,
"hash": hash_value,
"mime_type": mime_type,
}
)
soul = AgentSoulConfig.model_validate(soul_data)
with session_factory.create_session() as session, session.begin():
session.add_all(files)
return soul, warnings
def _save_resource(
self,
*,
storage_key: str,
payload: bytes,
files: list[ToolFile | UploadFile],
row: ToolFile | UploadFile,
) -> None:
try:
self._storage.save(storage_key, payload)
except Exception as exc:
raise RosterAgentPackageResourceUnavailableError() from exc
files.append(row)
@staticmethod
def _persist_import(
*,
@@ -348,16 +142,25 @@ class RosterAgentPackageImporter:
account: Account,
metadata: AgentPackageMetadata,
soul: AgentSoulConfig,
app_metadata: AgentPackageMetadata | 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)
app = App(**default_app_templates[AppMode.AGENT]["app"])
app.id = str(uuid4())
app.tenant_id = tenant_id
app.name = metadata.name
app.name = name
app.description = metadata.description
app.icon_type = DEFAULT_ICON_TYPE
app.icon = DEFAULT_ICON
app.icon_background = DEFAULT_ICON_BACKGROUND
app_metadata = app_metadata or metadata
if app_metadata.icon_type in {item.value for item in IconType} and is_valid_image_icon(
session=session, tenant_id=tenant_id, icon_type=app_metadata.icon_type, icon=app_metadata.icon
):
app.icon_type = IconType(app_metadata.icon_type)
app.icon = app_metadata.icon or DEFAULT_ICON
app.icon_background = app_metadata.icon_background or DEFAULT_ICON_BACKGROUND
app.api_rph = 0
app.api_rpm = 0
app.max_active_requests = None
@@ -374,13 +177,22 @@ class RosterAgentPackageImporter:
tenant_id=tenant_id,
account_id=account.id,
app_id=app.id,
name=metadata.name,
name=name,
description=metadata.description,
role=metadata.role,
icon_type=app.icon_type,
icon=app.icon,
icon_background=app.icon_background,
source=AgentSource.IMPORTED,
initial_soul=soul,
revision_operation=AgentConfigRevisionOperation.IMPORT_PACKAGE,
)
if metadata.icon_type in {item.value for item in AgentIconType} and is_valid_image_icon(
session=session, tenant_id=tenant_id, icon_type=metadata.icon_type, icon=metadata.icon
):
agent.icon_type = AgentIconType(metadata.icon_type)
agent.icon = metadata.icon
agent.icon_background = metadata.icon_background
snapshot = session.get(AgentConfigSnapshot, agent.active_config_snapshot_id)
if snapshot is None:
raise RuntimeError("Imported Agent snapshot was not created")
@@ -426,9 +238,5 @@ class RosterAgentPackageImporter:
created_records_initialized=True,
)
@staticmethod
def _extension(filename: str) -> str:
return os.path.splitext(filename)[1].lstrip(".").lower()
__all__ = ["RosterAgentPackageImportResult", "RosterAgentPackageImporter"]

View File

@@ -9,7 +9,7 @@ import stat
import tempfile
import zipfile
import zlib
from collections.abc import Hashable
from collections.abc import Hashable, Sequence
from typing import Any, BinaryIO, cast, override
import yaml
@@ -21,8 +21,11 @@ from services.agent.dsl_entities import AgentAppDsl
from services.agent.errors import InvalidRosterAgentPackageError, RosterAgentPackageTooLargeError
from services.agent.roster_package_entities import (
ROSTER_AGENT_PACKAGE_MAX_SIGNATURE_BYTES,
PackageIcon,
PreparedPackageArchive,
PreparedRosterAgentPackage,
RosterAgentPackageApp,
RosterAgentPackageFile,
RosterAgentPackageManifest,
RosterAgentPackageMember,
RosterAgentPackageSkill,
@@ -61,7 +64,7 @@ class RosterAgentPackageReader:
spool.close()
raise
def read_member_bytes(self, package: PreparedRosterAgentPackage, path: str, *, max_bytes: int) -> bytes:
def read_member_bytes(self, package: PreparedPackageArchive, path: str, *, max_bytes: int) -> bytes:
"""Read one already-validated member while rechecking its size and digest."""
member = package.members.get(path)
@@ -129,6 +132,7 @@ class RosterAgentPackageReader:
*(item.path for item in manifest.apps),
*(item.path for item in manifest.skills),
*(item.path for item in manifest.files),
*(item.path for item in manifest.icons),
}
actual_paths = set(info_by_path)
if "signature.sig" in actual_paths:
@@ -138,7 +142,6 @@ class RosterAgentPackageReader:
if actual_paths != expected_paths:
raise InvalidRosterAgentPackageError("Roster Agent package members do not match the manifest")
nested_uncompressed_size = 0
signature_info = info_by_path.get("signature.sig")
if signature_info is not None:
_, _, signature_size = self._read_member(
@@ -152,67 +155,92 @@ class RosterAgentPackageReader:
expected_size=signature_info.file_size,
)
streamed_size += signature_size
for resource in [*manifest.skills, *manifest.files]:
info = info_by_path[resource.path]
payload = b""
remaining_package_bytes = dify_config.AGENT_PACKAGE_MAX_BYTES - streamed_size
if isinstance(resource, RosterAgentPackageSkill):
max_skill_bytes = dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024
if info.file_size > max_skill_bytes:
raise RosterAgentPackageTooLargeError(
f"Roster Agent package Skill {resource.name!r} exceeds the size limit"
)
try:
payload, digest, actual_size = self._read_member(
archive,
info,
collect=True,
max_bytes=min(max_skill_bytes, remaining_package_bytes),
expected_size=resource.size,
)
except (InvalidRosterAgentPackageError, zipfile.BadZipFile, EOFError, zlib.error):
invalid_skills[resource.id] = "archive_integrity_failed"
streamed_size += info.file_size
continue
else:
_, digest, actual_size = self._read_member(
archive,
info,
collect=False,
max_bytes=remaining_package_bytes,
expected_size=resource.size,
)
streamed_size += actual_size
if digest != resource.sha256:
if isinstance(resource, RosterAgentPackageSkill):
invalid_skills[resource.id] = "checksum_mismatch"
continue
raise InvalidRosterAgentPackageError(
f"Roster Agent package resource {resource.path!r} failed integrity checks",
)
members[resource.path] = RosterAgentPackageMember(
size=resource.size,
sha256=digest,
)
if isinstance(resource, RosterAgentPackageSkill):
try:
inspection = self._skill_packages.inspect(content=payload, filename=resource.path)
except SkillPackageError as exc:
invalid_skills[resource.id] = exc.code
continue
nested_uncompressed_size += inspection.uncompressed_size
if nested_uncompressed_size > dify_config.AGENT_PACKAGE_MAX_BYTES:
raise RosterAgentPackageTooLargeError(
"Roster Agent package nested Skill contents exceed the size limit"
)
if inspection.name != resource.name:
invalid_skills[resource.id] = "skill_name_mismatch"
self._validate_resource_members(
archive,
info_by_path,
[*manifest.skills, *manifest.files, *manifest.icons],
members=members,
invalid_skills=invalid_skills,
streamed_size=streamed_size,
)
return manifest, apps, members
except (InvalidRosterAgentPackageError, RosterAgentPackageTooLargeError):
raise
except (OSError, zipfile.BadZipFile, EOFError, RuntimeError, ValueError, zlib.error) as exc:
raise InvalidRosterAgentPackageError("Roster Agent package is not a valid ZIP") from exc
def _validate_resource_members(
self,
archive: zipfile.ZipFile,
info_by_path: dict[str, zipfile.ZipInfo],
resources: Sequence[RosterAgentPackageSkill | RosterAgentPackageFile | PackageIcon],
*,
members: dict[str, RosterAgentPackageMember],
invalid_skills: dict[str, str],
streamed_size: int,
) -> None:
nested_uncompressed_size = 0
for resource in resources:
info = info_by_path[resource.path]
if (
isinstance(resource, PackageIcon)
and resource.size > dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT * 1024 * 1024
):
raise RosterAgentPackageTooLargeError("Packaged icon exceeds the image size limit")
payload = b""
remaining_package_bytes = dify_config.AGENT_PACKAGE_MAX_BYTES - streamed_size
if isinstance(resource, RosterAgentPackageSkill):
max_skill_bytes = dify_config.UPLOAD_SKILL_FILE_SIZE_LIMIT * 1024 * 1024
if info.file_size > max_skill_bytes:
raise RosterAgentPackageTooLargeError(
f"Roster Agent package Skill {resource.name!r} exceeds the size limit"
)
try:
payload, digest, actual_size = self._read_member(
archive,
info,
collect=True,
max_bytes=min(max_skill_bytes, remaining_package_bytes),
expected_size=resource.size,
)
except (InvalidRosterAgentPackageError, zipfile.BadZipFile, EOFError, zlib.error):
invalid_skills[resource.id] = "archive_integrity_failed"
streamed_size += info.file_size
continue
else:
_, digest, actual_size = self._read_member(
archive,
info,
collect=False,
max_bytes=remaining_package_bytes,
expected_size=resource.size,
)
streamed_size += actual_size
if digest != resource.sha256:
if isinstance(resource, RosterAgentPackageSkill):
invalid_skills[resource.id] = "checksum_mismatch"
continue
raise InvalidRosterAgentPackageError(
f"Roster Agent package resource {resource.path!r} failed integrity checks",
)
members[resource.path] = RosterAgentPackageMember(
size=resource.size,
sha256=digest,
)
if isinstance(resource, RosterAgentPackageSkill):
try:
inspection = self._skill_packages.inspect(content=payload, filename=resource.path)
except SkillPackageError as exc:
invalid_skills[resource.id] = exc.code
continue
nested_uncompressed_size += inspection.uncompressed_size
if nested_uncompressed_size > dify_config.AGENT_PACKAGE_MAX_BYTES:
raise RosterAgentPackageTooLargeError(
"Roster Agent package nested Skill contents exceed the size limit"
)
if inspection.name != resource.name:
invalid_skills[resource.id] = "skill_name_mismatch"
def _index_archive(self, archive: zipfile.ZipFile) -> dict[str, zipfile.ZipInfo]:
infos = archive.infolist()
if not infos:

View File

@@ -0,0 +1,680 @@
"""Forward-only, provider-authoritative sandbox accounting.
The activation event is also a project-scoped transaction lock. This keeps
deduplication, checkpoints, and execution projection atomic
across API workers without coupling billing records to business transactions.
Business creation/lookups never call accounting. Ownership is resolved only
while collecting provider events. No network calls belong in this service.
"""
import hashlib
import json
from collections.abc import Callable, Mapping
from datetime import UTC, datetime
from typing import Any, Literal, TypeVar
from uuid import UUID
import sqlalchemy as sa
from pydantic import BaseModel, ConfigDict, Field, JsonValue
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from configs import dify_config
from core.db.session_factory import session_factory
from libs.datetime_utils import naive_utc_now
from models.agent import Agent, AgentWorkspace, AgentWorkspaceBinding
from models.agent_sandbox_usage import AgentSandboxExecution, AgentSandboxUsageEvent
from models.model import App
_T = TypeVar("_T")
_COLLECTOR_EVENT_TYPES = {"collector_checkpoint", "collector_retention_gap"}
_TERMINAL_TYPES = {"sandbox.lifecycle.paused", "sandbox.lifecycle.killed"}
_NONTERMINAL_TYPES = {"sandbox.lifecycle.created", "sandbox.lifecycle.resumed", "sandbox.lifecycle.updated"}
class SandboxUsageError(ValueError):
def __init__(self, code: str, *, status_code: int = 400) -> None:
self.code = code
self.status_code = status_code
super().__init__(code)
class SandboxUsageEvent(BaseModel):
id: str = Field(min_length=1, max_length=255)
source: Literal["application", "provider"]
type: str = Field(min_length=1, max_length=96)
timestamp: str | None = None
sandbox_id: str | None = Field(default=None, max_length=128)
execution_id: str | None = Field(default=None, max_length=128)
payload: dict[str, JsonValue]
model_config = ConfigDict(extra="forbid")
def _utc(value: object) -> datetime | None:
if value is None:
return None
if not isinstance(value, str):
raise SandboxUsageError("invalid_timestamp")
try:
result = datetime.fromisoformat(value)
except ValueError as exc:
raise SandboxUsageError("invalid_timestamp") from exc
if result.tzinfo is None:
raise SandboxUsageError("timestamp_requires_timezone")
return result.astimezone(UTC).replace(tzinfo=None)
def _iso(value: datetime | None) -> str | None:
return value.replace(tzinfo=UTC).isoformat().replace("+00:00", "Z") if value else None
def _hash(payload: Mapping[str, Any]) -> str:
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
def _mapping(value: object) -> dict[str, Any]:
return dict(value) if isinstance(value, dict) else {}
def _pick(data: Mapping[str, Any], *names: str) -> Any:
for name in names:
if name in data and data[name] is not None:
return data[name]
return None
def _positive_int(value: object, *, allow_zero: bool = False) -> int | None:
if value is None:
return None
if type(value) is not int or value < (0 if allow_zero else 1) or value > 2**63 - 1:
raise SandboxUsageError("invalid_provider_quantity")
return value
def _provider_values(event: SandboxUsageEvent, project_id: str) -> dict[str, Any]:
"""Normalize the current v2 API and webhook spellings, not old event versions."""
raw = event.payload
data = _mapping(_pick(raw, "eventData", "event_data", "data"))
execution = _mapping(data.get("execution"))
team = _pick(raw, "sandboxTeamId", "sandbox_team_id")
if team != project_id:
raise SandboxUsageError("provider_project_mismatch", status_code=403)
raw_id = raw.get("id")
raw_type = raw.get("type")
sandbox_id = _pick(raw, "sandboxId", "sandbox_id")
execution_id = _pick(raw, "sandboxExecutionId", "sandbox_execution_id")
if raw_id != event.id or raw_type != event.type:
raise SandboxUsageError("provider_envelope_mismatch")
for declared, actual in ((event.sandbox_id, sandbox_id), (event.execution_id, execution_id)):
if declared is not None and declared != actual:
raise SandboxUsageError("provider_envelope_mismatch")
for identifier in (sandbox_id, execution_id):
if identifier is not None and (not isinstance(identifier, str) or not identifier or len(identifier) > 128):
raise SandboxUsageError("invalid_provider_identifier")
return {
"id": event.id,
"version": raw.get("version"),
"type": event.type,
"timestamp": _iso(_utc(raw.get("timestamp"))),
"sandbox_id": sandbox_id,
"execution_id": execution_id,
"project_id": project_id,
"template_id": _pick(raw, "sandboxTemplateId", "sandbox_template_id"),
"template_build_id": _pick(raw, "sandboxBuildId", "sandbox_build_id"),
"started_at": _iso(_utc(_pick(execution, "started_at", "startedAt"))),
"duration_ms": _positive_int(_pick(execution, "execution_time", "executionTime"), allow_zero=True),
"vcpu_count": _positive_int(_pick(execution, "vcpu_count", "vcpuCount")),
"memory_mib": _positive_int(_pick(execution, "memory_mb", "memoryMb")),
"metadata": _mapping(_pick(data, "sandbox_metadata", "sandboxMetadata")),
}
def _enrich(existing: dict[str, Any], incoming: dict[str, Any]) -> dict[str, Any] | None:
"""Accept absent-field enrichment; never silently choose contradictory facts."""
merged = dict(existing)
for key, value in incoming.items():
old = merged.get(key)
if isinstance(old, dict) and isinstance(value, dict):
value = _enrich(old, value)
if value is None:
return None
elif old is not None and value is not None and old != value:
return None
if value is not None:
merged[key] = value
return merged
class SandboxUsageService:
@staticmethod
def _config(project_id: str | None = None) -> tuple[str, datetime]:
if not dify_config.AGENT_SANDBOX_METERING_ENABLED:
raise SandboxUsageError("sandbox_metering_disabled", status_code=503)
configured = dify_config.AGENT_SANDBOX_METERING_PROJECT_ID.strip()
if not configured or len(configured) > 128:
raise SandboxUsageError("sandbox_metering_project_not_configured", status_code=503)
if project_id is not None and project_id != configured:
raise SandboxUsageError("sandbox_metering_project_not_allowed", status_code=403)
try:
start = _utc(dify_config.AGENT_SANDBOX_METERING_START_AT)
except SandboxUsageError as exc:
raise SandboxUsageError("sandbox_metering_start_not_configured", status_code=503) from exc
if start is None or start.microsecond:
raise SandboxUsageError("sandbox_metering_start_requires_whole_second", status_code=503)
return configured, start
@classmethod
def _write(cls, project_id: str, action: Callable[[Session, datetime], _T]) -> _T:
_, start = cls._config(project_id)
# The first activation can race. Retrying the whole short transaction
# also handles concurrent unique-key inserts without losing an event.
for attempt in range(3):
try:
with session_factory.create_session() as session, session.begin():
activation = cls._event(session, project_id, "application", "metering_activated", lock=True)
if activation is None:
activation = AgentSandboxUsageEvent(
provider="e2b",
provider_project_id=project_id,
source="application",
source_event_id="metering_activated",
event_type="metering_activated",
occurred_at=start,
payload={"started_at": _iso(start)},
canonical_hash=_hash({"started_at": _iso(start)}),
projection_status="applied",
)
session.add(activation)
session.flush()
elif _utc(activation.payload.get("started_at")) != start:
raise SandboxUsageError("sandbox_metering_start_is_immutable", status_code=409)
result = action(session, start)
return result
except IntegrityError:
if attempt == 2:
raise
raise AssertionError("unreachable")
@staticmethod
def _event(
session: Session, project_id: str, source: str, event_id: str, *, lock: bool = False
) -> AgentSandboxUsageEvent | None:
query = sa.select(AgentSandboxUsageEvent).where(
AgentSandboxUsageEvent.provider == "e2b",
AgentSandboxUsageEvent.provider_project_id == project_id,
AgentSandboxUsageEvent.source == source,
AgentSandboxUsageEvent.source_event_id == event_id,
)
return session.scalar(query.with_for_update() if lock else query)
@classmethod
def get_state(cls, *, project_id: str) -> dict[str, Any]:
if not dify_config.AGENT_SANDBOX_METERING_ENABLED:
return {
"enabled": False,
"project_id": project_id,
"started_at": None,
"checkpoint_at": None,
"full_scan_at": None,
"diagnostics": {
"unresolved_events": 0,
"conflict_events": 0,
"open_executions": 0,
"unattributed_executions": 0,
},
}
def read(session: Session, start: datetime) -> dict[str, Any]:
checkpoints = session.execute(
sa.select(AgentSandboxUsageEvent.purpose, sa.func.max(AgentSandboxUsageEvent.occurred_at))
.where(
AgentSandboxUsageEvent.provider_project_id == project_id,
AgentSandboxUsageEvent.source == "application",
AgentSandboxUsageEvent.event_type == "collector_checkpoint",
AgentSandboxUsageEvent.projection_status == "applied",
)
.group_by(AgentSandboxUsageEvent.purpose)
).all()
ends = [end for _purpose, end in checkpoints if end is not None]
full = [end for purpose, end in checkpoints if purpose == "full" and end is not None]
event_scope = sa.and_(
AgentSandboxUsageEvent.provider == "e2b",
AgentSandboxUsageEvent.provider_project_id == project_id,
sa.or_(
AgentSandboxUsageEvent.source == "provider",
AgentSandboxUsageEvent.event_type.in_(_COLLECTOR_EVENT_TYPES),
),
)
execution_scope = AgentSandboxExecution.provider_project_id == project_id
diagnostics = {
"unresolved_events": session.scalar(
sa.select(sa.func.count())
.select_from(AgentSandboxUsageEvent)
.where(event_scope, AgentSandboxUsageEvent.projection_status.in_(["pending", "unresolved"]))
)
or 0,
"conflict_events": session.scalar(
sa.select(sa.func.count())
.select_from(AgentSandboxUsageEvent)
.where(event_scope, AgentSandboxUsageEvent.projection_status == "conflict")
)
or 0,
"open_executions": session.scalar(
sa.select(sa.func.count())
.select_from(AgentSandboxExecution)
.where(execution_scope, AgentSandboxExecution.state == "open")
)
or 0,
"unattributed_executions": session.scalar(
sa.select(sa.func.count())
.select_from(AgentSandboxExecution)
.where(execution_scope, AgentSandboxExecution.attribution_status != "resolved")
)
or 0,
}
return {
"enabled": True,
"project_id": project_id,
"started_at": _iso(start),
"checkpoint_at": _iso(max(ends, default=None)),
"full_scan_at": _iso(max(full, default=None)),
"diagnostics": diagnostics,
}
_, configured_start = cls._config(project_id)
with session_factory.create_session() as session:
activation = cls._event(session, project_id, "application", "metering_activated")
if activation is not None:
if _utc(activation.payload.get("started_at")) != configured_start:
raise SandboxUsageError("sandbox_metering_start_is_immutable", status_code=409)
return read(session, configured_start)
# Only the first lookup initializes T0. Ordinary polling is SELECT-only,
# does not lock the project row, and cannot delay an ingestion transaction.
return cls._write(project_id, read)
@classmethod
def ingest(cls, *, project_id: str, events: list[SandboxUsageEvent]) -> dict[str, int]:
if len(events) > 100:
raise SandboxUsageError("too_many_usage_events")
def ingest_batch(session: Session, start: datetime) -> dict[str, int]:
counts = {"accepted": 0, "duplicates": 0, "conflicts": 0, "ignored": 0}
for event in events:
counts[cls._ingest_one(session, project_id, start, event)] += 1
return counts
return cls._write(project_id, ingest_batch)
@classmethod
def _ingest_one(cls, session: Session, project_id: str, start: datetime, event: SandboxUsageEvent) -> str:
if event.source == "provider":
canonical = _provider_values(event, project_id)
begun = _utc(canonical["started_at"])
occurred = _utc(canonical["timestamp"])
if (begun is not None and begun < start) or (begun is None and occurred is not None and occurred < start):
previous = cls._event(session, project_id, "provider", event.id)
physical = (
session.scalar(
sa.select(AgentSandboxExecution).where(
AgentSandboxExecution.provider_project_id == project_id,
AgentSandboxExecution.provider_execution_id == canonical["execution_id"],
)
)
if canonical["execution_id"]
else None
)
# New old executions are excluded; corrections to known facts
# must still go through conflict detection and preserve evidence.
if physical is None and previous is None:
return "ignored"
if physical is None or physical.started_at is None:
if physical is not None:
session.delete(physical)
for pending in session.scalars(
sa.select(AgentSandboxUsageEvent).where(
AgentSandboxUsageEvent.provider_project_id == project_id,
AgentSandboxUsageEvent.source == "provider",
AgentSandboxUsageEvent.provider_execution_id == canonical["execution_id"],
)
):
pending.projection_status = "applied"
pending.projection_error_code = "execution_predates_activation"
if previous is not None:
digest = _hash(canonical)
if not any(version.get("hash") == digest for version in previous.payload_versions):
previous.payload_versions = [
*previous.payload_versions,
{
"hash": digest,
"payload": event.payload,
"received_at": _iso(naive_utc_now()),
},
]
previous.resolution = {**previous.resolution, "scope": "excluded_before_activation"}
return "ignored"
else:
if event.type not in _COLLECTOR_EVENT_TYPES:
raise SandboxUsageError("unsupported_application_event")
occurred = _utc(event.timestamp)
if occurred is not None and occurred < start:
return "ignored"
canonical = event.model_dump(mode="json")
if event.type == "collector_checkpoint":
cls._validate_checkpoint(event.payload, start)
elif event.type == "collector_retention_gap":
lower = _utc(event.payload.get("uncovered_start"))
upper = _utc(event.payload.get("uncovered_end"))
if (
lower is None
or upper is None
or occurred is None
or not start <= lower < upper <= occurred
or _positive_int(event.payload.get("assumed_retention_seconds")) is None
):
raise SandboxUsageError("invalid_collector_retention_gap")
digest = _hash(canonical)
row = cls._event(session, project_id, event.source, event.id)
if row is not None:
if row.canonical_hash == digest:
if event.source == "provider" and row.projection_status != "conflict":
cls._project_provider(session, row, canonical, start)
return "duplicates"
versions = list(row.payload_versions or [])
if not any(version.get("hash") == digest for version in versions):
versions.append({"hash": digest, "payload": event.payload, "received_at": _iso(naive_utc_now())})
row.payload_versions = versions
old = _mapping(row.resolution.get("canonical"))
# Attribution labels are optional. A bad/changed label must not
# invalidate otherwise identical provider-measured runtime.
merged = (
_enrich(
{key: value for key, value in old.items() if key != "metadata"},
{key: value for key, value in canonical.items() if key != "metadata"},
)
if old and event.source == "provider"
else None
)
if merged is not None:
merged["metadata"] = canonical.get("metadata") or old.get("metadata", {})
if merged is None or row.projection_status == "conflict":
cls._conflict(session, row, "event_content_conflict")
return "conflicts"
if merged == old:
return "duplicates"
canonical = merged
row.resolution = {"canonical": canonical}
row.canonical_hash = _hash(canonical)
else:
row = AgentSandboxUsageEvent(
provider="e2b",
provider_project_id=project_id,
source=event.source,
source_event_id=event.id,
event_type=event.type,
sandbox_id=event.sandbox_id,
provider_execution_id=event.execution_id,
occurred_at=occurred,
payload=event.payload,
canonical_hash=digest,
resolution={"canonical": canonical},
)
session.add(row)
session.flush()
if event.source == "provider":
cls._project_provider(session, row, canonical, start)
else:
row.projection_status = "applied"
if event.type == "collector_checkpoint":
row.occurred_at = _utc(event.payload["window_end"])
row.purpose = str(event.payload["mode"])
elif event.type == "collector_retention_gap":
row.projection_status = "unresolved"
row.projection_error_code = "provider_retention_gap"
row.processed_at = naive_utc_now()
session.flush()
return "conflicts" if row.projection_status == "conflict" else "accepted"
@staticmethod
def _validate_checkpoint(payload: Mapping[str, Any], start: datetime) -> None:
lower = _utc(payload.get("window_start"))
upper = _utc(payload.get("window_end"))
scan = _utc(payload.get("scan_started_at"))
if (
payload.get("completed") is not True
or payload.get("mode") not in {"incremental", "full"}
or lower is None
or upper is None
or scan is None
or not start <= lower <= upper <= scan
or scan > naive_utc_now()
or type(payload.get("pages")) is not int
or payload["pages"] < 1
or type(payload.get("events")) is not int
or payload["events"] < 0
):
raise SandboxUsageError("invalid_collector_checkpoint")
@staticmethod
def _verified_owner(session: Session, sandbox_id: str, metadata: dict[str, Any]) -> AgentWorkspaceBinding | None:
"""Resolve optional labels through the existing tenant-owned resource chain.
Metadata alone is never ownership evidence. Retired rows are still valid
historical associations until physical collection removes them.
"""
binding_id = metadata.get("dify.binding_id")
tenant_id = metadata.get("dify.tenant_id")
if not isinstance(binding_id, str) or not isinstance(tenant_id, str):
return None
try:
binding_id, tenant_id = str(UUID(binding_id)), str(UUID(tenant_id))
except ValueError:
return None
binding = session.scalar(
sa.select(AgentWorkspaceBinding)
.join(
AgentWorkspace,
sa.and_(
AgentWorkspace.id == AgentWorkspaceBinding.workspace_id,
AgentWorkspace.tenant_id == AgentWorkspaceBinding.tenant_id,
AgentWorkspace.app_id == AgentWorkspaceBinding.app_id,
),
)
.join(
App,
sa.and_(
App.id == AgentWorkspaceBinding.app_id,
App.tenant_id == AgentWorkspaceBinding.tenant_id,
),
)
.join(
Agent,
sa.and_(
Agent.id == AgentWorkspaceBinding.agent_id,
Agent.tenant_id == AgentWorkspaceBinding.tenant_id,
),
)
.where(
AgentWorkspaceBinding.id == binding_id,
AgentWorkspaceBinding.tenant_id == tenant_id,
AgentWorkspaceBinding.backend_binding_ref == sandbox_id,
AgentWorkspace.backend_workspace_ref == sandbox_id,
)
)
if binding is None:
return None
for key, expected in (
("dify.agent_id", binding.agent_id),
("dify.workspace_id", binding.workspace_id),
):
supplied = metadata.get(key)
if supplied is not None and supplied != expected:
return None
return binding
@classmethod
def _attribute_execution(cls, session: Session, execution: AgentSandboxExecution, metadata: dict[str, Any]) -> None:
owner = cls._verified_owner(session, execution.sandbox_id, metadata)
if owner is None or execution.attribution_status == "conflict":
# Existing snapshots survive missing/deleted business resources or
# malformed labels. No fallback to unverified allocation metadata.
return
expected = (owner.tenant_id, owner.app_id, owner.agent_id, owner.id, owner.workspace_id)
retained = (
execution.tenant_id,
execution.app_id,
execution.agent_id,
execution.binding_id,
execution.workspace_id,
)
if execution.binding_id is not None:
if retained != expected:
execution.attribution_status = "conflict"
return
other_owner = session.scalar(
sa.select(AgentSandboxExecution.id)
.where(
AgentSandboxExecution.provider == execution.provider,
AgentSandboxExecution.provider_project_id == execution.provider_project_id,
AgentSandboxExecution.sandbox_id == execution.sandbox_id,
AgentSandboxExecution.attribution_status == "resolved",
AgentSandboxExecution.binding_id != owner.id,
)
.limit(1)
)
if other_owner is not None:
execution.attribution_status = "conflict"
return
execution.tenant_id, execution.app_id, execution.agent_id = owner.tenant_id, owner.app_id, owner.agent_id
execution.binding_id, execution.workspace_id = owner.id, owner.workspace_id
execution.attribution_status = "resolved"
@classmethod
def _project_provider(
cls, session: Session, row: AgentSandboxUsageEvent, data: dict[str, Any], start: datetime
) -> None:
row.sandbox_id = data["sandbox_id"]
row.provider_execution_id = data["execution_id"]
started_at = _utc(data["started_at"])
if data["version"] != "v2" or not row.sandbox_id or not row.provider_execution_id:
row.projection_status = "unresolved"
row.projection_error_code = "incomplete_provider_execution"
return
if started_at is not None and started_at < start:
cls._conflict(session, row, "execution_start_scope_conflict")
return
excluded = session.scalar(
sa.select(AgentSandboxUsageEvent.id)
.where(
AgentSandboxUsageEvent.provider_project_id == row.provider_project_id,
AgentSandboxUsageEvent.source == "provider",
AgentSandboxUsageEvent.provider_execution_id == row.provider_execution_id,
AgentSandboxUsageEvent.projection_error_code == "execution_predates_activation",
)
.limit(1)
)
if excluded is not None:
if started_at is not None:
cls._conflict(session, row, "execution_start_scope_conflict")
else:
row.projection_status = "applied"
row.projection_error_code = "execution_predates_activation"
return
execution = session.scalar(
sa.select(AgentSandboxExecution).where(
AgentSandboxExecution.provider == "e2b",
AgentSandboxExecution.provider_project_id == row.provider_project_id,
AgentSandboxExecution.provider_execution_id == row.provider_execution_id,
)
)
if execution is None:
execution = AgentSandboxExecution(
provider="e2b",
provider_project_id=row.provider_project_id,
sandbox_id=row.sandbox_id,
provider_execution_id=row.provider_execution_id,
started_at=started_at,
started_at_source="provider_execution" if started_at else None,
started_at_precision_ms=1000 if started_at else None,
state="open",
quality="pending",
attribution_status="unresolved",
)
session.add(execution)
session.flush()
if execution.sandbox_id != row.sandbox_id or (
execution.started_at is not None and started_at is not None and execution.started_at != started_at
):
cls._conflict(session, row, "execution_identity_conflict", execution)
return
if started_at is not None and execution.started_at is None:
execution.started_at = started_at
execution.started_at_source = "provider_execution"
execution.started_at_precision_ms = 1000
cls._attribute_execution(session, execution, _mapping(data["metadata"]))
for field, previous in (
("template_id", execution.template_id),
("template_build_id", execution.template_build_id),
("vcpu_count", execution.vcpu_count),
("memory_mib", execution.memory_mib),
):
value = data[field]
if previous is not None and value is not None and previous != value:
cls._conflict(session, row, "execution_resources_conflict", execution)
return
if value is not None:
setattr(execution, field, value)
if row.event_type in _TERMINAL_TYPES:
duration = data["duration_ms"]
if (
execution.metered_duration_ms is not None
and duration is not None
and execution.metered_duration_ms != duration
):
cls._conflict(session, row, "execution_duration_conflict", execution)
return
execution.state = "closed"
if duration is not None:
execution.metered_duration_ms = duration
if execution.quality != "conflict" and all(
value is not None
for value in (
execution.metered_duration_ms,
execution.started_at,
execution.vcpu_count,
execution.memory_mib,
)
):
execution.quality = "metered"
execution.measurement_source = "provider_execution"
if execution.terminal_event_id is None:
execution.terminal_event_id = row.id
execution.terminal_event_at = row.occurred_at
execution.close_reason = row.event_type.rsplit(".", 1)[-1]
execution.last_reconciled_at = naive_utc_now()
execution.revision += 1
if execution.quality == "conflict":
row.projection_status = "conflict"
row.projection_error_code = "execution_conflict"
elif execution.quality == "metered" or row.event_type in _NONTERMINAL_TYPES:
row.projection_status = "applied"
row.projection_error_code = None
else:
row.projection_status = "unresolved"
row.projection_error_code = "incomplete_provider_execution"
@staticmethod
def _conflict(
session: Session, row: AgentSandboxUsageEvent, code: str, execution: AgentSandboxExecution | None = None
) -> None:
row.projection_status = "conflict"
row.projection_error_code = code
if execution is None and row.provider_execution_id:
execution = session.scalar(
sa.select(AgentSandboxExecution).where(
AgentSandboxExecution.provider_project_id == row.provider_project_id,
AgentSandboxExecution.provider_execution_id == row.provider_execution_id,
)
)
if execution:
execution.quality = "conflict"
execution.revision += 1

View File

@@ -49,8 +49,10 @@ from models.model import AppModelConfig, AppModelConfigDict, IconType, load_anno
from models.workflow import Workflow
from services.agent.dsl_entities import AgentPackage, make_agent_app_dsl
from services.agent.dsl_service import AgentDslService
from services.agent.package_resource_exporter import AgentPackageResourceExporter
from services.agent.retirement_service import WorkflowAgentRetirementService
from services.agent.workflow_publish_service import WorkflowAgentPublishService
from services.app_package_service import PreparedAppPackage
from services.dsl_content import DSL_MAX_SIZE, dsl_content_size
from services.dsl_version import check_version_compatibility
from services.enterprise.enterprise_service import EnterpriseService
@@ -104,6 +106,7 @@ class PendingData(PendingImportOwner):
icon: str | None = None
icon_background: str | None = None
app_id: str | None = None
warnings: list[DslImportWarning] = Field(default_factory=list)
class CheckDependenciesPendingData(BaseModel):
@@ -181,8 +184,9 @@ class AppDslService:
icon_background: str | None = None,
app_id: str | None = None,
import_app_id: str | None = None,
package: PreparedAppPackage | None = None,
) -> Import:
"""Import an app from YAML content or URL."""
"""Import an App DSL, materializing validated archive resources before database writes."""
self._warnings = []
import_id = str(uuid.uuid4())
@@ -282,6 +286,25 @@ class AppDslService:
error="Missing app data in YAML content",
)
if package is not None and (package.agent_resources or package.icons):
tenant_id = account.current_tenant_id
if tenant_id is None:
raise ValueError("Current tenant is not set")
# Authorize overwrites in a separate read session before storage I/O.
# The normal import below reloads and rechecks the target after upload.
if app_id:
with Session(self._session.get_bind()) as authorization_session:
target = AppDslService(authorization_session)._load_app_for_overwrite(account, app_id)
if target is None:
raise ValueError("App not found")
self._validate_workflow_overwrite(target, data)
package.materialize_icons(data=data, tenant_id=tenant_id, account_id=account.id)
if package.agent_resources:
data["agent_packages"], self._warnings = package.materialize_agents(
tenant_id=tenant_id, account_id=account.id
)
content = yaml.safe_dump(data, allow_unicode=True)
# If app_id is provided, check if it exists
app = None
if app_id:
@@ -318,6 +341,7 @@ class AppDslService:
icon=icon,
icon_background=icon_background,
app_id=app_id,
warnings=self._warnings,
)
redis_client.setex(
f"{IMPORT_INFO_REDIS_KEY_PREFIX}{import_id}",
@@ -425,6 +449,7 @@ class AppDslService:
error="Import information expired or does not exist",
)
data = yaml.safe_load(pending_data.yaml_content)
self._warnings = list(pending_data.warnings)
app = None
if pending_data.app_id:
@@ -777,13 +802,36 @@ class AppDslService:
workflow_id: str | None = None,
version_id: uuid.UUID | None = None,
) -> str:
return yaml.dump(
cls.export_data(
app_model,
session=session,
include_secret=include_secret,
workflow_id=workflow_id,
version_id=version_id,
),
allow_unicode=True,
)
@classmethod
def export_data(
cls,
app_model: App,
*,
session: Session,
include_secret: bool = False,
workflow_id: str | None = None,
version_id: uuid.UUID | None = None,
resource_exporter: AgentPackageResourceExporter | None = None,
) -> dict[str, Any]:
"""
Export app
Build the App definition before serializing it as YAML or a resource archive.
:param app_model: App instance
:param session: Database session used to load export data
:param include_secret: Whether include secret variable
:param workflow_id: Optional published workflow version to export
:param version_id: Optional published Agent version to export
:param resource_exporter: Optional collector for workflow Agent assets in App archives
:raises AgentVersionNotFoundError: If the selected Agent version is unavailable or not visible in history
:raises WorkflowNotFoundError: If the selected workflow version does not exist
:raises IsDraftWorkflowError: If the selected workflow is a draft
@@ -811,11 +859,12 @@ class AppDslService:
include_secret=include_secret,
workflow_id=workflow_id,
session=session,
resource_exporter=resource_exporter,
)
else:
cls._append_model_config_export_data(export_data, app_model, session=session)
return yaml.dump(export_data, allow_unicode=True)
return export_data
@classmethod
def _append_workflow_export_data(
@@ -826,6 +875,7 @@ class AppDslService:
include_secret: bool,
session: Session,
workflow_id: str | None = None,
resource_exporter: AgentPackageResourceExporter | None = None,
):
"""
Append workflow export data
@@ -844,6 +894,7 @@ class AppDslService:
graph, agent_packages = AgentDslService(session).export_workflow_packages(
workflow=workflow,
graph=workflow_dict.get("graph", {}),
resource_exporter=resource_exporter,
)
workflow_dict["graph"] = graph
# TODO: refactor: we need a better way to filter workspace related data from nodes

View File

@@ -1,101 +1,264 @@
"""Package ordinary App DSLs while retaining the existing Roster Agent format."""
"""Package App DSLs and their node-bound Agent resources."""
from __future__ import annotations
import hashlib
import io
import re
import tempfile
import zipfile
import zlib
from typing import BinaryIO, Literal, cast
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Any, BinaryIO, Literal, Self, cast
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from core.db.session_factory import session_factory
from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidator
from models.agent import WorkflowAgentBindingType
from models.agent_config_entities import WorkflowNodeJobConfig
from models.model import App
from services.agent.dsl_entities import AgentPackage
from services.agent.errors import InvalidRosterAgentPackageError, RosterAgentPackageTooLargeError
from services.agent.roster_package_entities import RosterAgentPackageApp, RosterAgentPackageExport
from services.agent.package_resource_exporter import AgentPackageResourceExporter
from services.agent.package_resource_importer import AgentPackageResourceImporter
from services.agent.roster_package_entities import (
AgentPackageResources,
PackageIcon,
PreparedPackageArchive,
RosterAgentPackageApp,
RosterAgentPackageExport,
RosterAgentPackageFile,
RosterAgentPackageMember,
RosterAgentPackageSkill,
validate_icon_references,
)
from services.agent.roster_package_reader import RosterAgentPackageReader
from services.dsl_content import DSL_MAX_SIZE
from services.entities.dsl_entities import DslImportWarning
class AppPackageManifest(BaseModel):
"""Index Agent resources by the same package references used in the workflow DSL."""
model_config = ConfigDict(extra="forbid")
format: Literal["dify.app"]
format_version: Literal[1]
apps: list[RosterAgentPackageApp] = Field(min_length=1, max_length=1)
agent_resources: dict[str, AgentPackageResources] = Field(default_factory=dict)
icons: list[PackageIcon] = Field(default_factory=list)
def iter_resources(self) -> Iterator[RosterAgentPackageSkill | RosterAgentPackageFile | PackageIcon]:
yield from self.icons
for group in self.agent_resources.values():
yield from group.skills
yield from group.files
@model_validator(mode="after")
def validate_resource_paths(self) -> Self:
resources = list(self.iter_resources())
paths = [item.path.casefold() for item in [*self.apps, *resources]]
ids = [item.id for item in resources]
if len(paths) != len(set(paths)) or len(ids) != len(set(ids)):
raise ValueError("App package resources must have unique paths and ids")
return self
@dataclass(kw_only=True)
class PreparedAppPackage(PreparedPackageArchive):
dsl: str
agents: dict[str, AgentPackage]
agent_resources: dict[str, AgentPackageResources]
icons: list[PackageIcon] = field(default_factory=list)
def materialize_icons(self, *, data: dict[str, Any], tenant_id: str, account_id: str) -> None:
importer = AgentPackageResourceImporter()
for ref, resources in self.agent_resources.items():
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())]
for item in metadata:
if isinstance(item, dict):
if item.get("icon_type") == "image" and item.get("icon") in mapping:
item["icon"] = mapping[item["icon"]]
elif item.icon_type == "image" and item.icon in mapping:
item.icon = mapping[item.icon]
def materialize_agents(self, *, tenant_id: str, account_id: str) -> tuple[dict[str, Any], list[DslImportWarning]]:
importer = AgentPackageResourceImporter()
# Validate every Agent before uploading any resource.
for ref, resources in self.agent_resources.items():
importer.validate(resources=resources, agent_package=self.agents[ref])
agents = dict(self.agents)
warnings: list[DslImportWarning] = []
for ref, resources in self.agent_resources.items():
agents[ref], resource_warnings = importer.materialize(
archive=self, resources=resources, agent_package=agents[ref], tenant_id=tenant_id, account_id=account_id
)
warnings.extend(
warning.model_copy(update={"path": f"agent_packages.{ref}.{warning.path}"})
for warning in resource_warnings
)
return {ref: agent.model_dump(mode="json") for ref, agent in agents.items()}, warnings
class AppPackageService(RosterAgentPackageReader):
"""Reuse the bounded ZIP reader; ordinary Apps retain DSL import semantics."""
"""Reuse the bounded ZIP reader; Apps retain DSL import semantics."""
def read_dsl(self, source: BinaryIO) -> str | None:
"""Return an ordinary DSL, or rewind a Roster package for its importer."""
def read_package(self, source: BinaryIO) -> PreparedAppPackage | None:
"""Validate an App archive, or rewind a Roster package for its importer."""
spool = cast(BinaryIO, tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024, mode="w+b")) # noqa: SIM115
try:
with tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024, mode="w+b") as spool:
self._copy_bounded(source, cast(BinaryIO, spool))
spool.seek(0)
with zipfile.ZipFile(spool) as archive:
infos = self._index_archive(archive)
data, _ = self._read_yaml_document(archive, infos, "manifest.yaml")
if isinstance(data, dict) and data.get("format") == "dify.roster-agent":
return None
try:
manifest = AppPackageManifest.model_validate(data)
except ValidationError as exc:
raise InvalidRosterAgentPackageError("App package manifest is invalid") from exc
resource = manifest.apps[0]
if set(infos) != {"manifest.yaml", resource.path}:
raise InvalidRosterAgentPackageError("App package members do not match the manifest")
app_data, _ = self._read_yaml_document(
archive, infos, resource.path, resource=resource, max_bytes=DSL_MAX_SIZE
self._copy_bounded(source, spool)
spool.seek(0)
with zipfile.ZipFile(spool) as archive:
infos = self._index_archive(archive)
data, manifest_size = self._read_yaml_document(archive, infos, "manifest.yaml")
if isinstance(data, dict) and data.get("format") == "dify.roster-agent":
spool.close()
return None
try:
manifest = AppPackageManifest.model_validate(data)
except ValidationError as exc:
raise InvalidRosterAgentPackageError("App package manifest is invalid") from exc
resource = manifest.apps[0]
resources = list(manifest.iter_resources())
if set(infos) != {"manifest.yaml", resource.path, *(item.path for item in resources)}:
raise InvalidRosterAgentPackageError("App package members do not match the manifest")
app_data, app_size = self._read_yaml_document(
archive, infos, resource.path, resource=resource, max_bytes=DSL_MAX_SIZE
)
if (
not isinstance(app_data, dict)
or app_data.get("kind") != "app"
or not isinstance(app_data.get("app"), dict)
or app_data["app"].get("mode")
not in {"workflow", "advanced-chat", "chat", "completion", "agent-chat", "channel", "rag-pipeline"}
):
raise InvalidRosterAgentPackageError("App package DSL is invalid")
agents = self._validate_agents(app_data, manifest)
try:
validate_icon_references(
manifest.icons, [app_data["app"], *(agent.metadata.model_dump() for agent in agents.values())]
)
if (
not isinstance(app_data, dict)
or app_data.get("kind") != "app"
or not isinstance(app_data.get("app"), dict)
or app_data["app"].get("mode")
not in {
"workflow",
"advanced-chat",
"chat",
"completion",
"agent-chat",
"channel",
"rag-pipeline",
}
):
raise InvalidRosterAgentPackageError("App package DSL is invalid")
# Preserve the original text for version confirmation and legacy DSL handling.
return archive.read(resource.path).decode("utf-8")
except ValueError as exc:
raise InvalidRosterAgentPackageError("App package icon references are invalid") from exc
members: dict[str, RosterAgentPackageMember] = {}
invalid_skills: dict[str, str] = {}
self._validate_resource_members(
archive,
infos,
resources,
members=members,
invalid_skills=invalid_skills,
streamed_size=manifest_size + app_size,
)
# Preserve the original text for version confirmation and legacy DSL handling.
dsl = archive.read(resource.path).decode("utf-8")
spool.seek(0)
return PreparedAppPackage(
archive=spool,
dsl=dsl,
agents=agents,
agent_resources=manifest.agent_resources,
icons=manifest.icons,
members=members,
invalid_skills=invalid_skills,
)
except (InvalidRosterAgentPackageError, RosterAgentPackageTooLargeError):
spool.close()
raise
except (OSError, zipfile.BadZipFile, EOFError, RuntimeError, ValueError, zlib.error) as exc:
spool.close()
raise InvalidRosterAgentPackageError("App package is not a valid ZIP") from exc
except BaseException:
spool.close()
raise
finally:
source.seek(0)
def export(self, *, dsl: str, name: str) -> RosterAgentPackageExport:
@staticmethod
def _validate_agents(data: dict[str, Any], manifest: AppPackageManifest) -> dict[str, AgentPackage]:
if not manifest.agent_resources:
return {}
try:
if data["app"]["mode"] not in {"workflow", "advanced-chat"}:
raise ValueError("Agent resources require a workflow App")
agents = {ref: AgentPackage.model_validate(value) for ref, value in data["agent_packages"].items()}
references: set[str] = set()
for _, node_data in WorkflowAgentNodeValidator.iter_agent_v2_nodes(data["workflow"]["graph"]):
binding = node_data["agent_binding"]
WorkflowAgentBindingType(binding["binding_type"])
reference = binding["package_ref"]
if not isinstance(reference, str):
raise ValueError("Agent package reference must be a string")
references.add(reference)
WorkflowNodeJobConfig.model_validate(node_data.get("agent_job") or {})
if set(agents) != set(manifest.agent_resources) or references != set(agents):
raise ValueError("Agent resources must match workflow package references")
for ref, group in manifest.agent_resources.items():
group.validate_packages([agents[ref]])
return agents
except (KeyError, TypeError, AttributeError, ValueError) as exc:
raise InvalidRosterAgentPackageError("App package Agent references are invalid") from exc
def export_app(
self, *, app_model: App, include_secret: bool = False, workflow_id: str | None = None
) -> RosterAgentPackageExport:
from services.app_dsl_service import AppDslService
resources = AgentPackageResourceExporter()
with session_factory.create_session() as session:
data = AppDslService.export_data(
app_model=app_model,
session=session,
include_secret=include_secret,
workflow_id=workflow_id,
resource_exporter=resources,
)
resources.collect_icon(session=session, tenant_id=app_model.tenant_id, metadata=data["app"])
resources.collect_workspace_skills()
if resources.packages:
data["agent_packages"] = {
ref: package.model_dump(mode="json") for ref, package in resources.packages.items()
}
return self.export(dsl=yaml.dump(data, allow_unicode=True), name=app_model.name, resources=resources)
def export(
self, *, dsl: str, name: str, resources: AgentPackageResourceExporter | None = None
) -> RosterAgentPackageExport:
payload = dsl.encode("utf-8")
if len(payload) > DSL_MAX_SIZE:
raise RosterAgentPackageTooLargeError("App package DSL exceeds the size limit")
manifest = AppPackageManifest(
format="dify.app",
format_version=1,
apps=[
RosterAgentPackageApp(path="app.yaml", size=len(payload), sha256=hashlib.sha256(payload).hexdigest())
],
)
archive = io.BytesIO()
archive = cast(BinaryIO, tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024, mode="w+b")) # noqa: SIM115
try:
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as package:
package.writestr("manifest.yaml", yaml.safe_dump(manifest.model_dump(mode="json")))
agent_resources, total_size = resources.write_resources(package) if resources is not None else ({}, 0)
icons, _ = resources.write_icons(package, total_size=total_size) if resources is not None else ([], 0)
manifest = AppPackageManifest(
format="dify.app",
format_version=1,
apps=[
RosterAgentPackageApp(
path="app.yaml", size=len(payload), sha256=hashlib.sha256(payload).hexdigest()
)
],
agent_resources=agent_resources,
icons=icons,
)
package.writestr(
"manifest.yaml", yaml.safe_dump(manifest.model_dump(mode="json", exclude_defaults=True))
)
package.writestr("app.yaml", payload)
size = archive.tell()
archive.seek(0)
# Validate exported containers against the same limits as imports.
self.read_dsl(archive)
validated = self.read_package(archive)
if validated is None:
raise InvalidRosterAgentPackageError("Expected an App package")
validated.close()
slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:80] or "app"
return RosterAgentPackageExport(archive=archive, filename=f"{slug}.ifpkg", size=size)
except Exception:

View File

@@ -6,12 +6,30 @@ new GraphEngine command channel mechanism.
"""
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.execution_coordinator import app_task_stop_flag_key
from core.app.entities.app_invoke_entities import InvokeFrom
from extensions.ext_redis import redis_client
from extensions.ext_redis import RedisClientWrapper, redis_client
from graphon.graph_engine.manager import GraphEngineManager
from models.model import AppMode
class AppTaskControlService:
"""Injected task control for entry points that already admit app access."""
def __init__(self, *, redis_client: RedisClientWrapper) -> None:
self._redis_client: RedisClientWrapper = redis_client
def stop_workflow_task_no_user_check(self, *, task_id: str) -> None:
"""Send both cancellation signals after the caller has admitted the app.
Preserve the legacy stop flag even if the GraphEngine command fails.
Trial workflow stops do not consult the task's user ownership cache.
"""
if task_id:
self._redis_client.setex(app_task_stop_flag_key(task_id), 600, 1)
GraphEngineManager(self._redis_client).send_stop_command(task_id)
class AppTaskService:
"""Service for managing application task operations."""

View File

@@ -5,6 +5,8 @@ import posixpath
import uuid
from collections.abc import Generator, Sequence # Changed Iterator to Generator
from contextlib import contextmanager, suppress
from dataclasses import dataclass
from datetime import datetime
from tempfile import NamedTemporaryFile
from typing import Literal
from zipfile import ZIP_DEFLATED, ZipFile
@@ -36,6 +38,27 @@ from .errors.file import BlockedFileExtensionError, FileNotExistsError, FileTooL
PREVIEW_WORDS_LIMIT = 3000
@dataclass(frozen=True, slots=True)
class FileUploadActor:
"""Creator identity, independent of the tenant receiving the upload."""
id: str
creator_role: CreatorUserRole
@dataclass(frozen=True, slots=True)
class FileUploadResult:
id: str
name: str
size: int
extension: str
mime_type: str
created_by: str
created_at: datetime
tenant_id: str
source_url: str
class FileService:
_session_maker: sessionmaker[Session]
@@ -54,12 +77,26 @@ class FileService:
filename: str,
content: bytes,
mimetype: str,
user: Account | EndUser,
user: Account | EndUser | FileUploadActor,
tenant_id: str | None = None,
source: Literal["datasets"] | None = None,
source_url: str = "",
default_file_size_limit: int | None = None,
) -> UploadFile:
"""Persist a file with the given creator and resource tenant.
For Account or EndUser, tenant_id=None (including when omitted) uses
the account's current workspace or the end user's tenant. An explicit
tenant_id takes precedence. FileUploadActor carries no tenant, so it
requires an explicit tenant_id.
default_file_size_limit is the non-media limit in MiB; None uses
dify_config.UPLOAD_FILE_SIZE_LIMIT, and 0 permits only empty files.
Images, video and audio always use their dedicated configured limits.
"""
if isinstance(user, FileUploadActor) and tenant_id is None:
raise TypeError("tenant_id is required when uploading with FileUploadActor")
# get file extension
extension = os.path.splitext(filename)[1].lstrip(".").lower()
@@ -92,7 +129,12 @@ class FileService:
# generate file key
file_uuid = str(uuid.uuid4())
resource_tenant_id = tenant_id if tenant_id is not None else extract_tenant_id(user)
if isinstance(user, FileUploadActor):
resource_tenant_id = tenant_id
creator_role = user.creator_role
else:
resource_tenant_id = tenant_id if tenant_id is not None else extract_tenant_id(user)
creator_role = CreatorUserRole.ACCOUNT if isinstance(user, Account) else CreatorUserRole.END_USER
file_key = "upload_files/" + (resource_tenant_id or "") + "/" + file_uuid + "." + extension
@@ -108,7 +150,7 @@ class FileService:
size=file_size,
extension=extension,
mime_type=mimetype,
created_by_role=(CreatorUserRole.ACCOUNT if isinstance(user, Account) else CreatorUserRole.END_USER),
created_by_role=creator_role,
created_by=user.id,
created_at=naive_utc_now(),
used=False,
@@ -125,6 +167,46 @@ class FileService:
return upload_file
def upload_file_for_actor(
self,
*,
actor: FileUploadActor,
resource_tenant_id: str,
filename: str,
content: bytes,
mimetype: str,
source: Literal["datasets"] | None = None,
source_url: str = "",
default_file_size_limit: int | None = None,
) -> FileUploadResult:
"""Upload into the explicitly admitted tenant and return detached values.
default_file_size_limit overrides the non-media limit in MiB, including
0 for empty files only. None uses dify_config.UPLOAD_FILE_SIZE_LIMIT.
Images, video and audio retain their dedicated configured limits.
"""
upload_file = self.upload_file(
filename=filename,
content=content,
mimetype=mimetype,
user=actor,
tenant_id=resource_tenant_id,
source=source,
source_url=source_url,
default_file_size_limit=default_file_size_limit,
)
return FileUploadResult(
id=upload_file.id,
name=upload_file.name,
size=upload_file.size,
extension=upload_file.extension,
mime_type=upload_file.mime_type,
created_by=upload_file.created_by,
created_at=upload_file.created_at,
tenant_id=upload_file.tenant_id,
source_url=upload_file.source_url,
)
@staticmethod
def is_file_size_within_limit(
*,
@@ -143,7 +225,13 @@ class FileService:
extension: str,
default_file_size_limit: int | None = None,
) -> int:
"""Return the size an extension is allowed, in bytes."""
"""Return the size an extension is allowed, in bytes.
default_file_size_limit is a non-media override in MiB. None uses
dify_config.UPLOAD_FILE_SIZE_LIMIT; 0 allows only empty files.
Images, video and audio use their dedicated limits regardless of this
override.
"""
if extension in IMAGE_EXTENSIONS:
file_size_limit = dify_config.UPLOAD_IMAGE_FILE_SIZE_LIMIT

View File

@@ -13,7 +13,7 @@ from graphon.file import helpers as file_helpers
from models import Account
from models.model import EndUser
from services.errors.file import FileTooLargeError
from services.file_service import FileService
from services.file_service import FileService, FileUploadActor
@dataclass(frozen=True, slots=True)
@@ -87,9 +87,19 @@ class RemoteFileService:
self,
*,
url: str,
user: Account | EndUser,
user: Account | EndUser | FileUploadActor,
tenant_id: str | None = None,
) -> RemoteFileUploadResult:
"""Fetch and persist a remote file under the requested tenant.
For Account or EndUser, tenant_id=None (including when omitted) uses
the account's current workspace or the end user's tenant. An explicit
tenant_id takes precedence. FileUploadActor requires an explicit
tenant_id; it is checked before fetching the remote file.
"""
if isinstance(user, FileUploadActor) and tenant_id is None:
raise TypeError("tenant_id is required when uploading with FileUploadActor")
response = self._fetch_for_upload(url=url)
try:
file_info = guess_file_info_from_response(response)

View File

@@ -0,0 +1,48 @@
"""Resolve trial execution access without exposing persistence models."""
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class TrialAppRef:
app_id: str
tenant_id: str
app_mode: str
@dataclass(frozen=True)
class TrialAppAccessSnapshot:
app: TrialAppRef
trial_limit: int
used_count: int | None
class TrialAppAccessQuery(Protocol):
def resolve(self, *, app_id: str, account_id: str) -> TrialAppAccessSnapshot | None: ...
class TrialAppUnavailableError(Exception):
"""The requested app is unavailable for trial execution."""
class TrialAppUsageLimitExceededError(Exception):
"""The account has exhausted the requested app's trial allowance."""
class TrialAppAccessService:
def __init__(self, *, apps: TrialAppAccessQuery) -> None:
self._apps: TrialAppAccessQuery = apps
def get_access(self, *, app_id: str, account_id: str) -> 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:
raise TrialAppUsageLimitExceededError(
f"Account {account_id} has used {access.used_count} trials of app {app_id} "
f"with a limit of {access.trial_limit}"
)
return access.app

View File

@@ -0,0 +1,66 @@
"""Bridge admitted trial references to the existing ORM generation runtime.
AppGenerateService and its generators still own their internal database and
provider operations. Keep that compatibility here until their migration.
"""
import logging
from collections.abc import Mapping
from typing import override
from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.app_invoke_entities import InvokeFrom
from models import Account, App
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_generate_service import AppGenerateService
from services.trial_app_access_service import TrialAppRef
from services.trial_app_generation_service import GenerationResponse, TrialAppGenerationRuntime
logger = logging.getLogger(__name__)
class AppGenerateServiceRuntime(TrialAppGenerationRuntime):
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory: sessionmaker[Session] = session_factory
@override
def generate(
self, *, app: TrialAppRef, account_id: str, args: Mapping[str, object], streaming: bool
) -> GenerationResponse:
with self._session_factory(expire_on_commit=False) as read_session:
app_model = read_session.get(App, app.app_id)
if app_model is None or app_model.tenant_id != app.tenant_id:
raise AppDefinitionUnavailableError(f"App {app.app_id} no longer exists in tenant {app.tenant_id}")
if app_model.mode != app.app_mode:
raise AppDefinitionUnavailableError(
f"App {app.app_id} mode changed from {app.app_mode} to {app_model.mode} after admission"
)
account = read_session.get(Account, account_id)
if account is None:
raise AccountNotFoundError(f"Account {account_id} no longer exists")
response: GenerationResponse | None = None
try:
# Release context reads before quota/provider I/O. Legacy generation
# receives its own session, never the admission or usage transaction.
with self._session_factory(expire_on_commit=False) as session:
generated_response: GenerationResponse = AppGenerateService.generate(
app_model=app_model,
user=account,
args=args,
invoke_from=InvokeFrom.EXPLORE,
session=session,
streaming=streaming,
)
response = generated_response
session.commit()
except BaseException:
if response is not None and not isinstance(response, Mapping):
try:
response.close()
except BaseException:
logger.exception("Failed to close trial generation response for app %s", app.app_id)
raise
return generated_response

View File

@@ -0,0 +1,95 @@
"""Generate responses and record usage for an admitted trial app."""
import logging
from collections.abc import Iterator, Mapping
from typing import Protocol
from services.trial_app_access_service import TrialAppRef
from services.trial_app_usage import TrialAppUsageRecorder
logger = logging.getLogger(__name__)
class GenerationStream(Protocol):
def __iter__(self) -> Iterator[str]: ...
def __next__(self) -> str: ...
def close(self) -> None: ...
type GenerationResponse = Mapping[str, object] | GenerationStream
class TrialAppNotCompletionError(ValueError):
"""The trial app does not support completion requests."""
class TrialAppNotChatError(ValueError):
"""The trial app does not support chat requests."""
class TrialAppNotWorkflowError(ValueError):
"""The trial app does not support workflow requests."""
class TrialAppGenerationRuntime(Protocol):
def generate(
self,
*,
app: TrialAppRef,
account_id: str,
args: Mapping[str, object],
streaming: bool,
) -> GenerationResponse: ...
class TrialAppGenerationService:
def __init__(self, *, runtime: TrialAppGenerationRuntime, usage: TrialAppUsageRecorder) -> None:
self._runtime: TrialAppGenerationRuntime = runtime
self._usage: TrialAppUsageRecorder = usage
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"}:
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
)
def generate_completion(
self, *, trial_app: TrialAppRef, account_id: str, args: Mapping[str, object]
) -> GenerationResponse:
if trial_app.app_mode != "completion":
raise TrialAppNotCompletionError(f"App {trial_app.app_id} is not a completion app")
return self._generate(
trial_app=trial_app,
account_id=account_id,
args={**args, "auto_generate_name": False},
streaming=args.get("response_mode") == "streaming",
)
def generate_workflow(
self, *, trial_app: TrialAppRef, account_id: str, args: Mapping[str, object]
) -> GenerationResponse:
if trial_app.app_mode != "workflow":
raise TrialAppNotWorkflowError(f"App {trial_app.app_id} is not a workflow app")
return self._generate(trial_app=trial_app, account_id=account_id, args=args, streaming=True)
def _generate(
self, *, trial_app: TrialAppRef, account_id: str, args: Mapping[str, object], streaming: bool
) -> GenerationResponse:
response = self._runtime.generate(app=trial_app, account_id=account_id, args=args, streaming=streaming)
try:
# Trial usage is recorded after generation returns, before any SSE
# consumption. A later stream failure does not refund this attempt.
self._usage.record(app_id=trial_app.app_id, account_id=account_id)
except BaseException:
if not isinstance(response, Mapping):
try:
response.close()
except BaseException:
logger.exception("Failed to close trial generation response for app %s", trial_app.app_id)
raise
return response

View File

@@ -7,7 +7,7 @@ with appropriate retry policies and error handling.
import logging
from datetime import UTC, datetime
from typing import Any, NotRequired
from typing import Any
from celery import shared_task
from sqlalchemy import select
@@ -46,8 +46,6 @@ logger = logging.getLogger(__name__)
class WorkflowGeneratorArgsDict(TypedDict):
inputs: dict[str, Any]
files: list[Any]
_skip_prepare_user_inputs: bool
workflow_id: NotRequired[str]
@shared_task(queue=AsyncWorkflowQueue.PROFESSIONAL_QUEUE)
@@ -103,7 +101,6 @@ def _build_generator_args(trigger_data: TriggerData) -> WorkflowGeneratorArgsDic
return {
"inputs": dict(trigger_data.inputs),
"files": list(trigger_data.files),
"_skip_prepare_user_inputs": True,
}
@@ -150,13 +147,9 @@ def _execute_workflow_common(
# Execute workflow using WorkflowAppGenerator
generator = WorkflowAppGenerator()
# Prepare args matching AppGenerateService.generate format
# Adapt trigger inputs and files for the generator.
args = _build_generator_args(trigger_data)
# If workflow_id was specified, add it to args
if trigger_data.workflow_id:
args["workflow_id"] = str(trigger_data.workflow_id)
pause_config = PauseStateLayerConfig(
session_factory=session_factory.get_session_maker(),
state_owner_user_id=workflow.created_by,

View File

@@ -188,7 +188,7 @@ class TestDatasetDocumentProperties:
db_session_with_containers.add(seg_waiting)
db_session_with_containers.flush()
assert dataset.available_segment_count == 2
assert dataset.get_available_segment_count(session=db_session_with_containers) == 2
def test_document_segment_count_property(self, db_session_with_containers: Session) -> None:
"""Test document can count its segments."""

View File

@@ -3,11 +3,12 @@ import zipfile
from collections.abc import Callable
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import Mock
from unittest.mock import ANY, Mock
from uuid import UUID
import httpx
import pytest
import yaml
from flask import Flask
from pydantic import ValidationError
from werkzeug.exceptions import BadRequest, Forbidden
@@ -323,10 +324,10 @@ def test_default_export_packages_ordinary_app_dsl(
) -> None:
model = App(id="app-1", tenant_id="tenant-1", mode=mode, name="My App")
dsl = f"kind: app\nversion: 0.7.0\napp:\n mode: {mode}\n"
export = Mock(return_value=dsl)
export = Mock(return_value=yaml.safe_load(dsl))
session = object()
monkeypatch.setattr(app_module, "db", SimpleNamespace(session=lambda: session))
monkeypatch.setattr(app_module.AppDslService, "export_dsl", export)
monkeypatch.setattr(app_module.AppDslService, "export_data", export)
with app.test_request_context():
response = unwrap(app_module.AppExportApi.get)(
app_module.AppExportApi(),
@@ -336,9 +337,14 @@ def test_default_export_packages_ordinary_app_dsl(
response.direct_passthrough = False
assert response.mimetype == "application/zip"
assert "my-app.ifpkg" in response.headers["Content-Disposition"]
assert AppPackageService().read_dsl(io.BytesIO(response.get_data())) == dsl
prepared = AppPackageService().read_package(io.BytesIO(response.get_data()))
assert prepared is not None
with prepared:
assert yaml.safe_load(prepared.dsl) == yaml.safe_load(dsl)
response.close()
export.assert_called_once_with(app_model=model, session=session, include_secret=True, workflow_id="revision-1")
export.assert_called_once_with(
app_model=model, session=ANY, include_secret=True, workflow_id="revision-1", resource_exporter=ANY
)
@pytest.mark.parametrize("from_url", [False, True])
@@ -380,8 +386,11 @@ def test_ordinary_package_import_uses_dsl_permissions_and_confirmation(
with app.test_request_context(method="POST", data=form):
assert unwrap(api.post)(api, account) == ({"status": "pending"}, status)
import_dsl.assert_called_once_with(
import_module.AppImportPayload(mode="yaml-content", yaml_content=dsl, name="Renamed", app_id=app_id), account
import_module.AppImportPayload(mode="yaml-content", yaml_content=dsl, name="Renamed", app_id=app_id),
account,
package=ANY,
)
assert import_dsl.call_args.kwargs["package"].archive.closed
agent_import.assert_not_called()

View File

@@ -10,25 +10,19 @@ from uuid import uuid4
import pytest
from flask import Flask, request
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
from werkzeug.exceptions import Forbidden, NotFound
import controllers.console.explore.trial as module
from controllers.console.app.error import (
AppUnavailableError,
CompletionRequestError,
ConversationCompletedError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderQuotaExceededError,
SpeechToTextDisabledError,
)
from controllers.console.explore.error import (
NotChatAppError,
NotCompletionAppError,
NotWorkflowAppError,
)
from controllers.console.explore.trial import ChatRequest, CompletionRequest, TextToSpeechRequest, WorkflowRunRequest
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from controllers.console.explore.error import NotChatAppError
from controllers.console.explore.trial import TextToSpeechRequest
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.errors.error import (
ModelCurrentlyNotSupportError,
@@ -42,26 +36,17 @@ from graphon.variables import SecretVariable, StringVariable
from models import Account, Tenant
from models.account import TenantStatus
from models.dataset import Dataset
from models.model import App, AppMode, Site, UploadFile
from models.model import App, AppMode, Site
from models.tools import WorkflowToolProvider
from models.workflow import Workflow
from services.app_ref_service import AppRef, MessageRef
from services.errors.audio import SpeechToTextDisabledServiceError
from services.errors.conversation import ConversationNotExistsError
from services.errors.llm import InvokeRateLimitError
from tests.unit_tests.model_factories import make_app, make_upload_file
from tests.unit_tests.model_factories import make_app
unwrap: Any = inspect_unwrap
class _UsesSQLiteSession:
sqlite_session: Session
@pytest.fixture(autouse=True)
def _provide_sqlite_session(self, sqlite_session: Session):
self.sqlite_session = sqlite_session
@pytest.fixture
def account() -> Account:
acc = Account(name="User", email="user@example.com")
@@ -84,18 +69,6 @@ def _app(*, app_id: str, mode: AppMode, tenant_id: str = "tenant-1") -> App:
return make_app(app_id=app_id, tenant_id=tenant_id, name="Trial App", mode=mode, icon_type=None, enable_api=False)
def _upload_file(*, file_id: str = "upload-file-id", tenant_id: str = "app-tenant-id") -> UploadFile:
return make_upload_file(
file_id=file_id,
tenant_id=tenant_id,
storage_type="opendal",
key="trial/file.txt",
name="file.txt",
size=1,
created_by="u1",
)
def _file_data() -> Any:
file_data: Any = BytesIO(b"fake audio data")
file_data.filename = "test.wav"
@@ -119,16 +92,6 @@ def trial_app_chat() -> App:
return _app(app_id="a-chat", mode=AppMode.CHAT)
@pytest.fixture
def trial_app_completion() -> App:
return _app(app_id="a-comp", mode=AppMode.COMPLETION)
@pytest.fixture
def trial_app_workflow() -> App:
return _app(app_id="a-workflow", mode=AppMode.WORKFLOW)
def test_trial_workflow_uses_trial_scoped_simple_account_model() -> None:
assert module.simple_account_model.name == "TrialSimpleAccount"
assert module.simple_account_model.__schema__["properties"].keys() >= {"id", "name", "email"}
@@ -215,654 +178,6 @@ def test_trial_app_detail_serializes_with_explicit_session(
module.TrialAppDetailResponse.model_validate.assert_called_once_with(response_view, from_attributes=True)
class TestTrialAppFileUploadApi:
def test_upload_uses_trial_app_tenant(self, app: Flask, account: Account) -> None:
api = module.TrialAppFileUploadApi()
method = unwrap(api.post)
app_model = _app(app_id="app-1", mode=AppMode.CHAT, tenant_id="app-tenant-id")
upload_file = _upload_file()
with (
app.test_request_context("/", method="POST"),
patch.object(module, "upload_file_from_request", return_value=upload_file) as upload,
patch.object(module, "dump_response", return_value={"id": "upload-file-id"}),
):
response, status = method(api, account, app_model)
assert status == 201
assert response == {"id": "upload-file-id"}
upload.assert_called_once_with(current_user=account, resource_tenant_id="app-tenant-id")
class TestTrialAppRemoteFileUploadApi:
def test_upload_uses_trial_app_tenant(self, app: Flask, account: Account) -> None:
api = module.TrialAppRemoteFileUploadApi()
method = unwrap(api.post)
app_model = _app(app_id="app-1", mode=AppMode.CHAT, tenant_id="app-tenant-id")
remote_file = MagicMock()
payload = module.RemoteFileUploadPayload(url="https://example.com/file.txt")
with (
app.test_request_context("/", method="POST", json={"url": "https://example.com/file.txt"}),
patch.object(module, "upload_remote_file", return_value=remote_file) as upload,
patch.object(module, "dump_response", return_value={"id": "upload-file-id"}) as dump,
):
response, status = method(api, payload, account, app_model)
assert status == 201
assert response == {"id": "upload-file-id"}
upload.assert_called_once_with(
url="https://example.com/file.txt",
current_user=account,
resource_tenant_id="app-tenant-id",
)
dump.assert_called_once_with(module.FileWithSignedUrl, remote_file)
class TestTrialAppWorkflowRunApi(_UsesSQLiteSession):
def test_not_workflow_app(self, app: Flask, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
method = unwrap(api.post)
with app.test_request_context("/", json={"inputs": {}}):
with pytest.raises(NotWorkflowAppError):
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
_app(app_id="not-workflow", mode=AppMode.CHAT),
)
def test_success(
self,
app: Flask,
trial_app_workflow: App,
account: Account,
trial_app_usage: MagicMock,
) -> None:
api = module.TrialAppWorkflowRunApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}}),
patch.object(module.AppGenerateService, "generate", return_value=MagicMock()),
):
result = method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
assert result is not None
trial_app_usage.record.assert_called_once_with(app_id="a-workflow", account_id="u1")
def test_workflow_provider_not_init(self, app: Flask, trial_app_workflow: App, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=ProviderTokenNotInitError("test"),
),
):
with pytest.raises(ProviderNotInitializeError):
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
def test_workflow_quota_exceeded(self, app: Flask, trial_app_workflow: App, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=QuotaExceededError(),
),
):
with pytest.raises(ProviderQuotaExceededError):
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
def test_workflow_model_not_support(self, app: Flask, trial_app_workflow: App, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=ModelCurrentlyNotSupportError(),
),
):
with pytest.raises(ProviderModelCurrentlyNotSupportError):
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
def test_workflow_invoke_error(self, app: Flask, trial_app_workflow: App, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=InvokeError("test error"),
),
):
with pytest.raises(CompletionRequestError):
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
def test_workflow_rate_limit_error(self, app: Flask, trial_app_workflow: App, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=InvokeRateLimitError("test"),
),
):
with pytest.raises(InvokeRateLimitHttpError):
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
def test_workflow_value_error(self, app: Flask, trial_app_workflow: App, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "files": []}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=ValueError("test error"),
),
):
with pytest.raises(ValueError):
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
def test_workflow_generic_exception(self, app: Flask, trial_app_workflow: App, account: Account) -> None:
api = module.TrialAppWorkflowRunApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "files": []}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=RuntimeError("unexpected error"),
),
):
with pytest.raises(InternalServerError):
method(
api,
WorkflowRunRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_workflow,
)
class TestTrialChatApi(_UsesSQLiteSession):
def test_not_chat_app(self, app: Flask, account: Account) -> None:
api = module.TrialChatApi()
method = unwrap(api.post)
with app.test_request_context("/", json={"inputs": {}, "query": "hi"}):
with pytest.raises(NotChatAppError):
method(
api,
ChatRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
_app(app_id="not-chat", mode=AppMode.COMPLETION),
)
def test_success(
self,
app: Flask,
trial_app_chat: App,
account: Account,
trial_app_usage: MagicMock,
) -> None:
api = module.TrialChatApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": "hi"}),
patch.object(module.AppGenerateService, "generate", return_value=MagicMock()),
):
result = method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
assert result is not None
trial_app_usage.record.assert_called_once_with(app_id="a-chat", account_id="u1")
def test_chat_conversation_not_exists(self, app: Flask, trial_app_chat: App, account: Account) -> None:
api = module.TrialChatApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": "hi"}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=module.services.errors.conversation.ConversationNotExistsError(),
),
):
with pytest.raises(NotFound):
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_conversation_completed(self, app: Flask, trial_app_chat: App, account: Account) -> None:
api = module.TrialChatApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": "hi"}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=module.services.errors.conversation.ConversationCompletedError(),
),
):
with pytest.raises(ConversationCompletedError):
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_app_config_broken(self, app: Flask, trial_app_chat: App, account: Account) -> None:
api = module.TrialChatApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": "hi"}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=module.services.errors.app_model_config.AppModelConfigBrokenError(),
),
):
with pytest.raises(AppUnavailableError):
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_provider_not_init(self, app: Flask, trial_app_chat: App, account: Account) -> None:
api = module.TrialChatApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": "hi"}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=ProviderTokenNotInitError("test"),
),
):
with pytest.raises(ProviderNotInitializeError):
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_quota_exceeded(self, app: Flask, trial_app_chat: App, account: Account) -> None:
api = module.TrialChatApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": "hi"}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=QuotaExceededError(),
),
):
with pytest.raises(ProviderQuotaExceededError):
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_model_not_support(self, app: Flask, trial_app_chat: App, account: Account) -> None:
api = module.TrialChatApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": "hi"}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=ModelCurrentlyNotSupportError(),
),
):
with pytest.raises(ProviderModelCurrentlyNotSupportError):
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_invoke_error(self, app: Flask, trial_app_chat: App, account: Account) -> None:
api = module.TrialChatApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": "hi"}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=InvokeError("test error"),
),
):
with pytest.raises(CompletionRequestError):
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_rate_limit_error(self, app: Flask, trial_app_chat: App, account: Account) -> None:
api = module.TrialChatApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": "hi"}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=InvokeRateLimitError("test"),
),
):
with pytest.raises(InvokeRateLimitHttpError):
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_value_error(self, app: Flask, trial_app_chat: App, account: Account) -> None:
api = module.TrialChatApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": "hi"}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=ValueError("test error"),
),
):
with pytest.raises(ValueError):
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
def test_chat_generic_exception(self, app: Flask, trial_app_chat: App, account: Account) -> None:
api = module.TrialChatApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": "hi"}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=RuntimeError("unexpected error"),
),
):
with pytest.raises(InternalServerError):
method(
api, ChatRequest.model_validate(request.get_json()), self.sqlite_session, account, trial_app_chat
)
class TestTrialCompletionApi(_UsesSQLiteSession):
def test_not_completion_app(self, app: Flask, account: Account) -> None:
api = module.TrialCompletionApi()
method = unwrap(api.post)
with app.test_request_context("/", json={"inputs": {}, "query": ""}):
with pytest.raises(NotCompletionAppError):
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
_app(app_id="not-completion", mode=AppMode.CHAT),
)
def test_success(
self,
app: Flask,
trial_app_completion: App,
account: Account,
trial_app_usage: MagicMock,
) -> None:
api = module.TrialCompletionApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": ""}),
patch.object(module.AppGenerateService, "generate", return_value=MagicMock()),
):
result = method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
assert result is not None
trial_app_usage.record.assert_called_once_with(app_id="a-comp", account_id="u1")
def test_completion_app_config_broken(self, app: Flask, trial_app_completion: App, account: Account) -> None:
api = module.TrialCompletionApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": ""}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=module.services.errors.app_model_config.AppModelConfigBrokenError(),
),
):
with pytest.raises(AppUnavailableError):
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_provider_not_init(self, app: Flask, trial_app_completion: App, account: Account) -> None:
api = module.TrialCompletionApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": ""}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=ProviderTokenNotInitError("test"),
),
):
with pytest.raises(ProviderNotInitializeError):
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_quota_exceeded(self, app: Flask, trial_app_completion: App, account: Account) -> None:
api = module.TrialCompletionApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": ""}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=QuotaExceededError(),
),
):
with pytest.raises(ProviderQuotaExceededError):
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_model_not_support(self, app: Flask, trial_app_completion: App, account: Account) -> None:
api = module.TrialCompletionApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": ""}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=ModelCurrentlyNotSupportError(),
),
):
with pytest.raises(ProviderModelCurrentlyNotSupportError):
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_invoke_error(self, app: Flask, trial_app_completion: App, account: Account) -> None:
api = module.TrialCompletionApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": ""}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=InvokeError("test error"),
),
):
with pytest.raises(CompletionRequestError):
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_rate_limit_error(self, app: Flask, trial_app_completion: App, account: Account) -> None:
api = module.TrialCompletionApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": ""}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=InvokeRateLimitError("test"),
),
):
with pytest.raises(InternalServerError):
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_value_error(self, app: Flask, trial_app_completion: App, account: Account) -> None:
api = module.TrialCompletionApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": ""}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=ValueError("test error"),
),
):
with pytest.raises(ValueError):
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
def test_completion_generic_exception(self, app: Flask, trial_app_completion: App, account: Account) -> None:
api = module.TrialCompletionApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={"inputs": {}, "query": ""}),
patch.object(
module.AppGenerateService,
"generate",
side_effect=RuntimeError("unexpected error"),
),
):
with pytest.raises(InternalServerError):
method(
api,
CompletionRequest.model_validate(request.get_json()),
self.sqlite_session,
account,
trial_app_completion,
)
class TestTrialMessageSuggestedQuestionApi:
def test_not_chat_app(self, app: Flask, account: Account) -> None:
api = module.TrialMessageSuggestedQuestionApi()
@@ -1363,30 +678,6 @@ class TestTrialChatTextApi:
)
class TestTrialAppWorkflowTaskStopApi:
def test_not_workflow_app(self, app: Flask, trial_app_chat: App) -> None:
api = module.TrialAppWorkflowTaskStopApi()
with app.test_request_context("/", json={"inputs": {}}):
with pytest.raises(NotWorkflowAppError):
api.post(trial_app_chat, str(uuid4()))
def test_success(self, app: Flask, trial_app_workflow: App) -> None:
api = module.TrialAppWorkflowTaskStopApi()
task_id = str(uuid4())
with (
app.test_request_context("/"),
patch.object(module.AppQueueManager, "set_stop_flag_no_user_check") as mock_set_flag,
patch.object(module.GraphEngineManager, "send_stop_command") as mock_send_cmd,
):
result = api.post(trial_app_workflow, task_id)
assert result == {"result": "success"}
mock_set_flag.assert_called_once_with(task_id)
mock_send_cmd.assert_called_once_with(task_id)
class TestTrialSitApi:
def test_no_site(
self,

View File

@@ -0,0 +1,559 @@
"""Trial uploads through HTTP, real file services, and SQLite admission/persistence."""
import hashlib
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from datetime import datetime
from io import BytesIO
from typing import Literal
from urllib.parse import parse_qs, urlsplit
from uuid import UUID, uuid4
import httpx
import pytest
from flask import Flask, Request
from sqlalchemy import Connection, delete, event, select
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
from werkzeug.test import TestResponse
import controllers.console.explore.trial as trial_module
import controllers.console.explore.trial_app_admission as admission_module
import controllers.console.files as files_module
import controllers.console.remote_files as remote_module
import controllers.console.wraps as console_wraps
import libs.login as login_module
import services.file_service as file_module
from core.file import remote_fetcher
from core.tools.errors import ToolSSRFError
from enums import DeploymentEdition
from extensions.ext_login import DifyLoginManager, unauthorized_handler
from libs.external_api import ExternalApi
from models import Account, AccountTrialAppRecord, App, AppMode, Tenant, TrialApp, UploadFile
from models.account import TenantAccountRole
from models.enums import CreatorUserRole
from repositories.trial_app_repository import TrialAppRepository
from services.entities.feature_entities import FeatureModel
from services.feature_service import FeatureService
from services.file_service import FileService
from services.remote_file_service import RemoteFileService
from services.trial_app_access_service import TrialAppAccessService
_Endpoint = Literal["files/upload", "remote-files/upload"]
_CREATED_AT = datetime(2024, 1, 1)
_REMOTE_URL = "https://example.com/trial.txt"
_CONTENT = b"trial upload"
@dataclass
class _Features:
enabled: bool = True
events: list[str] = field(default_factory=list)
def is_trial_enabled(self) -> bool:
self.events.append("feature")
return self.enabled
@dataclass
class _ExternalIO:
admission_sessions: list[Session]
saved: dict[str, bytes] = field(default_factory=dict)
requests: list[tuple[str, str, Mapping[str, object]]] = field(default_factory=list)
head_status: int = 200
get_status: int = 200
content_length: str = str(len(_CONTENT))
error: Exception | None = None
def assert_admission_closed(self) -> None:
assert self.admission_sessions
assert all(not session.in_transaction() and not session.identity_map for session in self.admission_sessions)
def save(self, key: str, content: bytes) -> None:
self.assert_admission_closed()
self.saved[key] = content
def request(self, method: str, *, url: str, **kwargs: object) -> httpx.Response:
self.assert_admission_closed()
self.requests.append((method, url, kwargs))
if self.error is not None:
raise self.error
return httpx.Response(
self.head_status if method == "HEAD" else self.get_status,
headers={"Content-Type": "text/plain", "Content-Length": self.content_length},
content=_CONTENT if method == "GET" else b"",
request=httpx.Request(method, url),
)
@dataclass(frozen=True)
class _ApplicationServices:
trial_app_access: TrialAppAccessService
recommended_app_queries: _Features
files: FileService
remote_files: RemoteFileService
@dataclass(frozen=True)
class _Harness:
app: Flask
account: Account
target: App
trial: TrialApp
factory: sessionmaker[Session]
features: _Features
io: _ExternalIO
def url(self, endpoint: _Endpoint) -> str:
return f"/trial-apps/{self.target.id}/{endpoint}"
def post(self, endpoint: _Endpoint, *, query: str = "") -> TestResponse:
if endpoint == "remote-files/upload":
return self.app.test_client().post(self.url(endpoint) + query, json={"url": _REMOTE_URL})
return self.app.test_client().post(
self.url(endpoint) + query,
data={"file": (BytesIO(_CONTENT), "trial.txt", "text/plain")},
)
def uploaded(self) -> list[UploadFile]:
with self.factory() as session:
return list(session.scalars(select(UploadFile)))
def usage(self) -> int | None:
with self.factory() as session:
return session.scalar(
select(AccountTrialAppRecord.count).where(
AccountTrialAppRecord.app_id == self.target.id,
AccountTrialAppRecord.account_id == self.account.id,
)
)
@pytest.fixture
def harness(
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
sqlite_session_factory: sessionmaker[Session],
) -> _Harness:
config_overrides(
DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY,
INIT_PASSWORD="",
LOGIN_DISABLED=False,
RBAC_ENABLED=False,
UPLOAD_FILE_SIZE_LIMIT=1,
inner_UPLOAD_FILE_EXTENSION_BLACKLIST="exe",
)
features = _Features()
account = Account(name="Trial viewer", email="trial@example.com")
account._current_tenant = Tenant(name="Viewer workspace")
account.role = TenantAccountRole.OWNER
target = App(tenant_id=str(uuid4()), name="Trial app", mode=AppMode.CHAT, enable_site=True, enable_api=False)
target.id = str(uuid4())
trial = TrialApp(app_id=target.id, tenant_id=str(uuid4()), trial_limit=3)
with sqlite_session_factory.begin() as session:
session.add_all([target, trial])
sessions: list[Session] = []
repository_factory = sessionmaker(bind=sqlite_session_factory.kw["bind"], expire_on_commit=False)
@event.listens_for(repository_factory, "after_begin")
def track_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
sessions.append(session)
io = _ExternalIO(sessions)
files = FileService(session_factory=sqlite_session_factory)
services = _ApplicationServices(
trial_app_access=TrialAppAccessService(apps=TrialAppRepository(session_factory=repository_factory)),
recommended_app_queries=features,
files=files,
remote_files=RemoteFileService(files=files),
)
def setup_completed() -> bool:
features.events.append("setup")
return True
def csrf(_request: Request, account_id: str) -> None:
assert account_id == account.id
features.events.append("csrf")
for module in (trial_module, admission_module, files_module, remote_module):
monkeypatch.setattr(module, "application_services", lambda: services)
monkeypatch.setattr(console_wraps, "_is_setup_completed", setup_completed)
monkeypatch.setattr(login_module, "current_user", account)
monkeypatch.setattr(login_module, "check_csrf_token", csrf)
monkeypatch.setattr(file_module.storage, "save", io.save)
monkeypatch.setattr(file_module, "naive_utc_now", lambda: _CREATED_AT)
monkeypatch.setattr(remote_fetcher, "make_request", io.request)
app = Flask(__name__)
app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False)
login_manager = DifyLoginManager()
login_manager.init_app(app)
login_manager.unauthorized_handler(unauthorized_handler)
api = ExternalApi(app)
api.add_resource(trial_module.TrialAppFileUploadApi, "/trial-apps/<uuid:app_id>/files/upload")
api.add_resource(trial_module.TrialAppRemoteFileUploadApi, "/trial-apps/<uuid:app_id>/remote-files/upload")
return _Harness(app, account, target, trial, sqlite_session_factory, features, io)
def _assert_error(response: TestResponse, status: int, code: str) -> None:
assert response.status_code == status, response.get_json()
body = response.get_json()
assert isinstance(body, dict)
assert body["code"] == code
assert body["status"] == status
assert isinstance(body["message"], str)
assert body["message"]
assert response.headers["Content-Type"] == "application/json"
def _assert_signed_url(url: object, file_id: str) -> None:
assert isinstance(url, str)
parsed = urlsplit(url)
assert parsed.path == f"/files/{file_id}/file-preview"
assert parse_qs(parsed.query).keys() == {"timestamp", "nonce", "sign"}
@pytest.mark.parametrize("content", [_CONTENT, b""])
def test_local_upload_preserves_complete_response_and_owner_tenant(harness: _Harness, content: bytes) -> None:
response = harness.app.test_client().post(
harness.url("files/upload"), data={"file": (BytesIO(content), "trial.txt", "text/plain")}
)
assert response.status_code == 201
assert response.headers["Content-Type"] == "application/json"
assert int(response.headers["Content-Length"]) == len(response.data)
body = response.get_json()
uploaded = harness.uploaded()
assert len(uploaded) == 1
file = uploaded[0]
assert body == {
"id": file.id,
"reference": None,
"name": "trial.txt",
"size": len(content),
"extension": "txt",
"mime_type": "text/plain",
"created_by": harness.account.id,
"created_at": int(_CREATED_AT.timestamp()),
"preview_url": None,
"source_url": body["source_url"],
"original_url": None,
"user_id": None,
"tenant_id": harness.target.tenant_id,
"conversation_id": None,
"file_key": None,
}
assert str(UUID(file.id)) == file.id
_assert_signed_url(body["source_url"], file.id)
assert file.created_by_role == CreatorUserRole.ACCOUNT
assert file.created_by == harness.account.id
assert file.tenant_id == harness.target.tenant_id
assert file.tenant_id not in {harness.trial.tenant_id, harness.account.current_tenant_id}
assert file.key.startswith(f"upload_files/{file.tenant_id}/")
assert file.hash == hashlib.sha3_256(content).hexdigest()
assert file.used is False
assert harness.io.saved == {file.key: content}
assert harness.features.events == ["setup", "csrf", "feature"]
assert harness.usage() is None
@pytest.mark.parametrize("head_status", [200, 405])
def test_remote_upload_fetches_once_and_preserves_response_and_actor(harness: _Harness, head_status: int) -> None:
harness.io.head_status = head_status
response = harness.post("remote-files/upload")
assert response.status_code == 201
assert response.headers["Content-Type"] == "application/json"
assert int(response.headers["Content-Length"]) == len(response.data)
body = response.get_json()
uploaded = harness.uploaded()
assert len(uploaded) == 1
file = uploaded[0]
assert body == {
"id": file.id,
"name": "trial.txt",
"size": len(_CONTENT),
"extension": "txt",
"url": body["url"],
"mime_type": "text/plain",
"created_by": harness.account.id,
"created_at": int(_CREATED_AT.timestamp()),
}
_assert_signed_url(body["url"], file.id)
assert file.source_url == _REMOTE_URL
assert file.tenant_id == harness.target.tenant_id
assert file.tenant_id not in {harness.trial.tenant_id, harness.account.current_tenant_id}
assert file.created_by == harness.account.id
assert file.created_by_role == CreatorUserRole.ACCOUNT
assert harness.io.saved == {file.key: _CONTENT}
assert harness.io.requests == [
("HEAD", _REMOTE_URL, {}),
("GET", _REMOTE_URL, {} if head_status == 200 else {"timeout": 3, "follow_redirects": True}),
]
assert harness.usage() is None
@pytest.mark.parametrize("endpoint", ["files/upload", "remote-files/upload"])
@pytest.mark.parametrize(
("denial", "code"),
[
("feature", "trial_app_feature_disabled"),
("missing_trial", "trial_app_not_allowed"),
("missing_app", "trial_app_not_allowed"),
("quota", "trial_app_limit_exceeded"),
],
)
def test_trial_admission_rejects_before_parsing_or_external_io(
harness: _Harness, endpoint: _Endpoint, denial: str, code: str
) -> None:
if denial == "feature":
harness.features.enabled = False
else:
with harness.factory.begin() as session:
if denial == "missing_trial":
session.execute(delete(TrialApp).where(TrialApp.app_id == harness.target.id))
elif denial == "missing_app":
session.execute(delete(App).where(App.id == harness.target.id))
else:
session.add(AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=3))
response = harness.app.test_client().post(harness.url(endpoint), json={})
_assert_error(response, 403, code)
assert harness.io.saved == {}
assert harness.io.requests == []
assert harness.uploaded() == []
assert harness.usage() == (3 if denial == "quota" else None)
if denial == "feature":
assert harness.io.admission_sessions == []
else:
harness.io.assert_admission_closed()
@pytest.mark.parametrize("endpoint", ["files/upload", "remote-files/upload"])
def test_upload_does_not_charge_usage_or_inherit_another_accounts_exhausted_quota(
harness: _Harness, endpoint: _Endpoint
) -> None:
other_account_id = str(uuid4())
with harness.factory.begin() as session:
session.add_all(
[
AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=1),
AccountTrialAppRecord(app_id=harness.target.id, account_id=other_account_id, count=3),
]
)
response = harness.post(endpoint)
assert response.status_code == 201
assert harness.usage() == 1
with harness.factory() as session:
assert (
session.scalar(
select(AccountTrialAppRecord.count).where(AccountTrialAppRecord.account_id == other_account_id)
)
== 3
)
@pytest.mark.parametrize(
("shape", "code"),
[("missing", "no_file_uploaded"), ("multiple", "too_many_files"), ("empty_name", "filename_not_exists_error")],
)
def test_multipart_shape_errors_precede_dataset_permissions(harness: _Harness, shape: str, code: str) -> None:
harness.account.role = TenantAccountRole.NORMAL
data = {}
if shape != "missing":
data["file"] = (BytesIO(_CONTENT), "" if shape == "empty_name" else "trial.txt", "text/plain")
if shape == "multiple":
data["second_file"] = (BytesIO(_CONTENT), "second.txt", "text/plain")
response = harness.app.test_client().post(harness.url("files/upload") + "?source=datasets", data=data)
_assert_error(response, 400, code)
assert harness.io.saved == {}
assert harness.uploaded() == []
@pytest.mark.parametrize(
("query_source", "form_source", "status"),
[("datasets", "unknown", 403), ("unknown", "datasets", 201), ("", "datasets", 403)],
)
def test_query_source_takes_precedence_over_form_before_active_workspace_permission(
harness: _Harness, query_source: str, form_source: str, status: int
) -> None:
harness.account.role = TenantAccountRole.NORMAL
response = harness.app.test_client().post(
harness.url("files/upload") + f"?source={query_source}",
data={"file": (BytesIO(_CONTENT), "trial.txt", "text/plain"), "source": form_source},
)
assert response.status_code == status
if status == 403:
_assert_error(response, status, "forbidden")
assert harness.io.saved == {}
else:
assert len(harness.uploaded()) == 1
@pytest.mark.parametrize(
("role", "rbac", "status"),
[
(TenantAccountRole.OWNER, False, 201),
(TenantAccountRole.DATASET_OPERATOR, False, 201),
(TenantAccountRole.NORMAL, False, 403),
(TenantAccountRole.NORMAL, True, 201),
],
)
def test_dataset_upload_uses_active_workspace_role_and_retains_rbac_bypass(
harness: _Harness, config_overrides: Callable[..., None], role: TenantAccountRole, rbac: bool, status: int
) -> None:
harness.account.role = role
config_overrides(RBAC_ENABLED=rbac)
response = harness.post("files/upload", query="?source=datasets")
assert response.status_code == status
assert len(harness.uploaded()) == (1 if status == 201 else 0)
@pytest.mark.parametrize("endpoint", ["files/upload", "remote-files/upload"])
def test_cloud_document_quota_uses_active_workspace_before_upload(
harness: _Harness, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None], endpoint: _Endpoint
) -> None:
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
checked_tenants: list[str] = []
def get_features(tenant_id: str, exclude_vector_space: bool = False) -> FeatureModel:
checked_tenants.append(tenant_id)
assert exclude_vector_space is True
features = FeatureModel()
features.documents_upload_quota.limit = 1
features.documents_upload_quota.size = 1
return features
monkeypatch.setattr(FeatureService, "get_features", get_features)
response = harness.post(endpoint, query="?source=datasets")
_assert_error(response, 403, "forbidden")
assert checked_tenants == [harness.account.current_tenant_id]
assert harness.io.saved == {}
assert harness.io.requests == []
assert harness.usage() is None
def test_dataset_file_size_limit_comes_from_app_owner_tenant(
harness: _Harness, monkeypatch: pytest.MonkeyPatch, config_overrides: Callable[..., None]
) -> None:
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
quota_tenants: list[str] = []
size_limit_tenants: list[str | None] = []
def get_features(tenant_id: str, exclude_vector_space: bool = False) -> FeatureModel:
quota_tenants.append(tenant_id)
assert exclude_vector_space is True
return FeatureModel()
def get_size_limit(tenant_id: str | None) -> int:
size_limit_tenants.append(tenant_id)
return 0
monkeypatch.setattr(FeatureService, "get_features", get_features)
monkeypatch.setattr(FeatureService, "get_knowledge_file_size_limit", get_size_limit)
response = harness.post("files/upload", query="?source=datasets")
_assert_error(response, 413, "file_too_large")
assert response.get_json()["message"] == "File size exceeded."
assert quota_tenants == [harness.account.current_tenant_id]
assert size_limit_tenants == [harness.target.tenant_id]
assert harness.io.saved == {}
assert harness.uploaded() == []
@pytest.mark.parametrize(
("filename", "query", "status", "code"),
[("trial.exe", "", 400, "file_extension_blocked"), ("trial.png", "?source=datasets", 415, "unsupported_file_type")],
)
def test_local_file_policy_errors_keep_specific_codes(
harness: _Harness, filename: str, query: str, status: int, code: str
) -> None:
response = harness.app.test_client().post(
harness.url("files/upload") + query, data={"file": (BytesIO(_CONTENT), filename, "application/octet-stream")}
)
_assert_error(response, status, code)
assert harness.io.saved == {}
assert harness.uploaded() == []
@pytest.mark.parametrize(
("get_status", "status", "code"),
[
(404, 404, "remote_file_not_found"),
(403, 400, "remote_file_access_denied"),
(503, 502, "remote_file_unavailable"),
],
)
def test_remote_http_failures_keep_precise_error_codes(
harness: _Harness, get_status: int, status: int, code: str
) -> None:
harness.io.head_status = 405
harness.io.get_status = get_status
response = harness.post("remote-files/upload")
_assert_error(response, status, code)
assert [method for method, _url, _kwargs in harness.io.requests] == ["HEAD", "GET"]
assert harness.io.saved == {}
assert harness.uploaded() == []
assert harness.usage() is None
@pytest.mark.parametrize(
("failure", "status", "code"),
[
("blocked", 400, "remote_file_url_blocked"),
("timeout", 502, "remote_file_unavailable"),
("metadata", 502, "remote_file_invalid_response"),
("size", 413, "file_too_large"),
],
)
def test_remote_transport_metadata_and_size_failures_prevent_storage(
harness: _Harness, failure: str, status: int, code: str
) -> None:
if failure == "blocked":
harness.io.error = ToolSSRFError("Blocked by SSRF proxy")
elif failure == "timeout":
harness.io.error = httpx.ReadTimeout("Timed out")
elif failure == "metadata":
harness.io.content_length = "invalid"
else:
harness.io.content_length = str(1024 * 1024 + 1)
response = harness.post("remote-files/upload")
_assert_error(response, status, code)
assert len(harness.io.requests) == 1
assert harness.io.saved == {}
assert harness.uploaded() == []
assert harness.usage() is None
def test_remote_invalid_url_fails_before_network(harness: _Harness) -> None:
response = harness.app.test_client().post(harness.url("remote-files/upload"), json={"url": "file:///etc/passwd"})
_assert_error(response, 400, "remote_file_invalid_url")
assert harness.io.requests == []
assert harness.io.saved == {}
def test_remote_payload_validation_stays_on_http_boundary(harness: _Harness) -> None:
response = harness.app.test_client().post(harness.url("remote-files/upload"), json={})
assert response.status_code == 422
assert "url" in response.get_json()["message"]
assert harness.io.requests == []
assert harness.io.saved == {}

View File

@@ -0,0 +1,570 @@
"""Trial admission and generation through real HTTP handlers and SQLite repositories."""
from collections.abc import Callable, Iterator, Mapping
from dataclasses import dataclass, field
from typing import override
from uuid import uuid4
import pytest
from flask import Flask, Request
from sqlalchemy import Connection, event, select
from sqlalchemy.orm import Mapper, Session, SessionTransaction, sessionmaker
from werkzeug.exceptions import Unauthorized
from werkzeug.test import TestResponse
import controllers.console.explore.trial as trial_module
import controllers.console.explore.trial_app_admission as admission_module
import controllers.console.wraps as console_wraps
import libs.login as login_module
from core.errors.error import (
AppInvokeQuotaExceededError,
ModelCurrentlyNotSupportError,
ProviderTokenNotInitError,
QuotaExceededError,
)
from enums import DeploymentEdition
from extensions.ext_login import DifyLoginManager, unauthorized_handler
from graphon.model_runtime.errors.invoke import InvokeError
from libs.external_api import ExternalApi
from models import Account, AccountTrialAppRecord, App, AppMode, Tenant, TrialApp
from models.account import AccountStatus
from repositories.trial_app_repository import TrialAppRepository
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.errors.app_model_config import AppModelConfigBrokenError
from services.errors.conversation import ConversationCompletedError, ConversationNotExistsError
from services.errors.llm import InvokeRateLimitError
from services.trial_app_access_service import TrialAppAccessService, TrialAppRef
from services.trial_app_generation_service import GenerationResponse, TrialAppGenerationService
@dataclass
class _FeatureState:
enabled: bool = True
setup_completed: bool = True
events: list[str] = field(default_factory=list)
def is_trial_enabled(self) -> bool:
self.events.append("feature")
return self.enabled
@dataclass(frozen=True)
class _GenerationCall:
app: TrialAppRef
account_id: str
args: Mapping[str, object]
streaming: bool
@dataclass
class _Stream(Iterator[str]):
chunks: Iterator[str] = field(default_factory=lambda: iter(['data: {"answer":"first"}\n\n', "data: [DONE]\n\n"]))
reads: int = 0
close_calls: int = 0
close_error: Exception | None = None
on_read: Callable[[], None] | None = None
@override
def __next__(self) -> str:
if self.on_read is not None:
self.on_read()
chunk = next(self.chunks)
self.reads += 1
return chunk
def close(self) -> None:
self.close_calls += 1
if self.close_error is not None:
raise self.close_error
@dataclass
class _Runtime:
sessions: list[Session]
response: GenerationResponse = field(
default_factory=lambda: {"answer": "", "metadata": dict[str, object](), "created_at": 0}
)
error: Exception | None = None
calls: list[_GenerationCall] = field(default_factory=list)
before_generate: Callable[[], None] | None = None
def generate(
self, *, app: TrialAppRef, account_id: str, args: Mapping[str, object], streaming: bool
) -> GenerationResponse:
# Database admission must release its connection before model invocation.
assert self.sessions
assert all(not session.in_transaction() and not session.identity_map for session in self.sessions)
self.calls.append(_GenerationCall(app, account_id, dict(args), streaming))
if self.before_generate is not None:
self.before_generate()
if self.error is not None:
raise self.error
return self.response
@dataclass
class _Tasks:
calls: list[str] = field(default_factory=list)
def stop_workflow_task_no_user_check(self, *, task_id: str) -> None:
self.calls.append(task_id)
@dataclass(frozen=True)
class _ApplicationServices:
trial_app_access: TrialAppAccessService
trial_app_generation: TrialAppGenerationService
recommended_app_queries: _FeatureState
app_tasks: _Tasks
@dataclass(frozen=True)
class _Harness:
app: Flask
account: Account
target: App
trial: TrialApp
factory: sessionmaker[Session]
services: _ApplicationServices
runtime: _Runtime
state: _FeatureState
def url(self, endpoint: str, *, app_id: str | None = None) -> str:
return f"/trial-apps/{app_id or self.target.id}/{endpoint}"
def post(self, endpoint: str, *, payload: Mapping[str, object] | None = None) -> TestResponse:
return self.app.test_client().post(self.url(endpoint), json=dict(payload or {"inputs": {}}))
def set_mode(self, mode: AppMode) -> None:
with self.factory.begin() as session:
app = session.get(App, self.target.id)
assert app is not None
app.mode = mode
def usage(self) -> int | None:
with self.factory() as session:
return session.scalar(
select(AccountTrialAppRecord.count).where(
AccountTrialAppRecord.app_id == self.target.id,
AccountTrialAppRecord.account_id == self.account.id,
)
)
@pytest.fixture
def harness(
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
sqlite_session_factory: sessionmaker[Session],
) -> _Harness:
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, INIT_PASSWORD="", LOGIN_DISABLED=False)
state = _FeatureState()
account = Account(name="Viewer", email="viewer@example.com")
account._current_tenant = Tenant(name="Viewer workspace")
target = App(tenant_id=str(uuid4()), name="Trial app", mode=AppMode.COMPLETION, enable_site=True, enable_api=True)
target.id = str(uuid4())
trial = TrialApp(app_id=target.id, tenant_id=target.tenant_id, trial_limit=3)
with sqlite_session_factory.begin() as session:
session.add_all([target, trial])
sessions: list[Session] = []
repository_factory = sessionmaker(bind=sqlite_session_factory.kw["bind"], expire_on_commit=False)
@event.listens_for(repository_factory, "after_begin")
def track_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
sessions.append(session)
runtime = _Runtime(sessions=sessions)
repository = TrialAppRepository(session_factory=repository_factory)
services = _ApplicationServices(
trial_app_access=TrialAppAccessService(apps=repository),
trial_app_generation=TrialAppGenerationService(runtime=runtime, usage=repository),
recommended_app_queries=state,
app_tasks=_Tasks(),
)
def setup_completed() -> bool:
state.events.append("setup")
return state.setup_completed
def csrf(_request: Request, account_id: str) -> None:
assert account_id == account.id
state.events.append("csrf")
for module in (trial_module, admission_module):
monkeypatch.setattr(module, "application_services", lambda: services)
monkeypatch.setattr(console_wraps, "_is_setup_completed", setup_completed)
monkeypatch.setattr(login_module, "current_user", account)
monkeypatch.setattr(login_module, "check_csrf_token", csrf)
app = Flask(__name__)
app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False)
login_manager = DifyLoginManager()
login_manager.init_app(app)
login_manager.unauthorized_handler(unauthorized_handler)
api = ExternalApi(app)
for resource, endpoint in (
(trial_module.TrialAppWorkflowRunApi, "workflows/run"),
(trial_module.TrialChatApi, "chat-messages"),
(trial_module.TrialCompletionApi, "completion-messages"),
(trial_module.TrialAppWorkflowTaskStopApi, "workflows/tasks/<string:task_id>/stop"),
):
api.add_resource(resource, f"/trial-apps/<uuid:app_id>/{endpoint}")
return _Harness(app, account, target, trial, sqlite_session_factory, services, runtime, state)
def _assert_error(response: TestResponse, status: int, code: str) -> Mapping[str, object]:
assert response.status_code == status
body = response.get_json()
assert isinstance(body, dict)
assert body["code"] == code
assert body["status"] == status
assert response.headers["Content-Type"] == "application/json"
assert isinstance(body["message"], str)
assert body["message"]
return body
@pytest.mark.parametrize("response_mode", [None, "blocking"])
def test_completion_blocking_preserves_response_and_uses_app_owner(
harness: _Harness, response_mode: str | None
) -> None:
response = harness.post("completion-messages", payload={"inputs": {}, "response_mode": response_mode})
assert response.status_code == 200
assert response.get_json() == {"answer": "", "metadata": {}, "created_at": 0}
assert response.headers["Content-Type"] == "application/json; charset=utf-8"
assert int(response.headers["Content-Length"]) == len(response.data)
assert harness.usage() == 1
assert harness.state.events == ["setup", "csrf", "feature"]
call = harness.runtime.calls[0]
assert call.app == TrialAppRef(
app_id=harness.target.id, tenant_id=harness.target.tenant_id, app_mode=AppMode.COMPLETION
)
assert call.app.tenant_id != harness.account.current_tenant_id
assert call.account_id == harness.account.id
assert call.streaming is False
assert call.args == {
"inputs": {},
"query": "",
"files": None,
"response_mode": response_mode,
"retriever_from": "explore_app",
"auto_generate_name": False,
}
@pytest.mark.parametrize(
("endpoint", "mode", "payload"),
[
("workflows/run", AppMode.WORKFLOW, {"inputs": {"number": 0}, "files": []}),
("chat-messages", AppMode.CHAT, {"inputs": {}, "query": "hello"}),
("chat-messages", AppMode.ADVANCED_CHAT, {"inputs": {}, "query": "hello"}),
("chat-messages", AppMode.AGENT_CHAT, {"inputs": {}, "query": "hello"}),
("completion-messages", AppMode.COMPLETION, {"inputs": {}, "response_mode": "streaming"}),
],
)
def test_stream_records_usage_after_runtime_returns_before_consumption(
harness: _Harness, endpoint: str, mode: AppMode, payload: Mapping[str, object]
) -> None:
harness.set_mode(mode)
def before_generate() -> None:
assert harness.usage() is None
def before_read() -> None:
assert harness.usage() == 1
stream = _Stream(on_read=before_read)
harness.runtime.response = stream
harness.runtime.before_generate = before_generate
response = harness.post(endpoint, payload=payload)
assert response.status_code == 200
assert response.headers["Content-Type"] == "text/event-stream; charset=utf-8"
assert "Content-Length" not in response.headers
assert response.data == b'data: {"answer":"first"}\n\ndata: [DONE]\n\n'
response.close()
assert harness.usage() == 1
assert harness.runtime.calls[0].streaming is True
if endpoint == "chat-messages":
assert harness.runtime.calls[0].args["auto_generate_name"] is False
elif endpoint == "workflows/run":
assert harness.runtime.calls[0].args == payload
@pytest.mark.parametrize(
"endpoint", ["completion-messages", "chat-messages", "workflows/run", "workflows/tasks/task/stop"]
)
@pytest.mark.parametrize(
("state", "status", "code"), [("setup", 401, "not_setup"), ("feature", 403, "trial_app_feature_disabled")]
)
def test_bootstrap_and_feature_admission_apply_before_trial_lookup(
harness: _Harness, endpoint: str, state: str, status: int, code: str
) -> None:
if state == "setup":
harness.state.setup_completed = False
else:
harness.state.enabled = False
response = harness.app.test_client().post(harness.url(endpoint, app_id=str(uuid4())), json={})
_assert_error(response, status, code)
assert harness.state.events == (["setup"] if state == "setup" else ["setup", "csrf", "feature"])
assert harness.runtime.calls == []
assert harness.services.app_tasks.calls == []
@pytest.mark.parametrize(
("case", "code"),
[
("missing_trial", "trial_app_not_allowed"),
("missing_app", "trial_app_not_allowed"),
("limit", "trial_app_limit_exceeded"),
],
)
def test_trial_lookup_and_account_limit_block_generation(harness: _Harness, case: str, code: str) -> None:
with harness.factory.begin() as session:
if case == "missing_trial":
trial = session.get(TrialApp, harness.trial.id)
assert trial is not None
session.delete(trial)
elif case == "missing_app":
target = session.get(App, harness.target.id)
assert target is not None
session.delete(target)
else:
session.add(AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=3))
response = harness.post("completion-messages")
_assert_error(response, 403, code)
assert harness.runtime.calls == []
@pytest.mark.parametrize("count", [0, 2])
def test_usage_increments_existing_record_and_ignores_other_account(harness: _Harness, count: int) -> None:
other_id = str(uuid4())
with harness.factory.begin() as session:
session.add_all(
[
AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=count),
AccountTrialAppRecord(app_id=harness.target.id, account_id=other_id, count=99),
]
)
response = harness.post("completion-messages")
assert response.status_code == 200
assert harness.usage() == count + 1
with harness.factory() as session:
assert (
session.scalar(select(AccountTrialAppRecord.count).where(AccountTrialAppRecord.account_id == other_id))
== 99
)
@pytest.mark.parametrize(
("endpoint", "mode", "code"),
[
("completion-messages", AppMode.CHAT, "not_completion_app"),
("chat-messages", AppMode.COMPLETION, "not_chat_app"),
("workflows/run", AppMode.CHAT, "not_workflow_app"),
("workflows/tasks/task/stop", AppMode.CHAT, "not_workflow_app"),
],
)
def test_wrong_mode_returns_specific_error_without_invocation(
harness: _Harness, endpoint: str, mode: AppMode, code: str
) -> None:
harness.set_mode(mode)
response = harness.post(endpoint, payload={"inputs": {}, "query": "hello"})
_assert_error(response, 400, code)
assert harness.runtime.calls == []
assert harness.services.app_tasks.calls == []
assert harness.usage() is None
@pytest.mark.parametrize("field", ["conversation_id", "parent_message_id"])
def test_chat_rejects_invalid_uuid_before_runtime(harness: _Harness, field: str) -> None:
harness.set_mode(AppMode.CHAT)
response = harness.post("chat-messages", payload={"inputs": {}, "query": "hello", field: "not-a-uuid"})
_assert_error(response, 400, "invalid_param")
assert harness.runtime.calls == []
assert harness.usage() is None
def test_chat_normalizes_uuid_and_preserves_empty_ids(harness: _Harness) -> None:
harness.set_mode(AppMode.CHAT)
conversation_id = uuid4()
response = harness.post(
"chat-messages",
payload={
"inputs": {},
"query": "hello",
"conversation_id": conversation_id.hex.upper(),
"parent_message_id": "",
},
)
assert response.status_code == 200
assert harness.runtime.calls[0].args["conversation_id"] == str(conversation_id)
assert harness.runtime.calls[0].args["parent_message_id"] == ""
@pytest.mark.parametrize(
("error", "status", "code"),
[
(ProviderTokenNotInitError("missing key for provider"), 400, "provider_not_initialize"),
(QuotaExceededError(), 400, "provider_quota_exceeded"),
(ModelCurrentlyNotSupportError(), 400, "model_currently_not_support"),
(InvokeError("provider rejected request"), 400, "completion_request_error"),
(InvokeRateLimitError("too many requests"), 429, "rate_limit_error"),
(AppInvokeQuotaExceededError("active requests exceeded"), 429, "rate_limit_error"),
(AppDefinitionUnavailableError("app removed"), 400, "app_unavailable"),
(AccountNotFoundError("account removed"), 401, "unauthorized"),
(AppModelConfigBrokenError(), 400, "app_unavailable"),
],
)
@pytest.mark.parametrize(
("endpoint", "mode"),
[
("completion-messages", AppMode.COMPLETION),
("chat-messages", AppMode.CHAT),
("workflows/run", AppMode.WORKFLOW),
],
)
def test_runtime_errors_keep_specific_codes_and_do_not_record_usage(
harness: _Harness, endpoint: str, mode: AppMode, error: Exception, status: int, code: str
) -> None:
harness.set_mode(mode)
harness.runtime.error = error
response = harness.post(endpoint, payload={"inputs": {}, "query": "hello"})
body = _assert_error(response, status, code)
if isinstance(error, (ProviderTokenNotInitError, InvokeError, InvokeRateLimitError)):
assert body["message"] == error.description
assert harness.usage() is None
@pytest.mark.parametrize(
("error", "status", "code"),
[
(ConversationNotExistsError(), 404, "not_found"),
(ConversationCompletedError(), 400, "conversation_completed"),
(AppModelConfigBrokenError(), 400, "app_unavailable"),
],
)
@pytest.mark.parametrize(
("endpoint", "mode"), [("completion-messages", AppMode.COMPLETION), ("chat-messages", AppMode.CHAT)]
)
def test_conversation_and_config_errors_remain_actionable(
harness: _Harness, endpoint: str, mode: AppMode, error: Exception, status: int, code: str
) -> None:
harness.set_mode(mode)
harness.runtime.error = error
response = harness.post(endpoint, payload={"inputs": {}, "query": "hello"})
_assert_error(response, status, code)
assert harness.usage() is None
def test_stop_retains_trial_quota_admission_without_recording_usage(harness: _Harness) -> None:
harness.set_mode(AppMode.WORKFLOW)
response = harness.post("workflows/tasks/task-123/stop")
assert response.status_code == 200
assert response.get_json() == {"result": "success"}
assert harness.services.app_tasks.calls == ["task-123"]
assert harness.usage() is None
with harness.factory.begin() as session:
session.add(AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=3))
blocked = harness.post("workflows/tasks/task-456/stop")
_assert_error(blocked, 403, "trial_app_limit_exceeded")
assert harness.services.app_tasks.calls == ["task-123"]
def test_unauthenticated_account_cannot_enter_trial(harness: _Harness, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(login_module, "current_user", None)
response = harness.post("completion-messages")
assert response.status_code == 401
assert harness.state.events == ["setup"]
assert harness.runtime.calls == []
def test_uninitialized_account_cannot_enter_trial(harness: _Harness) -> None:
harness.account.status = AccountStatus.UNINITIALIZED
response = harness.post("completion-messages")
_assert_error(response, 400, "account_not_initialized")
assert harness.state.events == ["setup", "csrf"]
assert harness.runtime.calls == []
def test_failed_csrf_cannot_enter_trial(harness: _Harness, monkeypatch: pytest.MonkeyPatch) -> None:
def reject_csrf(_request: Request, _account_id: str) -> None:
raise Unauthorized("invalid csrf token")
monkeypatch.setattr(login_module, "check_csrf_token", reject_csrf)
response = harness.post("completion-messages")
_assert_error(response, 401, "unauthorized")
assert harness.runtime.calls == []
def test_payload_validation_keeps_field_context(harness: _Harness) -> None:
response = harness.post("completion-messages", payload={"inputs": [], "response_mode": "invalid"})
body = _assert_error(response, 422, "unprocessable_entity")
assert "inputs" in str(body)
assert harness.runtime.calls == []
assert harness.usage() is None
@pytest.mark.parametrize("cleanup_fails", [False, True])
@pytest.mark.parametrize(
("endpoint", "mode"),
[("completion-messages", AppMode.COMPLETION), ("chat-messages", AppMode.CHAT), ("workflows/run", AppMode.WORKFLOW)],
)
def test_usage_write_failure_closes_unconsumed_stream_without_hiding_database_error(
harness: _Harness, endpoint: str, mode: AppMode, cleanup_fails: bool, caplog: pytest.LogCaptureFixture
) -> None:
harness.set_mode(mode)
stream = _Stream(close_error=RuntimeError("cleanup failed") if cleanup_fails else None)
harness.runtime.response = stream
failure = RuntimeError("usage database unavailable")
def reject_usage_insert(
_mapper: Mapper[AccountTrialAppRecord], _connection: Connection, _record: AccountTrialAppRecord
) -> None:
raise failure
# Fail the persistence boundary, retaining the real service, unit of work,
# SQLAlchemy rollback and HTTP error handler.
event.listen(AccountTrialAppRecord, "before_insert", reject_usage_insert)
try:
response = harness.post(endpoint, payload={"inputs": {}, "query": "hello", "response_mode": "streaming"})
finally:
event.remove(AccountTrialAppRecord, "before_insert", reject_usage_insert)
_assert_error(response, 500, "internal_server_error")
assert stream.close_calls == 1
assert stream.reads == 0
assert harness.usage() is None
assert any(record.exc_info is not None and record.exc_info[1] is failure for record in caplog.records)
def test_later_stream_failure_does_not_refund_trial_usage(harness: _Harness) -> None:
def chunks() -> Iterator[str]:
yield 'data: {"answer":"partial"}\n\n'
raise RuntimeError("provider disconnected")
harness.runtime.response = _Stream(chunks=chunks())
response = harness.post("completion-messages", payload={"inputs": {}, "response_mode": "streaming"})
chunks = iter(response.response)
assert next(chunks) == b'data: {"answer":"partial"}\n\n'
with pytest.raises(RuntimeError, match="provider disconnected"):
next(chunks)
response.close()
assert harness.usage() == 1

View File

@@ -13,12 +13,12 @@ from controllers.console.explore.error import (
TrialAppLimitExceeded,
TrialAppNotAllowed,
)
from controllers.console.explore.trial_app_admission import trial_feature_enable
from controllers.console.explore.wraps import (
InstalledAppResource,
TrialAppResource,
installed_app_required,
trial_app_required,
trial_feature_enable,
user_allowed_to_access_app,
)
from models import Account, AccountTrialAppRecord, App, InstalledApp, TrialApp
@@ -242,14 +242,14 @@ def test_trial_app_required_success(
assert result.id == app.id
def test_trial_feature_enable_disabled():
def test_trial_feature_enable_disabled() -> None:
@trial_feature_enable
def view():
def view() -> str:
return "ok"
services = MagicMock()
services.recommended_app_queries.is_trial_enabled.return_value = False
with patch("controllers.console.explore.wraps.application_services", return_value=services):
with patch("controllers.console.explore.trial_app_admission.application_services", return_value=services):
with pytest.raises(TrialAppFeatureDisabledError) as exc_info:
view()
@@ -260,14 +260,14 @@ def test_trial_feature_enable_disabled():
}
def test_trial_feature_enable_enabled():
def test_trial_feature_enable_enabled() -> None:
@trial_feature_enable
def view():
def view() -> str:
return "ok"
services = MagicMock()
services.recommended_app_queries.is_trial_enabled.return_value = True
with patch("controllers.console.explore.wraps.application_services", return_value=services):
with patch("controllers.console.explore.trial_app_admission.application_services", return_value=services):
assert view() == "ok"

View File

@@ -0,0 +1,219 @@
"""Authenticated HTTP contract backed by the real accounting service and DB."""
from collections.abc import Iterator
from typing import TypedDict
from uuid import uuid4
import pytest
import sqlalchemy as sa
from flask import Flask
from flask.testing import FlaskClient
from pydantic import JsonValue
from sqlalchemy.orm import Session, sessionmaker
from controllers.inner_api import bp as inner_api_bp
from controllers.inner_api import inner_api_ns
from models.agent_sandbox_usage import AgentSandboxUsageEvent
from tests.unit_tests.config_override import config_overrides_context
PROJECT = "431de237-596f-4d59-8a85-20a9846bf243"
class _EventPayload(TypedDict):
id: str
source: str
type: str
timestamp: str
payload: dict[str, JsonValue]
class _BatchPayload(TypedDict):
project_id: str
events: list[_EventPayload]
@pytest.fixture
def client() -> Iterator[FlaskClient]:
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(inner_api_bp)
with config_overrides_context(
PLUGIN_DAEMON_KEY="test-daemon",
INNER_API_KEY_FOR_PLUGIN="test-inner",
AGENT_SANDBOX_METERING_ENABLED=True,
AGENT_SANDBOX_METERING_PROJECT_ID=PROJECT,
AGENT_SANDBOX_METERING_START_AT="2026-09-20T00:00:00Z",
):
yield app.test_client()
def payload() -> _BatchPayload:
event_id = str(uuid4())
return {
"project_id": PROJECT,
"events": [
{
"id": event_id,
"source": "provider",
"type": "sandbox.lifecycle.paused",
"timestamp": "2026-09-20T00:01:00Z",
"payload": {
"id": event_id,
"version": "v2",
"type": "sandbox.lifecycle.paused",
"timestamp": "2026-09-20T00:01:00Z",
"sandboxId": "sandbox-test",
"sandboxExecutionId": "execution-test",
"sandboxTeamId": PROJECT,
"eventData": {
"execution": {
"started_at": "2026-09-20T00:00:02Z",
"execution_time": 12345,
"memory_mb": 1024,
"vcpu_count": 2,
}
},
},
}
],
}
def test_state_requires_inner_auth(client: FlaskClient) -> None:
response = client.get(f"/inner/api/agent/sandbox-usage/state?project_id={PROJECT}")
assert response.status_code == 404
def test_state_exposes_persisted_scope_without_secret_or_environment(client: FlaskClient) -> None:
response = client.get(
f"/inner/api/agent/sandbox-usage/state?project_id={PROJECT}", headers={"X-Inner-Api-Key": "test-inner"}
)
assert response.status_code == 200
assert response.json == {
"enabled": True,
"project_id": PROJECT,
"started_at": "2026-09-20T00:00:00Z",
"checkpoint_at": None,
"full_scan_at": None,
"diagnostics": {
"unresolved_events": 0,
"conflict_events": 0,
"open_executions": 0,
"unattributed_executions": 0,
},
}
@pytest.mark.parametrize("query", ["", "?project_id=", f"?project_id={PROJECT}&unexpected=value"])
def test_state_rejects_invalid_query_before_activation(
client: FlaskClient, sqlite_session_factory: sessionmaker[Session], query: str
) -> None:
response = client.get(f"/inner/api/agent/sandbox-usage/state{query}", headers={"X-Inner-Api-Key": "test-inner"})
assert response.status_code == 400
body = response.get_json()
assert isinstance(body, dict)
assert body["code"] == "sandbox_usage_invalid_request"
with sqlite_session_factory() as session:
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxUsageEvent)) == 0
def test_state_rejects_project_outside_allowlist_without_activation(
client: FlaskClient, sqlite_session_factory: sessionmaker[Session]
) -> None:
response = client.get(
"/inner/api/agent/sandbox-usage/state?project_id=other-project", headers={"X-Inner-Api-Key": "test-inner"}
)
assert response.status_code == 403
body = response.get_json()
assert isinstance(body, dict)
assert body["code"] == "sandbox_metering_project_not_allowed"
with sqlite_session_factory() as session:
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxUsageEvent)) == 0
def test_state_reports_start_change_conflict_and_preserves_activation(client: FlaskClient) -> None:
url = f"/inner/api/agent/sandbox-usage/state?project_id={PROJECT}"
headers = {"X-Inner-Api-Key": "test-inner"}
original = client.get(url, headers=headers)
assert original.status_code == 200
with config_overrides_context(AGENT_SANDBOX_METERING_START_AT="2026-09-21T00:00:00Z"):
changed = client.get(url, headers=headers)
assert changed.status_code == 409
body = changed.get_json()
assert isinstance(body, dict)
assert body["code"] == "sandbox_metering_start_is_immutable"
restored = client.get(url, headers=headers)
assert restored.status_code == 200
assert restored.json == original.json
def test_state_reports_invalid_start_as_configuration_error(client: FlaskClient) -> None:
with config_overrides_context(AGENT_SANDBOX_METERING_START_AT="2026-09-20T00:00:00"):
response = client.get(
f"/inner/api/agent/sandbox-usage/state?project_id={PROJECT}", headers={"X-Inner-Api-Key": "test-inner"}
)
assert response.status_code == 503
body = response.get_json()
assert isinstance(body, dict)
assert body["code"] == "sandbox_metering_start_not_configured"
def test_events_ack_only_after_persisted_and_duplicate_is_idempotent(
client: FlaskClient, sqlite_session_factory: sessionmaker[Session]
) -> None:
body = payload()
for expected in (
{"accepted": 1, "duplicates": 0, "conflicts": 0, "ignored": 0},
{"accepted": 0, "duplicates": 1, "conflicts": 0, "ignored": 0},
):
response = client.post(
"/inner/api/agent/sandbox-usage/events", json=body, headers={"X-Inner-Api-Key": "test-inner"}
)
assert response.status_code == 200
assert response.json == expected
with sqlite_session_factory() as session:
assert (
session.scalar(
sa.select(sa.func.count())
.select_from(AgentSandboxUsageEvent)
.where(AgentSandboxUsageEvent.source_event_id == body["events"][0]["id"])
)
== 1
)
def test_ingestion_rejects_other_project_and_oversized_batch(client: FlaskClient) -> None:
body = payload()
body["project_id"] = "other-project"
response = client.post(
"/inner/api/agent/sandbox-usage/events", json=body, headers={"X-Inner-Api-Key": "test-inner"}
)
assert response.status_code == 403
body = payload()
body["events"] *= 101
response = client.post(
"/inner/api/agent/sandbox-usage/events", json=body, headers={"X-Inner-Api-Key": "test-inner"}
)
assert response.status_code == 400
def test_ingestion_payload_limit(client: FlaskClient) -> None:
body = payload()
body["events"][0]["payload"] = {"oversized": "x" * (1024 * 1024)}
response = client.post(
"/inner/api/agent/sandbox-usage/events", json=body, headers={"X-Inner-Api-Key": "test-inner"}
)
assert response.status_code == 413
def test_schema_and_http_exclude_business_operation_envelope_fields(client: FlaskClient) -> None:
schema = inner_api_ns.models["SandboxUsageEvent"].__schema__
assert set(schema["properties"]) == {"id", "source", "type", "timestamp", "sandbox_id", "execution_id", "payload"}
assert schema["additionalProperties"] is False
body = payload()
response = client.post(
"/inner/api/agent/sandbox-usage/events",
json={**body, "events": [{**body["events"][0], "allocation_id": str(uuid4())}]},
headers={"X-Inner-Api-Key": "test-inner"},
)
assert response.status_code == 400

View File

@@ -11,7 +11,7 @@ from sqlalchemy.orm import Session, sessionmaker
import core.app.apps.workflow.app_generator as app_generator_module
from core.app.app_config.entities import WorkflowUIBasedAppConfig
from core.app.apps.draft_variable_saver import DraftVariableSaverFactory
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY, WorkflowAppGenerator
from core.app.apps.workflow.app_generator import WorkflowAppGenerator
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer
from core.ops.ops_trace_manager import TraceQueueManager
@@ -39,6 +39,7 @@ def _workflow(
kind=kind,
version="1",
created_by="creator",
graph={"nodes": [{"id": "start", "data": {"type": "start", "title": "Start", "variables": []}}], "edges": []},
)
@@ -123,24 +124,6 @@ def _repositories(
)
def test_should_prepare_user_inputs_defaults_to_true():
args = {"inputs": {}}
assert WorkflowAppGenerator()._should_prepare_user_inputs(args)
def test_should_prepare_user_inputs_skips_when_flag_truthy():
args = {"inputs": {}, SKIP_PREPARE_USER_INPUTS_KEY: True}
assert not WorkflowAppGenerator()._should_prepare_user_inputs(args)
def test_should_prepare_user_inputs_keeps_validation_when_flag_false():
args = {"inputs": {}, SKIP_PREPARE_USER_INPUTS_KEY: False}
assert WorkflowAppGenerator()._should_prepare_user_inputs(args)
def test_ensure_snippet_start_node_in_worker_returns_standard_workflow_without_lookup(
sqlite_session: Session,
) -> None:

View File

@@ -12,7 +12,7 @@ from sqlalchemy.orm import Session, sessionmaker
from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig
from core.app.apps.exc import GenerateTaskStoppedError
from core.app.apps.workflow import app_generator as app_generator_module
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY, WorkflowAppGenerator
from core.app.apps.workflow.app_generator import WorkflowAppGenerator
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
from core.ops.ops_trace_manager import TraceQueueManager
from models.enums import EndUserType
@@ -264,12 +264,6 @@ class TestWorkflowAppGeneratorValidation:
assert result is injected_workflow
ensure_start_node.assert_called_once_with(workflow, snippet)
def test_should_prepare_user_inputs(self):
generator = WorkflowAppGenerator()
assert generator._should_prepare_user_inputs({}) is True
assert generator._should_prepare_user_inputs({SKIP_PREPARE_USER_INPUTS_KEY: True}) is False
def test_single_iteration_generate_validates_args(self, sqlite_session: Session):
generator = WorkflowAppGenerator()
@@ -491,79 +485,95 @@ class TestWorkflowAppGeneratorHandleResponse:
class TestWorkflowAppGeneratorGenerate:
@pytest.mark.usefixtures("sqlite_generator_session")
def test_generate_skips_prepare_inputs_when_flag_set(
self,
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
):
@pytest.fixture
def generation(self, monkeypatch: pytest.MonkeyPatch, sqlite_generator_session: Session):
"""Keep input preparation real and stop at the execution boundary."""
generator = WorkflowAppGenerator()
app = _persist_app(sqlite_session)
workflow = _persist_workflow(sqlite_session)
user = _persist_end_user(sqlite_session)
app = _persist_app(sqlite_generator_session)
workflow = _persist_workflow(sqlite_generator_session)
user = _persist_end_user(sqlite_generator_session)
monkeypatch.setattr(app_generator_module, "TraceQueueManager", Mock(return_value=Mock(spec=TraceQueueManager)))
execute = Mock(return_value={"ok": True})
monkeypatch.setattr(generator, "_generate", execute)
return generator, app, workflow, user, execute
app_config = WorkflowUIBasedAppConfig(
tenant_id=TENANT_ID,
app_id=APP_ID,
app_mode=AppMode.WORKFLOW,
additional_features=AppAdditionalFeatures(),
variables=[],
workflow_id=WORKFLOW_ID,
)
repository_session_makers: list[sessionmaker[Session]] = []
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.WorkflowAppConfigManager.get_app_config",
lambda app_model, workflow: app_config,
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.FileUploadConfigManager.convert",
lambda features_dict, is_vision=False: None,
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.file_factory.build_from_mappings",
lambda **kwargs: [],
)
DummyTraceQueueManager = type(
"_DummyTraceQueueManager",
(TraceQueueManager,),
@pytest.mark.parametrize("node_type", ["trigger-webhook", "trigger-schedule", "trigger-plugin"])
@pytest.mark.parametrize("root_node_id", [None, "selected-trigger"])
@pytest.mark.parametrize("invoke_from", [InvokeFrom.SERVICE_API, InvokeFrom.DEBUGGER])
def test_generate_preserves_trigger_inputs(self, generation, node_type, root_node_id, invoke_from):
generator, app, workflow, user, execute = generation
workflow.graph = json.dumps(
{
"__init__": lambda self, app_id=None, user_id=None: (
setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id)
)
},
"nodes": [
{"id": "first-trigger", "data": {"type": node_type, "title": "First trigger"}},
{"id": "selected-trigger", "data": {"type": node_type, "title": "Selected trigger"}},
],
"edges": [],
}
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.TraceQueueManager",
DummyTraceQueueManager,
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository",
lambda **kwargs: repository_session_makers.append(kwargs["session_factory"]) or SimpleNamespace(),
)
monkeypatch.setattr(
"core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository",
lambda **kwargs: repository_session_makers.append(kwargs["session_factory"]) or SimpleNamespace(),
)
prepare_inputs = pytest.fail
monkeypatch.setattr(generator, "_prepare_user_inputs", lambda **kwargs: prepare_inputs())
monkeypatch.setattr(generator, "_generate", lambda **kwargs: {"ok": True})
inputs = {"event": {"message": "hello", "items": [1, 2]}, "enabled": False}
result = generator.generate(
app_model=app,
workflow=workflow,
user=user,
args={"inputs": {}, SKIP_PREPARE_USER_INPUTS_KEY: True},
invoke_from=InvokeFrom.WEB_APP,
args={"inputs": inputs},
invoke_from=invoke_from,
streaming=False,
call_depth=0,
root_node_id=root_node_id,
)
assert result == {"ok": True}
assert len(repository_session_makers) == 2
assert all(factory.kw["bind"] is sqlite_session.get_bind() for factory in repository_session_makers)
assert execute.call_args.kwargs["application_generate_entity"].inputs == inputs
assert execute.call_args.kwargs["root_node_id"] == (root_node_id or "first-trigger")
@pytest.mark.parametrize("root_node_id", [None, "start"])
def test_generate_prepares_start_inputs(self, generation, root_node_id):
generator, app, workflow, user, execute = generation
workflow.graph = json.dumps(
{
"nodes": [
{
"id": "start",
"data": {
"type": "start",
"title": "Start",
"variables": [
{"variable": "question", "label": "Question", "type": "text-input", "required": True},
{"variable": "count", "label": "Count", "type": "number", "default": "3"},
],
},
}
],
"edges": [],
}
)
generator.generate(
app_model=app,
workflow=workflow,
user=user,
args={"inputs": {"question": "hello", "unknown": "filtered"}},
invoke_from=InvokeFrom.SERVICE_API,
streaming=False,
root_node_id=root_node_id,
)
assert execute.call_args.kwargs["application_generate_entity"].inputs == {"question": "hello", "count": 3}
assert execute.call_args.kwargs["root_node_id"] == "start"
execute.reset_mock()
with pytest.raises(ValueError, match="question is required in input form"):
generator.generate(
app_model=app,
workflow=workflow,
user=user,
args={"inputs": {}},
invoke_from=InvokeFrom.SERVICE_API,
streaming=False,
root_node_id=root_node_id,
)
execute.assert_not_called()
class TestWorkflowAppGeneratorResume:

View File

@@ -3,6 +3,8 @@ from typing import Any
import pytest
import core.tools.utils.message_transformer as mt
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.file_access import FileAccessScope, bind_file_access_scope, get_current_file_access_scope
from core.tools.entities.tool_entities import ToolInvokeMessage
from models.tools import ToolFile
@@ -135,3 +137,32 @@ def test_transform_tool_invoke_messages_parses_existing_tool_file_link_meta():
assert len(out) == 1
assert out[0].meta["tool_file_id"] == "existing-tool-file"
def test_transform_tool_invoke_messages_grants_tool_file_to_current_end_user_scope():
"""Workflow-as-tool LLM nodes need a grant for plugin files (#41169)."""
msg = ToolInvokeMessage(
type=ToolInvokeMessage.MessageType.IMAGE_LINK,
message=ToolInvokeMessage.TextMessage(text="/files/tools/existing-tool-file.png"),
meta={},
)
scope = FileAccessScope(
tenant_id="t1",
user_id="end-user-1",
user_from=UserFrom.END_USER,
invoke_from=InvokeFrom.WEB_APP,
)
with bind_file_access_scope(scope):
list(
mt.ToolFileMessageTransformer.transform_tool_invoke_messages(
messages=_gen([msg]),
user_id="u1",
tenant_id="t1",
conversation_id="c1",
)
)
current_scope = get_current_file_access_scope()
assert current_scope is not None
assert "existing-tool-file" in current_scope.granted_tool_file_ids

View File

@@ -162,6 +162,7 @@ class TestCelerySSLConfiguration:
# Mock all the scheduler configs
mock_config.CELERY_BEAT_SCHEDULER_TIME = 1
mock_config.AGENT_SANDBOX_METERING_ENABLED = False
mock_config.ENABLE_CONVERSATION_CLEANUP_TASK = False
mock_config.CONVERSATION_CLEANUP_TASK_INTERVAL = 5
mock_config.ENABLE_CLEAN_EMBEDDING_CACHE_TASK = False
@@ -212,6 +213,7 @@ class TestCelerySSLConfiguration:
mock_config.CELERY_TASK_ANNOTATIONS = {}
mock_config.CELERY_BEAT_SCHEDULER_TIME = 1
mock_config.AGENT_SANDBOX_METERING_ENABLED = False
mock_config.ENABLE_CONVERSATION_CLEANUP_TASK = True
mock_config.CONVERSATION_CLEANUP_TASK_INTERVAL = 5
mock_config.ENABLE_CLEAN_EMBEDDING_CACHE_TASK = False

View File

@@ -19,7 +19,7 @@ from extensions import ext_application_services
from extensions.ext_redis import RedisClientWrapper
from machinery.context import RequestContext
from models.account import Account
from models.model import AccountTrialAppRecord, DifySetup
from models.model import AccountTrialAppRecord, App, DifySetup, TrialApp
from repositories.account_activation_repository import SQLAlchemyAccountActivationRepository
from repositories.account_integration_repository import SQLAlchemyAccountIntegrationRepository
from repositories.account_oauth_repository import (
@@ -63,6 +63,7 @@ from services.account_oauth_adapters import (
DeploymentOAuthPolicyGateway,
RedisOAuthAccountClaimLock,
)
from services.app_generate_service import AppGenerateService
from services.app_site_service import AppSiteService
from services.app_tracing_config_gateway import OpsTraceManagerGateway
from services.app_tracing_config_service import AppTracingConfigService
@@ -700,6 +701,42 @@ def test_build_application_services_wires_trial_app_usage(
assert record.count == 1
def test_trial_generation_uses_configured_access_runtime_and_usage(
sqlite_session_factory: sessionmaker[Session],
) -> None:
services = ext_application_services.build_application_services(
database_client=sqlite_session_factory,
deployment_edition=DeploymentEdition.COMMUNITY,
initialization_password="",
redis=MagicMock(spec=RedisClientWrapper),
)
app_id, tenant_id, account_id = str(uuid4()), str(uuid4()), str(uuid4())
with sqlite_session_factory.begin() as session:
session.add(
App(id=app_id, tenant_id=tenant_id, name="Trial", mode="completion", enable_site=True, enable_api=False)
)
account = Account(name="Account", email="trial@example.com")
account.id = account_id
session.add(account)
session.add(TrialApp(app_id=app_id, tenant_id=tenant_id))
admitted = services.trial_app_access.get_access(app_id=app_id, account_id=account_id)
with patch.object(AppGenerateService, "generate", return_value={"answer": "hello"}):
response = services.trial_app_generation.generate_completion(
trial_app=admitted, account_id=account_id, args={"inputs": {}}
)
assert response == {"answer": "hello"}
with sqlite_session_factory() as session:
record = session.scalar(
select(AccountTrialAppRecord).where(
AccountTrialAppRecord.app_id == app_id, AccountTrialAppRecord.account_id == account_id
)
)
assert record is not None
assert record.count == 1
def test_build_application_services_adapts_enterprise_webapp_access_mode(
sqlite_session_factory: sessionmaker[Session],
) -> None:

View File

@@ -10,7 +10,12 @@ from sqlalchemy import Engine
from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.file_access import DatabaseFileAccessController, FileAccessScope, bind_file_access_scope
from core.app.file_access import (
DatabaseFileAccessController,
FileAccessScope,
bind_file_access_scope,
grant_tool_file_access,
)
from core.workflow.file_reference import build_file_reference, parse_file_reference, resolve_file_record_id
from extensions.storage.storage_type import StorageType
from factories.file_factory.builders import build_from_mapping as _build_from_mapping
@@ -448,6 +453,26 @@ def test_build_from_mapping_scopes_tool_file_to_end_user():
build_from_mapping(mapping=tool_file_mapping(), tenant_id=TEST_TENANT_ID)
def test_build_from_mapping_allows_granted_tool_file_for_other_end_user():
"""Tool files produced in this run must remain readable by later nodes (#41169).
Workflow-as-tool from an agent binds an EndUser scope. Plugin downloads often
store ToolFile.user_id as a different identity, so a grant is required.
"""
unauthorized_scope = FileAccessScope(
tenant_id=TEST_TENANT_ID,
user_id="different-end-user",
user_from=UserFrom.END_USER,
invoke_from=InvokeFrom.WEB_APP,
)
with bind_file_access_scope(unauthorized_scope):
grant_tool_file_access([TEST_TOOL_FILE_ID])
file = build_from_mapping(mapping=tool_file_mapping(), tenant_id=TEST_TENANT_ID)
assert resolve_file_record_id(file.reference) == TEST_TOOL_FILE_ID
assert file.type == FileType.DOCUMENT
def test_disallowed_file_types():
"""Test that disallowed file types are rejected."""
# Config that only allows image and document types

View File

@@ -1,28 +0,0 @@
import uuid
from unittest.mock import MagicMock
from sqlalchemy.orm import Session, sessionmaker
from models.model import TrialApp
from repositories.trial_app_query_repository import TrialAppQueryRepository
def test_existing_ids_returns_only_trial_apps(sqlite_session_factory: sessionmaker[Session]) -> None:
eligible_id = str(uuid.uuid4())
other_id = str(uuid.uuid4())
with sqlite_session_factory() as session:
session.add(TrialApp(app_id=eligible_id, tenant_id=str(uuid.uuid4())))
session.commit()
result = TrialAppQueryRepository(sqlite_session_factory).existing_ids([eligible_id, other_id])
assert result == frozenset({eligible_id})
def test_existing_ids_skips_session_for_empty_input() -> None:
session_factory = MagicMock(spec=sessionmaker)
result = TrialAppQueryRepository(session_factory).existing_ids([])
assert result == frozenset()
session_factory.assert_not_called()

View File

@@ -0,0 +1,232 @@
import uuid
from unittest.mock import MagicMock
import pytest
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import QueuePool
from models.model import AccountTrialAppRecord, App, AppMode, TrialApp
from repositories.trial_app_repository import TrialAppRepository
from services.trial_app_access_service import (
TrialAppAccessService,
TrialAppAccessSnapshot,
TrialAppRef,
TrialAppUnavailableError,
TrialAppUsageLimitExceededError,
)
_APP_ID = "11111111-1111-1111-1111-111111111111"
_OWNER_TENANT_ID = "22222222-2222-2222-2222-222222222222"
_ACCOUNT_ID = "33333333-3333-3333-3333-333333333333"
_OTHER_APP_ID = "44444444-4444-4444-4444-444444444444"
_OTHER_ACCOUNT_ID = "55555555-5555-5555-5555-555555555555"
_OTHER_TENANT_ID = "66666666-6666-6666-6666-666666666666"
def _add_app(session: Session) -> None:
session.add(
App(
id=_APP_ID,
tenant_id=_OWNER_TENANT_ID,
name="Trial App",
mode=AppMode.CHAT,
enable_site=True,
enable_api=False,
)
)
def _add_trial(session: Session, *, trial_limit: int = 3) -> None:
# The actual app owner can differ from the tenant stored on the trial listing.
session.add(TrialApp(app_id=_APP_ID, tenant_id=_OTHER_TENANT_ID, trial_limit=trial_limit))
def test_existing_ids_returns_only_trial_apps(sqlite_session_factory: sessionmaker[Session]) -> None:
eligible_id = str(uuid.uuid4())
other_id = str(uuid.uuid4())
with sqlite_session_factory() as session:
session.add(TrialApp(app_id=eligible_id, tenant_id=str(uuid.uuid4())))
session.commit()
result = TrialAppRepository(sqlite_session_factory).existing_ids([eligible_id, other_id])
assert result == frozenset({eligible_id})
def test_existing_ids_skips_session_for_empty_input() -> None:
session_factory = MagicMock(spec=sessionmaker)
result = TrialAppRepository(session_factory).existing_ids([])
assert result == frozenset()
session_factory.assert_not_called()
def test_resolve_returns_actual_app_owner_and_account_usage(sqlite_session_factory: sessionmaker[Session]) -> None:
with sqlite_session_factory.begin() as session:
_add_app(session)
_add_trial(session)
session.add(AccountTrialAppRecord(app_id=_APP_ID, account_id=_ACCOUNT_ID, count=2))
result = TrialAppRepository(sqlite_session_factory).resolve(app_id=_APP_ID, account_id=_ACCOUNT_ID)
assert result == TrialAppAccessSnapshot(
app=TrialAppRef(app_id=_APP_ID, tenant_id=_OWNER_TENANT_ID, app_mode="chat"),
trial_limit=3,
used_count=2,
)
@pytest.mark.parametrize(("has_app", "has_trial"), [(False, False), (True, False), (False, True)])
def test_resolve_requires_both_trial_listing_and_app(
sqlite_session_factory: sessionmaker[Session], has_app: bool, has_trial: bool
) -> None:
with sqlite_session_factory.begin() as session:
if has_app:
_add_app(session)
if has_trial:
_add_trial(session)
session.add_all(
[
App(
id=_OTHER_APP_ID,
tenant_id=_OTHER_TENANT_ID,
name="Another trial app",
mode=AppMode.CHAT,
enable_site=True,
enable_api=False,
),
TrialApp(app_id=_OTHER_APP_ID, tenant_id=_OTHER_TENANT_ID, trial_limit=3),
]
)
repository = TrialAppRepository(sqlite_session_factory)
assert repository.resolve(app_id=_OTHER_APP_ID, account_id=_ACCOUNT_ID) == TrialAppAccessSnapshot(
app=TrialAppRef(app_id=_OTHER_APP_ID, tenant_id=_OTHER_TENANT_ID, app_mode="chat"),
trial_limit=3,
used_count=None,
)
assert repository.resolve(app_id=_APP_ID, account_id=_ACCOUNT_ID) is None
with pytest.raises(TrialAppUnavailableError, match=_APP_ID):
TrialAppAccessService(apps=repository).get_access(app_id=_APP_ID, account_id=_ACCOUNT_ID)
def test_resolve_does_not_use_other_account_or_app_usage(sqlite_session_factory: sessionmaker[Session]) -> None:
with sqlite_session_factory.begin() as session:
_add_app(session)
_add_trial(session)
session.add_all(
[
AccountTrialAppRecord(app_id=_APP_ID, account_id=_OTHER_ACCOUNT_ID, count=10),
AccountTrialAppRecord(app_id=_OTHER_APP_ID, account_id=_ACCOUNT_ID, count=10),
]
)
repository = TrialAppRepository(sqlite_session_factory)
result = repository.resolve(app_id=_APP_ID, account_id=_ACCOUNT_ID)
assert result is not None
assert result.used_count is None
assert TrialAppAccessService(apps=repository).get_access(app_id=_APP_ID, account_id=_ACCOUNT_ID) == result.app
@pytest.mark.parametrize(
("trial_limit", "used_count", "allowed"),
[
pytest.param(3, None, True, id="first-use"),
pytest.param(0, None, True, id="first-use-zero-limit"),
pytest.param(-1, None, True, id="first-use-negative-limit"),
pytest.param(3, 0, True, id="zero-count"),
pytest.param(3, 2, True, id="below-limit"),
pytest.param(3, 3, False, id="at-limit"),
pytest.param(3, 4, False, id="over-limit"),
pytest.param(0, 0, False, id="zero-limit-existing-count"),
pytest.param(-1, 0, False, id="negative-limit-existing-count"),
],
)
def test_access_enforces_limits_only_for_existing_usage(
sqlite_session_factory: sessionmaker[Session], trial_limit: int, used_count: int | None, allowed: bool
) -> None:
with sqlite_session_factory.begin() as session:
_add_app(session)
_add_trial(session, trial_limit=trial_limit)
if used_count is not None:
session.add(AccountTrialAppRecord(app_id=_APP_ID, account_id=_ACCOUNT_ID, count=used_count))
service = TrialAppAccessService(apps=TrialAppRepository(sqlite_session_factory))
if allowed:
assert service.get_access(app_id=_APP_ID, account_id=_ACCOUNT_ID) == TrialAppRef(
app_id=_APP_ID, tenant_id=_OWNER_TENANT_ID, app_mode="chat"
)
else:
with pytest.raises(TrialAppUsageLimitExceededError) as error:
service.get_access(app_id=_APP_ID, account_id=_ACCOUNT_ID)
assert _APP_ID in str(error.value)
assert _ACCOUNT_ID in str(error.value)
assert f"used {used_count}" in str(error.value)
assert f"limit of {trial_limit}" in str(error.value)
@pytest.mark.parametrize("has_trial", [True, False])
def test_resolve_releases_connection_before_returning(
sqlite_session_factory: sessionmaker[Session], sqlite_engine: Engine, has_trial: bool
) -> None:
with sqlite_session_factory.begin() as session:
_add_app(session)
if has_trial:
_add_trial(session)
assert isinstance(sqlite_engine.pool, QueuePool)
assert sqlite_engine.pool.checkedout() == 0
result = TrialAppRepository(sqlite_session_factory).resolve(app_id=_APP_ID, account_id=_ACCOUNT_ID)
assert (result is not None) == has_trial
assert sqlite_engine.pool.checkedout() == 0
def _record(
session_factory: sessionmaker[Session],
*,
app_id: str,
account_id: str,
) -> AccountTrialAppRecord | None:
with session_factory() as session:
return session.scalar(
select(AccountTrialAppRecord).where(
AccountTrialAppRecord.app_id == app_id,
AccountTrialAppRecord.account_id == account_id,
)
)
def test_record_increments_existing_usage(sqlite_session_factory: sessionmaker[Session]) -> None:
app_id = str(uuid.uuid4())
account_id = str(uuid.uuid4())
with sqlite_session_factory.begin() as session:
session.add(AccountTrialAppRecord(app_id=app_id, account_id=account_id, count=3))
TrialAppRepository(sqlite_session_factory).record(app_id=app_id, account_id=account_id)
record = _record(sqlite_session_factory, app_id=app_id, account_id=account_id)
assert record is not None
assert record.count == 4
def test_record_does_not_commit_caller_session(sqlite_session_factory: sessionmaker[Session]) -> None:
pending_app_id = str(uuid.uuid4())
usage_app_id = str(uuid.uuid4())
account_id = str(uuid.uuid4())
with sqlite_session_factory() as caller_session:
caller_session.add(AccountTrialAppRecord(app_id=pending_app_id, account_id=account_id, count=1))
TrialAppRepository(sqlite_session_factory).record(app_id=usage_app_id, account_id=account_id)
caller_session.rollback()
assert _record(sqlite_session_factory, app_id=pending_app_id, account_id=account_id) is None
usage = _record(sqlite_session_factory, app_id=usage_app_id, account_id=account_id)
assert usage is not None
assert usage.count == 1

View File

@@ -1,53 +0,0 @@
import uuid
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from models.model import AccountTrialAppRecord
from repositories.trial_app_usage_repository import TrialAppUsageRepository
def _record(
session_factory: sessionmaker[Session],
*,
app_id: str,
account_id: str,
) -> AccountTrialAppRecord | None:
with session_factory() as session:
return session.scalar(
select(AccountTrialAppRecord).where(
AccountTrialAppRecord.app_id == app_id,
AccountTrialAppRecord.account_id == account_id,
)
)
def test_record_increments_existing_usage(sqlite_session_factory: sessionmaker[Session]) -> None:
app_id = str(uuid.uuid4())
account_id = str(uuid.uuid4())
with sqlite_session_factory.begin() as session:
session.add(AccountTrialAppRecord(app_id=app_id, account_id=account_id, count=3))
TrialAppUsageRepository(sqlite_session_factory).record(app_id=app_id, account_id=account_id)
record = _record(sqlite_session_factory, app_id=app_id, account_id=account_id)
assert record is not None
assert record.count == 4
def test_record_does_not_commit_caller_session(sqlite_session_factory: sessionmaker[Session]) -> None:
pending_app_id = str(uuid.uuid4())
usage_app_id = str(uuid.uuid4())
account_id = str(uuid.uuid4())
with sqlite_session_factory() as caller_session:
caller_session.add(AccountTrialAppRecord(app_id=pending_app_id, account_id=account_id, count=1))
TrialAppUsageRepository(sqlite_session_factory).record(app_id=usage_app_id, account_id=account_id)
caller_session.rollback()
assert _record(sqlite_session_factory, app_id=pending_app_id, account_id=account_id) is None
usage = _record(sqlite_session_factory, app_id=usage_app_id, account_id=account_id)
assert usage is not None
assert usage.count == 1

View File

@@ -0,0 +1,179 @@
"""The scheduled collector reuses existing Celery workers and bounded HTTP."""
from collections.abc import Iterator
from datetime import timedelta
from unittest.mock import MagicMock
import httpx
import pytest
from celery.contrib.testing.app import setup_default_app
from configs.extra.agent_backend_config import AgentBackendConfig
from dify_app import DifyApp
from extensions import ext_celery
from schedule.collect_agent_sandbox_usage import collect_agent_sandbox_usage
from tests.unit_tests.config_override import config_overrides_context
PROJECT = "431de237-596f-4d59-8a85-20a9846bf243"
@pytest.fixture(autouse=True)
def restore_celery_app_state() -> Iterator[None]:
# init_app changes Celery's process-wide current/default app; close() alone
# leaves later shared tasks pointing at the now-unregistered test app.
with setup_default_app(collect_agent_sandbox_usage.app):
yield
@pytest.fixture(autouse=True)
def configured_collection() -> Iterator[None]:
with config_overrides_context(
AGENT_SANDBOX_METERING_ENABLED=True,
AGENT_SANDBOX_METERING_PROJECT_ID=PROJECT,
AGENT_BACKEND_BASE_URL="http://agent_backend:5001/",
AGENT_BACKEND_API_TOKEN="backend-token",
):
yield
def response(payload: object, status: int = 200) -> httpx.Response:
return httpx.Response(
status, json=payload, request=httpx.Request("POST", "http://agent_backend:5001/internal/e2b/usage/collect")
)
def test_task_uses_existing_queue_deadlines_and_single_authenticated_call(monkeypatch: pytest.MonkeyPatch) -> None:
post = MagicMock(return_value=response({"completed": True}))
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
assert collect_agent_sandbox_usage() is True
post.assert_called_once_with(
"http://agent_backend:5001/internal/e2b/usage/collect",
headers={"Authorization": "Bearer backend-token"},
json={"project_id": PROJECT},
max_retries=0,
timeout=260,
)
dispatch = MagicMock()
monkeypatch.setattr(collect_agent_sandbox_usage.app, "send_task", dispatch)
collect_agent_sandbox_usage.apply_async()
assert dispatch.call_args.kwargs["queue"] == "ops_trace"
assert collect_agent_sandbox_usage.soft_time_limit == 270
assert collect_agent_sandbox_usage.time_limit == 300
def test_disabled_job_makes_no_http_request(monkeypatch: pytest.MonkeyPatch) -> None:
post = MagicMock()
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
with config_overrides_context(AGENT_SANDBOX_METERING_ENABLED=False):
assert collect_agent_sandbox_usage() is False
post.assert_not_called()
@pytest.mark.parametrize(
"missing", ["AGENT_BACKEND_BASE_URL", "AGENT_BACKEND_API_TOKEN", "AGENT_SANDBOX_METERING_PROJECT_ID"]
)
def test_incomplete_configuration_only_fails_collection_job(missing: str, monkeypatch: pytest.MonkeyPatch) -> None:
post = MagicMock()
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
with config_overrides_context(**{missing: ""}):
with pytest.raises(ValueError, match="requires backend URL, token, and project"):
collect_agent_sandbox_usage()
post.assert_not_called()
@pytest.mark.parametrize("payload", [{"completed": False}, {"completed": "true"}, {"unexpected": True}])
def test_incomplete_or_invalid_scan_is_a_job_failure_without_retry(
payload: dict[str, object], monkeypatch: pytest.MonkeyPatch
) -> None:
post = MagicMock(return_value=response(payload))
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
with pytest.raises((ValueError, RuntimeError)):
collect_agent_sandbox_usage()
assert post.call_count == 1
@pytest.mark.parametrize("status", [403, 503, 504])
def test_backend_failure_does_not_trigger_http_retry(status: int, monkeypatch: pytest.MonkeyPatch) -> None:
post = MagicMock(return_value=response({"detail": "unavailable"}, status))
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
with pytest.raises(httpx.HTTPStatusError):
collect_agent_sandbox_usage()
assert post.call_count == 1
def test_transport_failure_is_logged_without_credentials_or_raw_message(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
post = MagicMock(side_effect=httpx.ReadTimeout("do-not-log-secret"))
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
with pytest.raises(httpx.ReadTimeout):
collect_agent_sandbox_usage()
assert post.call_count == 1
assert "Background sandbox usage collection failed" in caplog.text
assert "do-not-log-secret" not in caplog.text
assert "backend-token" not in caplog.text
@pytest.mark.parametrize("enabled", [False, True])
@pytest.mark.parametrize("interval", [90, "90"])
def test_beat_registers_only_enabled_collection_with_expiring_schedule(
enabled: bool, interval: int | str, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(ext_celery, "setup_workflow_warm_shutdown_handler", lambda: None)
with config_overrides_context(
AGENT_SANDBOX_METERING_ENABLED=enabled,
AGENT_SANDBOX_METERING_INTERVAL_SECONDS=interval,
DISABLE_TELEMETRY=True,
):
celery = ext_celery.init_app(DifyApp(__name__))
try:
assert "schedule.collect_agent_sandbox_usage" in celery.conf.imports
schedule = celery.conf.beat_schedule.get("collect_agent_sandbox_usage")
if enabled:
assert schedule == {
"task": "schedule.collect_agent_sandbox_usage.collect_agent_sandbox_usage",
"schedule": timedelta(seconds=90),
"options": {"expires": 90},
}
else:
assert schedule is None
finally:
celery.close()
@pytest.mark.parametrize("raw_interval", ["0", "-1", "not-a-number", "1.5", "9" * 30])
def test_invalid_interval_env_only_disables_optional_schedule(
raw_interval: str, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.setenv("AGENT_SANDBOX_METERING_INTERVAL_SECONDS", raw_interval)
config = AgentBackendConfig()
assert raw_interval == config.AGENT_SANDBOX_METERING_INTERVAL_SECONDS
monkeypatch.setattr(ext_celery, "setup_workflow_warm_shutdown_handler", lambda: None)
with config_overrides_context(
AGENT_SANDBOX_METERING_INTERVAL_SECONDS=config.AGENT_SANDBOX_METERING_INTERVAL_SECONDS,
ENABLE_CONVERSATION_CLEANUP_TASK=True,
CONVERSATION_CLEANUP_TASK_INTERVAL=2,
DISABLE_TELEMETRY=True,
):
app = DifyApp(__name__)
celery = ext_celery.init_app(app)
try:
assert app.extensions["celery"] is celery
assert "collect_agent_sandbox_usage" not in celery.conf.beat_schedule
assert "schedule.collect_agent_sandbox_usage" in celery.conf.imports
assert celery.conf.beat_schedule["conversation_cleanup_sweeper"] == {
"task": "tasks.delete_conversation_task.sweep_deleted_conversations",
"schedule": timedelta(minutes=2),
}
finally:
celery.close()
assert "Skipping sandbox usage schedule" in caplog.text
assert all(raw_interval not in record.getMessage() for record in caplog.records)
def test_manual_collection_does_not_depend_on_beat_interval(monkeypatch: pytest.MonkeyPatch) -> None:
post = MagicMock(return_value=response({"completed": True}))
monkeypatch.setattr("schedule.collect_agent_sandbox_usage.ssrf_proxy.post", post)
with config_overrides_context(AGENT_SANDBOX_METERING_INTERVAL_SECONDS="invalid"):
assert collect_agent_sandbox_usage() is True
post.assert_called_once()

View File

@@ -1,5 +1,6 @@
import json
from unittest.mock import Mock
from uuid import uuid4
import pytest
from pydantic import ValidationError
@@ -26,6 +27,7 @@ from models.agent_config_entities import AgentConfigFileRefConfig, AgentConfigSk
from models.dataset import Dataset
from models.enums import AppStatus
from models.model import App, AppMode
from models.tools import ToolFile
from models.workflow import Workflow, WorkflowType
from services.agent.dsl_entities import (
AGENT_NODE_JOB_DSL_KEY,
@@ -36,6 +38,7 @@ from services.agent.dsl_entities import (
)
from services.agent.dsl_service import AgentDslService, AgentPackageImportResult, is_agent_v2_graph
from services.entities.dsl_entities import DslImportWarning
from tests.unit_tests.model_factories import make_upload_file
def _agent(
@@ -415,7 +418,7 @@ def test_import_agent_app_package_creates_config_and_unpublished_draft(
soul = AgentSoulConfig(config_note="portable")
warning = DslImportWarning(code="setup", path="agent.soul", message="setup required")
service.resolve_package_soul = Mock(return_value=(soul, [warning]))
service._unique_roster_name = Mock(return_value="Portable Agent import")
service.unique_roster_name = Mock(return_value="Portable Agent import")
agent = _agent()
agent.active_config_snapshot_id = "snapshot-1"
agent.active_config_is_published = True
@@ -906,6 +909,56 @@ def test_resolve_package_soul_preserves_existing_and_marks_missing_knowledge(
}
@pytest.mark.parametrize(
("field", "file_kind"),
[("config_skills", "tool_file"), ("config_files", "tool_file"), ("config_files", "upload_file")],
)
@pytest.mark.parametrize("availability", ["local", "foreign", "deleted", "invalid_id"])
def test_resolve_legacy_package_asset_references(
sqlite_session: Session, field: str, file_kind: str, availability: str
) -> None:
tenant_id = "tenant-2" if availability == "foreign" else "tenant-1"
file_id = "legacy-file-id" if availability == "invalid_id" else str(uuid4())
if availability in {"local", "foreign"}:
if file_kind == "upload_file":
row = make_upload_file(file_id=file_id, tenant_id=tenant_id)
else:
row = ToolFile(
tenant_id=tenant_id,
user_id="account-1",
conversation_id=None,
file_key="tools/asset.zip",
mimetype="application/zip",
)
row.id = file_id
sqlite_session.add(row)
sqlite_session.flush()
package = AgentPackage.model_validate(
{
"metadata": {"name": "Legacy Agent"},
"soul": {field: [{"name": "asset", "file_kind": file_kind, "file_id": file_id, "size": 42}]},
}
)
resolved, warnings = AgentDslService(sqlite_session).resolve_package_soul(
tenant_id="tenant-1", package=package, package_path="agent_packages.agent_1"
)
ref = (resolved.config_skills if field == "config_skills" else resolved.config_files)[0]
assert ref.is_missing is (availability != "local")
assert ref.file_id == (file_id if availability == "local" else "")
assert ref.name == "asset"
assert ref.size == 42
original_ref = (package.soul.config_skills if field == "config_skills" else package.soul.config_files)[0]
assert original_ref.file_id == file_id
if availability == "local":
assert warnings == []
else:
kind = "skill" if field == "config_skills" else "file"
assert [warning.code for warning in warnings] == [f"agent_{kind}_missing"]
assert warnings[0].path == f"agent_packages.agent_1.soul.{field}.0"
def test_create_snapshot_increments_version_and_records_revision(sqlite_session: Session) -> None:
agent = _agent()
sqlite_session.add_all(
@@ -946,7 +999,7 @@ def test_unique_roster_name_uses_first_available_suffix(sqlite_session: Session)
)
sqlite_session.commit()
result = AgentDslService(sqlite_session)._unique_roster_name(tenant_id="tenant-1", requested="Agent")
result = AgentDslService(sqlite_session).unique_roster_name(tenant_id="tenant-1", requested="Agent")
assert result == "Agent import 2"

View File

@@ -591,34 +591,64 @@ def test_import_validates_all_file_limits_before_staging(
assert storage.files == {}
def test_import_rejects_duplicate_agent_name_before_staging(
@pytest.mark.parametrize(
("requested", "existing_names", "expected"),
[
("Imported Agent", [], "Imported Agent"),
("Imported Agent", ["Imported Agent"], "Imported Agent import"),
(
"Imported Agent",
["Imported Agent", "Imported Agent import", "Imported Agent import 2"],
"Imported Agent import 3",
),
(
"Imported Agent",
["Imported Agent", "Imported Agent import 2"],
"Imported Agent import",
),
("A" * 255, ["A" * 255], "A" * 248 + " import"),
],
)
def test_import_uses_available_name_for_app_and_agent(
sqlite_session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
requested: str,
existing_names: list[str],
expected: str,
) -> None:
monkeypatch.setattr(AppService, "finalize_created_app", lambda *_args, **_kwargs: None)
with sqlite_session_factory() as session, session.begin():
session.add(
Agent(
tenant_id="tenant-1",
name="Imported Agent",
description="",
role="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
created_by=_account().id,
updated_by=_account().id,
for name in existing_names:
session.add(
Agent(
tenant_id="tenant-1",
name=name,
description="",
role="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
created_by=_account().id,
updated_by=_account().id,
)
)
)
storage = _MemoryStorage()
with pytest.raises(AgentNameConflictError):
RosterAgentPackageImporter(storage_backend=storage).import_package(
source=io.BytesIO(_package()),
tenant_id="tenant-1",
account=_account(),
)
result = RosterAgentPackageImporter(storage_backend=storage).import_package(
source=io.BytesIO(_package(name=requested)),
tenant_id="tenant-1",
account=_account(),
)
assert storage.files == {}
with sqlite_session_factory() as session:
app = session.get(App, result.app_id)
agent = session.get(Agent, result.agent_id)
assert app is not None
assert agent is not None
assert app.name == agent.name == expected
assert _count(session, Agent) == len(existing_names) + 1
assert len(storage.files) == 4
@pytest.mark.parametrize("fail_after_write", [False, True])
@@ -672,7 +702,7 @@ def test_import_preserves_error_mapping(
def fail(**_kwargs) -> None:
raise failure
monkeypatch.setattr(importer, "_ensure_name_available", fail)
monkeypatch.setattr(importer._resources, "materialize", fail)
with pytest.raises(expected) as caught:
importer.import_package(source=io.BytesIO(_package()), tenant_id="tenant-1", account=_account())
if isinstance(failure, expected):
@@ -815,3 +845,55 @@ def test_import_succeeds_when_post_commit_initialization_fails(
uploaded = session.scalar(select(UploadFile))
assert uploaded is not None
assert uploaded.used is True
@pytest.mark.parametrize("shared_icon", [False, True])
@pytest.mark.parametrize("duplicate_name", [False, True])
def test_import_restores_app_and_agent_image_icons(
monkeypatch: pytest.MonkeyPatch,
sqlite_session_factory: sessionmaker[Session],
shared_icon: bool,
duplicate_name: bool,
) -> None:
with zipfile.ZipFile(io.BytesIO(_package())) as archive:
members = {name: archive.read(name) for name in archive.namelist()}
manifest = yaml.safe_load(members["manifest.yaml"])
app = yaml.safe_load(members["app.yaml"])
app["app"].update(icon_type="image", icon="i_000001")
agent_icon = "i_000001" if shared_icon else "i_000002"
app["agent_packages"]["agent_1"]["metadata"].update(icon_type="image", icon=agent_icon)
manifest["icons"] = []
for icon_id in sorted({"i_000001", agent_icon}):
payload = f"image-{icon_id}".encode()
members[f"{icon_id}.png"] = payload
manifest["icons"].append(
{
"id": icon_id,
"path": f"{icon_id}.png",
"size": len(payload),
"sha256": hashlib.sha256(payload).hexdigest(),
}
)
members["app.yaml"] = yaml.safe_dump(app).encode()
manifest["apps"][0].update(size=len(members["app.yaml"]), sha256=hashlib.sha256(members["app.yaml"]).hexdigest())
members["manifest.yaml"] = yaml.safe_dump(manifest).encode()
storage = _MemoryStorage()
monkeypatch.setattr(AppService, "finalize_created_app", lambda *_args, **_kwargs: None)
importer = RosterAgentPackageImporter(storage_backend=storage)
if duplicate_name:
importer.import_package(source=io.BytesIO(_zip(members)), tenant_id="destination", account=_account())
result = importer.import_package(source=io.BytesIO(_zip(members)), tenant_id="destination", account=_account())
with sqlite_session_factory() as session:
imported_app = session.get(App, result.app_id)
agent = session.get(Agent, result.agent_id)
assert imported_app is not None
assert agent is not None
for owner, original_id in [(imported_app, "i_000001"), (agent, agent_icon)]:
assert owner.icon_type == "image"
upload = session.get(UploadFile, owner.icon)
assert upload is not None
assert upload.tenant_id == "destination"
assert upload.created_by == _account().id
assert storage.files[upload.key] == members[f"{original_id}.png"]
assert imported_app.name == agent.name == ("Imported Agent import" if duplicate_name else "Imported Agent")
assert (imported_app.icon == agent.icon) is shared_icon

View File

@@ -10,7 +10,9 @@ import pytest
import yaml
from flask import Flask, send_file
from pydantic import ValidationError
from sqlalchemy import Engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import QueuePool
from configs import dify_config
from models.agent import (
@@ -40,6 +42,7 @@ from services.agent.errors import (
RosterAgentPackageResourceUnavailableError,
RosterAgentPackageTooLargeError,
)
from services.agent.package_resource_exporter import AgentPackageResourceExporter, _FileSource, _SkillSource
from services.agent.roster_package_entities import (
ROSTER_AGENT_PACKAGE_FORMAT,
ROSTER_AGENT_PACKAGE_FORMAT_VERSION,
@@ -635,23 +638,22 @@ def test_preflight_rejects_aggregate_nested_skill_expansion(monkeypatch: pytest.
with pytest.raises(RosterAgentPackageTooLargeError, match="nested Skill contents"):
RosterAgentPackageReader().read(io.BytesIO(package))
exporter = RosterAgentPackageExporter(storage_backend=_MemoryStorage(skill_payloads))
exporter = RosterAgentPackageExporter()
resources = AgentPackageResourceExporter(storage_backend=_MemoryStorage(skill_payloads))
sources = [
roster_package_exporter_module._SkillSource(
_SkillSource(
path=item.path,
storage_key=item.path,
id=item.id,
scope=item.scope,
name=item.name,
display_name=None,
description="Research skill.",
priority=None,
audit_ref="source",
)
for item in manifest.skills
]
resources.sources["agent_1"] = (sources, list[_FileSource]())
with pytest.raises(RosterAgentPackageTooLargeError, match="nested Skill contents"):
exporter._build_archive(app=_package_app(soul), skill_sources=sources, file_sources=[])
exporter._build_archive(app=_package_app(soul), resources=resources)
@pytest.mark.parametrize("failure", ["checksum", "size", "name", "crc"])
@@ -724,22 +726,21 @@ def test_export_rejects_unusable_or_oversized_skill_payload(
payload = _zip({"README.md": b"missing skill manifest"}) if case == "missing_manifest" else _skill_archive("other")
if case == "size_limit":
apply_config_overrides(monkeypatch, UPLOAD_SKILL_FILE_SIZE_LIMIT=0)
source = roster_package_exporter_module._SkillSource(
source = _SkillSource(
path="s_000001.zip",
storage_key="skill",
id="s_000001",
scope="agent_config",
name="research",
display_name=None,
description="Research skill.",
priority=None,
audit_ref="source",
)
app = _package_app()
app.package.soul.config_files = []
exporter = RosterAgentPackageExporter(storage_backend=_MemoryStorage({"skill": payload}))
exporter = RosterAgentPackageExporter()
resources = AgentPackageResourceExporter(storage_backend=_MemoryStorage({"skill": payload}))
resources.sources["agent_1"] = ([source], list[_FileSource]())
with pytest.raises(error_type, match=message):
exporter._build_archive(app=app, skill_sources=[source], file_sources=[])
exporter._build_archive(app=app, resources=resources)
@pytest.mark.parametrize("max_bytes", [0, 8])
@@ -1025,13 +1026,23 @@ def test_export_accepts_legacy_agent_and_preserves_caller_transaction(
def test_export_uses_current_workspace_skill_bindings(
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
sqlite_engine: Engine,
) -> None:
monkeypatch.setattr(DependenciesAnalysisService, "generate_dependencies", lambda **_kwargs: [])
workspace_payload = _skill_archive("legacy-published")
storage = _MemoryStorage({"tools/workspace.zip": workspace_payload})
pool = sqlite_engine.pool
assert isinstance(pool, QueuePool)
def load_legacy_skill(*, tenant_id: str, file_id: str) -> bytes:
assert tenant_id == "tenant-1"
assert file_id
assert pool.checkedout() == 0
return workspace_payload
monkeypatch.setattr(
"services.skill_management_service.SkillManagementService._load_tool_file_bytes",
staticmethod(lambda **_kwargs: workspace_payload),
staticmethod(load_legacy_skill),
)
archive_file = ToolFile(
user_id="account-1",
@@ -1323,9 +1334,9 @@ def test_member_read_reports_closed_prepared_archive() -> None:
def test_export_download_closes_owned_archive(app: Flask) -> None:
exported = RosterAgentPackageExporter()._build_archive(
app=_package_app(AgentSoulConfig()), skill_sources=[], file_sources=[]
)
resources = AgentPackageResourceExporter()
resources.sources["agent_1"] = (list[_SkillSource](), list[_FileSource]())
exported = RosterAgentPackageExporter()._build_archive(app=_package_app(AgentSoulConfig()), resources=resources)
with app.test_request_context("/"):
response = send_file(
exported.archive, mimetype="application/zip", as_attachment=True, download_name=exported.filename
@@ -1358,7 +1369,7 @@ def test_export_preserves_file_metadata_in_dsl(
from models.model import UploadFile
exporter = RosterAgentPackageExporter(storage_backend=_MemoryStorage({}))
exporter = AgentPackageResourceExporter(storage_backend=_MemoryStorage({}))
tool_file = ToolFile(
user_id="account-1",
tenant_id="tenant-1",

View File

@@ -0,0 +1,720 @@
"""Accounting invariants tested against real SQLAlchemy persistence."""
from collections.abc import Iterator
from copy import deepcopy
from datetime import datetime
from uuid import uuid4
import pytest
import sqlalchemy as sa
from pydantic import JsonValue
from sqlalchemy import event as sqlalchemy_event
from sqlalchemy.dialects import postgresql
from sqlalchemy.engine import Connection, Engine
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.sql import ClauseElement
from models.agent import (
Agent,
AgentConfigVersionKind,
AgentScope,
AgentSource,
AgentWorkspace,
AgentWorkspaceBinding,
AgentWorkspaceOwnerType,
)
from models.agent_sandbox_usage import AgentSandboxExecution, AgentSandboxUsageEvent
from models.model import App
from services.agent.runtime_usage_service import SandboxUsageError, SandboxUsageEvent, SandboxUsageService
from tests.unit_tests.config_override import config_overrides_context
PROJECT = "431de237-596f-4d59-8a85-20a9846bf243"
START = "2026-09-20T00:00:00Z"
@pytest.fixture(autouse=True)
def metering_config(monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
monkeypatch.setattr("services.agent.runtime_usage_service.naive_utc_now", lambda: datetime(2026, 9, 21))
with config_overrides_context(
AGENT_SANDBOX_METERING_ENABLED=True,
AGENT_SANDBOX_METERING_PROJECT_ID=PROJECT,
AGENT_SANDBOX_METERING_START_AT=START,
):
yield
def provider_event(
*,
event_id: str = "event-1",
execution_id: str = "execution-1",
sandbox_id: str = "sandbox-1",
duration: int | None = 12345,
started_at: str | None = "2026-09-20T00:00:02Z",
kind: str = "paused",
metadata: dict[str, JsonValue] | None = None,
) -> SandboxUsageEvent:
return SandboxUsageEvent.model_validate(
{
"id": event_id,
"source": "provider",
"type": f"sandbox.lifecycle.{kind}",
"sandbox_id": sandbox_id,
"execution_id": execution_id,
"payload": {
"id": event_id,
"version": "v2",
"type": f"sandbox.lifecycle.{kind}",
"timestamp": "2026-09-20T00:00:14.551745815Z",
"sandboxId": sandbox_id,
"sandboxExecutionId": execution_id,
"sandboxTeamId": PROJECT,
"sandboxTemplateId": "template-1",
"sandboxBuildId": "build-1",
"eventData": {
"execution": {
"started_at": started_at,
"execution_time": duration,
"memory_mb": 1024,
"vcpu_count": 2,
},
"sandbox_metadata": metadata or {},
},
},
}
)
def ingest(*events: SandboxUsageEvent) -> dict[str, int]:
return SandboxUsageService.ingest(project_id=PROJECT, events=list(events))
def execution(factory: sessionmaker[Session]) -> AgentSandboxExecution:
with factory() as session:
return session.scalars(sa.select(AgentSandboxExecution)).one()
def test_duplicate_terminal_and_api_webhook_aliases_count_once(sqlite_session_factory: sessionmaker[Session]) -> None:
event = provider_event()
assert ingest(event)["accepted"] == 1
alias = deepcopy(event.payload)
for camel, snake in (
("sandboxId", "sandbox_id"),
("sandboxExecutionId", "sandbox_execution_id"),
("sandboxTeamId", "sandbox_team_id"),
("sandboxTemplateId", "sandbox_template_id"),
("sandboxBuildId", "sandbox_build_id"),
("eventData", "event_data"),
):
alias[snake] = alias.pop(camel)
assert ingest(event.model_copy(update={"payload": alias}))["duplicates"] == 1
assert ingest(provider_event(event_id="killed-event", kind="killed"))["accepted"] == 1
row = execution(sqlite_session_factory)
assert row.metered_duration_ms == 12345
assert row.quality == "metered"
assert row.started_at == datetime(2026, 9, 20, 0, 0, 2)
assert row.terminal_event_at == datetime(2026, 9, 20, 0, 0, 14, 551745)
def test_same_sandbox_resume_is_a_new_execution(sqlite_session_factory: sessionmaker[Session]) -> None:
ingest(provider_event(), provider_event(event_id="second", execution_id="execution-2", duration=6789))
with sqlite_session_factory() as session:
assert session.scalar(sa.select(sa.func.sum(AgentSandboxExecution.metered_duration_ms))) == 19134
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxExecution)) == 2
def test_state_reports_pending_and_conflict_counts_without_owner_ids() -> None:
created = provider_event(event_id="created", kind="created", duration=None, started_at=None)
created.payload["eventData"] = dict[str, JsonValue]()
ingest(created)
assert SandboxUsageService.get_state(project_id=PROJECT)["diagnostics"] == {
"unresolved_events": 0,
"conflict_events": 0,
"open_executions": 1,
"unattributed_executions": 1,
}
ingest(provider_event())
ingest(provider_event(duration=1))
diagnostics = SandboxUsageService.get_state(project_id=PROJECT)["diagnostics"]
assert diagnostics == {
"unresolved_events": 0,
"conflict_events": 1,
"open_executions": 0,
"unattributed_executions": 1,
}
def test_unknown_duration_stays_null_and_enrichment_is_replayable(
sqlite_session_factory: sessionmaker[Session],
) -> None:
missing = provider_event(duration=None)
ingest(missing)
assert ingest(missing)["duplicates"] == 1
assert execution(sqlite_session_factory).metered_duration_ms is None
assert execution(sqlite_session_factory).quality == "pending"
assert ingest(provider_event())["accepted"] == 1
assert execution(sqlite_session_factory).metered_duration_ms == 12345
assert ingest(missing)["duplicates"] == 1
with sqlite_session_factory() as session:
event = session.scalars(
sa.select(AgentSandboxUsageEvent).where(AgentSandboxUsageEvent.source_event_id == "event-1")
).one()
assert event.payload["eventData"]["execution"]["execution_time"] is None
assert event.payload_versions
assert event.resolution["canonical"]["duration_ms"] == 12345
def test_conflicting_event_keeps_both_payloads_and_quarantines_usage(
sqlite_session_factory: sessionmaker[Session],
) -> None:
ingest(provider_event())
assert ingest(provider_event(duration=999))["conflicts"] == 1
assert execution(sqlite_session_factory).quality == "conflict"
assert execution(sqlite_session_factory).metered_duration_ms == 12345
with sqlite_session_factory() as session:
event = session.scalars(
sa.select(AgentSandboxUsageEvent).where(AgentSandboxUsageEvent.source_event_id == "event-1")
).one()
assert event.payload_versions[0]["payload"]["eventData"]["execution"]["execution_time"] == 999
ingest(provider_event(event_id="new-terminal"))
assert execution(sqlite_session_factory).quality == "conflict"
def test_conflicting_executions_never_add_or_overwrite_duration(sqlite_session_factory: sessionmaker[Session]) -> None:
ingest(provider_event())
assert ingest(provider_event(event_id="other-terminal", duration=1))["conflicts"] == 1
assert execution(sqlite_session_factory).metered_duration_ms == 12345
def test_late_start_does_not_reopen_closed_execution(sqlite_session_factory: sessionmaker[Session]) -> None:
ingest(provider_event())
ingest(provider_event(event_id="start", kind="resumed", duration=None))
row = execution(sqlite_session_factory)
assert (row.state, row.quality, row.metered_duration_ms) == ("closed", "metered", 12345)
def test_activation_survives_restarts_and_config_changes_are_rejected() -> None:
state = SandboxUsageService.get_state(project_id=PROJECT)
assert state["started_at"] == START
assert SandboxUsageService.get_state(project_id=PROJECT) == state
with config_overrides_context(AGENT_SANDBOX_METERING_START_AT="2026-09-21T00:00:00Z"):
with pytest.raises(SandboxUsageError, match="immutable"):
SandboxUsageService.get_state(project_id=PROJECT)
def test_no_old_data_even_when_terminal_arrives_after_activation(sqlite_session_factory: sessionmaker[Session]) -> None:
assert ingest(provider_event(started_at="2026-09-19T23:59:59Z"))["ignored"] == 1
with sqlite_session_factory() as session:
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxExecution)) == 0
assert (
session.scalar(
sa.select(sa.func.count())
.select_from(AgentSandboxUsageEvent)
.where(AgentSandboxUsageEvent.source == "provider")
)
== 0
)
def test_t0_filter_cannot_hide_contradictory_existing_metered_event(
sqlite_session_factory: sessionmaker[Session],
) -> None:
ingest(provider_event())
assert ingest(provider_event(started_at="2026-09-19T23:59:59Z"))["conflicts"] == 1
assert execution(sqlite_session_factory).quality == "conflict"
def test_unknown_start_proven_before_t0_removes_pending_execution(
sqlite_session_factory: sessionmaker[Session],
) -> None:
unknown = provider_event(started_at=None, duration=None)
ingest(unknown)
assert ingest(provider_event(started_at="2026-09-19T23:59:59Z"))["ignored"] == 1
ingest(provider_event(event_id="late-start", kind="created", started_at=None, duration=None))
with sqlite_session_factory() as session:
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxExecution)) == 0
def test_unusable_provider_data_is_visible_but_not_metered(sqlite_session_factory: sessionmaker[Session]) -> None:
event = provider_event(started_at=None)
assert ingest(event)["accepted"] == 1
with sqlite_session_factory() as session:
assert session.scalars(sa.select(AgentSandboxExecution)).one().quality == "pending"
row = session.scalars(
sa.select(AgentSandboxUsageEvent).where(AgentSandboxUsageEvent.source == "provider")
).one()
assert row.projection_status == "unresolved"
def test_created_without_execution_data_then_terminal_and_late_start(
sqlite_session_factory: sessionmaker[Session],
) -> None:
created = provider_event(event_id="created", kind="created", duration=None, started_at=None)
created.payload["eventData"] = dict[str, JsonValue]()
assert ingest(created)["accepted"] == 1
row = execution(sqlite_session_factory)
assert row.started_at is None
assert row.state == "open"
assert row.metered_duration_ms is None
ingest(provider_event())
assert execution(sqlite_session_factory).quality == "metered"
assert ingest(created)["duplicates"] == 1
late = created.model_copy(deep=True, update={"id": "late"})
late.payload["id"] = "late"
ingest(late)
row = execution(sqlite_session_factory)
assert (row.state, row.quality, row.metered_duration_ms) == ("closed", "metered", 12345)
def test_missing_provider_project_is_rejected() -> None:
event = provider_event()
event.payload.pop("sandboxTeamId")
with pytest.raises(SandboxUsageError, match="provider_project_mismatch"):
ingest(event)
def test_retention_gap_is_saved_without_advancing_checkpoint(sqlite_session_factory: sessionmaker[Session]) -> None:
with config_overrides_context(AGENT_SANDBOX_METERING_START_AT="2026-09-01T00:00:00Z"):
event = SandboxUsageEvent(
id="gap",
source="application",
type="collector_retention_gap",
timestamp=START,
payload={
"uncovered_start": "2026-09-01T00:00:00Z",
"uncovered_end": "2026-09-13T00:00:00Z",
"assumed_retention_seconds": 604800,
},
)
assert ingest(event)["accepted"] == 1
assert SandboxUsageService.get_state(project_id=PROJECT)["checkpoint_at"] is None
with sqlite_session_factory() as session:
row = session.scalars(
sa.select(AgentSandboxUsageEvent).where(AgentSandboxUsageEvent.source_event_id == "gap")
).one()
assert row.projection_status == "unresolved"
assert row.projection_error_code == "provider_retention_gap"
def test_checkpoint_only_advances_after_completed_scan() -> None:
event = SandboxUsageEvent(
id="checkpoint-1",
source="application",
type="collector_checkpoint",
timestamp=START,
payload={
"scan_started_at": START,
"window_start": START,
"window_end": START,
"completed": True,
"mode": "full",
"pages": 1,
"events": 0,
},
)
ingest(event)
state = SandboxUsageService.get_state(project_id=PROJECT)
assert state["checkpoint_at"] == START
assert state["full_scan_at"] == START
with pytest.raises(SandboxUsageError, match="invalid_collector_checkpoint"):
ingest(event.model_copy(update={"id": "partial", "payload": {**event.payload, "completed": False}}))
def test_forbidden_project_and_reserved_application_events_fail_without_partial_batch(
sqlite_session_factory: sessionmaker[Session],
) -> None:
with pytest.raises(SandboxUsageError, match="not_allowed"):
SandboxUsageService.ingest(project_id="other-project", events=[provider_event()])
reserved = SandboxUsageEvent(id="forged", source="application", type="allocation_registered", payload={})
with pytest.raises(SandboxUsageError, match="unsupported_application_event"):
ingest(provider_event(), reserved)
with sqlite_session_factory() as session:
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxExecution)) == 0
def test_provider_team_must_match_configured_project() -> None:
event = provider_event()
event.payload["sandboxTeamId"] = "other-project"
with pytest.raises(SandboxUsageError, match="provider_project_mismatch"):
ingest(event)
def test_disabled_ingestion_does_not_ack(
sqlite_session_factory: sessionmaker[Session],
) -> None:
with config_overrides_context(AGENT_SANDBOX_METERING_ENABLED=False):
assert SandboxUsageService.get_state(project_id=PROJECT)["enabled"] is False
with pytest.raises(SandboxUsageError, match="disabled"):
ingest(provider_event())
with sqlite_session_factory() as session:
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxUsageEvent)) == 0
@pytest.mark.parametrize(
("timestamp", "error"),
[
(123, "invalid_timestamp"),
("not-a-time", "invalid_timestamp"),
("2026-09-20T00:00:14", "timestamp_requires_timezone"),
],
)
def test_invalid_provider_time_rolls_back_entire_batch(
timestamp: JsonValue, error: str, sqlite_session_factory: sessionmaker[Session]
) -> None:
bad = provider_event(event_id="bad-time", execution_id="bad-execution")
bad.payload["timestamp"] = timestamp
with pytest.raises(SandboxUsageError, match=error):
ingest(provider_event(), bad)
with sqlite_session_factory() as session:
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxExecution)) == 0
@pytest.mark.parametrize("quantity", [-1, True, 1.5, 2**63])
def test_invalid_duration_is_rejected_before_any_accounting(quantity: JsonValue) -> None:
event = provider_event()
data = event.payload["eventData"]
assert isinstance(data, dict)
details = data["execution"]
assert isinstance(details, dict)
details["execution_time"] = quantity
with pytest.raises(SandboxUsageError, match="invalid_provider_quantity"):
ingest(event)
@pytest.mark.parametrize("field", ["id", "sandboxId", "sandboxExecutionId"])
def test_raw_identity_cannot_disagree_with_delivery_envelope(field: str) -> None:
event = provider_event()
event.payload[field] = "different-id"
with pytest.raises(SandboxUsageError, match="provider_envelope_mismatch"):
ingest(event)
def test_invalid_provider_identifier_without_envelope_hint_is_rejected() -> None:
event = provider_event().model_copy(update={"sandbox_id": None})
event.payload["sandboxId"] = ""
with pytest.raises(SandboxUsageError, match="invalid_provider_identifier"):
ingest(event)
@pytest.mark.parametrize(
("configured_start", "error"),
[
("invalid", "sandbox_metering_start_not_configured"),
("2026-09-20T00:00:00.001Z", "sandbox_metering_start_requires_whole_second"),
],
)
def test_invalid_activation_configuration_cannot_create_ledger_scope(configured_start: str, error: str) -> None:
with config_overrides_context(AGENT_SANDBOX_METERING_START_AT=configured_start):
with pytest.raises(SandboxUsageError, match=error) as result:
SandboxUsageService.get_state(project_id=PROJECT)
assert result.value.status_code == 503
@pytest.mark.parametrize(
("key", "value"),
[
("dify.usage_allocation_id", 42),
("dify.usage_allocation_id", "invalid-uuid"),
("dify.binding_id", 42),
("dify.binding_id", "invalid-uuid"),
],
)
def test_invalid_owner_metadata_does_not_discard_metered_usage(
key: str, value: JsonValue, sqlite_session_factory: sessionmaker[Session]
) -> None:
assert ingest(provider_event(metadata={key: value}))["accepted"] == 1
row = execution(sqlite_session_factory)
assert row.quality == "metered"
assert row.metered_duration_ms == 12345
assert row.tenant_id is None
assert row.attribution_status == "unresolved"
@pytest.mark.parametrize("change", ["sandbox", "started_at", "resources"])
def test_execution_identity_and_resource_conflicts_preserve_original_usage(
change: str, sqlite_session_factory: sessionmaker[Session]
) -> None:
ingest(provider_event())
different = provider_event(event_id="conflicting-terminal")
if change == "sandbox":
different = provider_event(event_id="conflicting-terminal", sandbox_id="other-sandbox")
elif change == "started_at":
different = provider_event(event_id="conflicting-terminal", started_at="2026-09-20T00:00:03Z")
else:
data = different.payload["eventData"]
assert isinstance(data, dict)
details = data["execution"]
assert isinstance(details, dict)
details["vcpu_count"] = 4
assert ingest(different)["conflicts"] == 1
row = execution(sqlite_session_factory)
assert row.quality == "conflict"
assert row.sandbox_id == "sandbox-1"
assert row.vcpu_count == 2
assert row.metered_duration_ms == 12345
def test_unsupported_provider_contract_is_visible_without_fabricated_execution(
sqlite_session_factory: sessionmaker[Session],
) -> None:
event = provider_event()
event.payload["version"] = "unsupported"
assert ingest(event)["accepted"] == 1
with sqlite_session_factory() as session:
assert session.scalar(sa.select(sa.func.count()).select_from(AgentSandboxExecution)) == 0
row = session.scalars(
sa.select(AgentSandboxUsageEvent).where(AgentSandboxUsageEvent.source_event_id == event.id)
).one()
assert row.projection_status == "unresolved"
assert row.projection_error_code == "incomplete_provider_execution"
def business_owner(
session: Session,
*,
binding_id: str | None = None,
tenant_id: str | None = None,
sandbox_id: str = "sandbox-1",
) -> AgentWorkspaceBinding:
tenant_id = tenant_id or str(uuid4())
app_id, agent_id, workspace_id = (str(uuid4()) for _ in range(3))
session.add_all(
[
App(
id=app_id,
tenant_id=tenant_id,
name="Metering test",
description="",
mode="agent",
enable_site=False,
enable_api=False,
max_active_requests=None,
),
Agent(
id=agent_id, tenant_id=tenant_id, name=str(uuid4()), scope=AgentScope.ROSTER, source=AgentSource.ROSTER
),
AgentWorkspace(
id=workspace_id,
tenant_id=tenant_id,
app_id=app_id,
owner_type=AgentWorkspaceOwnerType.CONVERSATION,
owner_id=str(uuid4()),
owner_scope_key="root",
backend_workspace_ref=sandbox_id,
),
]
)
binding = AgentWorkspaceBinding(
id=binding_id or str(uuid4()),
tenant_id=tenant_id,
app_id=app_id,
agent_id=agent_id,
workspace_id=workspace_id,
agent_config_version_id=str(uuid4()),
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
backend_binding_ref=sandbox_id,
)
session.add(binding)
session.commit()
return binding
def owner_metadata(binding: AgentWorkspaceBinding) -> dict[str, JsonValue]:
return {
"dify.binding_id": binding.id,
"dify.tenant_id": binding.tenant_id,
"dify.agent_id": binding.agent_id,
"dify.workspace_id": binding.workspace_id,
}
def test_provider_usage_without_business_rows_remains_metered(sqlite_session_factory: sessionmaker[Session]) -> None:
ingest(provider_event(metadata={"dify.binding_id": str(uuid4()), "dify.tenant_id": str(uuid4())}))
row = execution(sqlite_session_factory)
assert row.quality == "metered"
assert row.metered_duration_ms == 12345
assert row.attribution_status == "unresolved"
assert row.tenant_id is None
assert row.allocation_id is None
def test_background_attribution_uses_verified_business_chain(
sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
) -> None:
binding = business_owner(sqlite_session)
ingest(provider_event(metadata=owner_metadata(binding)))
row = execution(sqlite_session_factory)
assert (row.tenant_id, row.app_id, row.agent_id, row.binding_id, row.workspace_id) == (
binding.tenant_id,
binding.app_id,
binding.agent_id,
binding.id,
binding.workspace_id,
)
assert row.attribution_status == "resolved"
assert row.allocation_id is None
with sqlite_session_factory() as session:
assert (
session.scalar(
sa.select(sa.func.count())
.select_from(AgentSandboxUsageEvent)
.where(AgentSandboxUsageEvent.event_type == "allocation_registered")
)
== 0
)
def test_duplicate_provider_replay_can_resolve_business_rows_committed_later(
sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
) -> None:
binding_id, tenant_id = str(uuid4()), str(uuid4())
event = provider_event(metadata={"dify.binding_id": binding_id, "dify.tenant_id": tenant_id})
ingest(event)
assert execution(sqlite_session_factory).attribution_status == "unresolved"
binding = business_owner(sqlite_session, binding_id=binding_id, tenant_id=tenant_id)
assert ingest(event)["duplicates"] == 1
row = execution(sqlite_session_factory)
assert row.binding_id == binding.id
assert row.attribution_status == "resolved"
assert row.metered_duration_ms == 12345
@pytest.mark.parametrize("missing", ["binding", "workspace", "app", "agent"])
def test_deleted_business_rows_do_not_erase_retained_owner_or_usage(
missing: str, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
) -> None:
binding = business_owner(sqlite_session)
event = provider_event(metadata=owner_metadata(binding))
ingest(event)
if missing == "binding":
sqlite_session.delete(binding)
elif missing == "workspace":
sqlite_session.execute(sa.delete(AgentWorkspace).where(AgentWorkspace.id == binding.workspace_id))
elif missing == "app":
sqlite_session.execute(sa.delete(App).where(App.id == binding.app_id))
else:
sqlite_session.execute(sa.delete(Agent).where(Agent.id == binding.agent_id))
sqlite_session.commit()
assert ingest(event)["duplicates"] == 1
row = execution(sqlite_session_factory)
assert (row.binding_id, row.tenant_id) == (binding.id, binding.tenant_id)
assert row.attribution_status == "resolved"
assert row.metered_duration_ms == 12345
@pytest.mark.parametrize(
"broken_link",
[
"tenant_metadata",
"agent_metadata",
"workspace_metadata",
"binding_sandbox",
"workspace_sandbox",
"app_tenant",
"agent_tenant",
],
)
def test_unverified_business_chain_never_guesses_an_owner(
broken_link: str, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
) -> None:
binding = business_owner(sqlite_session)
metadata = owner_metadata(binding)
if broken_link == "tenant_metadata":
metadata["dify.tenant_id"] = str(uuid4())
elif broken_link in {"agent_metadata", "workspace_metadata"}:
metadata[f"dify.{broken_link.removesuffix('_metadata')}_id"] = str(uuid4())
elif broken_link == "binding_sandbox":
binding.backend_binding_ref = "different-sandbox"
elif broken_link == "workspace_sandbox":
sqlite_session.execute(
sa.update(AgentWorkspace)
.where(AgentWorkspace.id == binding.workspace_id)
.values(backend_workspace_ref="different-sandbox")
)
elif broken_link == "app_tenant":
sqlite_session.execute(sa.update(App).where(App.id == binding.app_id).values(tenant_id=str(uuid4())))
else:
sqlite_session.execute(sa.update(Agent).where(Agent.id == binding.agent_id).values(tenant_id=str(uuid4())))
sqlite_session.commit()
ingest(provider_event(metadata=metadata))
row = execution(sqlite_session_factory)
assert row.quality == "metered"
assert row.tenant_id is None
assert row.attribution_status == "unresolved"
@pytest.mark.parametrize("bad_value", [None, 42, "invalid-uuid"])
def test_bad_attribution_labels_do_not_invalidate_measured_runtime(
bad_value: JsonValue, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
) -> None:
binding = business_owner(sqlite_session)
ingest(provider_event(metadata=owner_metadata(binding)))
changed = owner_metadata(binding)
changed["dify.tenant_id"] = bad_value
assert ingest(provider_event(metadata=changed))["accepted"] == 1
row = execution(sqlite_session_factory)
assert row.quality == "metered"
assert row.attribution_status == "resolved"
assert row.tenant_id == binding.tenant_id
assert row.metered_duration_ms == 12345
def test_owner_conflict_does_not_reassign_history_or_discard_project_usage(
sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
) -> None:
first, second = business_owner(sqlite_session), business_owner(sqlite_session)
ingest(provider_event(metadata=owner_metadata(first)))
ingest(provider_event(event_id="other-owner", metadata=owner_metadata(second)))
row = execution(sqlite_session_factory)
assert row.attribution_status == "conflict"
assert row.binding_id == first.id
assert row.tenant_id == first.tenant_id
assert row.quality == "metered"
assert row.metered_duration_ms == 12345
def test_same_sandbox_cannot_change_owner_across_executions(
sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]
) -> None:
first, second = business_owner(sqlite_session), business_owner(sqlite_session)
ingest(provider_event(metadata=owner_metadata(first)))
resumed = provider_event(event_id="new-execution", execution_id="execution-2", metadata=owner_metadata(second))
ingest(resumed)
# A subsequent replay with the original owner cannot erase the conflicting
# association. Both physical executions remain counted at project level.
ingest(provider_event(event_id="later-event", execution_id="execution-2", metadata=owner_metadata(first)))
with sqlite_session_factory() as session:
rows = list(
session.scalars(sa.select(AgentSandboxExecution).order_by(AgentSandboxExecution.provider_execution_id))
)
assert [(row.attribution_status, row.binding_id) for row in rows] == [
("resolved", first.id),
("conflict", None),
]
assert [row.quality for row in rows] == ["metered", "metered"]
assert sum(row.metered_duration_ms or 0 for row in rows) == 24690
@pytest.mark.parametrize("event_type", ["operation_requested", "operation_observed", "allocation_registered"])
def test_application_operations_are_no_longer_accepted(event_type: str) -> None:
event = SandboxUsageEvent(id="legacy-operation", source="application", type=event_type, payload={})
with pytest.raises(SandboxUsageError, match="unsupported_application_event"):
ingest(event)
def test_steady_state_lookup_only_selects_without_locking_activation(sqlite_session: Session) -> None:
engine = sqlite_session.get_bind()
assert isinstance(engine, Engine)
initial = SandboxUsageService.get_state(project_id=PROJECT)
statements: list[str] = []
def observe(_connection: Connection, statement: ClauseElement, *_args: object) -> None:
statements.append(str(statement.compile(dialect=postgresql.dialect())))
sqlalchemy_event.listen(engine, "before_execute", observe)
try:
assert SandboxUsageService.get_state(project_id=PROJECT) == initial
finally:
sqlalchemy_event.remove(engine, "before_execute", observe)
assert statements
assert all(statement.lstrip().startswith("SELECT") for statement in statements)
assert all("FOR UPDATE" not in statement for statement in statements)

View File

@@ -0,0 +1,530 @@
"""Workflow packages restore node-owned Agents and assets across workspaces."""
from __future__ import annotations
import hashlib
import io
import zipfile
from collections.abc import Callable, Generator
from types import SimpleNamespace
from typing import cast
from unittest.mock import Mock
import pytest
import yaml
from sqlalchemy import Engine, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import QueuePool
from models import Account, App, AppMode
from models.agent import (
Agent,
AgentConfigSnapshot,
AgentScope,
AgentSource,
AgentStatus,
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import AgentSoulConfig
from models.model import UploadFile
from models.skill import AgentSkillBinding, AgentSkillBindingSnapshot, Skill, SkillVersion, SkillVersionManifest
from models.tools import ToolFile
from models.workflow import Workflow, WorkflowType
from services.agent.dsl_entities import AgentPackage
from services.agent.errors import InvalidRosterAgentPackageError, RosterAgentPackageExportFailedError
from services.agent.package_resource_exporter import AgentPackageResourceExporter
from services.agent.package_resource_importer import AgentPackageResourceImporter
from services.app_dsl_service import AppDslService, Import
from services.app_package_service import AppPackageManifest, AppPackageService
from services.entities.dsl_entities import ImportStatus
from services.errors.account import NoPermissionError
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from tests.unit_tests.model_factories import make_account, make_app, make_tenant, make_upload_file, make_workflow
class _MemoryStorage:
def __init__(self) -> None:
self.files: dict[str, bytes] = {}
self.saved: list[str] = []
def load_stream(self, filename: str) -> Generator[bytes, None, None]:
yield self.files[filename]
def save(self, filename: str, data: bytes) -> None:
self.files[filename] = data
self.saved.append(filename)
@pytest.fixture
def storage(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> _MemoryStorage:
backend = _MemoryStorage()
monkeypatch.setattr("services.workflow_service.db", SimpleNamespace(engine=sqlite_engine))
monkeypatch.setattr(
"services.app_package_service.AgentPackageResourceExporter",
lambda: AgentPackageResourceExporter(storage_backend=backend),
)
monkeypatch.setattr(
"services.app_package_service.AgentPackageResourceImporter",
lambda: AgentPackageResourceImporter(storage_backend=backend),
)
monkeypatch.setattr(DependenciesAnalysisService, "generate_dependencies", lambda **_kwargs: [])
return backend
def _skill(name: str, marker: str) -> bytes:
output = io.BytesIO()
with zipfile.ZipFile(output, "w") as archive:
archive.writestr("SKILL.md", f"---\nname: {name}\ndescription: {marker}\n---\n\n# Instructions\n{marker}\n")
archive.writestr("scripts/run.py", f"print({marker!r})\n")
return output.getvalue()
def _tool_file(session: Session, storage: _MemoryStorage, key: str, payload: bytes) -> ToolFile:
row = ToolFile(
tenant_id="tenant-1",
user_id="account-1",
conversation_id=None,
file_key=key,
name=key.rsplit("/", 1)[-1],
mimetype="application/zip",
size=len(payload),
original_url=None,
)
storage.files[key] = payload
session.add(row)
return row
def _source(
session: Session, storage: _MemoryStorage, *, mode: AppMode = AppMode.WORKFLOW, published: bool = False
) -> App:
app = make_app(mode=mode, name="Inline Workflow")
nodes = [
{
"id": f"node-{index}",
"data": {"type": "agent", "version": "2", "title": f"Agent {index}", "agent_node_kind": "dify_agent"},
}
for index in (1, 2)
]
workflow = make_workflow(
workflow_id="workflow-1",
app_id=app.id,
graph={"nodes": nodes, "edges": []},
workflow_type=WorkflowType.CHAT if mode == AppMode.ADVANCED_CHAT else WorkflowType.WORKFLOW,
version="2026-01-01 00:00:00" if published else Workflow.VERSION_DRAFT,
)
session.add_all([app, workflow])
for index in (1, 2):
skill_file = _tool_file(
session, storage, f"tools/skill-{index}.zip", _skill("research", f"Agent {index} skill")
)
notes = _tool_file(session, storage, f"tools/notes-{index}.txt", f"Agent {index} notes".encode())
upload = make_upload_file(file_id=f"upload-{index}", key=f"uploads/guide-{index}.txt", name="guide.txt")
storage.files[upload.key] = f"Agent {index} guide".encode()
agent = Agent(
id=f"source-agent-{index}",
tenant_id=app.tenant_id,
name=f"Inline {index}",
scope=AgentScope.WORKFLOW_ONLY,
source=AgentSource.WORKFLOW,
app_id=app.id,
workflow_id=workflow.id,
workflow_node_id=f"node-{index}",
status=AgentStatus.ACTIVE,
created_by="account-1",
)
soul = AgentSoulConfig.model_validate(
{
"prompt": {"system_prompt": f"Bound snapshot {index}"},
"config_skills": [{"name": "research", "file_id": skill_file.id}],
"config_files": [
{"name": "notes.txt", "file_kind": "tool_file", "file_id": notes.id},
{"name": "guide.txt", "file_kind": "upload_file", "file_id": upload.id},
],
}
)
snapshot = AgentConfigSnapshot(
id=f"source-snapshot-{index}",
tenant_id=app.tenant_id,
agent_id=agent.id,
version=1,
config_snapshot=soul,
created_by="account-1",
)
active = AgentConfigSnapshot(
id=f"active-snapshot-{index}",
tenant_id=app.tenant_id,
agent_id=agent.id,
version=2,
config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "Unbound active snapshot"}}),
created_by="account-1",
)
agent.active_config_snapshot_id = active.id
binding = WorkflowAgentNodeBinding(
tenant_id=app.tenant_id,
app_id=app.id,
workflow_id=workflow.id,
workflow_version=workflow.version,
node_id=f"node-{index}",
binding_type=WorkflowAgentBindingType.INLINE_AGENT,
agent_id=agent.id,
current_snapshot_id=snapshot.id,
node_job_config={"workflow_prompt": f"Job {index}"},
created_by="account-1",
)
session.add_all([upload, agent, snapshot, active, binding])
if index == 1:
workspace_archive = _tool_file(
session, storage, "tools/workspace.zip", _skill("workspace", "Workspace skill")
)
skill = Skill(
id="source-workspace-skill",
tenant_id=app.tenant_id,
name="workspace",
display_name="Workspace",
description="Workspace skill",
created_by="account-1",
updated_by="account-1",
)
version = SkillVersion(
id="source-skill-version",
skill_id=skill.id,
version_number=1,
version_name="1.0",
publish_note="",
manifest=SkillVersionManifest(
name="workspace", display_name="Workspace", description="Workspace skill", files=[]
),
archive_tool_file_id=workspace_archive.id,
archive_size=workspace_archive.size,
hash_code="workspace-hash",
published_by="account-1",
)
skill.latest_published_version_id = version.id
session.add_all(
[
skill,
version,
AgentSkillBindingSnapshot(
tenant_id=app.tenant_id,
agent_id=agent.id,
config_snapshot_id=snapshot.id,
skill_id=skill.id,
priority=0,
created_by="account-1",
),
]
)
session.commit()
return app
def _account(tenant_id: str = "tenant-2") -> Account:
return make_account(tenant=make_tenant(tenant_id=tenant_id))
def _import(session: Session, content: bytes, *, app_id: str | None = None, account: Account | None = None) -> Import:
prepared = AppPackageService().read_package(io.BytesIO(content))
assert prepared is not None
with prepared:
result = AppDslService(session).import_app(
account=account or _account(),
import_mode="yaml-content",
yaml_content=prepared.dsl,
app_id=app_id,
package=prepared,
)
if result.status == ImportStatus.FAILED:
session.rollback()
else:
session.commit()
return result
def _rewrite(
content: bytes, mutate: Callable[[AppPackageManifest, dict[str, object], dict[str, bytes]], None]
) -> bytes:
with zipfile.ZipFile(io.BytesIO(content)) as archive:
members = {name: archive.read(name) for name in archive.namelist()}
manifest = AppPackageManifest.model_validate(yaml.safe_load(members["manifest.yaml"]))
data = yaml.safe_load(members["app.yaml"])
mutate(manifest, data, members)
members["app.yaml"] = yaml.safe_dump(data).encode()
manifest.apps[0].size = len(members["app.yaml"])
manifest.apps[0].sha256 = hashlib.sha256(members["app.yaml"]).hexdigest()
members["manifest.yaml"] = yaml.safe_dump(manifest.model_dump(mode="json")).encode()
output = io.BytesIO()
with zipfile.ZipFile(output, "w") as archive:
for name, payload in members.items():
archive.writestr(name, payload)
return output.getvalue()
@pytest.mark.parametrize("mode", [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@pytest.mark.parametrize("published", [False, True])
def test_package_round_trip_restores_bound_agents_and_resources(
sqlite_session: Session,
storage: _MemoryStorage,
mode: AppMode,
published: bool,
) -> None:
app = _source(sqlite_session, storage, mode=mode, published=published)
with AppPackageService().export_app(app_model=app, workflow_id="workflow-1" if published else None) as exported:
content = exported.archive.read()
prepared = AppPackageService().read_package(io.BytesIO(content))
assert prepared is not None
with prepared:
assert len(prepared.agent_resources) == 2
assert [p.soul.prompt.system_prompt for p in prepared.agents.values()] == [
"Bound snapshot 1",
"Bound snapshot 2",
]
assert all(not p.omitted_assets for p in prepared.agents.values())
assert {r.name for group in prepared.agent_resources.values() for r in group.skills} == {
"research",
"workspace",
}
result = _import(sqlite_session, content)
assert result.status == ImportStatus.COMPLETED, result.error
assert result.app_mode == mode
bindings = sqlite_session.scalars(
select(WorkflowAgentNodeBinding)
.where(WorkflowAgentNodeBinding.app_id == result.app_id)
.order_by(WorkflowAgentNodeBinding.node_id)
).all()
assert len(bindings) == 2
for index, binding in enumerate(bindings, 1):
assert binding.tenant_id == "tenant-2"
assert binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT
assert binding.node_job_config_dict["workflow_prompt"] == f"Job {index}"
agent = sqlite_session.get(Agent, binding.agent_id)
assert agent is not None
assert (agent.scope, agent.app_id, agent.workflow_id, agent.workflow_node_id) == (
AgentScope.WORKFLOW_ONLY,
result.app_id,
binding.workflow_id,
binding.node_id,
)
snapshot = sqlite_session.get(AgentConfigSnapshot, binding.current_snapshot_id)
assert snapshot is not None
soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
assert soul.prompt.system_prompt == f"Bound snapshot {index}"
assert len(soul.config_skills) == (2 if index == 1 else 1)
for ref in soul.config_skills:
file = sqlite_session.get(ToolFile, ref.file_id)
assert file is not None
assert file.tenant_id == "tenant-2"
assert not ref.is_missing
with zipfile.ZipFile(io.BytesIO(storage.files[file.file_key])) as archive:
expected = f"Agent {index} skill" if ref.name == "research" else "Workspace skill"
assert expected.encode() in archive.read("SKILL.md")
for ref in soul.config_files:
row = sqlite_session.get(ToolFile if ref.file_kind == "tool_file" else UploadFile, ref.file_id)
assert row is not None
assert row.tenant_id == "tenant-2"
key = row.file_key if isinstance(row, ToolFile) else row.key
expected = f"Agent {index} notes" if ref.name == "notes.txt" else f"Agent {index} guide"
assert storage.files[key] == expected.encode()
if isinstance(row, UploadFile):
assert row.used is True
assert row.used_by == "account-1"
assert sqlite_session.scalar(select(AgentSkillBinding).where(AgentSkillBinding.agent_id == agent.id)) is None
def test_yaml_export_still_omits_resources(sqlite_session: Session, storage: _MemoryStorage) -> None:
app = _source(sqlite_session, storage)
data = yaml.safe_load(AppDslService.export_dsl(app, session=sqlite_session))
for package in data["agent_packages"].values():
assert package["omitted_assets"]
assert all(item["is_missing"] and item["file_id"] == "" for item in package["soul"]["config_files"])
def test_export_rejects_cross_tenant_assets(sqlite_session: Session, storage: _MemoryStorage) -> None:
app = _source(sqlite_session, storage)
row = sqlite_session.get(UploadFile, "upload-1")
assert row is not None
row.tenant_id = "tenant-elsewhere"
sqlite_session.commit()
with pytest.raises(RosterAgentPackageExportFailedError, match="payload is unavailable"):
AppPackageService().export_app(app_model=app)
@pytest.mark.parametrize(
"mutation", ["unknown_agent", "cross_agent_file", "unreferenced_agent", "duplicate_path", "file_checksum"]
)
def test_rejects_invalid_resource_ownership_before_upload(
sqlite_session: Session,
storage: _MemoryStorage,
mutation: str,
) -> None:
app = _source(sqlite_session, storage)
with AppPackageService().export_app(app_model=app) as exported:
content = exported.archive.read()
def mutate(manifest: AppPackageManifest, data: dict[str, object], members: dict[str, bytes]) -> None:
resources = manifest.agent_resources
if mutation == "unknown_agent":
resources["unknown"] = resources.pop("agent_1")
elif mutation == "cross_agent_file":
agents = cast(dict[str, object], data["agent_packages"])
first = AgentPackage.model_validate(agents["agent_1"])
second = AgentPackage.model_validate(agents["agent_2"])
first.soul.config_files[0].file_id = second.soul.config_files[0].file_id
agents["agent_1"] = first.model_dump(mode="json")
elif mutation == "unreferenced_agent":
cast(dict[str, dict[str, list[object]]], data["workflow"])["graph"]["nodes"].pop()
elif mutation == "duplicate_path":
resources["agent_2"].files[0] = resources["agent_1"].files[0]
else:
resource = resources["agent_1"].files[0]
members[resource.path] = b"x" * resource.size
with pytest.raises(InvalidRosterAgentPackageError):
AppPackageService().read_package(io.BytesIO(_rewrite(content, mutate)))
assert storage.saved == []
def test_pending_import_retains_materialized_resources_and_damage_warnings(
sqlite_session: Session,
storage: _MemoryStorage,
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = _source(sqlite_session, storage)
with AppPackageService().export_app(app_model=app) as exported:
content = exported.archive.read()
def mutate(manifest: AppPackageManifest, data: dict[str, object], members: dict[str, bytes]) -> None:
resources = manifest.agent_resources
data["version"] = "99.0.0"
resource = resources["agent_1"].skills[0]
members[resource.path] = b"x" * resource.size
pending: dict[str, str] = {}
monkeypatch.setattr(
"services.app_dsl_service.redis_client.setex", lambda key, _ttl, value: pending.__setitem__(key, value)
)
monkeypatch.setattr("services.app_dsl_service.redis_client.get", pending.get)
monkeypatch.setattr("services.app_dsl_service.redis_client.delete", pending.pop)
result = _import(sqlite_session, _rewrite(content, mutate))
assert result.status == ImportStatus.PENDING
saved_count = len(storage.saved)
confirmed = AppDslService(sqlite_session).confirm_import(import_id=result.id, account=_account())
assert confirmed.status == ImportStatus.COMPLETED_WITH_WARNINGS, confirmed.error
assert any(
warning.code == "agent_skill_missing" and warning.path.startswith("agent_packages.agent_1.")
for warning in confirmed.warnings
)
assert len(storage.saved) == saved_count
assert not pending
def test_overwrite_permission_is_checked_before_resource_upload(
sqlite_session: Session,
storage: _MemoryStorage,
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = _source(sqlite_session, storage)
with AppPackageService().export_app(app_model=app) as exported:
content = exported.archive.read()
monkeypatch.setattr(AppDslService, "_load_app_for_overwrite", Mock(side_effect=NoPermissionError("Denied")))
with pytest.raises(NoPermissionError):
_import(sqlite_session, content, app_id="protected-app")
assert not storage.saved
def test_overwrite_replaces_inline_agents_and_reports_previous_owners(
sqlite_session: Session,
storage: _MemoryStorage,
monkeypatch: pytest.MonkeyPatch,
) -> None:
app = _source(sqlite_session, storage)
with AppPackageService().export_app(app_model=app) as exported:
content = exported.archive.read()
original = _import(sqlite_session, content)
assert original.status == ImportStatus.COMPLETED, original.error
old_agents = set(
sqlite_session.scalars(
select(WorkflowAgentNodeBinding.agent_id).where(WorkflowAgentNodeBinding.app_id == original.app_id)
)
)
sqlite_session.commit()
retirement = Mock()
monkeypatch.setattr("services.app_dsl_service.WorkflowAgentRetirementService.retire_unowned", retirement)
updated = _import(sqlite_session, content, app_id=original.app_id)
assert updated.status == ImportStatus.COMPLETED, updated.error
assert updated.app_id == original.app_id
new_agents = set(
sqlite_session.scalars(
select(WorkflowAgentNodeBinding.agent_id).where(WorkflowAgentNodeBinding.app_id == original.app_id)
)
)
assert len(new_agents) == len(old_agents) == 2
assert not (new_agents & old_agents)
retirement.assert_called_once_with(tenant_id="tenant-2", agent_ids=old_agents, account_id="account-1")
@pytest.mark.parametrize("legacy_skill", [False, True])
def test_resource_io_runs_without_database_transactions(
sqlite_session: Session,
storage: _MemoryStorage,
monkeypatch: pytest.MonkeyPatch,
legacy_skill: bool,
) -> None:
app = _source(sqlite_session, storage)
if legacy_skill:
version = sqlite_session.get(SkillVersion, "source-skill-version")
assert version is not None
version.manifest = version.manifest.model_copy(update={"name": None})
sqlite_session.commit()
engine = sqlite_session.get_bind()
assert isinstance(engine, Engine)
pool = engine.pool
assert isinstance(pool, QueuePool)
def before_io() -> None:
assert not sqlite_session.in_transaction()
assert pool.checkedout() == 0
def load_legacy_skill(*, tenant_id: str, file_id: str) -> bytes:
assert tenant_id == "tenant-1"
assert file_id
before_io()
return storage.files["tools/workspace.zip"]
monkeypatch.setattr(
"services.skill_management_service.SkillManagementService._load_tool_file_bytes",
staticmethod(load_legacy_skill),
)
load_stream = storage.load_stream
save = storage.save
def checked_load(filename: str) -> Generator[bytes, None, None]:
before_io()
yield from load_stream(filename)
def checked_save(filename: str, data: bytes) -> None:
before_io()
save(filename, data)
monkeypatch.setattr(storage, "load_stream", checked_load)
monkeypatch.setattr(storage, "save", checked_save)
with AppPackageService().export_app(app_model=app) as exported:
content = exported.archive.read()
result = _import(sqlite_session, content)
assert result.status == ImportStatus.COMPLETED, result.error
def test_missing_assets_remain_explicit_import_warnings(sqlite_session: Session, storage: _MemoryStorage) -> None:
app = _source(sqlite_session, storage)
snapshot = sqlite_session.get(AgentConfigSnapshot, "source-snapshot-1")
assert snapshot is not None
soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
soul.config_files[0].is_missing = True
soul.config_files[0].file_id = ""
snapshot.config_snapshot = soul
sqlite_session.commit()
with AppPackageService().export_app(app_model=app) as exported:
result = _import(sqlite_session, exported.archive.read())
assert result.status == ImportStatus.COMPLETED_WITH_WARNINGS, result.error
assert any(warning.code == "agent_file_omitted" for warning in result.warnings)

View File

@@ -637,3 +637,67 @@ def test_workspace_collection_final_delete_failure_propagates(
assert exc_info.value is error
client.destroy_execution_binding_sync.assert_called_once()
def test_binding_creation_ignores_unavailable_metering_configuration(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
apply_config_overrides(
monkeypatch,
AGENT_SANDBOX_METERING_ENABLED=True,
AGENT_SANDBOX_METERING_PROJECT_ID="",
AGENT_SANDBOX_METERING_START_AT="invalid",
)
client = _backend_client()
monkeypatch.setattr(AgentWorkspaceService, "_client", lambda: nullcontext(client))
binding = AgentWorkspaceService.create_binding(
session=sqlite_session,
scope=_scope(),
agent_id="agent-1",
base_home_snapshot_id=None,
agent_config_version_id="config-1",
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
)
sqlite_session.commit()
assert binding.backend_binding_ref == "binding-ref"
client.create_execution_binding_sync.assert_called_once()
def test_binding_lookups_ignore_bad_metering_configuration_and_remain_tenant_scoped(
monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
) -> None:
apply_config_overrides(
monkeypatch,
AGENT_SANDBOX_METERING_ENABLED=True,
AGENT_SANDBOX_METERING_PROJECT_ID="",
AGENT_SANDBOX_METERING_START_AT="invalid",
)
workspace, binding = _workspace(), _binding()
sqlite_session.add_all([workspace, binding])
sqlite_session.commit()
assert (
AgentWorkspaceService.get_active_binding(
session=sqlite_session,
tenant_id=binding.tenant_id,
binding_id=binding.id,
expected_owner_scope=_scope(),
)
is binding
)
assert (
AgentWorkspaceService.resolve_active_binding_for_scope(
session=sqlite_session,
scope=_scope(),
agent_id=binding.agent_id,
)
is binding
)
assert (
AgentWorkspaceService.get_active_binding(
session=sqlite_session,
tenant_id="another-tenant",
binding_id=binding.id,
expected_owner_scope=_scope(),
)
is None
)

View File

@@ -36,7 +36,10 @@ def test_round_trip_retains_exact_dsl_and_defers_version_handling(mode: str, ver
service = AppPackageService()
with service.export(dsl=dsl, name="Example App") as package:
assert package.filename == "example-app.ifpkg"
assert service.read_dsl(package.archive) == dsl
prepared = service.read_package(package.archive)
assert prepared is not None
with prepared:
assert prepared.dsl == dsl
assert package.archive.tell() == 0
assert package.archive.closed
@@ -44,7 +47,7 @@ def test_round_trip_retains_exact_dsl_and_defers_version_handling(mode: str, ver
@pytest.mark.parametrize("overrides", [{"format": "unknown"}, {"format_version": 2}, {"apps": []}, {"files": []}])
def test_rejects_unsupported_manifests(overrides: dict[str, object]) -> None:
with pytest.raises(InvalidRosterAgentPackageError, match="manifest"):
AppPackageService().read_dsl(_archive("kind: app\napp: {mode: workflow}\n", **overrides))
AppPackageService().read_package(_archive("kind: app\napp: {mode: workflow}\n", **overrides))
@pytest.mark.parametrize(
@@ -58,14 +61,14 @@ def test_rejects_unsupported_manifests(overrides: dict[str, object]) -> None:
)
def test_rejects_invalid_or_agent_dsl_in_ordinary_container(dsl: str) -> None:
with pytest.raises(InvalidRosterAgentPackageError):
AppPackageService().read_dsl(_archive(dsl))
AppPackageService().read_package(_archive(dsl))
def test_rejects_checksum_mismatch() -> None:
dsl = "kind: app\napp: {mode: workflow}\n"
source = _archive(dsl, apps=[{"path": "app.yaml", "size": len(dsl), "sha256": "0" * 64}])
with pytest.raises(InvalidRosterAgentPackageError, match="integrity"):
AppPackageService().read_dsl(source)
AppPackageService().read_package(source)
@pytest.mark.parametrize("path", ["extra.txt", "../escape", "APP.YAML"])
@@ -75,14 +78,14 @@ def test_rejects_unlisted_unsafe_and_duplicate_members(path: str) -> None:
archive.writestr(path, "unexpected")
source.seek(0)
with pytest.raises(InvalidRosterAgentPackageError):
AppPackageService().read_dsl(source)
AppPackageService().read_package(source)
def test_rejects_oversized_container(config_overrides: Callable[..., None]) -> None:
source = _archive("kind: app\napp: {mode: workflow}\n")
config_overrides(AGENT_PACKAGE_MAX_BYTES=10)
with pytest.raises(RosterAgentPackageTooLargeError):
AppPackageService().read_dsl(source)
AppPackageService().read_package(source)
def _dsl_with_size(size: int) -> str:
@@ -96,7 +99,10 @@ def test_package_round_trip_accepts_large_dsl_within_legacy_limit(size: int) ->
dsl = _dsl_with_size(size)
service = AppPackageService()
with service.export(dsl=dsl, name="Large Workflow") as package:
assert service.read_dsl(package.archive) == dsl
prepared = service.read_package(package.archive)
assert prepared is not None
with prepared:
assert prepared.dsl == dsl
def test_export_rejects_dsl_exceeding_legacy_byte_limit() -> None:
@@ -109,4 +115,4 @@ def test_import_rejects_compressed_dsl_exceeding_legacy_byte_limit() -> None:
dsl = _dsl_with_size(DSL_MAX_SIZE + 1)
source = _archive(dsl)
with pytest.raises(RosterAgentPackageTooLargeError):
AppPackageService().read_dsl(source)
AppPackageService().read_package(source)

View File

@@ -0,0 +1,51 @@
"""Injected workflow cancellation preserves both transport mechanisms."""
from unittest.mock import MagicMock, patch
import pytest
from extensions.ext_redis import RedisClientWrapper
from services.app_task_service import AppTaskControlService
@pytest.mark.parametrize("command_fails", [False, True])
@pytest.mark.parametrize("task_id", ["task-1", ""])
def test_workflow_stop_sets_flag_before_sending_command(command_fails: bool, task_id: str) -> None:
redis = MagicMock(spec=RedisClientWrapper)
events: list[str] = []
redis.setex.side_effect = lambda *_: events.append("flag")
failure = RuntimeError("command channel unavailable")
def send_stop(requested_task_id: str) -> None:
assert requested_task_id == task_id
events.append("command")
if command_fails:
raise failure
with patch("services.app_task_service.GraphEngineManager") as manager:
manager.return_value.send_stop_command.side_effect = send_stop
service = AppTaskControlService(redis_client=redis)
if command_fails:
with pytest.raises(RuntimeError) as caught:
service.stop_workflow_task_no_user_check(task_id=task_id)
assert caught.value is failure
else:
service.stop_workflow_task_no_user_check(task_id=task_id)
manager.assert_called_once_with(redis)
if task_id:
assert events == ["flag", "command"]
redis.setex.assert_called_once_with("generate_task_stopped:task-1", 600, 1)
else:
assert events == ["command"]
redis.setex.assert_not_called()
redis.get.assert_not_called()
def test_flag_failure_does_not_send_command() -> None:
redis = MagicMock(spec=RedisClientWrapper)
redis.setex.side_effect = RuntimeError("flag store unavailable")
with patch("services.app_task_service.GraphEngineManager") as manager:
with pytest.raises(RuntimeError, match="flag store unavailable"):
AppTaskControlService(redis_client=redis).stop_workflow_task_no_user_check(task_id="task-1")
manager.assert_not_called()

View File

@@ -235,6 +235,8 @@ class TestFileService:
# Default
assert FileService.is_file_size_within_limit(extension="txt", file_size=5 * 1024 * 1024) is True
assert FileService.is_file_size_within_limit(extension="pdf", file_size=6 * 1024 * 1024) is False
assert FileService.is_file_size_within_limit(extension="txt", file_size=0, default_file_size_limit=0) is True
assert FileService.is_file_size_within_limit(extension="txt", file_size=1, default_file_size_limit=0) is False
assert (
FileService.is_file_size_within_limit(
extension="pdf",
@@ -274,6 +276,9 @@ class TestFileService:
assert FileService.file_size_limit(extension="mp4") == 20 * 1024 * 1024
assert FileService.file_size_limit(extension="mp3") == 30 * 1024 * 1024
assert FileService.file_size_limit(extension="txt") == 5 * 1024 * 1024
assert FileService.file_size_limit(extension="txt", default_file_size_limit=None) == 5 * 1024 * 1024
assert FileService.file_size_limit(extension="txt", default_file_size_limit=0) == 0
assert FileService.file_size_limit(extension="jpg", default_file_size_limit=0) == 10 * 1024 * 1024
assert FileService.file_size_limit(extension="txt", default_file_size_limit=7) == 7 * 1024 * 1024
def test_get_file_base64_success(self, file_service: FileService, db_session: Session):

View File

@@ -0,0 +1,232 @@
import hashlib
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
import httpx
import pytest
from sqlalchemy import Connection, Engine, event, select
from sqlalchemy.orm import Session, sessionmaker
from core.file import remote_fetcher
from extensions.ext_storage import storage
from graphon.file import helpers as file_helpers
from models.enums import CreatorUserRole
from models.model import UploadFile
from services.file_service import FileService, FileUploadActor, FileUploadResult
from services.remote_file_service import RemoteFileService
_TENANT_ID = "11111111-1111-1111-1111-111111111111"
_ACTOR_ID = "22222222-2222-2222-2222-222222222222"
_REMOTE_URL = "https://example.com/report.txt"
@dataclass
class _UploadHarness:
files: FileService
stored: dict[str, bytes]
active_transactions: set[Connection]
io_events: list[str]
database_events: list[str]
@pytest.fixture
def upload_harness(
sqlite_engine: Engine,
sqlite_session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
) -> Iterator[_UploadHarness]:
harness = _UploadHarness(
files=FileService(session_factory=sqlite_session_factory),
stored={},
active_transactions=set(),
io_events=[],
database_events=[],
)
def begin(connection: Connection) -> None:
harness.active_transactions.add(connection)
harness.database_events.append("begin")
def end(connection: Connection) -> None:
harness.active_transactions.remove(connection)
harness.database_events.append("end")
def save(key: str, content: bytes) -> None:
assert not harness.active_transactions
harness.io_events.append("storage")
harness.stored[key] = content
def signed_url(upload_file_id: str) -> str:
assert not harness.active_transactions
harness.io_events.append("sign")
return f"https://files.example.com/{upload_file_id}?sign=test"
event.listen(sqlite_engine, "begin", begin)
event.listen(sqlite_engine, "commit", end)
event.listen(sqlite_engine, "rollback", end)
monkeypatch.setattr(storage, "save", save)
monkeypatch.setattr(file_helpers, "get_signed_file_url", signed_url)
try:
yield harness
finally:
event.remove(sqlite_engine, "begin", begin)
event.remove(sqlite_engine, "commit", end)
event.remove(sqlite_engine, "rollback", end)
@pytest.mark.parametrize("role", [CreatorUserRole.ACCOUNT, CreatorUserRole.END_USER])
@pytest.mark.parametrize("source_url", ["", _REMOTE_URL])
def test_actor_upload_preserves_owner_creator_and_source_url_without_extra_queries(
upload_harness: _UploadHarness,
sqlite_session_factory: sessionmaker[Session],
role: CreatorUserRole,
source_url: str,
) -> None:
actor = FileUploadActor(id=_ACTOR_ID, creator_role=role)
content = "文件内容".encode()
result = upload_harness.files.upload_file_for_actor(
actor=actor,
resource_tenant_id=_TENANT_ID,
filename="report.TXT",
content=content,
mimetype="text/plain",
source_url=source_url,
)
assert isinstance(result, FileUploadResult)
assert result.name == "report.TXT"
assert result.size == len(content)
assert result.extension == "txt"
assert result.mime_type == "text/plain"
assert result.created_by == actor.id
assert isinstance(result.created_at, datetime)
assert result.tenant_id == _TENANT_ID
assert result.source_url == (source_url or f"https://files.example.com/{result.id}?sign=test")
assert upload_harness.database_events == ["begin", "end"]
assert not upload_harness.active_transactions
assert upload_harness.io_events == (["storage"] if source_url else ["storage", "sign"])
with sqlite_session_factory() as session:
persisted = session.get(UploadFile, result.id)
assert persisted is not None
assert persisted.created_by_role == role
assert persisted.created_by == actor.id
assert persisted.tenant_id == _TENANT_ID
assert persisted.source_url == source_url
assert persisted.hash == hashlib.sha3_256(content).hexdigest()
assert persisted.key.startswith(f"upload_files/{_TENANT_ID}/")
assert upload_harness.stored == {persisted.key: content}
@pytest.mark.parametrize("head_status", [httpx.codes.OK, httpx.codes.METHOD_NOT_ALLOWED])
def test_remote_actor_upload_fetches_before_transaction_and_retains_original_url(
upload_harness: _UploadHarness,
sqlite_session_factory: sessionmaker[Session],
monkeypatch: pytest.MonkeyPatch,
head_status: int,
) -> None:
actor = FileUploadActor(id=_ACTOR_ID, creator_role=CreatorUserRole.ACCOUNT)
content = b"remote file content"
requests: list[tuple[str, dict[str, object]]] = []
def make_request(method: str, *, url: str, **kwargs: object) -> httpx.Response:
assert not upload_harness.active_transactions
assert url == _REMOTE_URL
requests.append((method, kwargs))
upload_harness.io_events.append(method)
return httpx.Response(
head_status if method == "HEAD" else httpx.codes.OK,
content=content if method == "GET" else b"",
headers={"Content-Type": "text/plain", "Content-Length": str(len(content))},
request=httpx.Request(method, url),
)
monkeypatch.setattr(remote_fetcher, "make_request", make_request)
result = RemoteFileService(files=upload_harness.files).upload_from_url(
url=_REMOTE_URL, user=actor, tenant_id=_TENANT_ID
)
assert result.name == "report.txt"
assert result.size == len(content)
assert result.extension == "txt"
assert result.mime_type == "text/plain"
assert result.created_by == actor.id
assert result.url == f"https://files.example.com/{result.id}?sign=test"
assert requests == [
("HEAD", {}),
("GET", {} if head_status == httpx.codes.OK else {"timeout": 3, "follow_redirects": True}),
]
assert upload_harness.io_events == ["HEAD", "GET", "storage", "sign"]
assert upload_harness.database_events == ["begin", "end"]
assert not upload_harness.active_transactions
with sqlite_session_factory() as session:
persisted = session.scalars(select(UploadFile)).one()
assert persisted.id == result.id
assert persisted.created_by == actor.id
assert persisted.created_by_role == CreatorUserRole.ACCOUNT
assert persisted.tenant_id == _TENANT_ID
assert persisted.source_url == _REMOTE_URL
assert persisted.key.startswith(f"upload_files/{_TENANT_ID}/")
assert upload_harness.stored == {persisted.key: content}
def test_same_actor_can_upload_to_separately_admitted_tenants(
upload_harness: _UploadHarness,
sqlite_session_factory: sessionmaker[Session],
) -> None:
actor = FileUploadActor(id=_ACTOR_ID, creator_role=CreatorUserRole.ACCOUNT)
tenant_ids = (_TENANT_ID, "33333333-3333-3333-3333-333333333333")
results = [
upload_harness.files.upload_file_for_actor(
actor=actor,
resource_tenant_id=tenant_id,
filename="report.txt",
content=tenant_id.encode(),
mimetype="text/plain",
)
for tenant_id in tenant_ids
]
assert [result.tenant_id for result in results] == list(tenant_ids)
assert len({result.id for result in results}) == 2
with sqlite_session_factory() as session:
for result, tenant_id in zip(results, tenant_ids, strict=True):
persisted = session.get(UploadFile, result.id)
assert persisted is not None
assert persisted.tenant_id == tenant_id
assert persisted.created_by == actor.id
assert persisted.created_by_role == actor.creator_role
assert persisted.key.startswith(f"upload_files/{tenant_id}/")
assert upload_harness.stored[persisted.key] == tenant_id.encode()
@pytest.mark.parametrize("remote", [False, True], ids=["local", "remote"])
def test_actor_upload_without_target_tenant_fails_before_external_io(
upload_harness: _UploadHarness,
monkeypatch: pytest.MonkeyPatch,
remote: bool,
) -> None:
actor = FileUploadActor(id=_ACTOR_ID, creator_role=CreatorUserRole.ACCOUNT)
def unexpected_request(*_args: object, **_kwargs: object) -> httpx.Response:
pytest.fail("Missing target tenant must be rejected before fetching the remote file")
monkeypatch.setattr(remote_fetcher, "make_request", unexpected_request)
if remote:
with pytest.raises(TypeError, match="tenant_id"):
RemoteFileService(files=upload_harness.files).upload_from_url(url=_REMOTE_URL, user=actor)
else:
with pytest.raises(TypeError, match="tenant_id"):
upload_harness.files.upload_file(
filename="report.txt", content=b"file content", mimetype="text/plain", user=actor
)
assert upload_harness.io_events == []
assert upload_harness.database_events == []
assert upload_harness.stored == {}

View File

@@ -0,0 +1,167 @@
"""Packaged image icons travel with Apps and receive destination-owned file ids."""
import hashlib
import io
import zipfile
from collections.abc import Callable
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock
from uuid import uuid4
import pytest
import yaml
from services.agent.errors import (
InvalidRosterAgentPackageError,
RosterAgentPackageExportFailedError,
RosterAgentPackageTooLargeError,
)
from services.agent.package_resource_exporter import AgentPackageResourceExporter, _FileSource, _SkillSource
from services.agent.package_resource_importer import AgentPackageResourceImporter
from services.agent.roster_package_entities import PackageIcon, PreparedPackageArchive, RosterAgentPackageMember
from services.app_package_service import AppPackageService
def _icon_archive(*, payload: bytes = b"image", digest: str | None = None, reference: str = "i_000001") -> io.BytesIO:
dsl = yaml.safe_dump({"kind": "app", "app": {"mode": "chat", "icon_type": "image", "icon": reference}})
with AppPackageService().export(dsl=dsl.replace(reference, "legacy-id"), name="Example") as exported:
with zipfile.ZipFile(exported.archive) as package:
manifest = yaml.safe_load(package.read("manifest.yaml"))
app_bytes = dsl.encode()
manifest["apps"][0].update(size=len(app_bytes), sha256=hashlib.sha256(app_bytes).hexdigest())
manifest["icons"] = [
{
"id": "i_000001",
"path": "i_000001.png",
"size": len(payload),
"sha256": digest or hashlib.sha256(payload).hexdigest(),
}
]
output = io.BytesIO()
with zipfile.ZipFile(output, "w") as package:
package.writestr("manifest.yaml", yaml.safe_dump(manifest))
package.writestr("app.yaml", app_bytes)
package.writestr("i_000001.png", payload)
output.seek(0)
return output
def test_app_icon_export_deduplicates_uploads_and_restores_references(monkeypatch: pytest.MonkeyPatch) -> None:
source_id = str(uuid4())
payload = b"image payload"
backend = Mock()
backend.load_stream.side_effect = lambda _: iter([payload])
exporter = AgentPackageResourceExporter(storage_backend=backend)
upload_lookup = Mock(return_value={source_id: SimpleNamespace(key="source/icon.png", extension="png")})
monkeypatch.setattr(exporter, "_upload_files", upload_lookup)
metadata = {"mode": "chat", "icon_type": "image", "icon": source_id}
duplicate = dict(metadata)
exporter.collect_icon(session=Mock(), tenant_id="source-tenant", metadata=metadata)
exporter.collect_icon(session=Mock(), tenant_id="source-tenant", metadata=duplicate)
assert metadata["icon"] == duplicate["icon"] == "i_000001"
assert upload_lookup.call_args.kwargs["tenant_id"] == "source-tenant"
assert upload_lookup.call_count == 1
dsl = yaml.safe_dump({"kind": "app", "app": metadata})
with AppPackageService().export(dsl=dsl, name="Example", resources=exporter) as exported:
with zipfile.ZipFile(exported.archive) as package:
assert package.read("i_000001.png") == payload
exported.archive.seek(0)
prepared = AppPackageService().read_package(exported.archive)
assert prepared is not None
with prepared:
restore = Mock(return_value={"i_000001": "destination-id"})
monkeypatch.setattr(AgentPackageResourceImporter, "materialize_icons", restore)
data = yaml.safe_load(prepared.dsl)
prepared.materialize_icons(data=data, tenant_id="destination", account_id="account")
assert data["app"]["icon"] == "destination-id"
assert restore.call_args.kwargs["tenant_id"] == "destination"
def test_icon_export_rejects_unavailable_tenant_upload(monkeypatch: pytest.MonkeyPatch) -> None:
exporter = AgentPackageResourceExporter(storage_backend=Mock())
monkeypatch.setattr(exporter, "_upload_files", Mock(return_value={}))
with pytest.raises(RosterAgentPackageExportFailedError, match="unavailable"):
exporter.collect_icon(session=Mock(), tenant_id="tenant", metadata={"icon_type": "image", "icon": str(uuid4())})
def test_icon_checksum_is_required() -> None:
with pytest.raises(InvalidRosterAgentPackageError, match="integrity"):
AppPackageService().read_package(_icon_archive(digest="0" * 64))
@pytest.mark.parametrize("reference", ["i_000002", "unrelated-upload"])
def test_icon_must_resolve_to_metadata(reference: str) -> None:
with pytest.raises(InvalidRosterAgentPackageError, match="references"):
AppPackageService().read_package(_icon_archive(reference=reference))
def test_icon_image_limit_is_enforced(config_overrides: Callable[..., None]) -> None:
config_overrides(UPLOAD_IMAGE_FILE_SIZE_LIMIT=0)
with pytest.raises(RosterAgentPackageTooLargeError, match="image size"):
AppPackageService().read_package(_icon_archive())
def test_materialized_icon_is_owned_by_destination(monkeypatch: pytest.MonkeyPatch) -> None:
payload = b"image payload"
digest = hashlib.sha256(payload).hexdigest()
archive = io.BytesIO()
with zipfile.ZipFile(archive, "w") as package:
package.writestr("i_000001.png", payload)
prepared = PreparedPackageArchive(
archive=archive, members={"i_000001.png": RosterAgentPackageMember(size=len(payload), sha256=digest)}
)
icon = PackageIcon(id="i_000001", path="i_000001.png", size=len(payload), sha256=digest)
session = MagicMock()
monkeypatch.setattr(
"services.agent.package_resource_importer.session_factory.create_session",
MagicMock(return_value=session),
)
backend = Mock()
with prepared:
mapping = AgentPackageResourceImporter(storage_backend=backend).materialize_icons(
archive=prepared, icons=[icon], tenant_id="destination", account_id="importer"
)
row = session.__enter__.return_value.add_all.call_args.args[0][0]
assert row.id == mapping["i_000001"]
assert row.tenant_id == "destination"
assert row.created_by == "importer"
assert row.mime_type == "image/png"
assert row.key.startswith("upload_files/destination/")
backend.save.assert_called_once_with(row.key, payload)
def test_roster_export_embeds_agent_icon(monkeypatch: pytest.MonkeyPatch) -> None:
from models.agent_config_entities import AgentSoulConfig
from models.model import App
from services.agent.dsl_entities import AgentPackage, AgentPackageMetadata, make_agent_app_dsl
from services.agent.roster_package_exporter import RosterAgentPackageExporter
from services.agent.roster_package_reader import RosterAgentPackageReader
backend = Mock()
backend.load_stream.side_effect = lambda _: iter([b"icon"])
resources = AgentPackageResourceExporter(storage_backend=backend)
resources.sources["agent_1"] = (list[_SkillSource](), list[_FileSource]())
metadata = {"name": "Agent", "icon_type": "image", "icon": "source-id"}
monkeypatch.setattr(
resources,
"_upload_files",
Mock(
return_value={
"source-id": SimpleNamespace(key="icon.png", extension="png"),
}
),
)
resources.collect_icon(session=Mock(), tenant_id="source", metadata=metadata)
app = make_agent_app_dsl(
App(name="Agent", mode="agent"),
package_ref="agent_1",
packages={
"agent_1": AgentPackage(metadata=AgentPackageMetadata.model_validate(metadata), soul=AgentSoulConfig())
},
dependencies=[],
)
with RosterAgentPackageExporter(storage_backend=backend)._build_archive(app=app, resources=resources) as exported:
with RosterAgentPackageReader().read(exported.archive) as prepared:
assert prepared.apps["app.yaml"].package.metadata.icon == "i_000001"
assert prepared.manifest.icons[0].path == "i_000001.png"
assert RosterAgentPackageReader().read_member_bytes(prepared, "i_000001.png", max_bytes=4) == b"icon"

View File

@@ -0,0 +1,465 @@
from collections.abc import Callable, Iterator, Mapping
from dataclasses import dataclass, field, replace
from typing import cast, override
from uuid import uuid4
import pytest
from sqlalchemy import Engine, inspect, select, update
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.pool import QueuePool
from core.app.apps.chat.app_generator import ChatAppGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from models import Account, App, AppMode, Conversation
from models.enums import ConversationFromSource
from models.model import AccountTrialAppRecord
from repositories.trial_app_repository import TrialAppRepository
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_generate_service import AppGenerateService
from services.errors.app_model_config import AppModelConfigBrokenError
from services.errors.conversation import ConversationNotExistsError
from services.trial_app_access_service import TrialAppRef
from services.trial_app_generation_adapters import AppGenerateServiceRuntime
from services.trial_app_generation_service import GenerationResponse, TrialAppGenerationService
_ARGS: dict[str, object] = {"inputs": {"count": 0}, "query": "hello", "auto_generate_name": False}
@dataclass
class _Harness:
runtime: AppGenerateServiceRuntime
app: TrialAppRef
account_id: str
closed_sessions: list[Session]
committed_sessions: list[Session]
@pytest.fixture
def harness(sqlite_engine: Engine, sqlite_session_factory: sessionmaker[Session]) -> _Harness:
with sqlite_session_factory.begin() as session:
app = App(tenant_id=str(uuid4()), name="Trial app", mode=AppMode.CHAT, enable_site=True, enable_api=False)
account = Account(name="Viewer", email="viewer@example.com")
session.add_all([app, account])
closed_sessions: list[Session] = []
committed_sessions: list[Session] = []
class TrackedSession(Session):
@override
def commit(self) -> None:
failure = self.info.get("commit_failure")
if isinstance(failure, BaseException):
raise failure
super().commit()
committed_sessions.append(self)
@override
def close(self) -> None:
super().close()
closed_sessions.append(self)
failure = self.info.get("close_failure")
if isinstance(failure, BaseException):
raise failure
factory = sessionmaker(bind=sqlite_engine, class_=TrackedSession, expire_on_commit=True)
return _Harness(
runtime=AppGenerateServiceRuntime(session_factory=cast(sessionmaker[Session], factory)),
app=TrialAppRef(app_id=app.id, tenant_id=app.tenant_id, app_mode=app.mode),
account_id=account.id,
closed_sessions=closed_sessions,
committed_sessions=committed_sessions,
)
@dataclass
class _Stream:
close_error: Exception | None = None
close_calls: int = 0
read_calls: int = 0
_chunks: Iterator[str] = field(default_factory=lambda: iter(("data: first\n\n", "data: second\n\n")))
def __iter__(self) -> "_Stream":
return self
def __next__(self) -> str:
self.read_calls += 1
return next(self._chunks)
def close(self) -> None:
self.close_calls += 1
if self.close_error is not None:
raise self.close_error
def _patch_generation(
monkeypatch: pytest.MonkeyPatch,
harness: _Harness,
generate: Callable[[Session], GenerationResponse],
*,
streaming: bool,
args: Mapping[str, object] = _ARGS,
) -> None:
expected_streaming = streaming
expected_args = args
def legacy_generate(
*,
session: Session,
app_model: App,
user: Account,
args: Mapping[str, object],
invoke_from: InvokeFrom,
streaming: bool,
) -> GenerationResponse:
assert len(harness.closed_sessions) == 1
read_session = harness.closed_sessions[0]
assert not read_session.in_transaction()
assert not read_session.identity_map
assert session is not read_session
assert not session.in_transaction()
assert inspect(app_model).detached
assert inspect(user).detached
assert (app_model.id, app_model.tenant_id, user.id) == (
harness.app.app_id,
harness.app.tenant_id,
harness.account_id,
)
assert (app_model.name, user.name) == ("Trial app", "Viewer")
assert invoke_from == InvokeFrom.EXPLORE
assert streaming is expected_streaming
assert args == expected_args
return generate(session)
monkeypatch.setattr(AppGenerateService, "generate", legacy_generate)
def test_mapping_result_is_unchanged_and_generation_writes_are_committed(
harness: _Harness, monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
) -> None:
response: dict[str, object] = {"answer": "", "metadata": {"tokens": 0}}
def generate(session: Session) -> GenerationResponse:
session.execute(update(App).where(App.id == harness.app.app_id).values(name="Generated app"))
return response
_patch_generation(monkeypatch, harness, generate, streaming=False)
result = harness.runtime.generate(app=harness.app, account_id=harness.account_id, args=_ARGS, streaming=False)
assert result is response
assert len(harness.closed_sessions) == 2
assert harness.committed_sessions == [harness.closed_sessions[1]]
assert all(not session.in_transaction() for session in harness.closed_sessions)
with sqlite_session_factory() as session:
app = session.get(App, harness.app.app_id)
assert app is not None
assert app.name == "Generated app"
def test_usage_is_recorded_after_generation_session_closes_and_before_stream_consumption(
harness: _Harness,
monkeypatch: pytest.MonkeyPatch,
sqlite_session_factory: sessionmaker[Session],
sqlite_engine: Engine,
) -> None:
stream = _Stream()
def generate(session: Session) -> GenerationResponse:
session.execute(update(App).where(App.id == harness.app.app_id).values(name="Generated app"))
return stream
class UsageRepository(TrialAppRepository):
@override
def record(self, *, app_id: str, account_id: str) -> None:
assert len(harness.closed_sessions) == 2
assert harness.committed_sessions == [harness.closed_sessions[1]]
assert isinstance(sqlite_engine.pool, QueuePool)
assert sqlite_engine.pool.checkedout() == 0
assert stream.read_calls == 0
super().record(app_id=app_id, account_id=account_id)
_patch_generation(monkeypatch, harness, generate, streaming=True)
service = TrialAppGenerationService(runtime=harness.runtime, usage=UsageRepository(sqlite_session_factory))
result = service.generate_chat(trial_app=harness.app, account_id=harness.account_id, args=_ARGS)
assert result is stream
assert stream.read_calls == stream.close_calls == 0
with sqlite_session_factory() as session:
usage = session.scalar(
select(AccountTrialAppRecord).where(
AccountTrialAppRecord.app_id == harness.app.app_id,
AccountTrialAppRecord.account_id == harness.account_id,
)
)
assert usage is not None
assert usage.count == 1
assert next(stream) == "data: first\n\n"
stream.close()
assert stream.close_calls == 1
@pytest.mark.parametrize("failure", [ValueError("Invalid query"), RuntimeError("Provider failed")])
def test_generation_failure_preserves_error_and_rolls_back_writes(
harness: _Harness,
monkeypatch: pytest.MonkeyPatch,
sqlite_session_factory: sessionmaker[Session],
failure: Exception,
) -> None:
def generate(session: Session) -> GenerationResponse:
session.execute(update(App).where(App.id == harness.app.app_id).values(name="Uncommitted app"))
raise failure
_patch_generation(monkeypatch, harness, generate, streaming=True)
with pytest.raises(type(failure)) as raised:
harness.runtime.generate(app=harness.app, account_id=harness.account_id, args=_ARGS, streaming=True)
assert raised.value is failure
assert len(harness.closed_sessions) == 2
assert harness.committed_sessions == []
with sqlite_session_factory() as session:
app = session.get(App, harness.app.app_id)
assert app is not None
assert app.name == "Trial app"
@pytest.mark.parametrize("close_failure", [False, True])
def test_commit_failure_closes_created_stream_and_preserves_commit_error(
harness: _Harness,
monkeypatch: pytest.MonkeyPatch,
sqlite_session_factory: sessionmaker[Session],
close_failure: bool,
) -> None:
failure = RuntimeError("Commit failed")
stream = _Stream(close_error=RuntimeError("Stream close failed") if close_failure else None)
def generate(session: Session) -> GenerationResponse:
session.execute(update(App).where(App.id == harness.app.app_id).values(name="Uncommitted app"))
session.info["commit_failure"] = failure
return stream
_patch_generation(monkeypatch, harness, generate, streaming=True)
with pytest.raises(RuntimeError) as raised:
harness.runtime.generate(app=harness.app, account_id=harness.account_id, args=_ARGS, streaming=True)
assert raised.value is failure
assert stream.close_calls == 1
assert stream.read_calls == 0
assert len(harness.closed_sessions) == 2
assert harness.committed_sessions == []
with sqlite_session_factory() as session:
app = session.get(App, harness.app.app_id)
assert app is not None
assert app.name == "Trial app"
def test_session_close_failure_closes_created_stream_and_preserves_session_error(
harness: _Harness, monkeypatch: pytest.MonkeyPatch
) -> None:
failure = RuntimeError("Session close failed")
stream = _Stream()
def generate(session: Session) -> GenerationResponse:
session.info["close_failure"] = failure
return stream
_patch_generation(monkeypatch, harness, generate, streaming=True)
with pytest.raises(RuntimeError) as raised:
harness.runtime.generate(app=harness.app, account_id=harness.account_id, args=_ARGS, streaming=True)
assert raised.value is failure
assert stream.close_calls == 1
assert stream.read_calls == 0
assert len(harness.closed_sessions) == 2
assert harness.committed_sessions == [harness.closed_sessions[1]]
@pytest.mark.parametrize("invalid", ["missing-app", "wrong-owner", "missing-account"])
def test_unavailable_generation_context_fails_before_entering_runtime(
harness: _Harness, monkeypatch: pytest.MonkeyPatch, invalid: str
) -> None:
def unexpected_generate(**_kwargs: object) -> GenerationResponse:
pytest.fail("Unavailable context must fail before generation")
monkeypatch.setattr(AppGenerateService, "generate", unexpected_generate)
app = harness.app
account_id = harness.account_id
error_type: type[Exception] = AppDefinitionUnavailableError
if invalid == "missing-app":
app = replace(app, app_id=str(uuid4()))
elif invalid == "wrong-owner":
app = replace(app, tenant_id=str(uuid4()))
else:
account_id = str(uuid4())
error_type = AccountNotFoundError
with pytest.raises(error_type):
harness.runtime.generate(app=app, account_id=account_id, args=_ARGS, streaming=True)
assert len(harness.closed_sessions) == 1
assert not harness.closed_sessions[0].in_transaction()
assert harness.committed_sessions == []
@pytest.mark.parametrize("current_mode", [AppMode.COMPLETION, AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
def test_mode_changed_after_admission_fails_before_generation_and_usage_recording(
harness: _Harness,
monkeypatch: pytest.MonkeyPatch,
sqlite_session_factory: sessionmaker[Session],
current_mode: AppMode,
) -> None:
with sqlite_session_factory.begin() as session:
session.execute(update(App).where(App.id == harness.app.app_id).values(mode=current_mode))
def unexpected_generate(**_kwargs: object) -> GenerationResponse:
pytest.fail("A stale app mode must fail before generation")
monkeypatch.setattr(AppGenerateService, "generate", unexpected_generate)
service = TrialAppGenerationService(runtime=harness.runtime, usage=TrialAppRepository(sqlite_session_factory))
with pytest.raises(AppDefinitionUnavailableError) as raised:
service.generate_chat(trial_app=harness.app, account_id=harness.account_id, args=_ARGS)
assert harness.app.app_id in str(raised.value)
assert harness.app.app_mode in str(raised.value)
assert current_mode.value in str(raised.value)
assert len(harness.closed_sessions) == 1
assert not harness.closed_sessions[0].in_transaction()
assert harness.committed_sessions == []
with sqlite_session_factory() as session:
assert session.scalar(select(AccountTrialAppRecord)) is None
def _add_conversation(harness: _Harness, session_factory: sessionmaker[Session]) -> str:
with session_factory.begin() as session:
conversation = Conversation(
app_id=harness.app.app_id,
mode=AppMode.CHAT,
name="Conversation",
inputs={},
from_source=ConversationFromSource.CONSOLE,
from_account_id=harness.account_id,
from_end_user_id=None,
)
session.add(conversation)
session.flush()
return conversation.id
def _patch_chat_generation(monkeypatch: pytest.MonkeyPatch, harness: _Harness) -> None:
def legacy_generate(
*,
session: Session,
app_model: App,
user: Account,
args: Mapping[str, object],
invoke_from: InvokeFrom,
streaming: bool,
) -> GenerationResponse:
assert len(harness.closed_sessions) == 1
assert not harness.closed_sessions[0].in_transaction()
assert session is not harness.closed_sessions[0]
assert not session.in_transaction()
response = ChatAppGenerator().generate(
app_model=app_model, user=user, args=args, invoke_from=invoke_from, streaming=streaming, session=session
)
return cast(GenerationResponse, ChatAppGenerator.convert_to_event_stream(response))
monkeypatch.setattr(AppGenerateService, "generate", legacy_generate)
@pytest.mark.parametrize("mismatch", ["missing", "app", "account", "source", "end-user", "deleted"])
def test_chat_runtime_preserves_legacy_conversation_ownership_errors(
harness: _Harness,
monkeypatch: pytest.MonkeyPatch,
sqlite_session_factory: sessionmaker[Session],
mismatch: str,
) -> None:
conversation_id = _add_conversation(harness, sqlite_session_factory)
with sqlite_session_factory.begin() as session:
conversation = session.get(Conversation, conversation_id)
assert conversation is not None
match mismatch:
case "missing":
conversation_id = str(uuid4())
case "app":
conversation.app_id = str(uuid4())
case "account":
conversation.from_account_id = str(uuid4())
case "source":
conversation.from_source = ConversationFromSource.API
case "end-user":
conversation.from_end_user_id = str(uuid4())
case "deleted":
conversation.is_deleted = True
_patch_chat_generation(monkeypatch, harness)
with pytest.raises(ConversationNotExistsError):
harness.runtime.generate(
app=harness.app,
account_id=harness.account_id,
args={**_ARGS, "conversation_id": conversation_id},
streaming=True,
)
assert len(harness.closed_sessions) == 2
assert all(not session.in_transaction() for session in harness.closed_sessions)
assert harness.committed_sessions == []
def test_chat_runtime_accepts_owned_conversation_before_checking_its_model_config(
harness: _Harness, monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
) -> None:
conversation_id = _add_conversation(harness, sqlite_session_factory)
args = {**_ARGS, "conversation_id": conversation_id}
_patch_chat_generation(monkeypatch, harness)
# A visible conversation reaches the following shared model-config check.
with pytest.raises(AppModelConfigBrokenError):
harness.runtime.generate(app=harness.app, account_id=harness.account_id, args=args, streaming=True)
assert len(harness.closed_sessions) == 2
assert all(not session.in_transaction() for session in harness.closed_sessions)
assert harness.committed_sessions == []
@pytest.mark.parametrize(("query", "message"), [("", "query is required"), (1, "query must be a string")])
def test_chat_runtime_keeps_query_validation_before_missing_conversation_error(
harness: _Harness, monkeypatch: pytest.MonkeyPatch, query: str | int, message: str
) -> None:
_patch_chat_generation(monkeypatch, harness)
with pytest.raises(ValueError, match=message):
harness.runtime.generate(
app=harness.app,
account_id=harness.account_id,
args={**_ARGS, "query": query, "conversation_id": str(uuid4())},
streaming=True,
)
assert len(harness.closed_sessions) == 2
assert harness.committed_sessions == []
def test_advanced_chat_passes_conversation_to_runtime_without_eager_validation(
harness: _Harness, monkeypatch: pytest.MonkeyPatch, sqlite_session_factory: sessionmaker[Session]
) -> None:
with sqlite_session_factory.begin() as session:
session.execute(update(App).where(App.id == harness.app.app_id).values(mode=AppMode.ADVANCED_CHAT))
harness.app = replace(harness.app, app_mode=AppMode.ADVANCED_CHAT)
args = {**_ARGS, "conversation_id": str(uuid4())}
stream = _Stream()
_patch_generation(monkeypatch, harness, lambda _session: stream, streaming=True, args=args)
result = harness.runtime.generate(app=harness.app, account_id=harness.account_id, args=args, streaming=True)
assert result is stream
assert len(harness.closed_sessions) == 2
assert harness.committed_sessions == [harness.closed_sessions[1]]
assert stream.read_calls == stream.close_calls == 0
stream.close()

View File

@@ -1,27 +1,10 @@
import ast
import inspect
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY
from services.errors.app import WorkflowNotFoundError
from services.workflow.entities import WebhookTriggerData
from tasks import async_workflow_tasks
def test_build_generator_args_sets_skip_flag_for_webhook():
trigger_data = WebhookTriggerData(
app_id="app",
tenant_id="tenant",
workflow_id="workflow",
root_node_id="node",
inputs={"webhook_data": {"body": {"foo": "bar"}}},
)
args = async_workflow_tasks._build_generator_args(trigger_data)
assert args[SKIP_PREPARE_USER_INPUTS_KEY] is True
assert args["inputs"]["webhook_data"]["body"]["foo"] == "bar"
def test_workflow_not_found_error_message_is_a_single_formatted_string():
# Regression: previously raised with printf-style positional args
# ("...%s...", id1, id2), which made Exception store them as a tuple

16
api/uv.lock generated
View File

@@ -1695,7 +1695,7 @@ requires-dist = [
{ name = "dify-agent", editable = "../dify-agent" },
{ name = "fastopenapi", extras = ["flask"], specifier = "==0.7.0" },
{ name = "flask", specifier = ">=3.1.3,<4.0.0" },
{ name = "flask-compress", specifier = ">=1.24,<2.0.0" },
{ name = "flask-compress", specifier = ">=1.25,<2.0.0" },
{ name = "flask-cors", specifier = ">=6.0.5,<7.0.0" },
{ name = "flask-login", specifier = "==0.6.3" },
{ name = "flask-migrate", specifier = ">=4.1.0,<5.0.0" },
@@ -1705,7 +1705,7 @@ requires-dist = [
{ name = "gevent-websocket", specifier = "==0.10.1" },
{ name = "gmpy2", specifier = ">=2.3.0,<3.0.0" },
{ name = "google-api-python-client", specifier = ">=2.200.0,<3.0.0" },
{ name = "google-cloud-aiplatform", specifier = ">=2.1.0,<3.0.0" },
{ name = "google-cloud-aiplatform", specifier = ">=2.1.3,<3.0.0" },
{ name = "graphon", specifier = "==0.7.0" },
{ name = "gunicorn", specifier = ">=26.2.0,<27.0.0" },
{ name = "httpx", extras = ["socks"], specifier = "==0.28.1" },
@@ -2577,7 +2577,7 @@ wheels = [
[[package]]
name = "flask-compress"
version = "1.24"
version = "1.25"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "backports-zstd" },
@@ -2585,9 +2585,9 @@ dependencies = [
{ name = "brotlicffi", marker = "platform_python_implementation == 'PyPy'" },
{ name = "flask" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/de/2ae0118051b38ab53437328074a696f3ee7d61e15bf7454b78a3088e5bc3/flask_compress-1.24.tar.gz", hash = "sha256:14097cefe59ecb3e466d52a6aeb62f34f125a9f7dadf1f33a53e430ce4a50f31", size = 21089, upload-time = "2026-03-31T15:01:39.005Z" }
sdist = { url = "https://files.pythonhosted.org/packages/bb/96/ac77047588935c4ec96a087830f817b5e0730c4ab2d5717203f0731140e2/flask_compress-1.25.tar.gz", hash = "sha256:802954fb3af048cf4ca2a3b414393bf2b98466ae8067e6654ea0aa34ba34aff5", size = 21719, upload-time = "2026-09-15T09:53:05.798Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/0f/fe51e0b2301bbd429af44273a923ff92127b18d13abba5ae5a1d60e8e497/flask_compress-1.24-py3-none-any.whl", hash = "sha256:1e63668eb6e3242bd4f6ad98825a924e3984409be90c125477893d586007d00c", size = 11033, upload-time = "2026-03-31T15:01:37.302Z" },
{ url = "https://files.pythonhosted.org/packages/2d/b0/5f5ab470c3d3b31da361c63974ec70598cd50c9e4d2819641c1cf9988b1a/flask_compress-1.25-py3-none-any.whl", hash = "sha256:6ca78e29728525e575a9e76e0e8e7acc6e0bf1421e0cbfd452bca0a68626166f", size = 11069, upload-time = "2026-09-15T09:53:04.65Z" },
]
[[package]]
@@ -2891,7 +2891,7 @@ wheels = [
[[package]]
name = "google-cloud-aiplatform"
version = "2.1.0"
version = "2.1.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -2908,9 +2908,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6f/cc/0c562f5d268f07234e712ee8822dd81ec4c836559f449d9e5e91e9aa2025/google_cloud_aiplatform-2.1.0.tar.gz", hash = "sha256:964eca160d4af48a2e04b5ee476fb4d38c84388f23b4420e2c71b30151d625bd", size = 11331982, upload-time = "2026-09-01T19:11:15.526Z" }
sdist = { url = "https://files.pythonhosted.org/packages/54/22/2c0f9ccb918d67e9e34ca857337670c97fe49a84ab31979060eb9112dc64/google_cloud_aiplatform-2.1.3.tar.gz", hash = "sha256:6ccd59e9bd5f313209cfe1bef8c4ab2f57ef2c91b96003dd88053df3a5757dc6", size = 11335191, upload-time = "2026-09-16T02:29:39.991Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/26/10f3d4ab6333672ff43ad56255b74e672c2a59b21b7a054632d05b7dd677/google_cloud_aiplatform-2.1.0-py2.py3-none-any.whl", hash = "sha256:de5c6dace6cb81943fc6ee1fad02f7a02e9b18e50c24b267bd9627f6b9cd3d14", size = 9452898, upload-time = "2026-09-01T19:11:08.19Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b3/be1a23d1cbc239f43de9d4a97dd5e2623a04735125aa41a214822b2e8ad6/google_cloud_aiplatform-2.1.3-py2.py3-none-any.whl", hash = "sha256:846d5dcda761832933bac34aeca1faba5d12819299d200e7a215c22c04770843", size = 9455465, upload-time = "2026-09-16T02:29:36.445Z" },
]
[[package]]

View File

@@ -2,7 +2,10 @@
This guide describes how to run the MVP Dify Agent API server. The server is
implemented in `dify-agent/src/dify_agent/server/app.py` and uses Redis for run
records and per-run event streams only.
records and per-run event streams. Optional E2B usage collection is triggered by
the API's existing Celery Beat schedule through a separate one-shot endpoint.
It adds no operation logging, polling loop, leader election or accounting context
to business runtime execution.
## Default local startup
@@ -32,6 +35,9 @@ also reads `.env` and `dify-agent/.env` when present.
OpenShell-specific settings are listed in the
[OpenShell configuration reference](openshell.md#configuration).
Independent E2B execution accounting, activation and rollout checks are described
in [E2B runtime metering](runtime-metering.md).
| Environment variable | Default | Description |
| --- | --- | --- |
| `DIFY_AGENT_REDIS_URL` | `redis://localhost:6379/0` | Redis connection URL. |

View File

@@ -0,0 +1,156 @@
# E2B runtime metering
One ledger row represents one E2B `sandboxExecutionId`, from start/resume to
pause/kill. Provider execution time and resource sizes determine compute usage;
LLM token usage, message latency and local SDK-call duration do not.
## Scheduled collection and boundaries
The Dify API's existing Celery Beat schedule dispatches
`schedule.collect_agent_sandbox_usage.collect_agent_sandbox_usage` every 60 seconds
by default, through the existing `ops_trace` worker queue. The job calls
`POST /internal/e2b/usage/collect` on the Agent backend using its existing Bearer
service token and a `project_id` body. The E2B key stays in the Agent backend.
The provider-specific endpoint performs one bounded scan, using request-scoped
HTTP clients. It reads E2B lifecycle events and posts them to the Dify API's
idempotent usage ingestion endpoint. It returns `{"completed": true}` only after
all scanned pages and the checkpoint have been acknowledged. Partial scans return
`completed=false`; provider/API errors fail this accounting request only.
There is no Agent-process polling loop, Redis leader election, log outbox,
observation worker, or requested/observed operation reporting. Generic leases,
Agent runs, files, snapshots and binding lookups do not participate in metering.
Their timing, cancellation handling and SDK retries retain their normal behavior.
Metering misconfiguration is checked when collection is invoked, rather than
becoming a prerequisite for business runtime startup or binding operations.
The endpoint has a 240-second deadline. The scheduled HTTP caller has a
260-second timeout, and the Celery task configures 270/300-second soft/hard limits.
Pool support differs: gevent workers do not implement soft time limits, and
blocking work cannot rely on their hard limit. The collection and HTTP deadlines
are the primary bounds; verify the deployed worker pool before relying on Celery
limits. See the [Celery time-limit documentation](https://docs.celeryq.dev/en/stable/userguide/workers.html#time-limits).
Scheduled messages expire after one collection interval to avoid stale backlog.
There is no automatic HTTP retry; future scheduled scans can redeliver provider
IDs safely. Overlapping tasks may duplicate reads, but cannot multiply usage.
## Data and attribution
The API owns `agent_sandbox_usage_events` and `agent_sandbox_executions`.
Ingestion persists provider facts and projects execution rows atomically; the
execution key is `(provider, provider_project_id, provider_execution_id)`.
A short project transaction lock protects ingestion. Binding creation/lookups do
not register allocations, write accounting rows or acquire that lock.
Steady-state usage-state reads do not take a project `FOR UPDATE` lock; activation
can be initialized once when first needed.
E2B's existing sandbox metadata contains binding, tenant, Agent and workspace
identifiers. During background ingestion, optional attribution checks those IDs
against the existing Binding → Workspace → App/Agent tenant-owned chain and the
physical sandbox reference. It never trusts labels alone. Missing, malformed or
already-deleted business records leave attribution unresolved while preserving
complete project-level provider usage. A previously verified ownership snapshot
survives later business deletion. Conflicting ownership is not silently reassigned.
No accounting callback or transaction is added to the business path. There is no
pre-create allocation journal. Older diagnostic/allocation columns and records
are not destructively migrated in this revision; new accounting accepts only
provider events and collector checkpoint/coverage-gap control records. Table
retention is unchanged, with no new automatic cleanup policy.
## Configuration
Apply the existing usage-table migration before first enabling metering.
Configure Dify API, Beat and the workers consuming `ops_trace` consistently:
```dotenv
AGENT_SANDBOX_METERING_ENABLED=true
AGENT_SANDBOX_METERING_PROJECT_ID=<E2B project/team ID>
AGENT_SANDBOX_METERING_START_AT=<whole-second UTC instant>
AGENT_SANDBOX_METERING_INTERVAL_SECONDS=60
```
The task reuses `AGENT_BACKEND_BASE_URL` and `AGENT_BACKEND_API_TOKEN`; the
existing SSRF-safe HTTP policy must permit that internal service. No E2B key is
copied into API or worker configuration.
Configure the Agent backend's one-shot endpoint:
```dotenv
DIFY_AGENT_SANDBOX_METERING_ENABLED=true
DIFY_AGENT_E2B_PROJECT_ID=<same E2B project/team ID>
DIFY_AGENT_SANDBOX_METERING_OVERLAP_SECONDS=900
DIFY_AGENT_SANDBOX_METERING_FULL_SCAN_INTERVAL_SECONDS=3600
DIFY_AGENT_SANDBOX_METERING_MAX_PAGES=1000
```
It reuses the existing E2B key, `DIFY_AGENT_API_TOKEN`, inner API URL and inner API
key. The new endpoint requires a nonempty Bearer-token configuration even when
legacy control-plane routes permit unauthenticated local development.
`DIFY_AGENT_SANDBOX_METERING_POLL_INTERVAL_SECONDS` is no longer used: the interval
belongs to Celery Beat on the API side. Both metering feature flags default off.
Activation T0 is persisted and immutable. Only provider executions started at or
after T0 are metered; starting before T0 and ending after it does not include an
old execution. Resuming an older sandbox after T0 creates a new execution and is
included. There is no historical backfill and no environment column; databases
separate environments. A project ID also scopes and allowlists ingestion.
## Failure semantics
- Only complete provider terminal execution data populates metered duration. A
local `finally`, SDK expiry deadline or business success is not a billing clock.
- Missing terminal fields remain pending; conflicts remain visible. Late starts
cannot reopen closed executions, and duplicate facts do not add duration twice.
- Every scan starts provider offset pagination from zero, overlaps prior coverage
and periodically rescans the retained window. Failed, cancelled or page-bounded
scans never claim a completed checkpoint. SQL idempotency protects redelivery.
- The assumed default provider event retention is seven days. Older uncovered
intervals are recorded as gaps rather than reconstructed or counted as zero.
- A failed scheduled task affects accounting only. No accounting network request
is added to create/connect/pause/kill, lease helpers, runner or file operations.
The API's private ingestion endpoints remain:
`GET /inner/api/agent/sandbox-usage/state?project_id=...` and
`POST /inner/api/agent/sandbox-usage/events`, authenticated with `X-Inner-Api-Key`.
Event batches are limited to 100 records and 1 MiB, acknowledged after commit.
They are for the isolated collector, not business-operation logging.
## Querying usage
For complete `quality='metered'` executions:
```text
sandbox_hours = sum(metered_duration_ms) / 3600000
vcpu_hours = sum(metered_duration_ms * vcpu_count) / 3600000
ram_gib_hours = sum(metered_duration_ms * memory_mib) / (3600000 * 1024)
```
Project totals include complete executions with unresolved attribution; report
that count separately. Tenant/app reports require verified resolved attribution.
For UTC windows `[A, B)`, clip execution intervals using start plus provider
duration. Start time can have second precision, so cross-boundary allocation is
not claimed to be millisecond-accurate; complete-window totals conserve duration.
## Rollout checks
1. Verify Beat's schedule, task registration and an existing worker consuming
`ops_trace`, along with image revisions, flags, project and immutable T0.
2. Verify the authenticated one-shot endpoint, real scheduled execution and
advancing checkpoints. The Agent must not start a metering poller or leader.
3. Keep a real business binding until collection resolves its owner, then retire
it and confirm previously copied accounting remains. Also verify complete
project usage for test sandboxes without business records.
4. Exercise model success/failure/cancellation, files/list/read/download/errors,
snapshots/restoration and resource cleanup. Isolated bad accounting settings
or failing accounting-service stubs must not make business lookups fail.
5. Compare physical execution IDs, duration and resources against actual E2B
events; replay scans/events and confirm totals do not increase.
6. Verify old outbox and collector-leader keys are absent after deployment. Do
not alter run-store keys or stop shared services to inject accounting faults.
Disable the metering flags and restart the relevant processes to stop collection;
business runtime behavior remains the same. Keep the ledger and original T0.
The independent stdout-only sandbox file-error fix is tracked separately from
this accounting integration.

View File

@@ -5,7 +5,9 @@ instances for plugin-daemon and Dify API inner calls, route wiring, and a
process-local scheduler. Run execution happens in background ``asyncio`` tasks
rather than request handlers, so client disconnects do not cancel the agent
runtime. Redis persists run records and per-run event streams with configured
retention only; it is not used as a job queue. Agenton layers and providers
retention. Optional provider accounting is exposed as a separate one-shot HTTP
endpoint driven by Celery Beat; it starts no tasks during lifespan startup and
adds no reporting to business operations. Redis is not used as a run job queue. Agenton layers and providers
stay state-only: they borrow the lifespan-owned clients through the runner and
receive runtime-backend and Shell settings through provider construction rather
than reading environment variables themselves. The standard server mounts the
@@ -29,6 +31,7 @@ from dify_agent.runtime.compositor_factory import create_default_layer_providers
from dify_agent.runtime.run_scheduler import RunScheduler
from dify_agent.server.auth import create_bearer_token_dependency
from dify_agent.server.observability import configure_server_observability
from dify_agent.server.routes.e2b_usage import create_e2b_usage_router
from dify_agent.server.routes.runs import create_runs_router
from dify_agent.server.routes.execution_bindings import create_execution_bindings_router
from dify_agent.server.routes.home_snapshots import create_home_snapshots_router
@@ -146,6 +149,7 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
control_plane_router.include_router(create_home_snapshots_router(lambda: home_snapshot_service))
control_plane_router.include_router(create_binding_files_router(lambda: binding_file_service))
app.include_router(control_plane_router)
app.include_router(create_e2b_usage_router(resolved_settings))
app.include_router(
create_agent_stub_router(
token_codec=agent_stub_token_codec,

View File

@@ -50,6 +50,10 @@ import os
import stat
import sys
# The shell transport may expose stdout only. Keep Python errors in the same
# stream so the server can classify unavailable paths instead of returning 502.
sys.stderr = sys.stdout
path = sys.argv[1]
response_path = sys.argv[2]
limit = int(sys.argv[3])
@@ -89,6 +93,8 @@ import os
import stat
import sys
sys.stderr = sys.stdout
path = sys.argv[1]
response_path = sys.argv[2]
max_bytes = int(sys.argv[3])

View File

@@ -0,0 +1,112 @@
"""Provider-specific client for E2B usage facts and completed-scan checkpoints.
Only the scheduled collector uses this client. Business operations do not report
accounting events or borrow this connection pool.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
import json
import logging
from typing import Any
import httpx
from pydantic import BaseModel, ConfigDict, Field
logger = logging.getLogger(__name__)
_MAX_BATCH_BYTES = 1024 * 1024
class E2BUsageState(BaseModel):
enabled: bool
project_id: str
started_at: datetime | None = None
checkpoint_at: datetime | None = None
full_scan_at: datetime | None = None
model_config = ConfigDict(extra="ignore")
class _IngestResult(BaseModel):
accepted: int = Field(ge=0)
duplicates: int = Field(ge=0)
conflicts: int = Field(ge=0)
ignored: int = Field(ge=0)
@dataclass(slots=True)
class E2BUsageApiClient:
"""Use a collection-scoped inner client; credentials never reach E2B."""
client: httpx.AsyncClient
base_url: str
api_key: str
project_id: str
async def get_state(self) -> E2BUsageState:
response = await self.client.get(
f"{self.base_url.rstrip('/')}/inner/api/agent/sandbox-usage/state",
params={"project_id": self.project_id},
headers={"X-Inner-Api-Key": self.api_key},
timeout=15.0,
)
response.raise_for_status()
state = E2BUsageState.model_validate(response.json())
if state.project_id != self.project_id:
raise ValueError("sandbox usage state project mismatch")
for value in (state.started_at, state.checkpoint_at, state.full_scan_at):
if value is not None and value.utcoffset() is None:
raise ValueError("sandbox usage state requires timezone-aware timestamps")
if state.enabled and state.started_at is None:
raise ValueError("enabled sandbox usage state requires a fixed activation time")
return state
async def post_events(self, events: list[dict[str, Any]]) -> None:
if not events or len(events) > 100:
raise ValueError("sandbox usage batches require 1 to 100 events")
# The API limits the whole HTTP body, not just its event count. Splitting
# here covers provider pages. Repeated scheduled scans use stable
# provider IDs, so partial success cannot multiply execution usage.
batch: list[dict[str, Any]] = []
for event in events:
candidate = [*batch, event]
body = self._encode_batch(candidate)
if len(body) > _MAX_BATCH_BYTES:
if not batch:
raise ValueError("sandbox usage event exceeds API request size")
await self._post_batch(self._encode_batch(batch), len(batch))
batch = [event]
if len(self._encode_batch(batch)) > _MAX_BATCH_BYTES:
raise ValueError("sandbox usage event exceeds API request size")
else:
batch = candidate
if batch:
await self._post_batch(self._encode_batch(batch), len(batch))
def _encode_batch(self, events: list[dict[str, Any]]) -> bytes:
return json.dumps(
{"project_id": self.project_id, "events": events},
ensure_ascii=False,
allow_nan=False,
separators=(",", ":"),
).encode()
async def _post_batch(self, body: bytes, count: int) -> None:
response = await self.client.post(
f"{self.base_url.rstrip('/')}/inner/api/agent/sandbox-usage/events",
headers={"X-Inner-Api-Key": self.api_key, "Content-Type": "application/json"},
content=body,
timeout=15.0,
)
response.raise_for_status()
result = _IngestResult.model_validate(response.json())
if result.accepted + result.duplicates + result.conflicts + result.ignored != count:
raise ValueError("sandbox usage acknowledgement does not cover the entire batch")
if result.conflicts:
logger.error("sandbox usage ingestion reported conflicts", extra={"conflicts": result.conflicts})
def utc_now() -> datetime:
return datetime.now(UTC)

View File

@@ -0,0 +1,160 @@
"""A single bounded E2B event scan, invoked by the existing Celery Beat schedule.
Offsets are used within one bounded scan only. Every scan starts at zero, with
an overlap window and periodic rescan of the provider retention window. Neither
redelivery nor overlapping scheduled scans can inflate the API's idempotent
execution ledger. A partial scan never advances coverage.
"""
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timedelta
import logging
from typing import Any, Callable
from uuid import uuid4
import httpx
from dify_agent.server.e2b_usage_client import E2BUsageApiClient, utc_now
logger = logging.getLogger(__name__)
_PROVIDER_RETENTION = timedelta(days=7)
_EVENTS_URL = "https://api.e2b.app/events/sandboxes"
def _field(event: dict[str, Any], camel: str, snake: str) -> Any:
return event.get(camel, event.get(snake))
def _timestamp(value: Any) -> datetime | None:
if value is None:
return None
if not isinstance(value, str):
raise ValueError("provider event timestamp is invalid")
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
if parsed.utcoffset() is None:
raise ValueError("provider event timestamp must have a timezone")
return parsed
def provider_event(raw: dict[str, Any], *, project_id: str) -> dict[str, Any]:
"""Preserve the raw provider payload; API owns normalization and T0 filtering."""
project = _field(raw, "sandboxTeamId", "sandbox_team_id")
if project != project_id:
raise ValueError("E2B event project mismatch or missing project")
event_id = raw.get("id")
event_type = raw.get("type")
if not isinstance(event_id, str) or not event_id or not isinstance(event_type, str):
raise ValueError("E2B event missing stable identity")
_timestamp(raw.get("timestamp"))
return {
"id": event_id,
"source": "provider",
"type": event_type,
"timestamp": raw.get("timestamp"),
"sandbox_id": _field(raw, "sandboxId", "sandbox_id"),
"execution_id": _field(raw, "sandboxExecutionId", "sandbox_execution_id"),
"payload": raw,
}
@dataclass(slots=True)
class E2BUsageCollector:
provider_client: httpx.AsyncClient
usage_client: E2BUsageApiClient
api_key: str
project_id: str
overlap_seconds: int = 900
full_scan_interval_seconds: int = 3600
max_pages: int = 1000
clock: Callable[[], datetime] = utc_now
async def collect_once(self) -> bool:
state = await self.usage_client.get_state()
if not state.enabled:
return False
if state.started_at is None or state.project_id != self.project_id:
raise ValueError("sandbox usage activation/project state is invalid")
now = self.clock()
if state.started_at > now:
return False
retention_floor = now - _PROVIDER_RETENTION
full = (
state.full_scan_at is None or (now - state.full_scan_at).total_seconds() >= self.full_scan_interval_seconds
)
anchor = state.started_at if full else (state.checkpoint_at or state.started_at)
lower = max(state.started_at, retention_floor, anchor - timedelta(seconds=self.overlap_seconds))
if (state.checkpoint_at or state.started_at) < retention_floor:
# The API checkpoint also records window_start, so a retention gap is
# visible instead of being misrepresented as complete T0 coverage.
logger.error("sandbox usage collection gap exceeds provider retention")
await self.usage_client.post_events(
[
{
"id": str(uuid4()),
"source": "application",
"type": "collector_retention_gap",
"timestamp": now.isoformat(),
"payload": {
"uncovered_start": (state.checkpoint_at or state.started_at).isoformat(),
"uncovered_end": retention_floor.isoformat(),
"assumed_retention_seconds": int(_PROVIDER_RETENTION.total_seconds()),
},
}
]
)
count = 0
complete = False
pages = 0
for page in range(self.max_pages):
response = await self.provider_client.get(
_EVENTS_URL,
headers={"X-API-Key": self.api_key},
params={"limit": 100, "offset": page * 100, "orderAsc": "false"},
timeout=15.0,
)
response.raise_for_status()
raw_events = response.json()
if not isinstance(raw_events, list) or len(raw_events) > 100:
raise ValueError("E2B events response must be a page of up to 100 events")
pages += 1
events: list[dict[str, Any]] = []
all_older = bool(raw_events)
for raw in raw_events:
if not isinstance(raw, dict):
raise ValueError("invalid E2B event")
event = provider_event(raw, project_id=self.project_id)
occurred = _timestamp(raw.get("timestamp"))
if occurred is None or occurred >= lower:
all_older = False
events.append(event)
if events:
await self.usage_client.post_events(events)
count += len(events)
if len(raw_events) < 100 or all_older:
complete = True
break
if not complete:
logger.warning("sandbox usage scan reached page bound; checkpoint unchanged", extra={"pages": pages})
return False
await self.usage_client.post_events(
[
{
"id": str(uuid4()),
"source": "application",
"type": "collector_checkpoint",
"timestamp": now.isoformat(),
"payload": {
"scan_started_at": now.isoformat(),
"window_start": lower.isoformat(),
"window_end": now.isoformat(),
"completed": True,
"mode": "full" if full else "incremental",
"pages": pages,
"events": count,
},
}
]
)
return True

View File

@@ -0,0 +1,99 @@
"""One-shot provider accounting endpoint, invoked by the API's Celery task.
The E2B credential stays in its existing service. Collection owns separate HTTP
clients, has no Redis leader or recurring task, and never touches runtime leases.
Invalid optional accounting configuration fails only this endpoint.
"""
import asyncio
import logging
import httpx
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, ConfigDict, Field
from dify_agent.server.auth import create_bearer_token_dependency
from dify_agent.server.e2b_usage_client import E2BUsageApiClient
from dify_agent.server.e2b_usage_collector import E2BUsageCollector
from dify_agent.server.settings import ServerSettings
logger = logging.getLogger(__name__)
_COLLECTION_TIMEOUT_SECONDS = 240.0
class E2BUsageCollectRequest(BaseModel):
project_id: str = Field(min_length=1, max_length=128)
model_config = ConfigDict(extra="forbid")
class E2BUsageCollectResponse(BaseModel):
completed: bool
class _CollectionOptions(BaseModel):
overlap_seconds: int = Field(ge=1)
full_scan_interval_seconds: int = Field(ge=1)
max_pages: int = Field(ge=1, le=10000)
def create_e2b_usage_router(settings: ServerSettings) -> APIRouter:
async def require_configured_auth() -> None:
# Other legacy control-plane routes may permit a missing token. New
# collection requests must never expose the project key without auth.
if not settings.api_token:
raise HTTPException(status_code=503, detail="e2b_usage_collection_auth_unconfigured")
router = APIRouter(
prefix="/internal/e2b/usage",
dependencies=[Depends(require_configured_auth), create_bearer_token_dependency(settings.api_token)],
)
@router.post("/collect", response_model=E2BUsageCollectResponse)
async def collect(request: E2BUsageCollectRequest) -> E2BUsageCollectResponse:
project_id = settings.e2b_project_id.strip()
if (
not settings.sandbox_metering_enabled
or settings.runtime_backend != "e2b"
or not settings.e2b_api_key
or not project_id
or not settings.inner_api_key
):
raise HTTPException(status_code=503, detail="e2b_usage_collection_unavailable")
if request.project_id != project_id:
raise HTTPException(status_code=403, detail="e2b_usage_project_mismatch")
try:
options = _CollectionOptions.model_validate(
{
"overlap_seconds": settings.sandbox_metering_overlap_seconds,
"full_scan_interval_seconds": settings.sandbox_metering_full_scan_interval_seconds,
"max_pages": settings.sandbox_metering_max_pages,
}
)
async with (
asyncio.timeout(_COLLECTION_TIMEOUT_SECONDS),
httpx.AsyncClient(timeout=15.0, trust_env=False) as provider_client,
httpx.AsyncClient(timeout=15.0, trust_env=False) as inner_client,
):
collector = E2BUsageCollector(
provider_client=provider_client,
usage_client=E2BUsageApiClient(
client=inner_client,
base_url=settings.inner_api_url,
api_key=settings.inner_api_key,
project_id=project_id,
),
api_key=settings.e2b_api_key,
project_id=project_id,
overlap_seconds=options.overlap_seconds,
full_scan_interval_seconds=options.full_scan_interval_seconds,
max_pages=options.max_pages,
)
return E2BUsageCollectResponse(completed=await collector.collect_once())
except TimeoutError as exc:
logger.warning("E2B usage collection exceeded its deadline")
raise HTTPException(status_code=504, detail="e2b_usage_collection_timed_out") from exc
except Exception as exc:
logger.warning("E2B usage collection failed", extra={"error_type": type(exc).__name__})
raise HTTPException(status_code=503, detail="e2b_usage_collection_failed") from exc
return router

View File

@@ -88,6 +88,13 @@ class ServerSettings(BaseSettings):
le=E2B_MAX_ACTIVE_TIMEOUT_SECONDS,
)
e2b_shellctl_port: int = Field(default=5004, ge=1, le=65535)
sandbox_metering_enabled: bool = False
e2b_project_id: str = ""
# Optional accounting validates its numeric configuration at invocation,
# so a bad metering value cannot prevent ordinary runtime startup.
sandbox_metering_overlap_seconds: int | str = 900
sandbox_metering_full_scan_interval_seconds: int | str = 3600
sandbox_metering_max_pages: int | str = 1000
openshell_gateway_endpoint: str | None = None
openshell_workspace: str = "default"
openshell_bearer_token: str | None = None

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
import asyncio
import posixpath
from collections.abc import Callable
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
import logging
from typing import cast
@@ -191,7 +191,7 @@ class _ControlPlane:
def _mock_http(
monkeypatch: pytest.MonkeyPatch,
handler: Callable[[httpx.Request], httpx.Response],
handler: Callable[[httpx.Request], httpx.Response | Awaitable[httpx.Response]],
) -> list[httpx.AsyncClient]:
original_async_client = httpx.AsyncClient
transport = httpx.MockTransport(handler)
@@ -1082,3 +1082,117 @@ async def test_e2b_acquire_preserves_health_failure_when_close_and_pause_fail(
assert client.calls == 3
assert data_plane.close_calls == 1
assert sandbox.pauses == [True]
@pytest.mark.anyio
async def test_create_task_cancellation_during_initialization_kills_sandbox(
monkeypatch: pytest.MonkeyPatch,
) -> None:
entered = asyncio.Event()
async def blocked_make_dir(_self: _Files, _path: str) -> bool:
entered.set()
await asyncio.Event().wait()
return True
monkeypatch.setattr(_Files, "make_dir", blocked_make_dir)
control = _ControlPlane()
backend = _binding_backend(control)
task = asyncio.create_task(
backend.create_binding(
ExecutionBindingCreateSpec(
tenant_id="tenant",
agent_id="agent",
binding_id="binding",
workspace_id="workspace",
existing_workspace_ref=None,
)
)
)
async with asyncio.timeout(2):
await entered.wait()
task.cancel("cancel initialization")
with pytest.raises(asyncio.CancelledError, match="cancel initialization"):
await task
assert len(control.created) == 1
assert control.sandboxes["sandbox-1"].killed == 1
@pytest.mark.anyio
async def test_acquire_task_cancellation_during_health_closes_transport_and_pauses(
monkeypatch: pytest.MonkeyPatch,
) -> None:
entered = asyncio.Event()
async def handler(_request: httpx.Request) -> httpx.Response:
entered.set()
await asyncio.Event().wait()
return httpx.Response(200, json={"status": "ok"})
clients = _mock_http(monkeypatch, handler)
backend, sandbox = _connected_backend()
task = asyncio.create_task(backend.acquire(sandbox.sandbox_id))
async with asyncio.timeout(2):
await entered.wait()
task.cancel("cancel acquisition")
with pytest.raises(asyncio.CancelledError, match="cancel acquisition"):
await task
assert clients[0].is_closed
assert sandbox.pauses == [True]
@pytest.mark.anyio
async def test_release_task_cancellation_keeps_pause_retries_and_original_cancel(
sleep_delays: list[float],
) -> None:
entered = asyncio.Event()
class BlockingCloseDataPlane:
async def close(self) -> None:
entered.set()
await asyncio.Event().wait()
backend, sandbox = _connected_backend()
sandbox.pause_errors = [_transport_error(e2b_httpx.ReadTimeout), _transport_error(e2b_httpx.ReadTimeout)]
lease = E2BRuntimeLease(
sandbox=sandbox, # pyright: ignore[reportArgumentType]
data_plane=cast(ShellctlRuntimeLease, cast(object, BlockingCloseDataPlane())),
)
task = asyncio.create_task(backend.release(lease))
async with asyncio.timeout(2):
await entered.wait()
task.cancel("cancel release")
with pytest.raises(asyncio.CancelledError, match="cancel release"):
await task
assert sandbox.pauses == [True, True]
assert sleep_delays == [0.25]
@pytest.mark.anyio
async def test_acquire_primary_health_error_survives_task_cancel_during_compensation(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Preserve the pre-metering best-effort compensation contract: a failure in
# cleanup must not replace the primary acquisition error.
entered = asyncio.Event()
async def blocked_pause(sandbox: _Sandbox, keep_memory: bool = True) -> bool:
sandbox.pauses.append(keep_memory)
entered.set()
await asyncio.Event().wait()
return True
monkeypatch.setattr(_Sandbox, "pause", blocked_pause)
clients = _mock_http(
monkeypatch,
lambda _request: httpx.Response(401, json={"error": {"code": "unauthorized", "message": "bad token"}}),
)
backend, sandbox = _connected_backend()
task = asyncio.create_task(backend.acquire(sandbox.sandbox_id))
async with asyncio.timeout(2):
await entered.wait()
task.cancel("cancel compensation")
with pytest.raises(BindingAcquireError, match="bad token"):
await task
assert clients[0].is_closed
assert sandbox.pauses == [True]

View File

@@ -461,3 +461,35 @@ def test_server_settings_use_generic_outbound_http_args_for_shared_clients() ->
assert "outbound_http_max_connections" in model_fields
assert "outbound_http_max_keepalive_connections" in model_fields
assert "outbound_http_keepalive_expiry" in model_fields
@pytest.mark.parametrize("enabled", [False, True])
def test_optional_metering_never_starts_a_collector_or_blocks_runtime_startup(
monkeypatch: pytest.MonkeyPatch, enabled: bool
) -> None:
import dify_agent.server.routes.e2b_usage as usage_route
_patch_app_lifecycle(monkeypatch)
def unexpected_collector(*args: object, **kwargs: object) -> None:
raise AssertionError("collector must only be constructed by an explicit scheduled request")
monkeypatch.setattr(usage_route, "E2BUsageCollector", unexpected_collector)
settings = ServerSettings(
_env_file=None,
sandbox_metering_enabled=enabled,
runtime_backend="local",
api_token="control-token",
e2b_project_id="",
e2b_api_key=None,
inner_api_key=None,
)
with TestClient(create_app(settings)) as client:
assert client.get("/openapi.json").status_code == 200
response = client.post(
"/internal/e2b/usage/collect",
headers={"Authorization": "Bearer control-token"},
json={"project_id": "project"},
)
assert response.status_code == 503
assert FakeRunScheduler.created[-1].shutdown_called

View File

@@ -117,6 +117,8 @@ class _ProviderErrorCommands(_Commands):
@dataclass(slots=True)
class _LocalCommands(_Commands):
capture_stderr: bool = True
async def run(
self,
script: str,
@@ -133,7 +135,7 @@ class _LocalCommands(_Commands):
cwd=cwd,
env={**os.environ, **(env or {})},
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
stderr=asyncio.subprocess.STDOUT if self.capture_stderr else asyncio.subprocess.DEVNULL,
)
output, _ = await asyncio.wait_for(process.communicate(), timeout=timeout)
text = output.decode(errors="replace")
@@ -195,12 +197,16 @@ def _service(
return service, backend
def _local_service(tmp_path: Path) -> tuple[BindingFileService, _Backend, _LocalCommands, Path, Path]:
def _local_service(
tmp_path: Path,
*,
capture_stderr: bool = True,
) -> tuple[BindingFileService, _Backend, _LocalCommands, Path, Path]:
workspace = tmp_path / "workspace"
home = tmp_path / "home"
workspace.mkdir()
home.mkdir()
commands = _LocalCommands(outputs=[])
commands = _LocalCommands(outputs=[], capture_stderr=capture_stderr)
backend = _Backend(
lease=cast(
RuntimeLease,
@@ -412,6 +418,21 @@ async def test_browse_preserves_binding_file_error_and_releases_lease(operation:
assert backend.releases == 1
@pytest.mark.parametrize("operation", ["list", "read"])
@pytest.mark.anyio
async def test_missing_path_is_classified_when_transport_only_captures_stdout(tmp_path: Path, operation: str) -> None:
service, backend, commands, _, _ = _local_service(tmp_path, capture_stderr=False)
with pytest.raises(BindingFileError) as error:
if operation == "list":
await service.list_files(BindingFileListRequest(backend_binding_ref="binding-ref", path="missing"))
else:
await service.read_file(BindingFileReadRequest(backend_binding_ref="binding-ref", path="missing"))
assert error.value.code == "invalid_binding_path"
assert error.value.status_code == 400
assert backend.releases == 1
assert commands.deletes == ["local-job-1"]
@pytest.mark.parametrize("operation", ["list", "read"])
@pytest.mark.anyio
async def test_browse_maps_malformed_backend_response_to_unavailable_and_releases_lease(operation: str) -> None:

View File

@@ -0,0 +1,65 @@
from __future__ import annotations
import asyncio
import json
from typing import Any
import httpx
import pytest
from dify_agent.server.e2b_usage_client import E2BUsageApiClient
def _event(event_id: str = "event-1") -> dict[str, Any]:
return {"id": event_id, "source": "provider", "type": "sandbox.lifecycle.paused", "payload": {}}
def _ack(request: httpx.Request) -> httpx.Response:
count = len(json.loads(request.content)["events"])
return httpx.Response(200, json={"accepted": count, "duplicates": 0, "conflicts": 0, "ignored": 0})
def test_client_rejects_wrong_project_state_and_incomplete_ack() -> None:
async def scenario() -> None:
async def handler(request: httpx.Request) -> httpx.Response:
if request.method == "GET":
return httpx.Response(
200, json={"enabled": True, "project_id": "wrong", "started_at": "2026-09-20T00:00:00Z"}
)
return httpx.Response(200, json={"accepted": 0, "duplicates": 0, "conflicts": 0, "ignored": 0})
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
client = E2BUsageApiClient(http, "http://inner", "inner", "project")
with pytest.raises(ValueError, match="project mismatch"):
await client.get_state()
with pytest.raises(ValueError, match="entire batch"):
await client.post_events([_event()])
asyncio.run(scenario())
def test_large_batches_respect_api_byte_limit_and_keep_stable_ids_on_retry() -> None:
async def scenario() -> None:
bodies: list[list[dict[str, Any]]] = []
fail_second = True
async def handler(request: httpx.Request) -> httpx.Response:
assert len(request.content) <= 1024 * 1024
assert request.headers["Content-Type"] == "application/json"
batch = json.loads(request.content)["events"]
bodies.append(batch)
if fail_second and len(bodies) == 2:
return httpx.Response(503)
return httpx.Response(200, json={"accepted": len(batch), "duplicates": 0, "conflicts": 0, "ignored": 0})
events = [{**_event(str(i)), "payload": {"provider_metadata": "x" * 60000}} for i in range(40)]
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
client = E2BUsageApiClient(http, "http://inner", "inner", "project")
with pytest.raises(httpx.HTTPStatusError):
await client.post_events(events)
fail_second = False
await client.post_events(events)
assert bodies[0] == bodies[2]
assert [event["id"] for batch in bodies[2:] for event in batch] == [str(i) for i in range(40)]
asyncio.run(scenario())

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