Compare commits

..

749 Commits

Author SHA1 Message Date
CodingOnStar
cd6b4a4897 fix(web): remove unused access token translations blocking dev build 2026-09-29 16:54:37 +08:00
CodingOnStar
bcfb0a32d2 fix(web): hide installed plugins from workflow marketplace selectors
(cherry picked from commit f0f469b5e8)
2026-09-29 16:47:20 +08:00
Stephen Zhou
561c935415 fix(api): preserve resource token access across OpenAPI routes 2026-09-21 09:39:42 +08:00
Stephen Zhou
e003cc2918 Merge branch 'main' into deploy/dev 2026-09-21 09:34:16 +08:00
fatelei
f99d2b9434 fix: fix db migration 2026-09-20 13:29:38 +08:00
fatelei
0cae6dd813 Merge branch 'access_token' into deploy/dev 2026-09-20 11:54:53 +08:00
wangxiaolei
4ba0e68e56 feat: skill detail page draft content support export (#42502) 2026-09-18 16:31:46 +08:00
wangxiaolei
c0c658eef4 chore: remove skill export dialog (#42491) 2026-09-18 15:12:18 +08:00
zxhlyh
0bfeb23852 Merge branch 'codex/fix-skills-frontend-issues' into deploy/dev 2026-09-18 14:20:08 +08:00
zxhlyh
0c4074fc77 fix(skills): skip unavailable default builder models 2026-09-18 14:15:12 +08:00
zxhlyh
920410fda5 Merge branch 'codex/fix-skills-frontend-issues' into deploy/dev 2026-09-18 11:28:08 +08:00
zxhlyh
968694045d fix(skills): open duplicated skill for inline rename 2026-09-18 11:23:48 +08:00
zxhlyh
6d8d36ce06 fix(skills): reflect selected tags immediately 2026-09-18 11:00:02 +08:00
zxhlyh
aba44b75a6 fix(skills): show requirements before importing packages 2026-09-18 10:29:10 +08:00
zxhlyh
e01112cf6a fix(skills): align referenced TXT file icons 2026-09-18 10:12:17 +08:00
fatelei
dc42623bc0 Merge main into deploy/dev
Resolve Agent RBAC and marketplace conflicts against the current main interfaces. Preserve deploy/dev collection preview limits and join the migration heads.
2026-09-17 19:24:00 +08:00
fatelei
690d30fbbc feat: skill support duplicate and export use the latest draft 2026-09-17 17:16:26 +08:00
CodingOnStar
f6be752b5b fix: port marketplace image copy fixes to deploy/dev
Cherry-picked changes from 485c6b30dc, 2c419048a2, and afebe629f6. The image preview close fix is already present in 625c595409.
2026-09-17 16:44:02 +08:00
CodingOnStar
625c595409 fix(marketplace): hide detail close button during image preview
(cherry picked from commit e31856d7dc)

Preserve the deploy/dev gallery markup and adapt the regression selectors.
2026-09-17 15:47:02 +08:00
fatelei
0c72432ebc feat: cli support access token 2026-09-14 17:51:19 +08:00
fatelei
b0ae7744a1 feat: access token feature 2026-09-14 13:31:14 +08:00
CodingOnStar
95674ae71e fix(web): sync embedded template dialog with /templates/{publisher}/{id}
Open the marketplace template dialog from a Dify catalog URL, keep the
outer path in sync while it is open, and restore /templates on close.
2026-09-10 17:53:00 +08:00
CodingOnStar
7e27f10ea7 fix(web): allow clipboard-write on marketplace detail iframe
The embedded marketplace detail dialog is cross-origin. Without this
permission, navigator.clipboard.writeText is denied and Copy link fails.
2026-09-10 17:53:00 +08:00
CodingOnStar
0bd09d5b7a fix(web): use delivered cover_image for blog banners
Stop painting every blog slide with the bundled MCP Servers art. Keep
cover_image through banner normalization and render that artwork instead.
2026-09-10 16:37:25 +08:00
CodingOnStar
ba7df082bc feat(marketplace): port stable template UUID links to dev
Port 7f7e909826 while preserving deploy/dev Banner fallbacks and embedded navigation guards. Align the existing Banner call and tests with its unchanged analytics payload contract so the dev static gate passes.
2026-09-10 16:06:53 +08:00
CodingOnStar
0fb011126e fix(web): never navigate embedded recommend plugin or template cards
Dify console marketplace should open plugin and template dialogs for
catalog cards and recommend banners. Resolve plugin identity from
/plugins/org/name links when item_id is not org/name. The standalone
marketplace still navigates to the item page.
2026-09-09 16:48:27 +08:00
CodingOnStar
06d45f7c09 fix(web): open embedded recommend banner templates in the detail dialog
In-app marketplace recommend template cards now reuse the template
detail dialog, matching plugins. The standalone marketplace still
navigates to the template page.
2026-09-09 16:25:58 +08:00
CodingOnStar
0047772f15 Merge remote-tracking branch 'origin/feat/creator-profile-home' into deploy/dev 2026-09-03 14:19:08 +08:00
CodingOnStar
0584b26883 fix(web): satisfy vp check for marketplace catalog prefetch
PR style CI failed oxfmt on the catalog preview files. Split prefetch
out of the HydrateQueryClient module so the component file only exports
a component, and stop asserting extra list fields as Plugin.
2026-09-03 14:18:23 +08:00
CodingOnStar
b10d9aedd8 Merge remote-tracking branch 'origin/feat/creator-profile-home' into deploy/dev 2026-09-03 13:46:55 +08:00
CodingOnStar
ebccd86338 fix(web): overlap homepage banners with catalog prefetch
Banners and collection prefetch no longer run back-to-back 2.5s waits.
The home variant starts both under one server budget and reuses the
dehydrated state so a slow banner cannot add a second document stall.
2026-09-03 11:56:21 +08:00
CodingOnStar
aa40a404d9 fix(web): align embedded template collection previews with warmup
Embedded /templates still posted limit 24 and kept readme in the RSC
payload, so it missed the Redis bucket the scheduler writes at limit 20.
2026-09-03 11:48:59 +08:00
CodingOnStar
0b847ede3e fix(web): preview marketplace collection plugins at limit 20
Homepage carousels only show ~8 cards per page but posted an unbounded
collection body, so RSC hydrated full plugin rows. Match the template
preview contract so warmup can write the Redis key the catalog reads.
2026-09-03 11:45:45 +08:00
CodingOnStar
08b3585999 Merge remote-tracking branch 'origin/feat/creator-profile-home' into deploy/dev 2026-09-02 17:39:19 +08:00
CodingOnStar
463dc59d13 fix(web): navigate marketplace search results without App Router
Standalone suggestion clicks assign window.location instead of useRouter,
so marketplace tests can render the shared autocomplete without a Next
app-router tree.
2026-09-02 17:38:53 +08:00
CodingOnStar
f9106e5ccd Merge remote-tracking branch 'origin/feat/creator-profile-home' into deploy/dev 2026-09-02 17:24:39 +08:00
CodingOnStar
0960ef4f0f fix(web): open marketplace search suggestions as details
Drop View more from the autocomplete popup, match the panel width to the
search field, and send a chosen plugin or template to its detail instead
of submitting /search/all.
2026-09-02 17:23:52 +08:00
CodingOnStar
eb8e3616d2 Merge remote-tracking branch 'origin/deploy/dev' into deploy/dev 2026-09-02 16:40:18 +08:00
CodingOnStar
6317b5c49f Merge branch 'feat/creator-profile-home' into deploy/dev 2026-09-02 16:39:05 +08:00
CodingOnStar
39a289ffa7 Merge branch 'feat/creator-profile-home' into deploy/dev
# Conflicts:
#	api/controllers/console/agent/composer.py
#	api/controllers/console/agent/roster.py
2026-09-02 16:39:05 +08:00
CodingOnStar
630a3bd5d2 fix(web): keep standalone marketplace files in knip production
Mark the standalone host entries with `!` so knip --production traces
Marketplace, HydrateQueryClient, and MarketplaceLiveSearch.
2026-09-02 16:38:58 +08:00
CodingOnStar
f05875d390 fix(web): drop unused marketplace knip exports
Keep CREATOR_PAGE_SIZE, publisherPageHasMore, and MarketplaceSearchScope
file-local; they are only used in the same modules.
2026-09-02 16:29:19 +08:00
CodingOnStar
00ec45088f fix(dify-ui): wait for Form Dialog initial focus
The Name field is focused after the open animation, so asserting
immediately left the play function racing the trigger button.
2026-09-02 16:29:01 +08:00
CodingOnStar
7b0068fc27 fix(web): submit marketplace home search on Enter
Enter without a keyboard-highlighted suggestion submits the typed query,
matching View more, instead of opening a hovered result.
2026-09-02 16:15:33 +08:00
CodingOnStar
ea6c67b231 fix(web): paginate creator inventory with load more
SSR only the first publisher page instead of five sequential fetches,
and let the profile load remaining plugins and templates on demand.
Harden social_links and deps_plugins so dirty API payloads cannot crash
the creator page.
2026-09-02 15:22:17 +08:00
CodingOnStar
b86cc38f97 Merge remote-tracking branch 'origin/deploy/dev' into deploy/dev 2026-09-02 15:03:07 +08:00
CodingOnStar
d2b8b14ea8 Merge branch 'feat/creator-profile-home' into deploy/dev
Keep deploy/dev agent RBAC decorators where they conflicted with main.
2026-09-02 14:57:19 +08:00
CodingOnStar
17135522af fix(web): address marketplace review comments
Gate iframe install on plugin.install, keep the 15s deadline off package
downloads, abort cancelled collection fetches, bound /templates prefetch,
and stop caching partial template-collection failures. Revert unrelated
Main Nav order and icon styling, remove the unused performance E2E
bundle, and expose a standalone Marketplace entry instead of Knip path
aliases.
2026-09-02 14:25:14 +08:00
CodingOnStar
c04ca2ff36 Merge remote-tracking branch 'origin/main' into feat/creator-profile-home
# Conflicts:
#	e2e/package.json
2026-09-02 10:42:27 +08:00
fatelei
e85339e06e fix: fix conflict 2026-09-02 09:14:24 +08:00
fatelei
5fe4ceb651 fix: fix conflict 2026-09-02 09:09:32 +08:00
fatelei
d659610049 feat: add legacy app.acl.access_point_view and app.acl.access_point_manage 2026-09-02 09:01:40 +08:00
twwu
db012bb659 fix(i18n): add access point permission translations 2026-09-01 22:11:31 +08:00
CodingOnStar
00bff6a281 fix(web): ignore AbortError when canceling banner autoplay 2026-09-01 21:08:58 +08:00
CodingOnStar
d1403139a9 Merge branch 'feat/creator-profile-home' into deploy/dev 2026-09-01 19:46:17 +08:00
CodingOnStar
341a4d0935 fix(web): open in-app marketplace details from the whole card (ECO-483)
Plugin and template cards now open the detail dialog from the card surface
and keyboard, while Install stays its own action.
2026-09-01 19:32:39 +08:00
CodingOnStar
c346306cf8 fix(web): reuse standalone search popup in the embedded marketplace (ECO-482)
In-app marketplace search now uses the same autocomplete panel as the public
site so typing a query can pick plugins or templates without leaving the page.
2026-09-01 19:32:16 +08:00
CodingOnStar
8c3a046bba fix(web): add Guide tooltip to in-app marketplace header icon
The icon-only Guide control used by the Dify-embedded Marketplace header
now shows a "Guide" tooltip on hover and keyboard focus, with a matching
accessible name. Opening the docs dropdown is unchanged.
2026-09-01 19:31:53 +08:00
CodingOnStar
d8a376edb3 fix(web): remove the in-app marketplace header account entry (ECO-481)
The embedded marketplace already sits inside Dify chrome, so the extra avatar
in the marketplace header is redundant. Creator Center and docs stay.
2026-09-01 19:31:19 +08:00
CodingOnStar
d77d9a51cf fix(web): install marketplace plugins in place without a confirm dialog (ECO-477)
Clicking Install in the embedded plugin detail iframe starts the install
immediately and posts status back to the iframe, instead of opening a second
confirmation modal.
2026-09-01 19:30:57 +08:00
CodingOnStar
ae77dd29da fix(api): restore deploy/dev alembic revision c8e7f6a5b4d3
The deploy/dev database is stamped at empty merge revision
c8e7f6a5b4d3, which disappeared after deploy/dev was rewritten.
Reintroduce that revision on top of remove_agent_drive and merge
it with the current head so flask db upgrade can locate the DB
and apply the migrations that landed after 20 Aug.
2026-09-01 17:51:06 +08:00
CodingOnStar
ff260f9be3 Merge branch 'feat/creator-profile-home' into deploy/dev 2026-09-01 17:31:18 +08:00
CodingOnStar
36c0d8205a fix(web): prune unused oxlint barrel suppression
Removing the unused Trigger icon export left a no-barrel-files
suppression count of 3 on the plugin icon barrel. TS Common fails
when suppressions no longer match.
2026-09-01 10:04:49 +08:00
CodingOnStar
98ade77312 fix(web): drop unused Trigger plugin icon
Homepage catalog switched to the iconify trigger class, so knip failed
Style Check on the leftover generated SVG export.
2026-09-01 09:56:06 +08:00
CodingOnStar
93fd770780 chore(web): shorten marketplace timeout and Flight comments
Keep the constraint, drop incident-history prose that will go stale now
that tests encode the async-page rule and the 15s deadline.
2026-08-31 22:59:50 +08:00
CodingOnStar
642760f95d fix(web): reject unsafe marketplace banner hrefs
CMS/partner banner links only required a non-empty string, so
javascript: and other schemes could render on the console homepage.
Allow http(s) and same-origin relative paths only.
2026-08-31 22:58:50 +08:00
CodingOnStar
a6e14ee3ac fix(web): load remaining creator creations beyond the first page
Publisher plugins and templates were capped at page 1 of 40, so larger
catalogs were silently truncated. Follow the API total through a small
page budget.
2026-08-31 22:57:28 +08:00
CodingOnStar
ee60f5dd29 fix(web): surface creator profile API errors instead of 404
A timed-out or 500 publisher request was mapped to notFound(). Only a
successful empty creator response should 404; transport failures should
reach the route error boundary.
2026-08-31 22:56:14 +08:00
CodingOnStar
a2bf4c397b fix(web): paginate template search by locale language
Unfiltered search fetched mixed-language pages then filtered in the
client, so totals and pageCount were wrong. Send the UI locale to the
API and keep client filtering only for collection previews.
2026-08-31 22:54:59 +08:00
CodingOnStar
3780a9fe84 fix(web): dual-write marketplace banner impressions
Clicks already go to Amplitude and __marketplaceTracking__. Impressions
only hit Amplitude, so marketplace.dify.ai never recorded views.
2026-08-31 22:53:31 +08:00
CodingOnStar
42f7088d9d fix(web): keep templates route sync under the client shell
Async /templates pages under MainNavLayout Flight-double-resolve the
same way /marketplace did. Load data in an async child instead.
2026-08-31 22:52:06 +08:00
CodingOnStar
c9bc4018a1 fix(web): drop marketplace PWA tags from console layout
Console cloud.dify.ai was advertising missing manifest/icons and
swallowing ResizeObserver errors on every route. Keep favicon in
generateMetadata; PWA chrome belongs to marketplace.dify.ai.
2026-08-31 22:50:22 +08:00
CodingOnStar
df62ed7298 chore(web): restore unrelated shared components to main
Drop leftover Explore carousel overlay, unused CopyIcon className,
dotenv markdown alias, and the unused Trigger plugin icon so this
marketplace PR no longer carries those shared-component diffs.
2026-08-31 22:21:17 +08:00
fatelei
d5278f836f Merge branch 'agent_acl' into deploy/dev 2026-08-31 22:06:58 +08:00
fatelei
ce8ce7626e feat: agent backend api add rbac 2026-08-31 22:05:53 +08:00
CodingOnStar
5dec7c76d4 fix(web): finish banner loop reset without rAF
Settle the wrapping carousel from resetting to idle with rAF plus a
short timeout fallback so loop clones unmount even when animation frames
are delayed.
2026-08-31 20:09:01 +08:00
CodingOnStar
6864ba33cd fix(web): keep marketplace knip production exports live
Use shouldSearchMarketplacePlugins and HERO_GRID_PITCH_PX from production
catalog code, keep getStandaloneCreationHref exported via the marketplace
model entry, drop the test-only language helper, and remove an unused type import.
2026-08-31 20:08:22 +08:00
CodingOnStar
c48f932985 fix(web): drop unused marketplace knip exports
Un-export file-private creator-profile and banner-viewability symbols.
Restore model-provider hooks tests from origin/main, and mark the
standalone marketplace server helpers as knip entries so production
file checks stay green.
2026-08-31 19:54:35 +08:00
CodingOnStar
ce65f9ae4c merge: integrate remote skill-api restore into local lint fixes 2026-08-31 19:33:28 +08:00
CodingOnStar
8765886d6c fix(web): satisfy marketplace lint and restore skill APIs from main
Restore the current skill/agent stack from origin/main after the merge, then fix marketplace lint, formatting, and type mismatches so Style Check can pass.
2026-08-31 19:32:32 +08:00
CodingOnStar
f3c0aee073 merge: bring origin/main into feat/creator-profile-home
Keep the marketplace frontend branch current so leftover api/ diffs
against the old merge-base no longer appear in the PR.
2026-08-31 19:21:17 +08:00
CodingOnStar
0eec43d086 chore(api): drop leftover skill backend from marketplace PR
Restore api/ and the generated console tag/workspace contracts to
origin/main so this branch no longer reverts Agent Skill work that
already landed on main.
2026-08-31 19:20:40 +08:00
CodingOnStar
65af1ca9ec merge: bring origin/main into feat/creator-profile-home 2026-08-31 19:10:20 +08:00
CodingOnStar
66ed8776a1 fix(web): clamp desktop blog tags and hide mobile read more
Keep the green blog label on one line with ellipsis on desktop, let
the title wrap, and drop the mobile Read more control now that the
whole stacked slide is the link.
2026-08-31 18:51:48 +08:00
CodingOnStar
9301920fec fix(web): wrap mobile blog banner titles (ECO-476)
Let stacked blog titles wrap instead of single-line ellipsis, and grow
the 160px copy frame so the heading stays fully visible.
2026-08-31 18:07:45 +08:00
CodingOnStar
3030ff3e4e fix(web): stop stacking mobile search and tab padding
Tuck the catalog tabs under the sticky search padding so the banner
gap stays, but search and Plugins/Templates tabs no longer add two
gaps on top of each other.
2026-08-31 17:53:15 +08:00
CodingOnStar
1eccdf60ed fix(web): ellipsize mobile blog tag and title inside copy padding
Keep the green tag and title to one line with ellipsis so they stay
inside the 20px stacked copy inset instead of overflowing the right
edge.
2026-08-31 17:44:03 +08:00
CodingOnStar
9e4d667161 fix(web): keep mobile blog description two lines visible
Reserve 40px for the two-line description and ellipsize the title so
the stacked 160px copy frame no longer clips the second line.
2026-08-31 17:28:44 +08:00
CodingOnStar
1c5ec977ed fix(web): keep bottom padding under stuck mobile search (ECO-475)
Give the standalone mobile sticky search a 16px padding-bottom and
shift catalog sticky offset by the same amount so banners do not
sit flush against the field.
2026-08-31 17:20:40 +08:00
fatelei
1b2c11377f Merge branch 'agent_acl' into deploy/dev 2026-08-31 17:19:51 +08:00
fatelei
39093c0749 feat: agent support rbac 2026-08-31 17:18:15 +08:00
CodingOnStar
2b38ddd042 fix(web): clamp mobile blog banner subtitle and description
Ellipsize the subtitle at one line and the description at two lines
on the standalone marketplace mobile stacked blog frame.
2026-08-31 17:12:28 +08:00
CodingOnStar
a515fe0917 fix(web): pin desktop banner art so shrinking clips the right
Keep blog artwork at 400px and event/ad bitmaps at least 1200px on
PC so a narrower overflow-hidden frame trims the right, not the left.
2026-08-31 17:06:45 +08:00
CodingOnStar
006eb63bf2 fix(web): show mobile homepage banners at 800:721
Keep the 800x721 event/ad poster whole with contain instead of
cover-cropping it into the stacked 357px mobile frame.
2026-08-31 16:39:46 +08:00
CodingOnStar
19235d024e fix(web): crop homepage banners from the right
Keep event and ad artwork left-aligned with cover cropping so a narrower
frame trims the right edge on desktop and mobile.
2026-08-31 16:19:22 +08:00
CodingOnStar
c244b9d685 Merge origin/main into feat/creator-profile-home
Keep marketplace card-wrapper dialog and partner-only carousel autoplay.
Take main's skills permissions, skill card actions, and agent orchestrate
layout. Union oxlint suppressions from both sides.
2026-08-31 10:13:38 +08:00
CodingOnStar
027f90db27 fix(web): autoplay only partner collection carousels
Keep arrows and pagination on every collection carousel. Only the
Partners collection rotates on its own.
2026-08-31 10:00:21 +08:00
CodingOnStar
b83fb96827 fix(web): autoplay only partner collection carousels
Keep arrows and pagination on every collection carousel. Only the
Partners collection rotates on its own.
2026-08-31 09:57:29 +08:00
CodingOnStar
380b1de3ff fix(web): keep search-results search below the header
Search pages have no hero, so HomeSearch must not overlap the brand bar.
Allow a null catalog tab so neither Plugins nor Templates looks selected.
2026-08-30 10:24:24 +08:00
CodingOnStar
3c4a462ae4 fix(web): put template Partners CTA beside the title (ECO-472)
The plugin catalog already used the mobile partner header grid; the
Templates tab still rendered Become a Partner on the description row.
Share the same header styles so both catalogs match.
2026-08-30 00:44:09 +08:00
CodingOnStar
dfb8b2e376 fix(web): pin sticky marketplace search without jumping (ECO-473)
Keep the standalone mobile search sticky below the header instead of
dropping sticky, and stop Chromium from scrolling the in-flow search box
into view when the desktop header search is focused or typed into.
2026-08-30 00:22:57 +08:00
CodingOnStar
d8f30076b5 fix(web): keep partner CTA 12px from title (ECO-472) 2026-08-28 20:40:59 +08:00
CodingOnStar
d9d510e5ac fix(web): keep mobile marketplace search below header (ECO-473) 2026-08-28 20:13:00 +08:00
fatelei
ad20475144 Merge branch 'access_point' into deploy/dev 2026-08-28 19:26:51 +08:00
fatelei
f623e469a7 chore: update permission key name 2026-08-28 19:25:52 +08:00
fatelei
d5000f5218 Merge branch 'permission' into deploy/dev 2026-08-28 17:35:21 +08:00
fatelei
b3f1fd46eb Merge branch 'access_point' into deploy/dev 2026-08-28 17:19:34 +08:00
fatelei
17d73d0c55 feat: add access point permission 2026-08-28 17:15:59 +08:00
CodingOnStar
41cb3a8502 fix(web): keep marketplace search scroll stable 2026-08-28 14:48:34 +08:00
CodingOnStar
4a471894a9 fix(web): stabilize catalog tab header handoff (ECO-470) 2026-08-28 14:08:16 +08:00
fatelei
998ef75459 Merge branch 'skill_entry' into deploy/dev 2026-08-28 12:08:11 +08:00
fatelei
324b69bb3c feat: hide skill_entry when use has no skill.view permission 2026-08-28 12:01:09 +08:00
CodingOnStar
e2add239a8 fix: align marketplace search suggestions (ECO-455) 2026-08-28 11:35:30 +08:00
CodingOnStar
8dc32a121b fix(web): align mobile partner header (ECO-472) 2026-08-27 18:17:22 +08:00
CodingOnStar
87097815c0 fix(web): add mobile banner swipe (ECO-471) 2026-08-27 17:56:29 +08:00
CodingOnStar
2a7025e758 fix(web): smooth catalog tabs into header (ECO-470) 2026-08-27 17:07:19 +08:00
L1nSn0w
66a3c9620a fix(web): stop Marketplace search jitter and indefinite loading
Three independent defects made catalog search feel unstable.

Stale results flash on every search. `isSearchMode` was derived from the raw
nuqs `q` while the request body used the 500ms-debounced value, so keystroke #1
flipped into search mode with an empty query. That fired a full empty-query
search whose generic top-plugins list rendered for the debounce window before
the real results replaced it. Both now read the same debounced value.

Grid unmount on every query change. The infinite query had no
`placeholderData`, so `data` went undefined whenever the key changed and
ListWrapper's `!isLoading` gate tore the whole result grid out, collapsing the
container and jumping the scroll position. Measured against a live catalog, a
sort change blanked the grid for 152 of 360 sampled frames; with
`keepPreviousData` and an `isRefreshing` dim it is 0 of 461.

Requests that never settle. The Marketplace oRPC link called `globalThis.fetch`
with no deadline, so a stalled connection left the query pending forever with
no error state and no retry — the reported permanent spinner. Requests now
carry a 15s deadline composed with (never replacing) react-query's abort
signal. The server prefetch and the banner fetch get their own 2.5s budget:
awaiting them unbounded held the whole RSC response, pushing time-to-first-byte
to ~7s against a 3s-delayed API while the browser sat on the previous page.

Supporting changes:

- `getMarketplacePlugins` and `getMarketplaceCollectionsAndPlugins` no longer
  swallow every rejection into a successful empty page. That turned outages
  and aborted keystrokes into "no plugins found", suppressed retries, cached
  the emptiness for the full staleTime, and killed `getNextPageParam`
  permanently via `total: 0`. ListWrapper now renders a retry affordance.
- The collections fan-out is bounded to 4 concurrent requests instead of
  firing one per collection at once, and per-collection failures degrade to an
  empty carousel rather than a blank catalog.
- Scroll pagination measures once per frame instead of per scroll event. Each
  threshold hit calls `fetchNextPage`, which defaults to `cancelRefetch: true`,
  so an unthrottled burst aborted and restarted the in-flight page request —
  and the backend counts those aborts against its search circuit breaker.
- Cross-page results are deduped by `org/name`; Meilisearch resolves
  `install_count` ties by document order and the sync task rewrites those
  documents every minute, so pages can overlap and collide on React keys.
- Search queries no longer inherit `retry: 3`; ~7s of backoff reads as a hang.
2026-08-27 16:27:42 +08:00
CodingOnStar
cdcafa0827 fix(web): add subtle creator avatar shadow (ECO-469) 2026-08-27 16:24:19 +08:00
CodingOnStar
efb8db8db3 fix(web): keep creator profile background continuous (ECO-468) 2026-08-27 16:22:45 +08:00
CodingOnStar
74bfab20a6 fix(web): crop ad banners from the right (ECO-467) 2026-08-27 16:02:15 +08:00
CodingOnStar
259fe7116e fix(web): align mobile banner heights (ECO-466) 2026-08-27 15:58:54 +08:00
CodingOnStar
c31b852767 fix: remove persistent search result arrows (ECO-465) 2026-08-27 15:39:23 +08:00
CodingOnStar
e8954d609e fix: refine marketplace search result spacing (ECO-464) 2026-08-27 15:37:58 +08:00
CodingOnStar
abb8f5c239 fix: prevent marketplace search layout shift (ECO-462) 2026-08-27 15:33:42 +08:00
fatelei
5a7345c32d chore: add permission check back 2026-08-27 15:26:35 +08:00
CodingOnStar
54f4073f22 fix(web): make banner carousel loop seamlessly (ECO-458) 2026-08-27 15:02:31 +08:00
CodingOnStar
f3a08a78eb fix(web): preserve blog banner image corners (ECO-457) 2026-08-27 14:54:48 +08:00
CodingOnStar
2f7267962b fix(web): align marketplace catalog navigation (ECO-456) 2026-08-27 14:52:56 +08:00
CodingOnStar
96ba0cb1f8 fix(web): use divider between marketplace filters (ECO-454) 2026-08-27 14:28:04 +08:00
CodingOnStar
cb6d38d9f9 Revert "fix(web): keep creator profiles when only published work exists"
This reverts commit cd02aeb53c.
2026-08-26 18:05:13 +08:00
CodingOnStar
cd02aeb53c fix(web): keep creator profiles when only published work exists
Template author pages 404'd when the marketplace creators row was missing,
even if /templates/publisher/{handle} already returned public templates.
2026-08-26 18:02:54 +08:00
CodingOnStar
e7e502d1e7 merge: bring origin/main into feat/creator-profile-home 2026-08-26 15:28:59 +08:00
CodingOnStar
debda8ac5c fix(web): drop the extra rest-state border from marketplace search
Match the homepage Input Field: rest uses the fill only, with the gray
stroke only on hover and focus.
2026-08-25 19:34:00 +08:00
CodingOnStar
65d89e187f fix(web): snap marketplace hero icons to the grid and wash the title
Align the 41px hero grid to the same 50% origin as the six icons so
the 1px strokes sit flush with each tile. Match Figma Ellipse 5 for
the title/search wash, and size the hero to four grid rows so the
bottom icons are not clipped.
2026-08-25 18:59:26 +08:00
CodingOnStar
6476d0018c fix(web): fade marketplace home hero grid toward the edges
Match Figma's inset grid wash so the lines taper out instead of
meeting the viewport at full strength.
2026-08-25 15:22:34 +08:00
CodingOnStar
d942e963bb fix(web): restyle marketplace home hero with grid and six icons
Replace the cycling brand marks with the new Figma decorations: a
divider-subtle grid, six static category icons, and hide both on
mobile widths.
2026-08-25 14:47:36 +08:00
CodingOnStar
7181812aff fix(web): pick event/ad mobile banner images on phones
Phones use images.mobile when present, otherwise desktop. Tablet
media no longer matches mobile widths.
2026-08-25 13:55:11 +08:00
CodingOnStar
62c7b5c989 merge: bring origin/main into feat/creator-profile-home 2026-08-25 12:15:52 +08:00
CodingOnStar
0081fb4e2b fix(web): match standalone mobile recommend and event banners
Use the dedicated backdrop on independent-site recommend frames, and size event/ad mobile frames to the 800×721 slot with cover fill.
2026-08-25 12:11:52 +08:00
CodingOnStar
ebe06c7eee merge: bring origin/main into feat/creator-profile-home 2026-08-25 11:28:23 +08:00
CodingOnStar
87cb98087f fix(web): drop Forum from the help menu and label Discord
The sidebar help popup no longer links to the forum. The previous
Community entry now reads Discord and still opens the Discord invite.
2026-08-25 10:57:33 +08:00
CodingOnStar
6e5cd92eec fix(web): match standalone marketplace mobile banners and collections
Show recommend as icon-only on mobile, keep event/ad posters uncropped,
and hide collection carousel dots under 879px.
2026-08-24 19:02:46 +08:00
CodingOnStar
0da1c88de7 feat(web): add standalone marketplace homepage mobile layout
Stack homepage banners, search, and catalog to the 879px Figma mobile
frame on the independent marketplace site only. Event and ad slides use
the 357px mobile art frame; blog and recommend stay stacked 197+160.
2026-08-24 15:45:26 +08:00
CodingOnStar
c4615c3f2f fix(web): match marketplace banner play control to Figma 2026-08-24 14:40:11 +08:00
FFXN
c6b97a4936 Merge remote-tracking branch 'origin/main' 2026-08-24 14:00:11 +08:00
CodingOnStar
26763c33a1 fix(web): use current dialog close primitive 2026-08-24 12:50:40 +08:00
CodingOnStar
a9433195a1 feat(web): add Creator Center to the sidebar help menu 2026-08-24 12:39:10 +08:00
CodingOnStar
1503de3aa5 fix(web): hide bundles from marketplace plugin categories 2026-08-24 12:39:00 +08:00
CodingOnStar
e377dd0d91 merge: bring origin/main into feat/creator-profile-home
# Conflicts:
#	api/controllers/console/tag/tags.py
#	api/openapi/markdown/console-openapi.md
#	api/services/app_service.py
#	oxlint-suppressions.json
#	packages/contracts/generated/api/console/tags/types.gen.ts
#	packages/contracts/generated/api/console/tags/zod.gen.ts
#	packages/contracts/generated/api/console/workspaces/orpc.gen.ts
2026-08-24 12:15:37 +08:00
FFXN
21e2749356 fix(api): use external URL for plugin file previews 2026-08-24 11:35:15 +08:00
FFXN
731af391af Merge remote-tracking branch 'origin/main' 2026-08-24 11:27:30 +08:00
FFXN
368e52ea6b Merge remote-tracking branch 'origin/main' 2026-08-21 16:40:02 +08:00
FFXN
5c299c17c1 Merge remote-tracking branch 'origin/main' 2026-08-21 10:11:51 +08:00
FFXN
b20877fac2 Merge remote-tracking branch 'origin/main' 2026-08-20 18:11:25 +08:00
FFXN
ab9d0330ff Merge remote-tracking branch 'origin/fix/fix-snippet-export' 2026-08-20 16:34:56 +08:00
FFXN
ea4f31920b test(snippets): add unit tests for useExportSnippetMutation hook 2026-08-20 16:32:32 +08:00
autofix-ci[bot]
8d0603ec6f [autofix.ci] apply automated fixes 2026-08-20 08:14:53 +00:00
FFXN
3e00eb4b1e Merge remote-tracking branch 'origin/main'
# Conflicts:
#	api/controllers/console/snippets/snippet_workflow.py
#	api/tests/unit_tests/controllers/console/snippets/test_snippet_workflow.py
#	packages/contracts/generated/api/console/snippets/orpc.gen.ts
2026-08-20 16:05:00 +08:00
CodingOnStar
b19ed8869c fix(web): restore marketplace header request/publish dropdown
The redesigned HomeHeader Guide action jumped straight to docs. Restore
the four-option book-icon dropdown used on production Marketplace.
2026-08-20 15:50:35 +08:00
CodingOnStar
e3b8e26192 merge: bring origin/main into feat/creator-profile-home
# Conflicts:
#	api/openapi/markdown/console-openapi.md
#	api/services/agent_config_service.py
#	api/tests/unit_tests/services/agent/test_skill_standardize_service.py
#	knip.config.ts
#	oxlint-suppressions.json
#	packages/contracts/generated/api/console/agent/zod.gen.ts
#	packages/contracts/generated/api/console/apps/zod.gen.ts
2026-08-20 15:18:59 +08:00
FFXN
4fcca7a66d fix(snippets): support exporting and deleting workflow versions
- export DSL from a selected published snippet workflow
- wire the snippet DSL export handler into version history
- add the missing DELETE endpoint for historical versions
- prevent deletion of draft and currently active versions
- regenerate API contracts and add regression tests
2026-08-20 15:11:34 +08:00
FFXN
6965728f11 Merge remote-tracking branch 'origin/main' 2026-08-20 11:42:18 +08:00
CodingOnStar
5088a4bb47 fix(web): align marketplace_banner_click site event payload
Map recommend banners to recommendation, emit theme/item/blog/ad/event
fields only for the matching click target, and keep banner language as
stored.
2026-08-20 11:38:25 +08:00
CodingOnStar
5a55909579 fix(web): use Creator Center default avatar on marketplace profiles
Match the missing-avatar fallback to Creator Center settings: brand-blue gradient and a white initial, instead of the gray letter tile.
2026-08-20 11:12:46 +08:00
CodingOnStar
4ef15cae6a fix(web): sort creator profile creations through publisher APIs
Pass sort_by and sort_order to the publisher plugin and template lists, and keep the mixed Creations grid ordered after both responses return.
2026-08-19 18:23:35 +08:00
CodingOnStar
803c3cd2df fix(web): restore creator profile contracts and i18n
The standalone and embedded creator pages call marketplaceClient.creatorDetail,
but those routes were dropped from the marketplace contract. Restore them, skip
avatar fetches when none was uploaded, and add creatorProfile strings to every
plugin locale.
2026-08-19 16:37:14 +08:00
CodingOnStar
62cb8a9c74 fix(web): resume trending autoplay after pointer pagination
Pointer clicks leave hover/focus on the dots, which kept rotation
paused until blur. Keyboard selection still keeps the focus pause.
2026-08-19 09:32:39 +08:00
CodingOnStar
1dfdc7c4f9 fix(web): stop streaming async marketplace children on cloud
HydrateQueryClient is an async Server Component. Under the client
console shell, Flight still double-resolves it and throws
reason.enqueueModel even when the route module itself is sync.
2026-08-19 09:21:20 +08:00
CodingOnStar
a5e1e94c0c fix(web): keep embedded marketplace routes synchronous
An async /marketplace page under the client console shell makes Flight
double-resolve the segment and throw reason.enqueueModel on
cloud.dify.dev. Banner fetching already lives on the client.
2026-08-19 09:10:09 +08:00
CodingOnStar
f1e0e463c1 Merge remote-tracking branch 'origin/main' into feat/creator-profile-home 2026-08-19 08:52:17 +08:00
CodingOnStar
3713581520 Merge remote-tracking branch 'origin/main' into feat/creator-profile-home
# Conflicts:
#	.editorconfig
#	oxlint-suppressions.json
#	web/app/components/base/markdown-blocks/code-block.tsx
#	web/app/layout.tsx
2026-08-18 21:30:57 +08:00
CodingOnStar
cde9aa5237 fix(web): keep marketplace route layout on the server
A client marketplace layout wrapping the async page makes Flight
double-resolve streamed children (`reason.enqueueModel`) on
cloud.dify.dev/marketplace.
2026-08-18 14:34:12 +08:00
CodingOnStar
803d5d3fe5 fix(web): keep marketplace autocomplete open during async search
Tie controlled open to the typing session so outside press can dismiss
during debounce, and keep Status/Empty mounted for the pending live region.
2026-08-18 11:39:23 +08:00
CodingOnStar
6ab0c21172 refactor(web): split marketplace autocomplete onto main popup anatomy
Use Autocomplete Portal/Positioner/Popup after the dify-ui split, restore
creator-profile selection, and move the language filter search to InputGroup.
2026-08-18 10:44:16 +08:00
CodingOnStar
4d8bd40058 merge: bring origin/main into feat/creator-profile-home
Keep marketplace workspace-skill runtime tests and take main's
autocomplete popup anatomy plus input-group composition.
2026-08-18 10:39:42 +08:00
CodingOnStar
5712cf91dc feat(web): emit marketplace site events from the shared catalog
Forward standalone marketplace Amplitude events from shared catalog
UI, including banner, card, filter, search, and creator/partner clicks.
2026-08-17 21:22:38 +08:00
CodingOnStar
ec9eb308ef feat(web): animate marketplace hero icons with grouped spring swaps
Cycle the four hero marks as a group every 4s, retracting toward the
title and expanding back out so later 4-set icon lists can drop in.
2026-08-17 17:15:56 +08:00
CodingOnStar
55dcaea347 merge: restore marketplace catalog tag and language filters
Combine homepage catalog filters with the later marketplace hero icon fixes.
2026-08-17 16:19:08 +08:00
CodingOnStar
d4c265551a fix(web): swap marketplace hero icons for themed Figma brand marks
Replace the homepage hero decorations with light/dark Dropbox, DuckDuckGo, GitHub, and Google assets, and hide them on narrow viewports.
2026-08-17 16:12:12 +08:00
CodingOnStar
f1ab5df40d fix(web): keep marketplace permission provider inside SSR
Wrapping HydrateQueryClient with the client install-permission provider
makes Flight double-resolve streamed children and throw enqueueModel.
2026-08-17 15:49:34 +08:00
姜涵煦
9b2368ca6b feat(web): restore templates language filter on the catalog bar 2026-08-17 15:27:32 +08:00
姜涵煦
fdc7338431 feat(web): put TagFilter to the left of marketplace plugin categories 2026-08-17 15:14:00 +08:00
CodingOnStar
2ee3927a10 fix(web): render the marketplace hero Gmail mark
The iconify CSS class masks to currentColor, so the multi-color Gmail
logo was an empty tile. Use the SVG asset the same way as DuckDuckGo.
2026-08-17 15:12:54 +08:00
姜涵煦
7440e27381 feat(web): restore marketplace languages query state 2026-08-17 15:08:35 +08:00
CodingOnStar
7ed77c05d0 feat(web): track marketplace banner impressions and clicks
Count a frame impression after the current slide stays 50% visible for 1s, and emit Amplitude click/item_click events that match the banner export schema.
2026-08-17 14:28:49 +08:00
CodingOnStar
fab9d78c48 fix(web): request templates banners on the templates page 2026-08-17 14:14:30 +08:00
CodingOnStar
24b977da27 Merge branch 'feat/marketplace-homepage-redesign' into feat/creator-profile-home 2026-08-17 13:01:32 +08:00
CodingOnStar
f5ac519089 Merge branch 'main' into feat/marketplace-homepage-redesign 2026-08-17 12:54:54 +08:00
CodingOnStar
90692c2d92 fix(web): show marketplace search results without a searching placeholder
Keep the suggestion popup closed until results settle so the standalone
site does not flash the missing gotoAnything.searching key. Render
plugin and template icons from the marketplace asset URLs.
2026-08-16 17:14:59 +08:00
CodingOnStar
94aa8adebf fix: keep marketplace search autocomplete as a knip entry 2026-08-16 16:59:23 +08:00
CodingOnStar
1b4461f1c1 Merge branch 'main' into feat/marketplace-homepage-redesign 2026-08-16 16:40:44 +08:00
CodingOnStar
0746dcc354 fix(web): keep Creator Center on the matching marketplace environment
Prefer the current marketplace origin so marketplace.dify.dev opens creators.dify.dev instead of inheriting a baked-in .ai URL.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 16:31:03 +08:00
CodingOnStar
ea6935060b fix(web): keep embedded marketplace search live without suggestions
The public marketplace owns the suggestion dropdown. Console plugin and
template search keep updating results as the query changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 16:28:01 +08:00
CodingOnStar
cc2aa8a5d9 Merge remote-tracking branch 'origin/feat/marketplace-homepage-redesign' into feat/marketplace-homepage-redesign 2026-08-16 15:24:57 +08:00
CodingOnStar
cf121dcc10 refactor(web): share the marketplace home shell and validate banners with zod
Compose the Plugins and Templates catalog homes from one HomeShell so the
page scaffold cannot drift, split the templates page into category
navigation, pagination and link helpers, and hand the collection list
pre-filtered locale data instead of filtering twice. Replace the
hand-rolled banner payload guards with a zod schema keeping the same
lenient semantics, share the partner-collection constants, label the
plugin type filter group as categories, and backfill the missing Lao
trending title.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 15:22:51 +08:00
autofix-ci[bot]
d6ca57ca14 [autofix.ci] apply automated fixes 2026-08-16 07:07:33 +00:00
CodingOnStar
0fc3791bb6 refactor(web): split marketplace home trending and share catalog constants
Break the trending banner into navigation and slide modules with shared
home constants, hoist the marketplace container id and partner-collection
constants for reuse across the catalog, and drop the i18n keys the
redesigned home no longer renders.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 15:04:39 +08:00
CodingOnStar
66f63a074b fix(web): stop marketplace carousel autoplay from crashing on a single page
The autoplay plugin skips its own setup when a carousel has one page, so
the eager play() path dereferenced an undefined delay list and crashed
the standalone marketplace templates tab. Keep autoplay stopped for
single-page carousels and pause rotation permanently once focus enters,
dropping the play/pause toggle that resumed it.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 15:03:10 +08:00
CodingOnStar
5f52b94aa0 fix(web): declare webp assets and drop an unused carousel export
CI type-checks without the Next-generated image declarations, so *.webp
needs an explicit module declaration like the other formats. Also keep
getCarouselItemsPerPage module-private since only the hook consumes it,
which unblocks the knip production check.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 14:01:08 +08:00
CodingOnStar
a4a57ec662 fix(web): address marketplace review follow-ups on assets, locale and a11y
- resample the 3MB trending background to a 97KB webp and serve a frozen
  recommend banner from the E2E stub so the benchmark covers the carousel
- show the on-page filtered count in template search and redirect
  out-of-range pages to the last available page
- localize the carousel controls across all 24 locales and replace the
  i18n type casts with typed selectors
- surface Marketplace API failures with a retry action instead of
  rendering them as empty results
- use iconify marks for the hero decorations, share the carousel
  page-size hook to avoid a hydration mismatch, move banner types into
  @dify/contracts, scope the Cmd+K shortcut to the standalone platform,
  point the embedded logo at /marketplace, pass resolvedTheme to the
  detail dialogs, and reveal the detail iframe after a loading timeout

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 13:48:34 +08:00
CodingOnStar
3087b45017 fix(web): render marketplace Creator Center from a client module
home-header.tsx is a server component on the standalone marketplace
homepage, where calling buttonVariants() from the client-marked
dify-ui button module crashes SSR with a 500. Extract the Creator
Center link into its own 'use client' file, mirroring home-guide.tsx.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-16 11:35:18 +08:00
CodingOnStar
618f2b8e00 style: remove gray border lines from marketplace home header
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 19:46:59 +08:00
CodingOnStar
7eb37099d9 fix: restore marketplace entry modules consumed by dify-marketplace
The knip production check flagged these as unused, but the outer
dify-marketplace repository imports them via path aliases into its own
Next.js app, so deleting them broke that build. Restore them and
declare them as knip entries with the cross-repo consumer documented.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 16:06:57 +08:00
CodingOnStar
e07dd9e2f2 style: exempt generated suppression files from final-newline rule
autofix.ci regenerates oxlint-suppressions.json without a trailing
newline, which SuperLinter's editorconfig check then rejects, creating
an endless fix/revert loop between the two bots. Exempt the generated
suppression files instead.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 15:02:34 +08:00
CodingOnStar
71d0462287 fix: remove dead marketplace entry files and relax long-task budget
knip production flagged web/app/components/plugins/marketplace/index.tsx
and home/marketplace-live-search.tsx as unused after the homepage page
composed EmbeddedMarketplace directly; drop them with their specs. Raise
LONG_TASK_BUDGET_MS to 300ms since hydration peaks ~220ms on shared CI
runners under 4x CPU throttling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 14:53:19 +08:00
autofix-ci[bot]
83b53d7b5b [autofix.ci] apply automated fixes 2026-08-15 06:44:03 +00:00
CodingOnStar
e7ca2735bf fix: address marketplace review feedback on CI lane, templates and carousel a11y
- move the marketplace performance E2E lane out of main CI (opt-in workflow_dispatch)
- add server-rendered pagination for template search and validate page/view/sort params at the route boundary
- match template locale filtering by language prefix with an explicit English fallback
- cache the template collection fan-out (TTL + in-flight dedupe), lower per-collection limit and batch requests
- reset the forced search mode atom when the plugin catalog mounts
- clear stale search suggestions during the debounce window
- let the trending Play control override focus/hover pause reasons
- honor prefers-reduced-motion on the eagerly playing first-collection carousel
- give the Creator Center link an accessible, localized name
- replace the nested main landmark on the templates page with a div
- localize banner read-more actions and backfill marketplace.home keys across all 24 locales

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 14:40:03 +08:00
autofix-ci[bot]
05acab169b [autofix.ci] apply automated fixes 2026-08-15 06:01:08 +00:00
CodingOnStar
7b5bc3dc50 style: restore final newline in oxlint-suppressions.json
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-15 13:58:29 +08:00
autofix-ci[bot]
39b7b3ca51 [autofix.ci] apply automated fixes 2026-08-15 05:53:57 +00:00
CodingOnStar
d825dd75dc Merge remote-tracking branch 'origin/main' into feat/marketplace-homepage-redesign
Co-authored-by: Cursor <cursoragent@cursor.com>

# Conflicts:
#	oxlint-suppressions.json
#	web/app/account/oauth/authorize/__tests__/page.spec.tsx
#	web/app/account/oauth/authorize/page.tsx
#	web/app/components/plugins/marketplace/list/__tests__/list-with-collection.spec.tsx
2026-08-15 13:44:00 +08:00
CodingOnStar
f033532d7b fix(web): address marketplace review feedback and clean up login code
Resolve the review comments on the marketplace homepage: restore list SSR
hydration with an eager first collection, make the loading detail iframe
inert, drop stale search suggestions, add carousel pause controls and
off-screen a11y handling, widen the trending pause boundary, bind initial
banners to the server locale, and remove nested interactive links plus dead
CSS scan config. Refactor the marketplace silent-authorize hook to be
self-contained, extract shared OAuth URL helpers, and delete the unused
allowedAbsoluteOrigins option. Serve the performance benchmark from a frozen
local marketplace API stub and gate it with a dedicated Chromium CI lane.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 18:03:10 +08:00
CodingOnStar
cade438670 fix(web): unblock marketplace homepage CI
Stop exporting unused marketplace helpers and remove the leftover
Trigger plugin icon. Assert the card icon through its img src instead
of a background-image style the marketplace variant no longer uses.
2026-08-14 13:44:25 +08:00
CodingOnStar
14e26b3768 fix(web): make the blog banner fully clickable
Wrap the entire blog banner slide in the destination link so the
title, body, artwork, and Read more label all open the same URL.
2026-08-14 13:21:43 +08:00
CodingOnStar
cc06433dd3 fix(web): stop allowing Marketplace origin as a post-login redirect
Marketplace login returns to the same-origin authorize URL, so the
Marketplace prefix does not belong on the post-login allowlist.

Also add that origin to frame-src so the detail iframe is not blocked
when a CSP whitelist is enabled.
2026-08-14 12:38:03 +08:00
CodingOnStar
3785891102 fix(web): silent-authorize Marketplace by client id only
The flow=marketplace query was a hidden handshake with the Marketplace
authorize URL. Cloud now treats the configured Marketplace client as
first-party and skips consent without that marker.

Extract the silent-authorize effect so the generic OAuth page no longer
grows Marketplace-specific state.
2026-08-14 12:37:57 +08:00
CodingOnStar
f1d8d00ab7 fix(web): align marketplace hero decoration icons with Figma
Use the design-frame coordinates for Dropbox, DuckDuckGo, GitHub, and Gmail.
2026-08-14 10:53:31 +08:00
CodingOnStar
889bd18da4 Merge branch 'feat/marketplace-homepage-redesign' into feat/creator-profile-home 2026-08-14 10:34:47 +08:00
autofix-ci[bot]
8dcd7073de [autofix.ci] apply automated fixes 2026-08-14 02:34:31 +00:00
CodingOnStar
83805282fd Merge branch 'main' into feat/marketplace-homepage-redesign 2026-08-14 10:30:13 +08:00
CodingOnStar
d69ba7a8ee feat(web): add marketplace search decoration icons
Place Dropbox, DuckDuckGo, GitHub, and Gmail marks around the marketplace home search field.
2026-08-13 23:14:39 +08:00
CodingOnStar
685aaa7ef4 fix(web): harden marketplace oauth authorization 2026-08-13 14:53:20 +08:00
CodingOnStar
47d3663989 fix: remove marketplace detail dialog gradient 2026-08-13 14:52:43 +08:00
CodingOnStar
eac3d01f78 fix(web): match Creator Center dark text 2026-08-13 14:52:43 +08:00
CodingOnStar
17c1aaf944 fix(web): resolve marketplace lint failures 2026-08-13 14:52:43 +08:00
CodingOnStar
f457b40293 fix(web): keep marketplace tabs embedded 2026-08-13 14:52:43 +08:00
CodingOnStar
8fc21b7df1 fix(web): keep marketplace headers sticky 2026-08-13 14:52:43 +08:00
CodingOnStar
4f7e74cb30 fix(web): harden Creator Center link 2026-08-13 14:52:42 +08:00
CodingOnStar
8e26acd35b fix(web): restore creator center header link 2026-08-13 14:52:42 +08:00
CodingOnStar
a9861421de fix(web): stabilize marketplace search layout 2026-08-13 14:52:42 +08:00
CodingOnStar
8220f05b34 feat: add marketplace template detail dialog 2026-08-13 14:52:42 +08:00
CodingOnStar
fe05ace87b fix: remove duplicate marketplace clear control 2026-08-13 14:52:42 +08:00
CodingOnStar
c3f7c7c0c4 fix: restore marketplace search interactions 2026-08-13 14:52:42 +08:00
CodingOnStar
1bf2d191fe feat(web): add embedded marketplace templates 2026-08-13 14:52:42 +08:00
CodingOnStar
00861ac5eb perf(web): defer offscreen marketplace content 2026-08-13 14:52:42 +08:00
CodingOnStar
02f41e145b fix(web): preserve marketplace plugin caches 2026-08-13 14:52:08 +08:00
CodingOnStar
6f35232013 feat(web): support standalone marketplace plugin SSR 2026-08-13 14:52:08 +08:00
CodingOnStar
99ecbff011 feat: integrate Marketplace cloud auth 2026-08-13 14:52:08 +08:00
CodingOnStar
3d879197f3 chore(web): format marketplace after rebase 2026-08-13 14:52:08 +08:00
CodingOnStar
6c28ef44ce fix(web): open plugin installer from marketplace detail 2026-08-13 14:52:08 +08:00
CodingOnStar
0facec971f fix: repair marketplace entry auth flows 2026-08-13 14:52:08 +08:00
CodingOnStar
dbe9309ab8 fix: isolate standalone marketplace shared UI 2026-08-13 14:52:08 +08:00
CodingOnStar
3872eeb7f6 feat: support marketplace silent OAuth authorization 2026-08-13 14:52:08 +08:00
CodingOnStar
7910c10420 fix(web): pass marketplace plugin install state 2026-08-13 14:51:25 +08:00
CodingOnStar
c369cf211a fix(web): stabilize marketplace detail embeds 2026-08-13 14:51:25 +08:00
CodingOnStar
0bf3f5cc8d chore: remove detail dialog readme 2026-08-13 14:51:25 +08:00
CodingOnStar
3cc847dc36 feat: open marketplace details in dialog 2026-08-13 14:51:25 +08:00
CodingOnStar
4df28a09cf chore: include marketplace app styles 2026-08-13 14:50:52 +08:00
CodingOnStar
141c721a4a fix: add hybrid SSR for embedded marketplace 2026-08-13 14:50:52 +08:00
CodingOnStar
dcb4b4f6af fix: isolate embedded marketplace client 2026-08-13 14:50:52 +08:00
CodingOnStar
aa8f1fcd3a fix: simplify marketplace home tabs 2026-08-13 14:50:52 +08:00
CodingOnStar
33ef9a75f2 fix: localize marketplace homepage copy 2026-08-13 14:50:51 +08:00
CodingOnStar
effbe2ea32 feat: add themed marketplace logo 2026-08-13 14:50:51 +08:00
CodingOnStar
83883ad970 feat: share marketplace home shell with templates 2026-08-13 14:50:51 +08:00
CodingOnStar
5bd01c2060 fix: stabilize marketplace home sticky navigation 2026-08-13 14:50:51 +08:00
CodingOnStar
3fb85f76ad feat: complete marketplace trending carousel 2026-08-13 14:50:51 +08:00
CodingOnStar
09698d3cca refactor: preserve marketplace home server rendering 2026-08-13 14:50:50 +08:00
CodingOnStar
a222552d92 feat: align marketplace hero with redesign 2026-08-13 14:50:50 +08:00
CodingOnStar
33049c22f8 fix: restore marketplace category icons 2026-08-13 14:50:50 +08:00
CodingOnStar
923d92f9f5 feat: add sticky marketplace home navigation 2026-08-13 14:50:50 +08:00
CodingOnStar
1359e57e70 fix: restore marketplace home filter styles 2026-08-13 14:50:50 +08:00
CodingOnStar
f7ae7b6d01 fix: align marketplace home filters to left 2026-08-13 14:50:49 +08:00
CodingOnStar
744e5bb39b feat: add marketplace home catalog filters 2026-08-13 14:50:49 +08:00
CodingOnStar
7872c168b4 fix: align marketplace brand typography 2026-08-13 14:50:49 +08:00
CodingOnStar
b69df4d8b8 fix: align embedded marketplace header 2026-08-13 14:50:49 +08:00
CodingOnStar
0aa20ae2d7 fix: enable redesigned marketplace route 2026-08-13 14:50:48 +08:00
CodingOnStar
8bff4ad6a6 fix: align marketplace homepage with standalone design 2026-08-13 14:50:48 +08:00
CodingOnStar
270568f852 feat: redesign marketplace homepage 2026-08-13 14:50:48 +08:00
CodingOnStar
e91a8edc36 fix(web): resolve deploy build regressions 2026-08-12 14:24:41 +08:00
CodingOnStar
8102836ca8 Merge branch 'feat/creator-profile-home' into deploy/dev
# Conflicts:
#	oxlint-suppressions.json
#	packages/contracts/marketplace.ts
#	web/app/(commonLayout)/marketplace/page.tsx
#	web/app/components/main-nav/routes.ts
#	web/app/components/plugins/marketplace/detail-dialog/__tests__/index.spec.tsx
#	web/app/components/plugins/marketplace/detail-dialog/index.tsx
#	web/app/components/plugins/marketplace/home/__tests__/home-catalog-navigation.spec.tsx
#	web/app/components/plugins/marketplace/home/__tests__/home-header.spec.tsx
#	web/app/components/plugins/marketplace/home/__tests__/home-trending.spec.tsx
#	web/app/components/plugins/marketplace/home/home-catalog-tabs.tsx
#	web/app/components/plugins/marketplace/home/home-header.tsx
#	web/app/components/plugins/marketplace/home/home-search.tsx
#	web/app/components/plugins/marketplace/home/home-trending.tsx
#	web/app/components/plugins/marketplace/home/index.tsx
#	web/app/components/plugins/marketplace/index.tsx
#	web/app/components/plugins/marketplace/list/__tests__/card-wrapper.spec.tsx
#	web/app/components/plugins/marketplace/list/list-wrapper.tsx
#	web/app/components/plugins/marketplace/view.tsx
2026-08-12 12:53:47 +08:00
zxhlyh
685f611f86 Merge branch 'skill' into deploy/dev 2026-08-11 18:29:46 +08:00
zxhlyh
01552e03d6 fix(skills): align markdown placeholder with caret 2026-08-11 18:26:28 +08:00
CodingOnStar
3094a00211 fix(web): prevent duplicate oauth authorization 2026-08-11 17:59:48 +08:00
fatelei
e1bacf098a Merge branch 'skill' into deploy/dev 2026-08-11 17:56:57 +08:00
fatelei
37510f544f fix: fix agent pull skill name wrong 2026-08-11 17:56:17 +08:00
fatelei
33e5b84074 Merge branch 'skill' into deploy/dev 2026-08-11 15:24:09 +08:00
fatelei
953b264c07 fix: fix skill name not refresh 2026-08-11 15:19:57 +08:00
zxhlyh
c440fa1beb fix(skills): reload latest draft after conflicts 2026-08-11 15:01:28 +08:00
fatelei
330288ba3d Merge branch 'skill' into deploy/dev 2026-08-11 14:30:20 +08:00
fatelei
d95049cec8 fix: fix permission 2026-08-11 14:30:04 +08:00
zxhlyh
615628e4f0 fix(skills): avoid duplicate publish errors 2026-08-11 13:43:28 +08:00
fatelei
7d2a377374 Merge branch 'skill' into deploy/dev 2026-08-11 12:15:33 +08:00
fatelei
080a09b0f5 chore: add miss file 2026-08-11 12:15:11 +08:00
fatelei
f3df36aec1 fix: fix scroll 2026-08-11 11:57:55 +08:00
fatelei
a87e396b02 feat: add permission ui check 2026-08-11 11:57:50 +08:00
fatelei
6e4893f37f fix: fix scroll 2026-08-11 11:57:09 +08:00
fatelei
c7ae155a07 feat: add permission ui check 2026-08-11 11:53:43 +08:00
fatelei
a5d8af27d0 Merge branch 'skill' into deploy/dev 2026-08-11 11:32:39 +08:00
fatelei
179968303a fix: fix agent read wrong skill name and description 2026-08-11 11:31:49 +08:00
zxhlyh
202f650b7c fix(skills): localize published status 2026-08-11 11:09:32 +08:00
zxhlyh
f23739272f fix(skills): align tag selector interactions 2026-08-11 11:06:39 +08:00
fatelei
0958b120e2 Merge branch 'skill' into deploy/dev 2026-08-11 10:55:54 +08:00
fatelei
e6bf7d333c fix: fix agent delete not update skill ref 2026-08-11 10:55:23 +08:00
zxhlyh
98ff51d570 fix(skills): preserve markdown editor position 2026-08-11 10:49:17 +08:00
fatelei
b1249dfc79 Merge branch 'skill' into deploy/dev 2026-08-11 07:25:15 +08:00
fatelei
eef27dfc8e fix: fix publish 2026-08-11 07:24:54 +08:00
fatelei
85dd61de3a fix: fix ci failed 2026-08-11 07:20:09 +08:00
fatelei
fee28ca253 fix: fix ci failed 2026-08-10 22:16:26 +08:00
fatelei
1cbea4a95f Merge branch 'skill' into deploy/dev 2026-08-10 21:23:25 +08:00
fatelei
b1f11384b5 feat: version conflict dialog 2026-08-10 21:20:28 +08:00
autofix-ci[bot]
890c062c01 [autofix.ci] apply automated fixes 2026-08-10 09:44:14 +00:00
fatelei
ed4dd78210 Merge branch 'skill' into deploy/dev 2026-08-10 17:40:56 +08:00
fatelei
8bafbf7238 fix: fix list tag and duplicate skill 2026-08-10 17:40:02 +08:00
zxhlyh
f6522e6a34 fix(skills): hide empty builder draft marker 2026-08-10 17:17:40 +08:00
zxhlyh
a5336da117 fix(agent): enable skill tag filtering 2026-08-10 17:05:48 +08:00
zxhlyh
a0d2d0302e fix(skills): render markdown file previews 2026-08-10 16:43:29 +08:00
zxhlyh
f5ea9bbc02 fix(skills): commit custom metadata on blur 2026-08-10 16:11:34 +08:00
zxhlyh
3d7c403580 fix(skills): discard stale uploads after restart 2026-08-10 15:28:07 +08:00
zxhlyh
c3554ad525 fix(skills): preserve attachments when retrying 2026-08-10 14:29:58 +08:00
fatelei
f805038d16 fix: fix conflict 2026-08-10 14:01:55 +08:00
fatelei
53542cf84e fix: fix draft skill time display 2026-08-10 13:47:13 +08:00
zxhlyh
6609ac0b1e fix(skills): guard deletion during reference refresh 2026-08-10 13:43:20 +08:00
CodingOnStar
7928826b0f feat(web): add marketplace creator profile 2026-08-10 10:43:12 +08:00
CodingOnStar
2a38ef22e5 feat: add marketplace template detail dialog 2026-08-10 10:43:11 +08:00
CodingOnStar
011863ee33 fix: remove duplicate marketplace clear control 2026-08-10 10:43:11 +08:00
CodingOnStar
ab3bb9dd6c fix: restore marketplace search interactions 2026-08-10 10:43:11 +08:00
CodingOnStar
b5082d2796 feat(web): add embedded marketplace templates 2026-08-10 10:43:11 +08:00
CodingOnStar
b388ae71d7 perf(web): defer offscreen marketplace content 2026-08-10 10:43:11 +08:00
CodingOnStar
dfa144da3f fix(web): preserve marketplace plugin caches 2026-08-10 10:43:11 +08:00
CodingOnStar
e12eaf055c feat(web): support standalone marketplace plugin SSR 2026-08-10 10:43:11 +08:00
CodingOnStar
cda6b22a0c feat: integrate Marketplace cloud auth 2026-08-10 10:43:11 +08:00
CodingOnStar
3a2a400dcb chore(web): format marketplace after rebase 2026-08-10 10:43:11 +08:00
CodingOnStar
bae833a355 fix(web): open plugin installer from marketplace detail 2026-08-10 10:43:11 +08:00
CodingOnStar
8fa6f74a17 fix: repair marketplace entry auth flows 2026-08-10 10:43:11 +08:00
CodingOnStar
e7019ad7e0 fix: isolate standalone marketplace shared UI 2026-08-10 10:43:11 +08:00
CodingOnStar
610d371112 feat: support marketplace silent OAuth authorization 2026-08-10 10:43:10 +08:00
CodingOnStar
d6ebd4ec2b fix(web): pass marketplace plugin install state 2026-08-10 10:43:10 +08:00
CodingOnStar
57e62bb226 fix(web): stabilize marketplace detail embeds 2026-08-10 10:43:10 +08:00
CodingOnStar
1b2c8b5737 chore: remove detail dialog readme 2026-08-10 10:43:10 +08:00
CodingOnStar
f72c498a8a feat: open marketplace details in dialog 2026-08-10 10:43:10 +08:00
CodingOnStar
3c5204c8c7 chore: include marketplace app styles 2026-08-10 10:43:10 +08:00
CodingOnStar
a8ffc6072d fix: add hybrid SSR for embedded marketplace 2026-08-10 10:43:10 +08:00
CodingOnStar
3161f70ac3 fix: isolate embedded marketplace client 2026-08-10 10:43:10 +08:00
CodingOnStar
36fcc3499d fix: simplify marketplace home tabs 2026-08-10 10:43:10 +08:00
CodingOnStar
1f40540ef0 fix: localize marketplace homepage copy 2026-08-10 10:43:09 +08:00
CodingOnStar
c52f4eb61e feat: add themed marketplace logo 2026-08-10 10:43:09 +08:00
CodingOnStar
bafb0a689f feat: share marketplace home shell with templates 2026-08-10 10:43:09 +08:00
CodingOnStar
8e670491a3 fix: stabilize marketplace home sticky navigation 2026-08-10 10:43:09 +08:00
CodingOnStar
ecf72a0860 feat: complete marketplace trending carousel 2026-08-10 10:43:09 +08:00
CodingOnStar
742d1b93b0 refactor: preserve marketplace home server rendering 2026-08-10 10:43:09 +08:00
CodingOnStar
9f7528b10f feat: align marketplace hero with redesign 2026-08-10 10:43:09 +08:00
CodingOnStar
505d3d9f03 fix: restore marketplace category icons 2026-08-10 10:43:09 +08:00
CodingOnStar
a214565031 feat: add sticky marketplace home navigation 2026-08-10 10:43:09 +08:00
CodingOnStar
fc8e796620 fix: restore marketplace home filter styles 2026-08-10 10:43:08 +08:00
CodingOnStar
433a553f88 fix: align marketplace home filters to left 2026-08-10 10:43:08 +08:00
CodingOnStar
3ed6d9f9af feat: add marketplace home catalog filters 2026-08-10 10:43:08 +08:00
CodingOnStar
f0917cc325 fix: align marketplace brand typography 2026-08-10 10:43:08 +08:00
CodingOnStar
56bac46fd6 fix: align embedded marketplace header 2026-08-10 10:43:07 +08:00
CodingOnStar
cb7be807fd fix: enable redesigned marketplace route 2026-08-10 10:43:07 +08:00
CodingOnStar
dc4c82c006 fix: align marketplace homepage with standalone design 2026-08-10 10:43:07 +08:00
CodingOnStar
a866e2e2f3 feat: redesign marketplace homepage 2026-08-10 10:43:07 +08:00
autofix-ci[bot]
963410e562 [autofix.ci] apply automated fixes 2026-08-08 03:13:10 +00:00
fatelei
5e0d54d4ce fix: fix test issue 2026-08-08 11:08:50 +08:00
fatelei
94cf4c2957 fix: fix frontend test 2026-08-08 10:36:43 +08:00
autofix-ci[bot]
aa43aee224 [autofix.ci] apply automated fixes 2026-08-08 01:11:20 +00:00
fatelei
9d1a3c5a00 Merge remote-tracking branch 'upstream/main' into skill 2026-08-08 09:06:56 +08:00
fatelei
ebe0a269ea fix: fix backend test 2026-08-08 09:06:36 +08:00
fatelei
edff7a4471 Merge remote-tracking branch 'upstream/main' into skill 2026-08-07 22:21:52 +08:00
autofix-ci[bot]
9009960b35 [autofix.ci] apply automated fixes 2026-08-07 12:57:53 +00:00
fatelei
9969c9f845 fix: fix conflict 2026-08-07 20:53:07 +08:00
fatelei
2dace438a3 Merge branch 'skill' into deploy/dev 2026-08-07 19:37:09 +08:00
fatelei
19a8683bca fix: fix skill name is untitled-skill and optimize ai logic 2026-08-07 19:35:44 +08:00
fatelei
154926d4ac Merge branch 'skill' into deploy/dev 2026-08-07 17:12:46 +08:00
fatelei
e740e6c423 fix: fix llm context 2026-08-07 17:10:36 +08:00
fatelei
703289fb0b Merge branch 'skill' into deploy/dev 2026-08-07 15:55:06 +08:00
fatelei
89219c479c fix: fix version restore 2026-08-07 15:54:24 +08:00
zxhlyh
3bbbdd478f Merge branch 'skill' into deploy/dev 2026-08-07 15:42:02 +08:00
zxhlyh
8bf624401a fix(skill): prevent builder send icon distortion 2026-08-07 15:37:24 +08:00
fatelei
9d5f87f0b4 Merge branch 'skill' into deploy/dev 2026-08-07 15:29:18 +08:00
fatelei
f8ca584a81 fix: fix some ui issue 2026-08-07 15:24:15 +08:00
zxhlyh
520999691f fix(skills): require display name to confirm deletion 2026-08-07 15:09:43 +08:00
fatelei
4b1f4f3223 Merge branch 'skill' into deploy/dev 2026-08-07 14:29:20 +08:00
fatelei
5cc0f16bae fix: fix publish date 2026-08-07 14:27:15 +08:00
fatelei
e9caaa43a3 Merge branch 'skill' into deploy/dev 2026-08-07 12:56:27 +08:00
fatelei
793300066b feat: add image upload limit 2026-08-07 12:53:19 +08:00
fatelei
8b456fb4df Merge branch 'skill' into deploy/dev 2026-08-07 11:49:02 +08:00
fatelei
6c052bb9b5 fix: fix some issue 2026-08-07 11:47:32 +08:00
zxhlyh
bc3ef4de71 fix(skills): align publish bar expansion 2026-08-07 11:22:34 +08:00
fatelei
a14b9be0be Merge branch 'skill' into deploy/dev 2026-08-06 17:38:45 +08:00
fatelei
f39e55caf6 chore: increase limit 2026-08-06 17:37:29 +08:00
fatelei
d5964397b2 Merge branch 'skill' into deploy/dev 2026-08-06 16:27:12 +08:00
fatelei
0065d0e0f1 chore: remove single file limit 2026-08-06 16:26:27 +08:00
zxhlyh
5053828fad feat(skills): align file editor previews 2026-08-06 14:25:57 +08:00
fatelei
fd38ac854c Merge branch 'skill' into deploy/dev 2026-08-05 17:41:40 +08:00
fatelei
3b033f9eb5 chore: upload zip skill use UPLOAD_SKILL_FILE_SIZE_LIMIT 2026-08-05 17:41:10 +08:00
zxhlyh
0451a321cb fix(skills): refresh list after version changes 2026-08-05 17:00:44 +08:00
zxhlyh
cfe627e894 fix(skills): refresh tag cache after creation 2026-08-05 15:33:44 +08:00
fatelei
47788f1617 Merge branch 'skill' into deploy/dev 2026-08-04 19:02:57 +08:00
fatelei
dcdf6715a3 feat: add rbac 2026-08-04 19:02:34 +08:00
zxhlyh
ea94659e7c feat(skills): add detail sidebar actions 2026-08-04 18:03:50 +08:00
zxhlyh
b3e0f6c5b7 fix(skills): pluralize upload failure copy 2026-08-04 16:54:10 +08:00
fatelei
4bdc28959d Merge branch 'skill' into deploy/dev 2026-08-04 16:34:01 +08:00
fatelei
3482166f78 fix: fix skill builder upload icon 2026-08-04 16:31:56 +08:00
fatelei
ff1c935761 Merge branch 'skill' into deploy/dev 2026-08-04 16:28:29 +08:00
fatelei
1a9c03c0af fix: fix some ui issue 2026-08-04 16:28:11 +08:00
zxhlyh
a43f2ccc0a fix(skills): correct dark theme surfaces 2026-08-04 15:50:31 +08:00
zxhlyh
4e00f708f7 fix(agent): allow removing embedded skills in build drafts 2026-08-04 14:54:37 +08:00
fatelei
c9a0aff2f9 Merge branch 'skill' into deploy/dev 2026-08-04 14:43:48 +08:00
fatelei
5afbf25a59 fix: fix ui issue 2026-08-04 14:43:25 +08:00
zxhlyh
b29abeafb5 feat(skills): align tag management behavior 2026-08-04 14:26:30 +08:00
fatelei
3a8676371c fix: fix conflict 2026-08-04 13:44:24 +08:00
fatelei
5a13489fdd fix: fix some bugs 2026-08-04 13:39:32 +08:00
zxhlyh
2f5fee324a fix(skills): refine empty state layout 2026-08-04 13:06:35 +08:00
fatelei
c9eb56c4cb fix: fix some ui issue 2026-08-04 11:45:25 +08:00
fatelei
6aa5804503 Merge branch 'skill' into deploy/dev 2026-08-04 11:27:39 +08:00
fatelei
bbba892849 fix: fix some ui issue 2026-08-04 11:27:24 +08:00
zxhlyh
62d95441d1 feat(skills): add file upload review workflow 2026-08-03 17:41:56 +08:00
zxhlyh
09479cafa4 fix(skills): refine detail loading skeleton 2026-08-03 17:41:56 +08:00
fatelei
26a5465c5b fix: fix ci failed 2026-08-03 17:09:43 +08:00
fatelei
64558c418c fix: fix ci failed 2026-08-03 16:56:55 +08:00
autofix-ci[bot]
785622f7f3 [autofix.ci] apply automated fixes 2026-08-03 08:16:10 +00:00
zxhlyh
43440c6edb fix(skills): link detail header navigation 2026-08-03 16:08:51 +08:00
fatelei
7a6c9934d7 Merge branch 'skill' into deploy/dev 2026-08-03 16:07:23 +08:00
fatelei
21a4e6fbcc feat: add file check api 2026-08-03 16:06:11 +08:00
zxhlyh
a4621041b6 feat(agent-v2): split skill add sources 2026-08-03 15:20:04 +08:00
autofix-ci[bot]
7c7d30fe1f [autofix.ci] apply automated fixes 2026-08-03 06:59:05 +00:00
fatelei
4ce6b1f620 Merge branch 'skill' into deploy/dev 2026-08-03 14:56:17 +08:00
fatelei
f0fb69f4c7 fix: fix some ui issue 2026-08-03 14:55:04 +08:00
zxhlyh
3ac6e1b115 feat(skills): refine versions and account navigation 2026-08-03 14:39:52 +08:00
zxhlyh
8767767794 fix(skills): match sidebar resize states 2026-08-03 11:56:58 +08:00
zxhlyh
a7ca830fad feat(skills): refine editor and builder parity 2026-08-03 10:49:26 +08:00
fatelei
acf56bf154 Merge branch 'skill' into deploy/dev 2026-08-03 09:54:13 +08:00
fatelei
63615b2fab fix: fix delete dialog 2026-08-03 09:52:24 +08:00
fatelei
7196a370a7 chore: increase test coverage 2026-08-02 22:23:32 +08:00
fatelei
f1f9e34683 chore: increase test coverage 2026-08-02 22:09:08 +08:00
fatelei
fa7a349a2a chore: increase test coverage 2026-08-02 21:43:24 +08:00
fatelei
ac902eb2e9 fix: fix test failed 2026-08-02 20:57:47 +08:00
autofix-ci[bot]
bbef12212f [autofix.ci] apply automated fixes 2026-08-02 12:08:38 +00:00
fatelei
b252c0834d fix: fix conflict 2026-08-02 20:04:29 +08:00
zxhlyh
8b908cbc43 fix(skills): complete metadata editor parity 2026-08-02 17:54:51 +08:00
zxhlyh
0aea96233d feat(skills): restore editor design parity 2026-08-02 17:51:29 +08:00
fatelei
977b2c4c93 fix: fix delete skill ref 2026-08-02 17:46:30 +08:00
fatelei
b441e3e838 feat: support agent skill 2026-08-02 17:28:21 +08:00
Harsh Kashyap
b91a3b8c27 fix(api): bound JinaReader and adaptive-crawl HTTP timeouts (#39859) (#39860)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-08-02 17:21:57 +08:00
Taranum Wasu
49695a7575 fix(api): make SSRF-blocked error message actionable (#39867)
Co-authored-by: Taranum01 <50813317+Taranum01@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-02 17:21:57 +08:00
csurong
654904fce0 fix(matrixone): apply score threshold to vector search (#39784)
Co-authored-by: caosurong <surong.cao@thinkingdata.cn>
2026-08-02 17:21:57 +08:00
kenwoodjw
2f1276864d fix(api): prevent cookies from shadowing admin API keys (#39872)
Signed-off-by: kenwoodjw <blackxin55+@gmail.com>
2026-08-02 17:21:57 +08:00
yyh
5ff641a49c refactor(web): align Base UI state consumers (#39864)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-02 17:21:57 +08:00
Harsh Kashyap
75bb174719 fix(api): bound billing service HTTP timeouts (#39874) (#39875)
Co-authored-by: Harsh Kashyap <Harsh23Kashyap@users.noreply.github.com>
2026-08-02 17:19:28 +08:00
Jony
fe6375e39c chore(api): fix typos in config description and comment (#39870)
Co-authored-by: Jony <13896935+zyz619963502zyz@users.noreply.github.com>
2026-08-02 17:19:28 +08:00
WH-2099
9939df18f5 fix(api): scope segment batch deletes (#38217) 2026-08-02 17:19:28 +08:00
yyh
cac9be9d98 fix(web): align secondary sidebar help trigger (#39862) 2026-08-02 17:19:27 +08:00
Chester
e3350f7ada fix(auth): add bounded timeout to WaterCrawl credential validation (#39824) 2026-08-02 17:19:27 +08:00
Chester
7526ab3163 fix(rag): add bounded timeout to Notion extractor requests (#39833) 2026-08-02 17:19:27 +08:00
Chester
893fe3bb01 fix(rag): add bounded timeout to Firecrawl extractor requests (#39831) 2026-08-02 17:19:27 +08:00
Asuka Minato
851b01578e test: use SQLite sessions in services workflow (#39087) 2026-08-02 17:19:27 +08:00
Asuka Minato
765f1172d5 test: use SQLite sessions in core workflow (#39086) 2026-08-02 17:19:27 +08:00
Asuka Minato
6137c60651 test: use SQLite sessions in core callback_handler (#39081) 2026-08-02 17:19:27 +08:00
Asuka Minato
975b1a6303 test: use SQLite sessions in core repositories (#39078) 2026-08-02 17:19:27 +08:00
Asuka Minato
045b39c6d6 test: use SQLite sessions in core llm_generator (#39070) 2026-08-02 17:19:27 +08:00
Asuka Minato
385f2196a9 test: use SQLite sessions in core tools (#39068) 2026-08-02 17:19:27 +08:00
Asuka Minato
9f73e16373 test: use SQLite sessions in core ops (#39062)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-08-02 17:19:27 +08:00
Asuka Minato
cd48673c20 test: use SQLite sessions in core rag (#39055) 2026-08-02 17:19:27 +08:00
Asuka Minato
33a8d28d0d test: use SQLite sessions in services agent (#39054) 2026-08-02 17:19:26 +08:00
yyh
38a6567bad refactor(ui): simplify scroll area API (#39857) 2026-08-02 17:19:26 +08:00
yyh
1434758d42 fix(web): hide prompt log action in agent v2 logs (#39858) 2026-08-02 16:41:18 +08:00
fatelei
2941960545 Merge branch 'skill' into deploy/dev 2026-07-31 21:06:09 +08:00
zxhlyh
c50ec44bd8 feat(skills): restore editor design parity 2026-07-31 21:02:02 +08:00
非法操作
3a6b69bfe7 fix(web): skip auth validation for local file datasource (#39856) 2026-07-31 20:56:38 +08:00
Joel
78d43c6712 feat: prioritize configured tools in the agent prompt picker (#39850) 2026-07-31 20:56:38 +08:00
autofix-ci[bot]
fcf05b4b63 [autofix.ci] apply automated fixes 2026-07-31 12:52:10 +00:00
fatelei
58abd13450 fix: fix some bugs 2026-07-31 20:47:57 +08:00
fatelei
38b1a406bb fix: fix publish update button ui issue 2026-07-31 20:47:56 +08:00
zxhlyh
dd6a28ca1f Merge branch 'skill' into deploy/dev 2026-07-31 17:50:41 +08:00
zxhlyh
f2b0aaeb8f fix(skills): complete metadata editor parity 2026-07-31 17:49:28 +08:00
zxhlyh
977c6ca61f fix(skills): match metadata entry design 2026-07-31 17:49:28 +08:00
zxhlyh
a8879e7360 fix(skills): match add metadata button design 2026-07-31 17:49:28 +08:00
fatelei
80365aec68 fix: fix conflict 2026-07-31 17:46:43 +08:00
fatelei
b1b01630e5 fix: fix some ui issue 2026-07-31 17:44:16 +08:00
zxhlyh
ec2e511d0d Merge branch 'codex/skills-editor-design-parity' into skill 2026-07-31 17:24:13 +08:00
zxhlyh
003c7b4fa9 feat(skills): restore editor design parity 2026-07-31 17:15:51 +08:00
zxhlyh
5caa2eea03 feat(skills): refine publish workflow UI 2026-07-31 17:05:33 +08:00
fatelei
d326d38a0a fix: fix failed test 2026-07-31 15:58:46 +08:00
KVOJJJin
4275c24dc5 fix(workflow): remap snippet references (#39843) 2026-07-31 15:58:46 +08:00
Bond Zhu
542544d8eb fix(api): silence opentelemetry.context error reports to Sentry on cloud (#39841) 2026-07-31 15:58:45 +08:00
iridescentWen
f835f0c59c docs: fix typos in api comments and prompt example (#39838) 2026-07-31 15:58:45 +08:00
Xin Zhang
d6fd31c756 fix: refresh mcp server code by app id (#39828)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-31 15:58:45 +08:00
zxhlyh
0ef7bf929e fix(skills): refine detail interactions and rename flow 2026-07-31 15:53:11 +08:00
zxhlyh
64cb61f902 merge: skills detail layout fix 2026-07-31 15:02:32 +08:00
zxhlyh
779f57902e fix(skills): hide main navigation on detail pages 2026-07-31 15:02:22 +08:00
zxhlyh
4bfd7fd17c feat(skills): match file drag and upload interactions 2026-07-31 15:00:10 +08:00
林玮 (Jade Lin)
ce775fd3f9 feat(plugin): isolate from local packages
(cherry picked from commit e6b371c9daea6892cf3068a3966363be6bcf61f8)
2026-07-31 14:25:48 +08:00
JzoNg
a0a8ed7594 Merge branch 'fix/snippet-variable-remap' into deploy/dev 2026-07-31 13:57:47 +08:00
JzoNg
111260fa37 fix(workflow): remap snippet references 2026-07-31 13:55:53 +08:00
zxhlyh
f3853f48df refactor(skills): split skill detail page modules 2026-07-31 12:16:01 +08:00
zxhlyh
7fde7b7ca4 fix(skills): align detail page interactions 2026-07-31 11:53:47 +08:00
Escape0707
68c59ad047 test: exercise credential visibility with SQLite (#39827) 2026-07-31 11:37:04 +08:00
Parman Mohammadalizadeh
7e3514adae chore(api): consolidate chained .where() calls into single calls (#39819) 2026-07-31 11:37:04 +08:00
起岚
ffbde43b2c docs: remove trailing whitespace in docker/README.md (#39808) 2026-07-31 11:37:04 +08:00
fatelei
64b7451003 Merge branch 'skill' into deploy/dev 2026-07-31 11:18:33 +08:00
fatelei
3ec3242a66 fix: fix skill tag 2026-07-31 11:18:14 +08:00
fatelei
238887036b fix: fix conflict 2026-07-31 09:11:04 +08:00
autofix-ci[bot]
4ec9fb0a58 [autofix.ci] apply automated fixes 2026-07-30 15:01:47 +00:00
fatelei
51b668a2a6 feat: update publish button logic 2026-07-30 22:57:22 +08:00
zxhlyh
ce38c21253 fix(skills): align file tree interactions 2026-07-30 22:57:22 +08:00
zxhlyh
f5ba4a8e51 fix: block skill file editing before load 2026-07-30 22:57:22 +08:00
fatelei
52100bbb29 fix: fix create skill not reload 2026-07-30 22:57:22 +08:00
fatelei
65e6d72254 fix: fix test failed 2026-07-30 22:57:22 +08:00
fatelei
48b5dca16a fix: fix style check failed 2026-07-30 22:57:22 +08:00
zxhlyh
fbe4e586f6 fix(web): support library skills in agent prompts 2026-07-30 22:57:22 +08:00
zxhlyh
ca99dda275 fix(web): isolate refreshed main navigation icons 2026-07-30 22:57:22 +08:00
zxhlyh
b28d45bb2d fix(web): align agent skills interactions with design 2026-07-30 22:57:22 +08:00
autofix-ci[bot]
404f79f370 [autofix.ci] apply automated fixes 2026-07-30 22:57:22 +08:00
fatelei
5a002d8f6a fix: fix style check failed 2026-07-30 22:57:21 +08:00
fatelei
606d1d7926 chore: increase test coverage and fix skill duplicate 2026-07-30 22:57:21 +08:00
fatelei
277c1ddf9c fix: fix test failed 2026-07-30 22:57:21 +08:00
autofix-ci[bot]
d6fc092795 [autofix.ci] apply automated fixes 2026-07-30 22:57:21 +08:00
fatelei
3a422d4dde fix: fix test failed 2026-07-30 22:57:21 +08:00
zxhlyh
eb8cda6355 fix(web): block skill builder sends during upload 2026-07-30 22:57:21 +08:00
fatelei
c81c774b49 fix: fix file conflict 2026-07-30 22:57:21 +08:00
fatelei
b6431e82cd feat: agent can op file and fix version check 2026-07-30 22:57:21 +08:00
zxhlyh
9d3b0ddf58 fix(web): preserve IME composition in skill builder 2026-07-30 22:57:21 +08:00
zxhlyh
a6948b57e6 fix(web): protect skill bindings in version view 2026-07-30 22:57:21 +08:00
zxhlyh
4cc89a054b fix(web): paginate workspace skills list 2026-07-30 22:57:21 +08:00
autofix-ci[bot]
03b18bd4e3 [autofix.ci] apply automated fixes 2026-07-30 22:57:21 +08:00
fatelei
2907adb12e fix: fix db version 2026-07-30 22:57:20 +08:00
fatelei
d08ca0e069 fix: fix model not change 2026-07-30 22:57:20 +08:00
zxhlyh
ca22915bf1 fix(web): prevent duplicate skill autosaves 2026-07-30 22:57:20 +08:00
fatelei
a4430fd373 fix: fix model not update 2026-07-30 22:57:20 +08:00
fatelei
90e802d5e4 fix: fix failed test 2026-07-30 22:57:20 +08:00
fatelei
6113893a53 fix: fix skill not in agent 2026-07-30 22:57:20 +08:00
fatelei
5aea0b1489 fix: fix frontend issue and let draft check relax 2026-07-30 22:57:20 +08:00
fatelei
6d59f60f88 fix: fix ci failed 2026-07-30 22:57:20 +08:00
fatelei
a5a08455b2 fix: fix ci failed 2026-07-30 22:57:20 +08:00
fatelei
bc296aa440 fix: fix scroll and live mode can not enter 2026-07-30 22:57:20 +08:00
fatelei
d01d63275e fix: fix some bugs 2026-07-30 22:57:19 +08:00
fatelei
00e67c1db1 chore: adjust skill entrance 2026-07-30 22:57:19 +08:00
fatelei
946603c513 fix: fix delete skill ref 2026-07-30 22:57:19 +08:00
fatelei
6414d0a020 feat: support agent skill 2026-07-30 22:57:18 +08:00
fatelei
22cab8f4bd fix: fix conflict 2026-07-30 13:12:21 +08:00
fatelei
f08d5343d0 fix: fix create skill not reload 2026-07-30 13:00:08 +08:00
CodingOnStar
8c0fa26938 Merge branch 'feat/marketplace-homepage-redesign' into deploy/dev 2026-07-30 12:10:24 +08:00
fatelei
e07c1be932 fix: fix test failed 2026-07-30 11:52:08 +08:00
CodingOnStar
1e4ecbfe0a chore: remove detail dialog readme 2026-07-30 11:49:04 +08:00
CodingOnStar
fd242a37de feat: open marketplace details in dialog 2026-07-30 11:45:50 +08:00
fatelei
2d8440309d fix: fix style check failed 2026-07-30 11:38:27 +08:00
zxhlyh
4bdc2a3d71 fix(web): support library skills in agent prompts 2026-07-30 11:38:27 +08:00
zxhlyh
668b99fab7 fix(web): isolate refreshed main navigation icons 2026-07-30 11:38:27 +08:00
zxhlyh
14942e7f1c fix(web): align agent skills interactions with design 2026-07-30 11:38:27 +08:00
autofix-ci[bot]
c1ace7800b [autofix.ci] apply automated fixes 2026-07-30 11:38:27 +08:00
fatelei
9a602ab6d6 fix: fix style check failed 2026-07-30 11:38:27 +08:00
fatelei
36eddb6a6b chore: increase test coverage and fix skill duplicate 2026-07-30 11:38:27 +08:00
fatelei
340cf3a5e8 fix: fix test failed 2026-07-30 11:38:27 +08:00
autofix-ci[bot]
1e20db0758 [autofix.ci] apply automated fixes 2026-07-30 11:38:26 +08:00
fatelei
2c8f39c217 fix: fix test failed 2026-07-30 11:38:26 +08:00
zxhlyh
a87d0bed3a fix(web): block skill builder sends during upload 2026-07-30 11:38:26 +08:00
fatelei
c01167b1f4 fix: fix file conflict 2026-07-30 11:38:26 +08:00
fatelei
23b06d5710 feat: agent can op file and fix version check 2026-07-30 11:38:26 +08:00
zxhlyh
4731f0fd08 fix(web): preserve IME composition in skill builder 2026-07-30 11:38:26 +08:00
zxhlyh
cf791ff9f4 fix(web): protect skill bindings in version view 2026-07-30 11:38:26 +08:00
zxhlyh
2f2889f957 fix(web): paginate workspace skills list 2026-07-30 11:38:26 +08:00
autofix-ci[bot]
22ba24b6a4 [autofix.ci] apply automated fixes 2026-07-30 11:38:26 +08:00
fatelei
bbfbe3f94c fix: fix db version 2026-07-30 11:38:26 +08:00
fatelei
a8a081486b fix: fix model not change 2026-07-30 11:38:26 +08:00
zxhlyh
f966ada97b fix(web): prevent duplicate skill autosaves 2026-07-30 11:38:26 +08:00
fatelei
ba865c7c9c fix: fix model not update 2026-07-30 11:38:26 +08:00
fatelei
f72b30f5a2 fix: fix failed test 2026-07-30 11:38:26 +08:00
fatelei
30888bfcf9 fix: fix skill not in agent 2026-07-30 11:38:25 +08:00
fatelei
067e95435f fix: fix frontend issue and let draft check relax 2026-07-30 11:38:25 +08:00
fatelei
c0c0b22415 fix: fix ci failed 2026-07-30 11:38:25 +08:00
fatelei
b95dd9594e fix: fix ci failed 2026-07-30 11:38:25 +08:00
fatelei
fda3de1ec0 fix: fix scroll and live mode can not enter 2026-07-30 11:38:25 +08:00
fatelei
1c1fd3517c fix: fix some bugs 2026-07-30 11:38:25 +08:00
fatelei
7f162a924c chore: adjust skill entrance 2026-07-30 11:38:25 +08:00
fatelei
d3fcb6e045 fix: fix delete skill ref 2026-07-30 11:38:25 +08:00
fatelei
a3d17b5e86 feat: support agent skill 2026-07-30 11:38:25 +08:00
zxhlyh
e1595f4b55 Merge branch 'skill' into deploy/dev
# Conflicts:
#	api/services/skill_management_service.py
#	api/services/tag_service.py
#	api/tests/unit_tests/controllers/console/workspace/test_skills.py
#	api/tests/unit_tests/services/test_skill_management_service.py
#	packages/contracts/generated/api/console/tags/types.gen.ts
#	packages/contracts/generated/api/console/tags/zod.gen.ts
#	packages/contracts/generated/api/console/workspaces/types.gen.ts
#	packages/contracts/generated/api/console/workspaces/zod.gen.ts
#	web/app/components/header/account-setting/model-provider-page/model-parameter-modal/__tests__/index.spec.tsx
#	web/app/components/header/account-setting/model-provider-page/model-parameter-modal/index.tsx
#	web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx
#	web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx
#	web/app/components/main-nav/__tests__/index.spec.tsx
#	web/app/components/main-nav/routes.ts
#	web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/__tests__/index.spec.tsx
#	web/features/agent-v2/agent-detail/configure/components/orchestrate/skills/index.tsx
#	web/features/skills/__tests__/detail-page.spec.tsx
#	web/features/skills/detail-page.tsx
2026-07-29 19:31:15 +08:00
zxhlyh
ae20b3b189 fix(web): support library skills in agent prompts 2026-07-29 19:21:46 +08:00
zxhlyh
cd05b1d3e3 fix(web): isolate refreshed main navigation icons 2026-07-29 18:16:02 +08:00
zxhlyh
e61d4ff85d fix(web): align agent skills interactions with design 2026-07-29 17:16:53 +08:00
autofix-ci[bot]
8b72e93067 [autofix.ci] apply automated fixes 2026-07-29 09:07:07 +00:00
fatelei
ceea0b0222 fix: fix style check failed 2026-07-29 17:01:10 +08:00
fatelei
137354df91 chore: increase test coverage and fix skill duplicate 2026-07-29 15:39:28 +08:00
fatelei
039ca228fa fix: fix test failed 2026-07-29 15:39:28 +08:00
autofix-ci[bot]
44f7a814c3 [autofix.ci] apply automated fixes 2026-07-29 15:39:28 +08:00
fatelei
d5e702894a fix: fix test failed 2026-07-29 15:39:27 +08:00
zxhlyh
33f8999c60 fix(web): block skill builder sends during upload 2026-07-29 15:39:27 +08:00
fatelei
f5f3c59d33 fix: fix file conflict 2026-07-29 15:39:27 +08:00
fatelei
a046f66b76 feat: agent can op file and fix version check 2026-07-29 15:39:27 +08:00
zxhlyh
05259faaf9 fix(web): preserve IME composition in skill builder 2026-07-29 15:39:27 +08:00
zxhlyh
6d815cb16c fix(web): protect skill bindings in version view 2026-07-29 15:39:27 +08:00
zxhlyh
7aa44930a7 fix(web): paginate workspace skills list 2026-07-29 15:39:27 +08:00
autofix-ci[bot]
4763a02d55 [autofix.ci] apply automated fixes 2026-07-29 15:39:27 +08:00
fatelei
d82c30da65 fix: fix db version 2026-07-29 15:39:26 +08:00
fatelei
fff55e65ea fix: fix model not change 2026-07-29 15:39:26 +08:00
zxhlyh
53fe349fa8 fix(web): prevent duplicate skill autosaves 2026-07-29 15:39:26 +08:00
fatelei
a05db83783 fix: fix model not update 2026-07-29 15:39:26 +08:00
fatelei
ac5cde8d1e fix: fix failed test 2026-07-29 15:39:26 +08:00
fatelei
2c40f2d3cd fix: fix skill not in agent 2026-07-29 15:39:26 +08:00
fatelei
a006cd91a2 fix: fix frontend issue and let draft check relax 2026-07-29 15:39:26 +08:00
fatelei
136eef8c70 fix: fix ci failed 2026-07-29 15:39:26 +08:00
fatelei
b510cdcc48 fix: fix ci failed 2026-07-29 15:39:25 +08:00
fatelei
64745080f7 fix: fix scroll and live mode can not enter 2026-07-29 15:39:25 +08:00
fatelei
b991a91b80 fix: fix some bugs 2026-07-29 15:39:25 +08:00
fatelei
5a042854bd chore: adjust skill entrance 2026-07-29 15:39:25 +08:00
fatelei
61cec2e1be fix: fix delete skill ref 2026-07-29 15:39:25 +08:00
fatelei
3589fb3efc feat: support agent skill 2026-07-29 15:39:24 +08:00
fatelei
59295c05bf fix: fix conflict 2026-07-29 15:25:26 +08:00
fatelei
3fd75f6bd5 chore: increase test coverage and fix skill duplicate 2026-07-29 14:49:54 +08:00
fatelei
da94a3e334 fix: fix test failed 2026-07-29 12:05:21 +08:00
autofix-ci[bot]
9c96d3d417 [autofix.ci] apply automated fixes 2026-07-29 03:40:27 +00:00
fatelei
f7dc60bf90 fix: fix test failed 2026-07-29 11:35:33 +08:00
zxhlyh
58f17aa5a8 fix(web): block skill builder sends during upload 2026-07-29 11:15:04 +08:00
fatelei
95f76e12ef fix: fix file conflict 2026-07-29 10:58:21 +08:00
fatelei
3933334a4d feat: agent can op file and fix version check 2026-07-29 10:58:21 +08:00
zxhlyh
59e68de46b fix(web): preserve IME composition in skill builder 2026-07-29 10:58:21 +08:00
zxhlyh
e1e20c9981 fix(web): protect skill bindings in version view 2026-07-29 10:58:21 +08:00
zxhlyh
1495ea9a8f fix(web): paginate workspace skills list 2026-07-29 10:58:21 +08:00
autofix-ci[bot]
1af973bd79 [autofix.ci] apply automated fixes 2026-07-29 10:58:21 +08:00
fatelei
15ac8b8954 fix: fix db version 2026-07-29 10:58:21 +08:00
fatelei
1cd7699660 fix: fix model not change 2026-07-29 10:58:21 +08:00
zxhlyh
d264aee057 fix(web): prevent duplicate skill autosaves 2026-07-29 10:58:20 +08:00
fatelei
8f24e8af40 fix: fix model not update 2026-07-29 10:58:20 +08:00
fatelei
f92e7339f4 fix: fix failed test 2026-07-29 10:58:20 +08:00
fatelei
7908dd69be fix: fix skill not in agent 2026-07-29 10:58:20 +08:00
fatelei
a5ff29eea5 fix: fix frontend issue and let draft check relax 2026-07-29 10:58:20 +08:00
fatelei
2fe4790722 fix: fix ci failed 2026-07-29 10:58:20 +08:00
fatelei
35ddd2b6f4 fix: fix ci failed 2026-07-29 10:58:20 +08:00
fatelei
94dfaae3d0 fix: fix scroll and live mode can not enter 2026-07-29 10:58:20 +08:00
fatelei
78154faa62 fix: fix some bugs 2026-07-29 10:58:20 +08:00
fatelei
1ff2a7c05e chore: adjust skill entrance 2026-07-29 10:58:19 +08:00
fatelei
bb22386e30 fix: fix delete skill ref 2026-07-29 10:58:19 +08:00
fatelei
18fa5392fe feat: support agent skill 2026-07-29 10:58:19 +08:00
fatelei
33af2faf83 Merge branch 'skill' into deploy/dev 2026-07-29 10:36:54 +08:00
fatelei
e9e2b6e2a9 fix: fix file conflict 2026-07-29 10:33:18 +08:00
CodingOnStar
61c43c95e5 chore: include marketplace app styles 2026-07-28 20:14:17 +08:00
CodingOnStar
9316880ecb fix: add hybrid SSR for embedded marketplace 2026-07-28 19:16:17 +08:00
CodingOnStar
e39a7b10de fix: isolate embedded marketplace client 2026-07-28 19:10:05 +08:00
fatelei
2bb01984ed fix: fix conflict 2026-07-28 19:03:11 +08:00
fatelei
77e41a37ee feat: agent can op file and fix version check 2026-07-28 18:36:06 +08:00
zxhlyh
79229b7ede fix(web): preserve IME composition in skill builder 2026-07-28 17:46:12 +08:00
CodingOnStar
fd20d4f536 fix: simplify marketplace home tabs 2026-07-28 16:57:20 +08:00
CodingOnStar
796f32baa2 fix: localize marketplace homepage copy 2026-07-28 16:52:40 +08:00
CodingOnStar
a605790a84 feat: add themed marketplace logo 2026-07-28 16:40:33 +08:00
zxhlyh
36ae648a63 fix(web): protect skill bindings in version view 2026-07-28 16:21:43 +08:00
CodingOnStar
59bf9019c9 feat: share marketplace home shell with templates 2026-07-28 16:05:30 +08:00
zxhlyh
c409d531da fix(web): paginate workspace skills list 2026-07-28 15:49:44 +08:00
CodingOnStar
5fef0cf07c fix: stabilize marketplace home sticky navigation 2026-07-28 15:40:05 +08:00
autofix-ci[bot]
511ca69e4e [autofix.ci] apply automated fixes 2026-07-28 07:17:11 +00:00
fatelei
4c7e60d7e7 fix: fix db version 2026-07-28 15:12:58 +08:00
CodingOnStar
068aced02e feat: complete marketplace trending carousel 2026-07-28 15:07:38 +08:00
fatelei
a9e163403d fix: fix model not change 2026-07-28 14:52:16 +08:00
zxhlyh
bc136b89f9 fix(web): prevent duplicate skill autosaves 2026-07-28 14:52:16 +08:00
fatelei
b1bd4f9a8b fix: fix model not update 2026-07-28 14:52:16 +08:00
fatelei
06d3927f05 fix: fix failed test 2026-07-28 14:52:16 +08:00
fatelei
5c3516cae8 fix: fix skill not in agent 2026-07-28 14:52:16 +08:00
fatelei
1c18d8ddbd fix: fix frontend issue and let draft check relax 2026-07-28 14:52:16 +08:00
fatelei
253dfe9351 fix: fix ci failed 2026-07-28 14:52:15 +08:00
fatelei
2d3999e984 fix: fix ci failed 2026-07-28 14:52:15 +08:00
fatelei
a431cc726d fix: fix scroll and live mode can not enter 2026-07-28 14:52:15 +08:00
fatelei
08493d2429 fix: fix some bugs 2026-07-28 14:52:15 +08:00
fatelei
8dd0969006 chore: adjust skill entrance 2026-07-28 14:52:15 +08:00
fatelei
2d80d3c35c fix: fix delete skill ref 2026-07-28 14:52:14 +08:00
fatelei
4da764904c feat: support agent skill 2026-07-28 14:46:34 +08:00
fatelei
5c4f4fd1ef Merge branch 'skill' into deploy/dev 2026-07-28 14:44:57 +08:00
fatelei
54843971ac fix: fix model not change 2026-07-28 14:43:05 +08:00
zxhlyh
5ec5d3aeb9 fix(web): prevent duplicate skill autosaves 2026-07-28 14:30:17 +08:00
fatelei
d661b53e49 fix: fix model not update 2026-07-28 14:10:49 +08:00
zxhlyh
fd1e777f85 Merge branch 'codex/fix-skill-autosave-conflict' into deploy/dev 2026-07-28 14:04:25 +08:00
zxhlyh
e5f77ce185 fix(web): prevent duplicate skill autosaves 2026-07-28 14:02:18 +08:00
CodingOnStar
60bc4eaf3c refactor: preserve marketplace home server rendering 2026-07-28 11:55:01 +08:00
CodingOnStar
208073e228 feat: align marketplace hero with redesign 2026-07-28 11:28:29 +08:00
FFXN
fa1904e7ee Merge remote-tracking branch 'origin/main'
# Conflicts:
#	api/tests/unit_tests/services/test_telemetry_service.py
2026-07-27 11:02:28 +08:00
fatelei
99929d1c16 fix: fix conflict 2026-07-27 08:12:58 +08:00
fatelei
c699aa11db fix: fix failed test 2026-07-26 23:42:50 +08:00
fatelei
06cd0b56ac fix: fix skill not in agent 2026-07-26 21:00:38 +08:00
fatelei
241a9e1fec fix: fix frontend issue and let draft check relax 2026-07-26 21:00:38 +08:00
fatelei
3d79689fb5 fix: fix ci failed 2026-07-26 20:59:56 +08:00
fatelei
9f050f7957 fix: fix ci failed 2026-07-26 20:59:56 +08:00
fatelei
cb691d47d3 fix: fix scroll and live mode can not enter 2026-07-26 20:57:29 +08:00
fatelei
04f5ee58d0 fix: fix some bugs 2026-07-26 20:57:29 +08:00
fatelei
f5cff724f4 chore: adjust skill entrance 2026-07-26 20:57:29 +08:00
fatelei
1f4ccafdea fix: fix delete skill ref 2026-07-26 20:57:29 +08:00
fatelei
163049f6ca feat: support agent skill 2026-07-26 20:54:32 +08:00
CodingOnStar
895af9c1d1 fix: restore marketplace category icons 2026-07-24 18:31:00 +08:00
CodingOnStar
2bec40d7be feat: add sticky marketplace home navigation 2026-07-24 18:23:05 +08:00
FFXN
9fe9aa5157 Merge remote-tracking branch 'origin/main' 2026-07-24 18:00:47 +08:00
CodingOnStar
6d18502041 fix: restore marketplace home filter styles 2026-07-24 17:48:05 +08:00
CodingOnStar
e1432889cb fix: align marketplace home filters to left 2026-07-24 17:34:46 +08:00
CodingOnStar
0d65a338ef feat: add marketplace home catalog filters 2026-07-24 17:10:12 +08:00
CodingOnStar
3f2fa4d7de fix: align marketplace brand typography 2026-07-24 16:06:48 +08:00
CodingOnStar
0a23c0c386 fix: align embedded marketplace header 2026-07-24 15:53:21 +08:00
CodingOnStar
49bd13248f fix: enable redesigned marketplace route 2026-07-24 15:47:59 +08:00
fatelei
65e7507ca3 Merge branch 'skill' into deploy/dev 2026-07-24 13:38:15 +08:00
fatelei
ba157f9604 fix: fix skill not in agent 2026-07-24 13:37:19 +08:00
CodingOnStar
c989c3c6ac fix: align marketplace homepage with standalone design 2026-07-24 13:26:41 +08:00
fatelei
0dc913630e fix: fix conflict 2026-07-24 13:18:31 +08:00
fatelei
875cd30b1f fix: fix frontend issue and let draft check relax 2026-07-24 13:11:18 +08:00
fatelei
aa4a32ae84 fix: fix ci failed 2026-07-24 11:13:44 +08:00
fatelei
8573e14777 fix: fix ci failed 2026-07-24 11:13:44 +08:00
fatelei
1855be234c fix: fix scroll and live mode can not enter 2026-07-24 11:13:44 +08:00
fatelei
9bb960ff12 fix: fix some bugs 2026-07-24 11:13:44 +08:00
fatelei
1c14c7d467 chore: adjust skill entrance 2026-07-24 11:13:44 +08:00
fatelei
61faec16ca fix: fix delete skill ref 2026-07-24 11:13:44 +08:00
fatelei
57c836e692 feat: support agent skill 2026-07-24 11:13:43 +08:00
CodingOnStar
7d4718d8c5 Merge remote-tracking branch 'origin/main' into feat/marketplace-homepage-redesign
# Conflicts:
#	web/app/components/plugins/marketplace/index.tsx
#	web/app/components/plugins/marketplace/list/card-wrapper.tsx
#	web/app/components/plugins/marketplace/utils.ts
2026-07-24 10:26:43 +08:00
CodingOnStar
3b9176f538 feat: redesign marketplace homepage 2026-07-23 18:57:28 +08:00
samzong
626cc282b1 fix(web): echo OAuth state on authorize redirect (#39459)
Signed-off-by: samzong <samzong.lu@gmail.com>
2026-07-23 06:36:58 -04:00
fatelei
52624d54e3 fix: fix db migration 2026-07-23 16:28:27 +08:00
fatelei
5ce038ef92 fix: fix db migration 2026-07-23 16:25:33 +08:00
fatelei
30f4d4c0c6 fix: fix db migration 2026-07-23 16:04:09 +08:00
fatelei
510679a7d1 fix: fix db migration 2026-07-23 15:38:05 +08:00
FFXN
33a61e32ed Merge remote-tracking branch 'origin/main' 2026-07-23 15:33:31 +08:00
FFXN
2a7968d210 feat(telemetry): add community telemetry configuration and reporting 2026-07-23 15:32:49 +08:00
fatelei
9237f2a14a fix: fix conflict 2026-07-23 15:23:09 +08:00
fatelei
bd178c7b29 Merge branch 'skill' into deploy/dev 2026-07-23 14:38:04 +08:00
fatelei
d80947aa72 fix: fix scroll and live mode can not enter 2026-07-23 14:36:44 +08:00
FFXN
037b7f6b5f feat(telemetry): add community telemetry configuration and reporting 2026-07-23 14:18:09 +08:00
fatelei
1618c37d26 Merge branch 'skill' into deploy/dev 2026-07-23 14:17:52 +08:00
fatelei
701ab64462 fix: fix some bugs 2026-07-23 14:14:28 +08:00
FFXN
3e6c3ace7b Merge remote-tracking branch 'origin/main' 2026-07-23 11:56:26 +08:00
FFXN
f26812310f feat(telemetry): add community telemetry configuration and reporting 2026-07-23 11:55:42 +08:00
fatelei
b3298800e9 chore: adjust skill entrance 2026-07-22 16:30:38 +08:00
fatelei
0f1c6b3f78 chore: remove agent v2 feature 2026-07-22 16:26:00 +08:00
林玮 (Jade Lin)
9b4b246aad refactor(api): simplify batch cleanup refresh state
(cherry picked from commit 1ded32d949)
2026-07-22 14:49:11 +08:00
林玮 (Jade Lin)
1bd654a289 refactor(api): simplify vector cache refresh metric
(cherry picked from commit fa585cf134)
2026-07-22 14:49:11 +08:00
林玮 (Jade Lin)
fc70329bdb refactor(api): derive cleanup tenant from dataset
(cherry picked from commit b124ec86fc)
2026-07-22 14:49:11 +08:00
林玮 (Jade Lin)
cd8a82fbd4 fix(api): invalidate vector space cache after cleanup
(cherry picked from commit a07e5c6eb4)
2026-07-22 14:49:11 +08:00
fatelei
550cb7eff5 fix: fix db migration 2026-07-22 14:42:11 +08:00
fatelei
2e748c16e9 Merge branch 'skill' into deploy/dev 2026-07-22 14:17:08 +08:00
fatelei
577012b66d fix: fix delete skill ref 2026-07-22 14:11:55 +08:00
fatelei
251c324180 Merge branch 'skill' into deploy/dev 2026-07-22 12:59:22 +08:00
fatelei
865a618fd5 Revert "feat: support agent skill"
This reverts commit 991116990a.
2026-07-22 12:48:29 +08:00
fatelei
991116990a feat: support agent skill 2026-07-22 11:45:50 +08:00
fatelei
eb5d1da0e8 feat: support agent skill 2026-07-22 10:25:25 +08:00
FFXN
0a9f1d58bd feat(telemetry): add community telemetry configuration and reporting 2026-07-21 15:54:47 +08:00
FFXN
a1a2abb533 feat(telemetry): add community telemetry configuration and reporting 2026-07-21 15:45:26 +08:00
Joel
dcc660daa2 fix: not support jump to detail in marketplace 2026-07-21 13:49:18 +08:00
FFXN
3f3422c37f feat(telemetry): add community telemetry configuration and reporting 2026-07-20 14:55:10 +08:00
FFXN
ccbb556a8b Merge remote-tracking branch 'origin/main' 2026-07-20 14:43:08 +08:00
FFXN
ad2094c96f Merge remote-tracking branch 'origin/main' 2026-07-13 18:07:43 +08:00
fatelei
dce3b7a7fc feat: use celery task 2026-07-10 15:20:06 +08:00
fatelei
be386aba3b chore: add switch 2026-07-10 15:19:56 +08:00
JzoNg
02e51e7d7c Merge branch 'main' into deploy/dev 2026-07-10 10:28:07 +08:00
Xiyuan Chen
96b6d4f2c0 Merge branch 'fix/socketio-user-connect-app-context' into deploy/dev 2026-07-08 22:02:43 -07:00
GareArc
4c84c5957d fix: run user_connect authorization inside Flask app context
Socket.IO event handlers run in plain gevent greenlets without a Flask
application context, so the db.session() call added in #38227 raises
RuntimeError: Working outside of application context, and every
user_connect is rejected as unauthorized. Wrap the authorization call in
sio.app.app_context(), matching the existing connect handler.
2026-07-08 21:57:07 -07:00
CodingOnStar
34613ecdc5 fix(app): update app creation logic to use database session and ensure RBAC access initialization 2026-07-09 12:00:16 +08:00
CodingOnStar
6a14245401 Merge remote-tracking branch 'origin/main' into deploy/dev 2026-07-09 11:49:16 +08:00
CodingOnStar
a758ca2aef fix(amplitude): update API key for amplitude initialization 2026-07-09 11:37:28 +08:00
CodingOnStar
9e60d4e213 Merge branch 'fix/report-slug' into deploy/dev 2026-07-09 11:29:04 +08:00
CodingOnStar
ef29c8442c refactor(amplitude): comment out amplitude initialization logic for future implementation 2026-07-09 11:26:59 +08:00
CodingOnStar
a1b45415ac fix(web): preserve attribution from auth redirect 2026-07-09 11:00:37 +08:00
fatelei
7fc46d75bd chore: add switch 2026-07-08 18:08:02 +08:00
fatelei
953a4ef0ca feat: create app sync scope and workspace user 2026-07-08 16:12:40 +08:00
FFXN
0f77b623d8 Merge remote-tracking branch 'origin/main' 2026-07-07 15:15:08 +08:00
FFXN
87e7e1ca2d chore: improve error message for missing cherry-pick provenance. 2026-07-07 12:13:18 +08:00
FFXN
796328e9f2 Merge remote-tracking branch 'origin/main' 2026-07-07 11:54:54 +08:00
FFXN
526059faeb Merge remote-tracking branch 'origin/main' 2026-07-03 16:20:52 +08:00
FFXN
272bdeb555 feat: enhance SQL query safety and add metadata key validation 2026-07-02 15:37:14 +08:00
641 changed files with 10635 additions and 18920 deletions

View File

@@ -26,17 +26,15 @@ 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 standard Tailwind radius classes match the required design value. Apply [Styling] when choosing radius classes.
- Hardcoded magic class values for colors, spacing, radius, shadow, z-index, or typography when Dify tokens, component variants, or documented radius mappings exist.
- `!` 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.
@@ -45,6 +43,7 @@ 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
@@ -67,5 +66,3 @@ 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` |
| State styling, callbacks, and Tailwind | `packages/dify-ui/docs/styling.md` |
| Tailwind and radius tokens | `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,7 +10,6 @@ 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.
@@ -25,8 +24,6 @@ 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

View File

@@ -1,94 +0,0 @@
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@b659c744ee9bed25aea2307c017ad849bd104433 # v3.2.0
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.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@b659c744ee9bed25aea2307c017ad849bd104433 # v3.2.0
uses: hoverkraft-tech/compose-action@ee6af68587292d72db67743171809c19787df4c9 # v3.1.0
with:
compose-file: |
docker/docker-compose.middleware.yaml

View File

@@ -1,39 +0,0 @@
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"

View File

@@ -1,55 +0,0 @@
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@4036a180cf690f49529f5d8c79c998855287f590 # v1.0.230
uses: anthropics/claude-code-action@3b8197d3d486006dd4af54613517f21ac6ac625e # v1.0.227
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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.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@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
with:
path: web/.next/cache
key: ${{ steps.next-cache.outputs.cache-primary-key }}

View File

@@ -77,6 +77,9 @@ 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,22 +568,6 @@ 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,8 +23,3 @@ 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,18 +7,6 @@ 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

@@ -56,6 +56,7 @@ from . import (
knowledge_fs_proxy,
notification,
onboarding,
resource_access_token,
setup,
spec,
system,
@@ -234,6 +235,7 @@ __all__ = [
"rag_pipeline_workflow",
"rbac",
"recommended_app",
"resource_access_token",
"saved_message",
"setup",
"site",

View File

@@ -1086,10 +1086,14 @@ class AppExportApi(Resource):
tenant_id=app_model.tenant_id, agent_id=agent_id, version_id=req_data.version_id
)
else:
exported = AppPackageService().export_app(
app_model=app_model,
include_secret=req_data.include_secret,
workflow_id=req_data.workflow_id,
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,
)
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, PreparedAppPackage
from services.app_package_service import AppPackageService
from services.enterprise.enterprise_service import EnterpriseService
from services.entities.dsl_entities import CheckDependenciesResult, ImportStatus
from services.errors.account import NoPermissionError
@@ -165,16 +165,14 @@ 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)
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,
)
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,
)
return self._import_agent_package(current_user, source, payload)
@rbac_permission_required(RBACCheck(RBACPermission.AGENT_CREATE, Workspace()))
@@ -198,13 +196,7 @@ 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,
*,
package: PreparedAppPackage | None = None,
):
def _import_dsl(self, req_data: AppImportPayload, current_user: Account | 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
@@ -225,7 +217,6 @@ 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,6 +53,7 @@ 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
@@ -1736,12 +1737,15 @@ 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=event.workflow_args,
args=workflow_args,
invoke_from=InvokeFrom.DEBUGGER,
streaming=True,
root_node_id=node_id,
@@ -1890,11 +1894,14 @@ 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=trigger_debug_event.workflow_args,
args=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 Response, request
from flask import 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, Unauthorized
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
import services
from controllers.common.fields import (
@@ -19,7 +19,6 @@ 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,
@@ -46,16 +45,14 @@ 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_context
from controllers.console.flask_admission import console_account_admission
from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_request
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,
@@ -64,23 +61,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 machinery.context import RequestContext
from models import Account
from models import Account, App
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
@@ -98,14 +95,7 @@ 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__)
@@ -463,66 +453,68 @@ register_response_schema_models(
simple_account_model = console_ns.models[TrialSimpleAccount.__name__]
class TrialAppFileUploadApi(Resource):
class TrialAppFileUploadApi(TrialAppResource):
@cloud_edition_billing_resource_check("documents")
@console_ns.doc(consumes=["multipart/form-data"], params=FILE_UPLOAD_PARAMS)
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileResponse.__name__])
@console_account_admission()
@get_trial_app
@cloud_edition_billing_resource_check("documents")
def post(self, request_context: RequestContext, trial_app: TrialAppRef) -> JsonResponseWithStatus:
@with_current_user
def post(self, current_user: Account, app_model: App):
"""Upload a file into the tenant that owns the trial app."""
upload_file = upload_file_from_request_context(
request_context=request_context,
resource_tenant_id=trial_app.tenant_id,
upload_file = upload_file_from_request(
current_user=current_user,
resource_tenant_id=app_model.tenant_id,
)
return dump_response(FileResponse, upload_file), 201
class TrialAppRemoteFileUploadApi(Resource):
class TrialAppRemoteFileUploadApi(TrialAppResource):
@cloud_edition_billing_resource_check("documents")
@console_ns.expect(console_ns.models[RemoteFileUploadPayload.__name__])
@console_ns.response(201, "File uploaded successfully", console_ns.models[FileWithSignedUrl.__name__])
@console_account_admission()
@get_trial_app
@cloud_edition_billing_resource_check("documents")
@with_current_user
@model_validate(RemoteFileUploadPayload)
def post(
self, payload: RemoteFileUploadPayload, request_context: RequestContext, trial_app: TrialAppRef
) -> JsonResponseWithStatus:
def post(self, payload: RemoteFileUploadPayload, current_user: Account, app_model: App):
"""Upload a remote file into the tenant that owns the trial app."""
remote_file = upload_remote_file(
url=payload.url,
current_user=FileUploadActor(id=request_context.account_id, creator_role=CreatorUserRole.ACCOUNT),
resource_tenant_id=trial_app.tenant_id,
current_user=current_user,
resource_tenant_id=app_model.tenant_id,
)
return dump_response(FileWithSignedUrl, remote_file), 201
class TrialAppWorkflowRunApi(Resource):
class TrialAppWorkflowRunApi(TrialAppResource):
@console_ns.expect(console_ns.models[WorkflowRunRequest.__name__])
@console_ns.response(200, "Success")
@console_account_admission()
@get_trial_app
@with_current_user
@with_session
@model_validate(WorkflowRunRequest)
def post(self, req_data: WorkflowRunRequest, request_context: RequestContext, trial_app: TrialAppRef) -> Response:
def post(self, req_data: WorkflowRunRequest, session: Session, current_user: Account, trial_app):
"""
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:
response = application_services().trial_app_generation.generate_workflow(
trial_app=trial_app,
account_id=request_context.account_id,
args=req_data.model_dump(),
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,
)
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:
@@ -531,7 +523,7 @@ class TrialAppWorkflowRunApi(Resource):
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except (AppInvokeQuotaExceededError, InvokeRateLimitError) as ex:
except InvokeRateLimitError as ex:
raise InvokeRateLimitHttpError(ex.description)
except ValueError as e:
raise e
@@ -540,29 +532,41 @@ class TrialAppWorkflowRunApi(Resource):
raise InternalServerError()
class TrialAppWorkflowTaskStopApi(Resource):
class TrialAppWorkflowTaskStopApi(TrialAppResource):
@console_ns.response(200, "Success", console_ns.models[SimpleResultResponse.__name__])
@console_account_admission()
@get_trial_app
def post(self, request_context: RequestContext, trial_app: TrialAppRef, task_id: str) -> dict[str, object]:
def post(self, trial_app, task_id: str):
"""
Stop workflow task
"""
if trial_app.app_mode != "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()
application_services().app_tasks.stop_workflow_task_no_user_check(task_id=task_id)
# Stop using both mechanisms for backward compatibility
# Legacy stop flag mechanism (without user check)
AppQueueManager.set_stop_flag_no_user_check(task_id)
return dump_response(SimpleResultResponse, {"result": "success"})
# New graph engine command channel mechanism
GraphEngineManager(redis_client).send_stop_command(task_id)
return {"result": "success"}
class TrialChatApi(Resource):
class TrialChatApi(TrialAppResource):
@console_ns.expect(console_ns.models[ChatRequest.__name__])
@console_ns.response(200, "Success")
@console_account_admission()
@get_trial_app
@with_current_user
@with_session
@model_validate(ChatRequest)
def post(self, req_data: ChatRequest, request_context: RequestContext, trial_app: TrialAppRef) -> Response:
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()
args = req_data.model_dump()
# Validate UUID values if provided
@@ -571,20 +575,24 @@ class TrialChatApi(Resource):
if args.get("parent_message_id"):
args["parent_message_id"] = uuid_value(args["parent_message_id"])
args["auto_generate_name"] = False
try:
response = application_services().trial_app_generation.generate_chat(
trial_app=trial_app,
account_id=request_context.account_id,
# 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=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:
@@ -600,7 +608,7 @@ class TrialChatApi(Resource):
raise ProviderModelCurrentlyNotSupportError()
except InvokeError as e:
raise CompletionRequestError(e.description)
except (AppInvokeQuotaExceededError, InvokeRateLimitError) as ex:
except InvokeRateLimitError as ex:
raise InvokeRateLimitHttpError(ex.description)
except ValueError as e:
raise e
@@ -757,27 +765,39 @@ class TrialChatTextApi(TrialAppResource):
raise InternalServerError()
class TrialCompletionApi(Resource):
class TrialCompletionApi(TrialAppResource):
@console_ns.expect(console_ns.models[CompletionRequest.__name__])
@console_ns.response(200, "Success")
@console_account_admission()
@get_trial_app
@with_current_user
@with_session
@model_validate(CompletionRequest)
def post(self, req_data: CompletionRequest, request_context: RequestContext, trial_app: TrialAppRef) -> Response:
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
try:
response = application_services().trial_app_generation.generate_completion(
trial_app=trial_app,
account_id=request_context.account_id,
args=req_data.model_dump(),
# 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,
)
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:
@@ -793,8 +813,6 @@ class TrialCompletionApi(Resource):
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

@@ -1,49 +0,0 @@
"""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,11 +8,12 @@ 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
@@ -106,6 +107,16 @@ 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,11 +1,8 @@
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
@@ -32,12 +29,10 @@ 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 current_account_with_tenant, login_required
from libs.login import 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,
@@ -66,8 +61,8 @@ FILE_UPLOAD_PARAMS = {
}
def _parse_file_upload() -> tuple[FileStorage, str, Literal["datasets"] | None]:
"""Validate multipart shape before checking source-specific permissions."""
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."""
source_str = request.args.get("source") or request.form.get("source")
source: Literal["datasets"] | None = "datasets" if source_str == "datasets" else None
@@ -80,37 +75,21 @@ def _parse_file_upload() -> tuple[FileStorage, str, Literal["datasets"] | None]:
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
)
with _file_upload_errors():
try:
return application_services().files.upload_file(
filename=filename,
filename=file.filename,
content=file.stream.read(),
mimetype=file.mimetype,
user=current_user,
@@ -118,28 +97,12 @@ def upload_file_from_request(*, current_user: Account, resource_tenant_id: str |
source=source,
default_file_size_limit=default_file_size_limit,
)
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,
)
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
@console_ns.route("/files/upload")

View File

@@ -1,5 +1,3 @@
from typing import overload
from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field
@@ -27,7 +25,6 @@ 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,
)
@@ -87,37 +84,13 @@ 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.
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.
"""
"""Fetch a remote file and persist it under the requested tenant."""
try:
return application_services().remote_files.upload_from_url(
url=url,

View File

@@ -0,0 +1,256 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from flask_restx import Resource
from pydantic import Field, field_validator
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.common.session import with_session
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
model_validate,
setup_required,
with_current_tenant_id,
with_current_user,
)
from fields.base import ResponseModel
from libs.helper import dump_response, to_timestamp
from libs.login import login_required
from models import Account
from models.account import TenantAccountRole
from models.resource_access_token import ResourceAccessTokenResourceType
from services.resource_access_token_service import (
ResourceAccessTokenResource,
ResourceAccessTokenRow,
ResourceAccessTokenService,
)
class ResourceAccessTokenResourcePayload(ResponseModel):
type: ResourceAccessTokenResourceType
id: str
class ResourceAccessTokenCreatePayload(ResponseModel):
name: str = Field(min_length=1)
resources: list[ResourceAccessTokenResourcePayload] = Field(min_length=1)
class ResourceAccessTokenUpdatePayload(ResponseModel):
name: str = Field(min_length=1)
resources: list[ResourceAccessTokenResourcePayload] = Field(min_length=1)
class ResourceAccessTokenListQuery(ResponseModel):
page: int = Field(default=1, ge=1)
limit: int = Field(default=20, ge=1, le=100)
class ResourceAccessTokenRowResponse(ResponseModel):
token_id: str
relation_id: str
name: str
track_id: str
token: str | None = None
masked_token: str
resource_type: ResourceAccessTokenResourceType
resource_id: str
resource_name: str
created_at: int
last_used_at: int | None = None
@field_validator("created_at", "last_used_at", mode="before")
@classmethod
def _normalize_timestamp(cls, value: datetime | int | None) -> int | None:
return to_timestamp(value)
class ResourceAccessTokenListResponse(ResponseModel):
data: list[ResourceAccessTokenRowResponse]
page: int
limit: int
total: int
has_more: bool
class ResourceAccessTokenCreateResponse(ResponseModel):
token: str
data: list[ResourceAccessTokenRowResponse]
register_schema_models(
console_ns,
ResourceAccessTokenResourcePayload,
ResourceAccessTokenCreatePayload,
ResourceAccessTokenUpdatePayload,
ResourceAccessTokenListQuery,
)
register_response_schema_models(
console_ns,
ResourceAccessTokenRowResponse,
ResourceAccessTokenListResponse,
ResourceAccessTokenCreateResponse,
)
def _require_owner(current_user: Account) -> None:
if current_user.current_role != TenantAccountRole.OWNER:
raise Forbidden()
def _dump_rows(rows: list[ResourceAccessTokenRow]) -> list[dict[str, object]]:
return [
dump_response(
ResourceAccessTokenRowResponse,
ResourceAccessTokenRowResponse.model_validate(row, from_attributes=True),
)
for row in rows
]
@console_ns.route("/resource-access-tokens")
class ResourceAccessTokenListApi(Resource):
method_decorators = [account_initialization_required, login_required, setup_required]
@console_ns.doc("list_resource_access_tokens")
@console_ns.doc(params=query_params_from_model(ResourceAccessTokenListQuery))
@console_ns.response(200, "Resource access tokens", console_ns.models[ResourceAccessTokenListResponse.__name__])
@model_validate(ResourceAccessTokenListQuery)
@with_current_user
@with_current_tenant_id
@with_session(write=False)
def get(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
query: ResourceAccessTokenListQuery,
) -> dict[str, object]:
_require_owner(current_user)
rows = ResourceAccessTokenService.list_rows(
tenant_id=current_tenant_id,
page=query.page,
limit=query.limit,
session=session,
)
total = ResourceAccessTokenService.count_rows(tenant_id=current_tenant_id, session=session)
return dump_response(
ResourceAccessTokenListResponse,
{
"data": _dump_rows(rows),
"page": query.page,
"limit": query.limit,
"total": total,
"has_more": query.page * query.limit < total,
},
)
@console_ns.doc("create_resource_access_token")
@console_ns.expect(console_ns.models[ResourceAccessTokenCreatePayload.__name__])
@console_ns.response(
201,
"Resource access token created",
console_ns.models[ResourceAccessTokenCreateResponse.__name__],
)
@model_validate(ResourceAccessTokenCreatePayload)
@with_current_user
@with_current_tenant_id
@with_session
def post(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
payload: ResourceAccessTokenCreatePayload,
) -> tuple[dict[str, object], int]:
_require_owner(current_user)
result = ResourceAccessTokenService.create(
tenant_id=current_tenant_id,
created_by=current_user.id,
name=payload.name,
resources=[
ResourceAccessTokenResource(type=resource.type, id=resource.id) for resource in payload.resources
],
session=session,
)
return dump_response(
ResourceAccessTokenCreateResponse,
{"token": result.token, "data": _dump_rows(result.rows)},
), 201
@console_ns.route("/resource-access-tokens/<uuid:token_id>")
class ResourceAccessTokenApi(Resource):
method_decorators = [account_initialization_required, login_required, setup_required]
@console_ns.doc("update_resource_access_token")
@console_ns.expect(console_ns.models[ResourceAccessTokenUpdatePayload.__name__])
@console_ns.response(
200,
"Resource access token updated",
console_ns.models[ResourceAccessTokenListResponse.__name__],
)
@model_validate(ResourceAccessTokenUpdatePayload)
@with_current_user
@with_current_tenant_id
@with_session
def patch(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
payload: ResourceAccessTokenUpdatePayload,
token_id: UUID,
) -> dict[str, object]:
_require_owner(current_user)
rows = ResourceAccessTokenService.update(
tenant_id=current_tenant_id,
token_id=str(token_id),
name=payload.name,
resources=[
ResourceAccessTokenResource(type=resource.type, id=resource.id) for resource in payload.resources
],
session=session,
)
return dump_response(
ResourceAccessTokenListResponse,
{
"data": _dump_rows(rows),
"page": 1,
"limit": len(rows),
"total": len(rows),
"has_more": False,
},
)
@console_ns.route("/resource-access-tokens/<uuid:token_id>/relations/<uuid:relation_id>")
class ResourceAccessTokenRelationApi(Resource):
method_decorators = [account_initialization_required, login_required, setup_required]
@console_ns.doc("delete_resource_access_token_relation")
@console_ns.response(204, "Resource access token relation deleted")
@with_current_user
@with_current_tenant_id
@with_session
def delete(
self,
session: Session,
current_tenant_id: str,
current_user: Account,
token_id: UUID,
relation_id: UUID,
) -> tuple[str, int]:
_require_owner(current_user)
ResourceAccessTokenService.delete_relation(
tenant_id=current_tenant_id,
token_id=str(token_id),
relation_id=str(relation_id),
session=session,
)
return "", 204

View File

@@ -19,7 +19,6 @@ 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
@@ -35,7 +34,6 @@ __all__ = [
"_agent_config",
"_agent_files",
"_agent_llm",
"_agent_runtime_usage",
"_agent_tools",
"_app_dsl",
"_app_file_grants",

View File

@@ -1,100 +0,0 @@
"""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

@@ -34,7 +34,7 @@ from controllers.openapi.auth.requirements import (
CheckSubject,
CheckWorkspaceMember,
)
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
from controllers.service_api.app.error import (
AppUnavailableError,
CompletionRequestError,
@@ -155,7 +155,7 @@ _DISPATCH: dict[AppMode, Callable[[App, Any, AppRunRequest, Session], Any]] = {
class AppRunApi(Resource):
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_RUN),
@@ -197,7 +197,7 @@ class AppRunApi(Resource):
class AppRunTaskStopApi(Resource):
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_RUN),
@@ -207,6 +207,11 @@ class AppRunTaskStopApi(Resource):
returns=(200, TaskStopResponse, "Task stopped"),
)
def post(self, ctx: Context, app_id: str, task_id: str):
if isinstance(ctx.subject, ResourceAccessSubject):
# A bound app must not let a machine token stop another caller's task.
owner = redis_client.get(AppQueueManager._generate_task_belong_cache_key(task_id))
if owner != f"end-user-{ctx.end_user.id}".encode():
raise NotFound("Task not found")
AppQueueManager.set_stop_flag_no_user_check(task_id)
GraphEngineManager(redis_client).send_stop_command(task_id)
return TaskStopResponse(result="success")

View File

@@ -32,7 +32,7 @@ from controllers.openapi.auth.requirements import (
CheckSubject,
CheckWorkspaceMember,
)
from controllers.openapi.auth.subjects import AccountSubject
from controllers.openapi.auth.subjects import AccountSubject, ResourceAccessSubject
from controllers.service_api.app.error import AppUnavailableError
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from libs.oauth_bearer import Scope
@@ -104,7 +104,7 @@ def build_app_describe_response(app: App, fields: set[str] | None, *, session: S
class AppDescribeApi(Resource):
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_READ),
@@ -122,7 +122,7 @@ class AppDescribeApi(Resource):
class AppListApi(Resource):
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject,)),
CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)),
CheckScope(Scope.APPS_READ),
CheckWorkspaceMember(),
),
@@ -131,7 +131,7 @@ class AppListApi(Resource):
)
def get(self, ctx: Context, *, query: AppListQuery):
workspace_id = query.workspace_id
account_id = str(ctx.subject.account_id)
account_id = str(ctx.subject.account_id or ctx.subject.token_id)
empty = AppListResponse.build(page=query.page, limit=query.limit, total=0, items=[])
@@ -145,7 +145,7 @@ class AppListApi(Resource):
access_filter = (
resolve_app_access_filter(workspace_id, account_id, session=ctx.session)
if dify_config.RBAC_ENABLED
if dify_config.RBAC_ENABLED and isinstance(ctx.subject, AccountSubject)
else AppAccessFilter.unrestricted()
)
@@ -154,6 +154,10 @@ class AppListApi(Resource):
app: App | None = AppService.get_visible_app_by_id(str(parsed_uuid), ctx.session)
if app is None or str(app.tenant_id) != workspace_id:
return empty
if ctx.resource_app_ids is not None and (
str(app.id) not in ctx.resource_app_ids or app.status != AppStatus.NORMAL
):
return empty
if not _is_listable(app):
return empty
# Apply RBAC visibility to the UUID fast-path the same way the service
@@ -188,6 +192,9 @@ class AppListApi(Resource):
access_filter.apply_to_params(params)
if ctx.resource_app_ids is not None:
params.accessible_app_ids = list(ctx.resource_app_ids)
params.include_own_apps = False
pagination = AppService().get_paginate_apps(account_id, workspace_id, params, ctx.session)
if pagination is None:
return empty

View File

@@ -39,6 +39,7 @@ class Context:
subject: Subject
session: Session
view_args: Mapping[str, str]
resource_app_ids: frozenset[str] | None = field(default=None, init=False)
_app: App | None = field(default=None, init=False)
_workspace: Tenant | None = field(default=None, init=False)
_workspace_role: TenantAccountRole | None = field(default=None, init=False)

View File

@@ -17,8 +17,9 @@ from controllers.openapi.auth.requirements import (
Requirement,
ResolveCaller,
)
from controllers.openapi.auth.resource_access import CheckResourceAccess
from controllers.openapi.auth.spec import EndpointSpec
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, Subject
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject, Subject
from enums import DeploymentEdition
from libs.oauth_bearer import AuthContext, reset_auth_ctx, set_auth_ctx
from models.account import Account
@@ -63,6 +64,10 @@ class AccountPipeline(Pipeline, serves=AccountSubject):
fixed = (ResolveCaller(),)
class ResourceAccessPipeline(Pipeline, serves=ResourceAccessSubject):
fixed = (CheckResourceAccess(), ResolveCaller())
class _RequiresEnterprise(Requirement):
"""A gate on the token kind, not on a route, so no endpoint declares it.
It runs after `authenticate` on purpose: a `dfoe_` string no row backs

View File

@@ -22,7 +22,7 @@ from controllers.common.rbac import RBACCheck, enforce_rbac_checks
from controllers.openapi._audit import emit_wrong_surface
from controllers.openapi.auth.context import Context
from controllers.openapi.auth.loaders import load_app, load_caller, load_workspace, load_workspace_role
from controllers.openapi.auth.subjects import Subject
from controllers.openapi.auth.subjects import ResourceAccessSubject, Subject
from enums import DeploymentEdition
from libs.oauth_bearer import Scope
from models.account import TenantAccountRole
@@ -168,6 +168,9 @@ class CheckAppAccess(Requirement):
@override
def run(self, subject: Subject, ctx: Context, session: Session) -> None:
# Machine access is authorized by live resource bindings in its pipeline.
if isinstance(subject, ResourceAccessSubject):
return
if dify_config.DEPLOYMENT_EDITION != DeploymentEdition.ENTERPRISE:
return
access_mode = self._access_mode(str(load_app(ctx).id))

View File

@@ -0,0 +1,91 @@
"""Resource-token authentication and live resource authorization for OpenAPI.
Machine credentials never inherit their creator's account or workspace role.
Re-read bindings on every request so revocation takes effect without cache lag.
"""
import uuid
from typing import override
from flask import request
from sqlalchemy import select
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, Unauthorized
from controllers.openapi.auth.context import Context
from controllers.openapi.auth.requirements import Rank, Requirement
from controllers.openapi.auth.subjects import Subject
from core.db.session_factory import session_factory
from libs.datetime_utils import naive_utc_now
from libs.oauth_bearer import AuthContext, TokenType, sha256_hex
from libs.rate_limit import enforce_bearer_rate_limit
from models.account import Tenant, TenantStatus
from models.enums import AppStatus
from models.model import App
from models.resource_access_token import (
ResourceAccessToken,
ResourceAccessTokenRelation,
ResourceAccessTokenResourceType,
)
def authenticate_resource_token(token: str) -> AuthContext:
token_hash = sha256_hex(token)
enforce_bearer_rate_limit(token_hash)
with session_factory.create_session() as session:
row = session.scalar(select(ResourceAccessToken).where(ResourceAccessToken.token == token))
if row is None:
raise Unauthorized("invalid_bearer")
token_id = uuid.UUID(str(row.id))
return AuthContext(
subject_email=None,
subject_issuer=None,
account_id=None,
client_id=None,
token_id=token_id,
token_type=TokenType.RESOURCE_ACCESS,
expires_at=None,
)
class CheckResourceAccess(Requirement):
# CheckSubject rejects unsupported routes first, then bindings are loaded
# before any app or workspace requirements can read their context.
rank = Rank.FIRST
@override
def run(self, subject: Subject, ctx: Context, session: Session) -> None:
row = session.scalar(select(ResourceAccessToken).where(ResourceAccessToken.id == str(subject.token_id)))
if row is None:
raise Unauthorized("invalid_bearer")
tenant = session.get(Tenant, row.tenant_id)
if tenant is None or tenant.status != TenantStatus.NORMAL:
raise Forbidden("workspace unavailable")
workspace_id = ctx.view_args.get("workspace_id") or request.args.get("workspace_id")
if workspace_id and workspace_id != str(tenant.id):
raise Forbidden("resource_not_authorized")
ctx._workspace = tenant
ctx.resource_app_ids = frozenset(
session.scalars(
select(App.id)
.join(ResourceAccessTokenRelation, ResourceAccessTokenRelation.app_id == App.id)
.where(
ResourceAccessTokenRelation.token_id == row.id,
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.APP,
App.tenant_id == tenant.id,
)
)
)
app_id = ctx.view_args.get("app_id")
if app_id:
try:
app_id = str(uuid.UUID(app_id))
except ValueError:
raise Forbidden("resource_not_authorized")
if app_id not in ctx.resource_app_ids:
raise Forbidden("resource_not_authorized")
app = session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant.id))
if app is None or app.status != AppStatus.NORMAL or not app.enable_api:
raise Forbidden("resource_not_authorized")
ctx._app = app
row.last_used_at = naive_utc_now()

View File

@@ -11,11 +11,18 @@ from configs import dify_config
from controllers.openapi.auth.context import Context
from controllers.openapi.auth.pipelines import pipeline_for_subject
from controllers.openapi.auth.requirements import assert_license_valid
from controllers.openapi.auth.resource_access import authenticate_resource_token
from controllers.openapi.auth.spec import EndpointSpec
from controllers.openapi.auth.subjects import subject_from_auth
from core.db.session_factory import session_factory
from enums import DeploymentEdition
from libs.oauth_bearer import InvalidBearerError, assert_bearer_feature_enabled, extract_bearer, get_authenticator
from libs.oauth_bearer import (
InvalidBearerError,
TokenType,
assert_bearer_feature_enabled,
extract_bearer,
get_authenticator,
)
class AuthRouter:
@@ -34,21 +41,24 @@ class AuthRouter:
answers 404 before anything reveals whether the bearer was valid, and
an enterprise deployment's licence answers 403 before the missing-bearer
401. The licence is a fact about the deployment, not the route or the
caller, so this is the one place it is checked. The bearer feature flag
is the same kind of fact and answers 503 next.
caller, so this is the one place it is checked. The OAuth feature flag
answers 503 before OAuth authentication, but does not gate independent
resource credentials. Missing or unknown credentials still receive 503
when OAuth is disabled.
"""
if spec.edition is not None and dify_config.DEPLOYMENT_EDITION not in spec.edition:
raise NotFound()
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.ENTERPRISE:
assert_license_valid()
assert_bearer_feature_enabled()
token = extract_bearer(request)
is_resource_token = token is not None and TokenType.for_token(token) is TokenType.RESOURCE_ACCESS
if not is_resource_token:
assert_bearer_feature_enabled()
if not token:
raise Unauthorized("bearer required")
try:
auth = get_authenticator().authenticate(token)
auth = authenticate_resource_token(token) if is_resource_token else get_authenticator().authenticate(token)
except InvalidBearerError:
# One answer for every rejection reason - unknown prefix, no live row,
# expired - so a caller cannot probe which one it hit. Same reasoning as

View File

@@ -146,5 +146,28 @@ class ExternalSsoSubject(Subject):
return str(account.id) if account is not None else None
class ResourceAccessSubject(Subject):
subject_type = SubjectType.RESOURCE_ACCESS
caller_role = CreatorUserRole.END_USER
webapp_modes = frozenset[WebAppAccessMode]()
@override
def resolve_caller(self, ctx: Context, session: Session) -> EndUser:
return EndUserService.get_or_create_end_user_by_type(
EndUserType.OPENAPI,
tenant_id=str(ctx.workspace.id),
app_id=str(ctx.app.id),
user_id=f"resource-token:{self.token_id}",
)
@override
def mounts_caller(self, ctx: Context) -> bool:
return route_has_app(ctx)
@override
def webapp_user_id(self, session: Session) -> str | None:
return None
def subject_from_auth(auth: AuthContext) -> Subject:
return _SUBJECT_CLASSES[auth.subject_type](auth)

View File

@@ -25,7 +25,7 @@ from controllers.openapi.auth.requirements import (
CheckSubject,
CheckWorkspaceMember,
)
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
from extensions.ext_application_services import application_services
from fields.file_fields import FileResponse
from libs.oauth_bearer import Scope
@@ -46,7 +46,7 @@ class AppFileUploadApi(Resource):
)
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_RUN),

View File

@@ -27,7 +27,7 @@ from controllers.openapi.auth.requirements import (
CheckWorkspaceMember,
Requirement,
)
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, Subject
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject, Subject
from core.db.session_factory import session_factory
from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface
from extensions.ext_database import db
@@ -87,7 +87,7 @@ def _ensure_form_belongs_to_app(form, app_model: App) -> None:
class OpenApiWorkflowHumanInputFormApi(Resource):
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_RUN),
@@ -112,7 +112,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
class OpenApiWorkflowHumanInputFormSubmitApi(Resource):
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_RUN),

View File

@@ -30,7 +30,7 @@ from controllers.openapi.auth.requirements import (
CheckSubject,
CheckWorkspaceMember,
)
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
from core.app.apps.base_app_generator import BaseAppGenerator
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
@@ -55,7 +55,7 @@ class OpenApiWorkflowEventsApi(Resource):
@openapi_ns.doc(params=query_params_from_model(WorkflowEventsQuery))
@endpoint(
requirements=(
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject)),
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
CheckAppApiEnabled(),
CheckWorkspaceMember(),
CheckScope(Scope.APPS_RUN),

View File

@@ -43,7 +43,7 @@ from controllers.openapi.auth.requirements import (
CheckWorkspaceMember,
CheckWorkspaceRole,
)
from controllers.openapi.auth.subjects import AccountSubject
from controllers.openapi.auth.subjects import AccountSubject, ResourceAccessSubject
from enums import DeploymentEdition
from libs.oauth_bearer import Scope
from models import Account, Tenant, TenantAccountJoin
@@ -88,10 +88,19 @@ def _check_member_invite_quota(tenant_id: str) -> None:
@openapi_ns.route("/workspaces")
class WorkspacesApi(Resource):
@endpoint(
requirements=(CheckSubject(allowed=(AccountSubject,)), CheckScope(Scope.WORKSPACE_READ)),
requirements=(CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), CheckScope(Scope.WORKSPACE_READ)),
returns=(200, WorkspaceListResponse, "Workspace list"),
)
def get(self, ctx: Context):
if isinstance(ctx.subject, ResourceAccessSubject):
tenant = ctx.workspace
return WorkspaceListResponse(
workspaces=[
WorkspaceSummaryResponse(
id=str(tenant.id), name=tenant.name, role="", status=tenant.status, current=True
)
]
)
rows = TenantService.get_workspaces_for_account(str(ctx.subject.account_id), session=ctx.session)
return WorkspaceListResponse(workspaces=list(starmap(_workspace_summary, rows)))

View File

@@ -34,6 +34,7 @@ from services import dataset_api_key_service
from services.api_token_service import ApiTokenCache, fetch_token_with_single_flight, record_token_usage
from services.end_user_service import EndUserService
from services.feature_service import FeatureService
from services.resource_access_token_service import ResourceAccessTokenService
logger = logging.getLogger(__name__)
@@ -110,9 +111,16 @@ def validate_app_token[**P, R](
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
@wraps(view_func)
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:
api_token = validate_and_get_api_token("app")
app_model = db.session.get(App, api_token.app_id)
auth_token = peek_service_api_bearer_token()
if auth_token and ResourceAccessTokenService.is_resource_access_token(auth_token):
app_model = ResourceAccessTokenService.resolve_app_for_service_api(
token=auth_token,
requested_app_id=request.headers.get("X-Dify-App-ID"),
session=db.session(),
)
else:
api_token = validate_and_get_api_token("app")
app_model = db.session.get(App, api_token.app_id)
if not app_model:
raise Forbidden("The app no longer exists.")
@@ -318,8 +326,6 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
@wraps(view)
def decorated(*args: object, **kwargs: object) -> R:
api_token = validate_and_get_api_token("dataset")
# Flask may pass URL path parameters positionally, so inspect both kwargs and args.
dataset_id = kwargs.get("dataset_id")
@@ -332,17 +338,28 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
except Exception:
logger.exception("Failed to parse dataset_id from positional args")
# Per-knowledge-base scoping is expressed by DatasetApiTokenBinding rows:
# no rows -> the key can reach every dataset in its tenant (default / back-compat)
# N rows -> the key is limited to exactly those datasets
# A bound key may only call endpoints carrying one of its dataset ids; endpoints
# without a dataset id (e.g. list/create datasets) are rejected. The set is queried
# per request (not cached) so scope changes take effect immediately.
# db.session is Flask-SQLAlchemy's scoped_session proxy; cast so the plain-Session
# typed helper accepts it (runtime proxies every Session method through unchanged).
bound_dataset_ids = dataset_api_key_service.get_bound_dataset_ids(cast(Session, db.session), api_token.id)
if bound_dataset_ids and (not dataset_id or str(dataset_id) not in bound_dataset_ids):
raise Forbidden("The API key is not authorized to access this knowledge base.")
auth_token = peek_service_api_bearer_token()
if auth_token and ResourceAccessTokenService.is_resource_access_token(auth_token):
tenant_id = ResourceAccessTokenService.resolve_tenant_for_dataset_service_api(
token=auth_token,
dataset_id=str(dataset_id) if dataset_id else None,
session=db.session(),
)
else:
api_token = validate_and_get_api_token("dataset")
tenant_id = api_token.tenant_id
# Per-knowledge-base scoping is expressed by DatasetApiTokenBinding rows:
# no rows -> the key can reach every dataset in its tenant (default / back-compat)
# N rows -> the key is limited to exactly those datasets
# A bound key may only call endpoints carrying one of its dataset ids; endpoints
# without a dataset id (e.g. list/create datasets) are rejected. The set is queried
# per request (not cached) so scope changes take effect immediately.
# db.session is Flask-SQLAlchemy's scoped_session proxy; cast so the plain-Session
# typed helper accepts it (runtime proxies every Session method through unchanged).
bound_dataset_ids = dataset_api_key_service.get_bound_dataset_ids(cast(Session, db.session), api_token.id)
if bound_dataset_ids and (not dataset_id or str(dataset_id) not in bound_dataset_ids):
raise Forbidden("The API key is not authorized to access this knowledge base.")
if dataset_id:
dataset_id = str(dataset_id)
@@ -350,7 +367,7 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
select(Dataset)
.where(
Dataset.id == dataset_id,
Dataset.tenant_id == api_token.tenant_id,
Dataset.tenant_id == tenant_id,
)
.limit(1)
)
@@ -361,7 +378,7 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
tenant_account_join = db.session.execute(
select(Tenant, TenantAccountJoin).where(
Tenant.id == api_token.tenant_id,
Tenant.id == tenant_id,
TenantAccountJoin.tenant_id == Tenant.id,
TenantAccountJoin.role.in_(["owner"]),
Tenant.status == TenantStatus.NORMAL,
@@ -383,9 +400,9 @@ def validate_dataset_token[R](view: Callable[..., R]) -> Callable[..., R]:
if expects_bound_instance:
if not args:
raise TypeError("validate_dataset_token expected a bound resource instance.")
return view(args[0], api_token.tenant_id, *args[1:], **kwargs)
return view(args[0], tenant_id, *args[1:], **kwargs)
return view(api_token.tenant_id, *args, **kwargs)
return view(tenant_id, *args, **kwargs)
return decorated
@@ -401,15 +418,7 @@ def validate_and_get_api_token(scope: str | None = None):
The last_used_at field is updated asynchronously via Celery task
to avoid blocking the request.
"""
auth_header = request.headers.get("Authorization")
if auth_header is None or " " not in auth_header:
raise Unauthorized("Authorization header must be provided and start with 'Bearer'")
auth_scheme, auth_token = auth_header.split(None, 1)
auth_scheme = auth_scheme.lower()
if auth_scheme != "bearer":
raise Unauthorized("Authorization scheme must be 'Bearer'")
auth_token = extract_service_api_bearer_token()
# Try to get token from cache first
# Returns a CachedApiToken (plain Python object), not a SQLAlchemy model
@@ -425,6 +434,27 @@ def validate_and_get_api_token(scope: str | None = None):
return fetch_token_with_single_flight(auth_token, scope)
def extract_service_api_bearer_token() -> str:
auth_header = request.headers.get("Authorization")
if auth_header is None or " " not in auth_header:
raise Unauthorized("Authorization header must be provided and start with 'Bearer'")
auth_scheme, auth_token = auth_header.split(None, 1)
if auth_scheme.lower() != "bearer":
raise Unauthorized("Authorization scheme must be 'Bearer'")
return auth_token
def peek_service_api_bearer_token() -> str | None:
auth_header = request.headers.get("Authorization")
if auth_header is None or " " not in auth_header:
return None
auth_scheme, auth_token = auth_header.split(None, 1)
if auth_scheme.lower() != "bearer":
return None
return auth_token
class DatasetApiResource(Resource):
__apidoc__ = {"responses": DATASET_TOKEN_AUTH_RESPONSES}

View File

@@ -41,8 +41,6 @@ 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
@@ -60,6 +58,8 @@ 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,6 +89,10 @@ 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,
@@ -197,11 +201,9 @@ class WorkflowAppGenerator(BaseAppGenerator):
**extract_trace_session_id_from_args(args),
}
workflow_run_id = str(workflow_run_id or uuid.uuid4())
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):
# 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):
inputs = self._prepare_user_inputs(
user_inputs=inputs,
variables=app_config.variables,

View File

@@ -5,7 +5,6 @@ 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,
)
@@ -17,7 +16,6 @@ __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 and tool files explicitly granted by the
current execution context.
ownership markers, plus upload files explicitly granted by the current
execution context.
"""
_scope_getter: Callable[[], FileAccessScope | None]
@@ -80,16 +80,7 @@ class DatabaseFileAccessController(FileAccessControllerProtocol):
if not resolved_scope.requires_user_ownership:
return scoped_stmt
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),
)
)
return scoped_stmt.where(ToolFile.user_id == resolved_scope.user_id)
@override
def get_upload_file(

View File

@@ -21,11 +21,6 @@ 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.
@@ -37,7 +32,6 @@ 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:
@@ -65,24 +59,6 @@ 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,7 +9,6 @@ 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
@@ -215,11 +214,6 @@ 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,7 +57,8 @@ 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_repository import TrialAppRepository
from repositories.trial_app_query_repository import TrialAppQueryRepository
from repositories.trial_app_usage_repository import TrialAppUsageRepository
from repositories.upload_file_delivery_repository import UploadFileDeliveryQueryRepository
from repositories.web_passport_repository import WebPassportRepository
from repositories.webapp_access_query_repository import WebAppAccessQueryRepository
@@ -138,7 +139,6 @@ 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,9 +190,6 @@ 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
@@ -285,9 +282,6 @@ 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
@@ -434,7 +428,6 @@ 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()
@@ -714,16 +707,11 @@ def build_application_services(
),
recommended_app_queries=RecommendedAppQueryService(
catalog=recommended_app_catalog,
trial_apps=trial_apps,
trial_apps=TrialAppQueryRepository(session_factory=database_client),
trial_enabled=trial_app_enabled,
),
remote_files=remote_file_service,
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,
trial_app_usage=TrialAppUsageRepository(session_factory=database_client),
workflow_run_archives=WorkflowRunArchiveService(
bundles=WorkflowRunArchiveBundleQueryRepository(session_factory=database_client),
tasks=WorkflowRunArchiveDownloadTaskCache(redis=redis),

View File

@@ -1,7 +1,6 @@
import logging
import ssl
from datetime import timedelta
from typing import Any, NotRequired
from typing import Any
import pytz # type: ignore[import-untyped]
from celery import Celery, Task
@@ -15,8 +14,6 @@ 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
@@ -39,7 +36,6 @@ 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:
@@ -170,7 +166,6 @@ 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
@@ -187,20 +182,6 @@ 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, Iterable, Mapping
from collections.abc import Callable, Generator, 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] | Iterable[str],
response: Mapping[str, Any] | Generator[str, None, None] | RateLimitGenerator,
) -> Response:
if isinstance(response, Mapping):
return Response(

View File

@@ -53,6 +53,7 @@ class SubjectType(StrEnum):
# not is keyed by its external issuer instead.
bound_to_account: bool
RESOURCE_ACCESS = ("resource_access", frozenset({Scope.WORKSPACE_READ, Scope.APPS_READ, Scope.APPS_RUN}), False)
ACCOUNT = ("account", frozenset({Scope.FULL}), True)
EXTERNAL_SSO = ("external_sso", frozenset({Scope.APPS_RUN, Scope.APPS_READ_PERMITTED_EXTERNAL}), False)
@@ -72,6 +73,7 @@ class TokenType(StrEnum):
prefix: str
subject: SubjectType
RESOURCE_ACCESS = ("resource_access", "sk-", SubjectType.RESOURCE_ACCESS)
OAUTH_ACCOUNT = ("oauth_account", "dfoa_", SubjectType.ACCOUNT)
OAUTH_EXTERNAL_SSO = ("oauth_external_sso", "dfoe_", SubjectType.EXTERNAL_SSO)

View File

@@ -0,0 +1,108 @@
"""add resource access token tables
Revision ID: a8c9e2f1b704
Revises: d8e4a6b1c902
Create Date: 2026-08-18 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
import models.types
def _is_pg(conn):
return conn.dialect.name == "postgresql"
# revision identifiers, used by Alembic.
revision = "a8c9e2f1b704"
down_revision = "d8e4a6b1c902"
branch_labels = None
depends_on = None
def upgrade():
conn = op.get_bind()
if _is_pg(conn):
op.create_table(
"resource_access_tokens",
sa.Column("id", postgresql.UUID(), nullable=False),
sa.Column("tenant_id", postgresql.UUID(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("track_id", sa.String(length=32), nullable=False),
sa.Column("token", sa.String(length=255), nullable=False),
sa.Column("created_by", postgresql.UUID(), nullable=False),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP(0)"), nullable=False),
sa.PrimaryKeyConstraint("id", name="resource_access_token_pkey"),
)
op.create_table(
"resource_access_tokens_relation",
sa.Column("id", postgresql.UUID(), nullable=False),
sa.Column("token_id", postgresql.UUID(), nullable=False),
sa.Column("resource_type", sa.String(length=16), nullable=False),
sa.Column("app_id", postgresql.UUID(), nullable=True),
sa.Column("dataset_id", postgresql.UUID(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("CURRENT_TIMESTAMP(0)"), nullable=False),
sa.CheckConstraint(
"(resource_type = 'app' AND app_id IS NOT NULL AND dataset_id IS NULL) OR "
"(resource_type = 'knowledge' AND dataset_id IS NOT NULL AND app_id IS NULL)",
name="resource_access_token_relation_resource_check",
),
sa.PrimaryKeyConstraint("id", name="resource_access_token_relation_pkey"),
)
else:
op.create_table(
"resource_access_tokens",
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False),
sa.Column("name", sa.String(length=255), nullable=False),
sa.Column("track_id", sa.String(length=32), nullable=False),
sa.Column("token", sa.String(length=255), nullable=False),
sa.Column("created_by", models.types.StringUUID(), nullable=False),
sa.Column("last_used_at", sa.DateTime(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name="resource_access_token_pkey"),
)
op.create_table(
"resource_access_tokens_relation",
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("token_id", models.types.StringUUID(), nullable=False),
sa.Column("resource_type", sa.String(length=16), nullable=False),
sa.Column("app_id", models.types.StringUUID(), nullable=True),
sa.Column("dataset_id", models.types.StringUUID(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.CheckConstraint(
"(resource_type = 'app' AND app_id IS NOT NULL AND dataset_id IS NULL) OR "
"(resource_type = 'knowledge' AND dataset_id IS NOT NULL AND app_id IS NULL)",
name="resource_access_token_relation_resource_check",
),
sa.PrimaryKeyConstraint("id", name="resource_access_token_relation_pkey"),
)
with op.batch_alter_table("resource_access_tokens", schema=None) as batch_op:
batch_op.create_index("resource_access_token_tenant_idx", ["tenant_id"], unique=False)
batch_op.create_index("resource_access_token_token_idx", ["token"], unique=True)
batch_op.create_index("resource_access_token_track_id_idx", ["track_id"], unique=True)
with op.batch_alter_table("resource_access_tokens_relation", schema=None) as batch_op:
batch_op.create_index("resource_access_token_relation_token_idx", ["token_id"], unique=False)
batch_op.create_index("resource_access_token_relation_app_idx", ["app_id"], unique=False)
batch_op.create_index("resource_access_token_relation_dataset_idx", ["dataset_id"], unique=False)
batch_op.create_unique_constraint(
"resource_access_token_relation_app_unique",
["token_id", "resource_type", "app_id"],
)
batch_op.create_unique_constraint(
"resource_access_token_relation_dataset_unique",
["token_id", "resource_type", "dataset_id"],
)
def downgrade():
op.drop_table("resource_access_tokens_relation")
op.drop_table("resource_access_tokens")

View File

@@ -0,0 +1,26 @@
"""restore deploy/dev alembic revision c8e7f6a5b4d3
Revision ID: c8e7f6a5b4d3
Revises: 89919253ca7a
Create Date: 2026-08-20 15:45:00.000000
The deploy/dev database is stamped at this revision. The original file
was an empty merge of skill/debug heads and later disappeared when
deploy/dev was rewritten. Keep the revision id so flask db upgrade can
locate the database, and parent it at remove_agent_drive — the last
shared revision that was actually applied — so later migrations still run.
"""
# revision identifiers, used by Alembic.
revision: str = "c8e7f6a5b4d3"
down_revision = "89919253ca7a"
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,21 @@
"""merge c8e7f6a5b4d3 onto current deploy/dev head
Revision ID: ae0c8e7b4d31
Revises: c8e7f6a5b4d3, 5578e028b2f2
Create Date: 2026-09-01 17:48:00.000000
"""
# revision identifiers, used by Alembic.
revision: str = "ae0c8e7b4d31"
down_revision = ("c8e7f6a5b4d3", "5578e028b2f2")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,21 @@
"""merge deploy/dev and main migration heads
Revision ID: f2a7c9e4b610
Revises: ae0c8e7b4d31, d8e4a6b1c902
Create Date: 2026-09-17 12:00:00.000000
"""
# revision identifiers, used by Alembic.
revision: str = "f2a7c9e4b610"
down_revision = ("ae0c8e7b4d31", "d8e4a6b1c902")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -0,0 +1,21 @@
"""merge resource access token and deploy/dev migration heads
Revision ID: b6d4e8f2a901
Revises: a8c9e2f1b704, f2a7c9e4b610
Create Date: 2026-09-20 12:00:00.000000
"""
# revision identifiers, used by Alembic.
revision: str = "b6d4e8f2a901"
down_revision = ("a8c9e2f1b704", "f2a7c9e4b610")
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -1,123 +0,0 @@
"""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,7 +30,6 @@ from .agent import (
WorkflowAgentBindingType,
WorkflowAgentNodeBinding,
)
from .agent_sandbox_usage import AgentSandboxExecution, AgentSandboxUsageEvent
from .api_based_extension import APIBasedExtension, APIBasedExtensionPoint
from .comment import (
WorkflowComment,
@@ -114,6 +113,11 @@ from .provider import (
TenantDefaultModel,
TenantPreferredModelProvider,
)
from .resource_access_token import (
ResourceAccessToken,
ResourceAccessTokenRelation,
ResourceAccessTokenResourceType,
)
from .skill import AgentSkillBinding, Skill, SkillDraftFile, SkillFileKind, SkillFileStorage, SkillVersion
from .snippet import CustomizedSnippet, SnippetType
from .source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
@@ -172,8 +176,6 @@ __all__ = [
"AgentHomeSnapshot",
"AgentIconType",
"AgentKind",
"AgentSandboxExecution",
"AgentSandboxUsageEvent",
"AgentScope",
"AgentSkillBinding",
"AgentSource",
@@ -252,6 +254,9 @@ __all__ = [
"ProviderQuotaType",
"ProviderType",
"RecommendedApp",
"ResourceAccessToken",
"ResourceAccessTokenRelation",
"ResourceAccessTokenResourceType",
"SavedMessage",
"Site",
"Skill",

View File

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

View File

@@ -0,0 +1,80 @@
from __future__ import annotations
from datetime import datetime
from enum import StrEnum
from uuid import uuid4
import sqlalchemy as sa
from sqlalchemy import DateTime, String, func
from sqlalchemy.orm import Mapped, mapped_column
from libs.datetime_utils import naive_utc_now
from .base import Base
from .types import EnumText, StringUUID
class ResourceAccessTokenResourceType(StrEnum):
APP = "app"
KNOWLEDGE = "knowledge"
class ResourceAccessToken(Base):
__tablename__ = "resource_access_tokens"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="resource_access_token_pkey"),
sa.Index("resource_access_token_tenant_idx", "tenant_id"),
sa.Index("resource_access_token_token_idx", "token", unique=True),
sa.Index("resource_access_token_track_id_idx", "track_id", unique=True),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
track_id: Mapped[str] = mapped_column(String(32), nullable=False)
token: Mapped[str] = mapped_column(String(255), nullable=False)
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=naive_utc_now,
server_default=func.current_timestamp(),
)
class ResourceAccessTokenRelation(Base):
__tablename__ = "resource_access_tokens_relation"
__table_args__ = (
sa.PrimaryKeyConstraint("id", name="resource_access_token_relation_pkey"),
sa.CheckConstraint(
"(resource_type = 'app' AND app_id IS NOT NULL AND dataset_id IS NULL) OR "
"(resource_type = 'knowledge' AND dataset_id IS NOT NULL AND app_id IS NULL)",
name="resource_access_token_relation_resource_check",
),
sa.Index("resource_access_token_relation_token_idx", "token_id"),
sa.Index("resource_access_token_relation_app_idx", "app_id"),
sa.Index("resource_access_token_relation_dataset_idx", "dataset_id"),
sa.UniqueConstraint("token_id", "resource_type", "app_id", name="resource_access_token_relation_app_unique"),
sa.UniqueConstraint(
"token_id",
"resource_type",
"dataset_id",
name="resource_access_token_relation_dataset_unique",
),
)
id: Mapped[str] = mapped_column(StringUUID, default=lambda: str(uuid4()))
token_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
resource_type: Mapped[ResourceAccessTokenResourceType] = mapped_column(
EnumText(ResourceAccessTokenResourceType, length=16),
nullable=False,
)
app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
dataset_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime,
nullable=False,
default=naive_utc_now,
server_default=func.current_timestamp(),
)

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.25,<2.0.0",
"flask-compress>=1.24,<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.3,<3.0.0",
"google-cloud-aiplatform>=2.1.0,<3.0.0",
"httpx[socks]==0.28.1",
"opentelemetry-distro==0.65b0",
"opentelemetry-instrumentation-celery==0.65b0",

View File

@@ -0,0 +1,23 @@
"""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

@@ -1,65 +0,0 @@
"""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

@@ -0,0 +1,28 @@
"""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

@@ -1,70 +0,0 @@
"""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,12 +15,11 @@ from typing import Any, cast
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import func, select, update
from sqlalchemy import func, select
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,
@@ -39,9 +38,8 @@ from models.agent import (
WorkflowAgentNodeBinding,
)
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
from models.model import App, AppModelConfig, UploadFile
from models.model import App, AppModelConfig
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
@@ -55,7 +53,6 @@ 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
@@ -132,13 +129,9 @@ 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],
resource_exporter: AgentPackageResourceExporter | None = None,
self, *, workflow: Workflow, graph: Mapping[str, Any]
) -> tuple[dict[str, Any], dict[str, AgentPackage]]:
"""Replace persisted bindings with portable packages, optionally collecting their assets."""
"""Replace persisted Agent binding ids with portable package references."""
portable_graph = copy.deepcopy(dict(graph))
agent_nodes = dict(WorkflowAgentNodeValidator.iter_agent_v2_nodes(portable_graph))
@@ -148,7 +141,6 @@ 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)),
@@ -174,25 +166,16 @@ class AgentDslService:
if package_ref is None:
package_ref = f"agent_{len(packages) + 1}"
package_refs_by_source[source_key] = package_ref
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),
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,
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,
),
)
include_draft=False,
),
)
node_data["agent_binding"] = {
"binding_type": binding.binding_type.value,
AGENT_PACKAGE_REF_KEY: package_ref,
@@ -241,7 +224,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),
@@ -532,15 +515,6 @@ 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(
@@ -625,9 +599,6 @@ 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(
@@ -680,51 +651,6 @@ 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,
*,
@@ -765,7 +691,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

@@ -1,356 +0,0 @@
"""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

@@ -1,267 +0,0 @@
"""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,14 +3,13 @@
from __future__ import annotations
import re
from collections.abc import Iterable, Mapping
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any, BinaryIO, Final, Literal, Self
from typing import BinaryIO, Final, Literal, Self
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from constants import IMAGE_EXTENSIONS
from services.agent.dsl_entities import AgentAppDsl, AgentPackage
from services.agent.dsl_entities import AgentAppDsl
ROSTER_AGENT_PACKAGE_FORMAT: Final[Literal["dify.roster-agent"]] = "dify.roster-agent"
ROSTER_AGENT_PACKAGE_FORMAT_VERSION: Final[Literal[1]] = 1
@@ -82,21 +81,15 @@ class RosterAgentPackageFile(_RosterAgentPackageResource):
return self
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."""
class RosterAgentPackageManifest(BaseModel):
"""Package metadata and resource index stored in ``manifest.yaml``."""
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)
@@ -105,7 +98,7 @@ class AgentPackageResources(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.skills, *self.files]]
paths = [item.path.casefold() for item in [*self.apps, *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]
@@ -114,23 +107,20 @@ class AgentPackageResources(BaseModel):
return self
def validate_packages(self, packages: Iterable[AgentPackage]) -> None:
"""Resolve Agent references against this resource index."""
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")
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 package in packages:
if package.soul.schema_version != 1:
for app in apps.values():
if app.package.soul.schema_version != 1:
raise ValueError("unsupported Agent Soul schema version")
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:
for skill_ref in app.package.soul.config_skills:
if skill_ref.is_missing:
continue
skill_resource = skill_by_id.get(skill_ref.file_id)
@@ -139,13 +129,13 @@ class AgentPackageResources(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 package.workspace_skills]
app_workspace_names = [item.name for item in app.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 package.soul.config_files:
for file_ref in app.package.soul.config_files:
if file_ref.is_missing:
continue
if file_ref.file_id not in file_by_id:
@@ -159,55 +149,26 @@ class AgentPackageResources(BaseModel):
raise ValueError("file resources must be referenced by the Agent Soul")
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."""
@dataclass
class PreparedRosterAgentPackage:
"""Validated archive retained in a bounded spool for later materialization."""
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) -> Self:
def __enter__(self) -> PreparedRosterAgentPackage:
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."""
@@ -226,29 +187,10 @@ 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",
@@ -257,5 +199,4 @@ __all__ = [
"RosterAgentPackageManifest",
"RosterAgentPackageMember",
"RosterAgentPackageSkill",
"validate_icon_references",
]

View File

@@ -3,15 +3,18 @@
from __future__ import annotations
import hashlib
import os
import re
import tempfile
import zipfile
from collections.abc import Callable
from typing import BinaryIO, cast
from collections.abc import Callable, Generator, Sequence
from dataclasses import dataclass
from typing import BinaryIO, Literal, Protocol, 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
@@ -28,28 +31,60 @@ from models.agent import (
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import AppStatus
from models.model import App, AppMode
from models.model import App, AppMode, UploadFile
from models.tools import ToolFile
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:
@@ -66,7 +101,6 @@ 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)
@@ -126,44 +160,121 @@ class RosterAgentPackageExporter:
raise AgentVersionNotFoundError()
soul = AgentSoulConfig.model_validate(active_snapshot.config_snapshot_dict)
package = resources.collect_package(
resource_soul, skill_sources, file_sources = self._collect_payloads(
session=session,
agent=agent,
tenant_id=tenant_id,
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)
resources.collect_workspace_skills()
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
]
app.dependencies = self._dependency_provider(tenant_id, dependency_ids)
return self._build_archive(
app=app,
audit=audit,
resources=resources,
skill_sources=skill_sources,
file_sources=file_sources,
)
def _build_archive(
self,
*,
app: AgentAppDsl,
resources: AgentPackageResourceExporter,
skill_sources: Sequence[_SkillSource],
file_sources: Sequence[_FileSource],
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:
groups, total_size = resources.write_resources(archive)
group = groups[app.agent.package_ref]
icons, total_size = resources.write_icons(archive, total_size=total_size)
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),
)
)
app_bytes = yaml.safe_dump(
app.model_dump(mode="json", exclude_none=True), allow_unicode=True, sort_keys=False
).encode("utf-8")
@@ -176,15 +287,12 @@ class RosterAgentPackageExporter:
path="app.yaml", size=len(app_bytes), sha256=hashlib.sha256(app_bytes).hexdigest()
)
],
icons=icons,
skills=group.skills,
files=group.files,
skills=skills,
files=files,
)
manifest.validate_apps({"app.yaml": app})
manifest_bytes = yaml.safe_dump(
manifest.model_dump(mode="json", exclude_none=True, exclude={"icons"} if not icons else set()),
allow_unicode=True,
sort_keys=False,
manifest.model_dump(mode="json", exclude_none=True), 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:
@@ -207,6 +315,163 @@ 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,50 +2,67 @@
from __future__ import annotations
import hashlib
import logging
import os
from dataclasses import dataclass
from typing import BinaryIO
from typing import BinaryIO, Protocol
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,
AgentIconType,
AgentScope,
AgentSource,
AgentStatus,
)
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode, AppModelConfig, IconType, UploadFile
from services.agent.dsl_entities import AgentPackage, AgentPackageMetadata
from 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 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.icon_configuration import DEFAULT_ICON, DEFAULT_ICON_BACKGROUND, DEFAULT_ICON_TYPE, is_valid_image_icon
from services.file_service import FileService
from services.icon_configuration import DEFAULT_ICON, DEFAULT_ICON_BACKGROUND, DEFAULT_ICON_TYPE
logger = logging.getLogger(__name__)
class _Storage(Protocol):
def save(self, filename: str, data: bytes) -> None: ...
@dataclass(frozen=True)
class RosterAgentPackageImportResult:
app_id: str
@@ -56,8 +73,14 @@ 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._resources = AgentPackageResourceImporter(storage_backend=storage_backend)
def __init__(
self,
*,
storage_backend: _Storage = storage,
) -> None:
self._reader = RosterAgentPackageReader()
self._skill_packages = SkillPackageService()
self._storage = storage_backend
def import_package(
self,
@@ -66,31 +89,23 @@ class RosterAgentPackageImporter:
tenant_id: str,
account: Account,
) -> RosterAgentPackageImportResult:
with RosterAgentPackageReader().read(source) as package:
with self._reader.read(source) as package:
if len(package.apps) != 1:
raise InvalidRosterAgentPackageError("Import requires exactly one Agent App")
app_dsl = next(iter(package.apps.values()))
agent_package = app_dsl.package
self._resources.validate(resources=package.manifest, agent_package=agent_package)
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
check_package_dependencies(tenant_id=tenant_id, account=account, dependencies=app_dsl.dependencies)
try:
icons = self._resources.materialize_icons(
archive=package, icons=package.manifest.icons, tenant_id=tenant_id, account_id=account.id
)
if agent_package.metadata.icon_type == "image" and agent_package.metadata.icon in icons:
agent_package.metadata.icon = icons[agent_package.metadata.icon]
app_metadata = AgentPackageMetadata(
name=agent_package.metadata.name,
icon_type=app_dsl.app.get("icon_type"),
icon=app_dsl.app.get("icon"),
icon_background=app_dsl.app.get("icon_background"),
)
if app_metadata.icon_type == "image" and app_metadata.icon in icons:
app_metadata.icon = icons[app_metadata.icon]
materialized, skill_warnings = self._resources.materialize(
archive=package,
resources=package.manifest,
self._ensure_name_available(tenant_id=tenant_id, name=agent_package.metadata.name)
soul, skill_warnings = self._upload_resources(
package=package,
agent_package=agent_package,
tenant_id=tenant_id,
account_id=account.id,
@@ -98,7 +113,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=materialized.soul),
package=AgentPackage(metadata=agent_package.metadata, soul=soul),
package_path="agent",
)
warnings = [*skill_warnings, *warnings]
@@ -106,7 +121,6 @@ class RosterAgentPackageImporter:
tenant_id=tenant_id,
account=account,
metadata=agent_package.metadata,
app_metadata=app_metadata,
soul=resolved_soul,
)
except Exception as exc:
@@ -135,6 +149,198 @@ 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(
*,
@@ -142,25 +348,16 @@ 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 = name
app.name = metadata.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
@@ -177,22 +374,13 @@ class RosterAgentPackageImporter:
tenant_id=tenant_id,
account_id=account.id,
app_id=app.id,
name=name,
name=metadata.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")
@@ -238,5 +426,9 @@ 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, Sequence
from collections.abc import Hashable
from typing import Any, BinaryIO, cast, override
import yaml
@@ -21,11 +21,8 @@ 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,
@@ -64,7 +61,7 @@ class RosterAgentPackageReader:
spool.close()
raise
def read_member_bytes(self, package: PreparedPackageArchive, path: str, *, max_bytes: int) -> bytes:
def read_member_bytes(self, package: PreparedRosterAgentPackage, path: str, *, max_bytes: int) -> bytes:
"""Read one already-validated member while rechecking its size and digest."""
member = package.members.get(path)
@@ -132,7 +129,6 @@ 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:
@@ -142,6 +138,7 @@ 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(
@@ -155,92 +152,67 @@ class RosterAgentPackageReader:
expected_size=signature_info.file_size,
)
streamed_size += signature_size
self._validate_resource_members(
archive,
info_by_path,
[*manifest.skills, *manifest.files, *manifest.icons],
members=members,
invalid_skills=invalid_skills,
streamed_size=streamed_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"
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

@@ -1,680 +0,0 @@
"""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,10 +49,8 @@ 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
@@ -106,7 +104,6 @@ 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):
@@ -184,9 +181,8 @@ 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 DSL, materializing validated archive resources before database writes."""
"""Import an app from YAML content or URL."""
self._warnings = []
import_id = str(uuid.uuid4())
@@ -286,25 +282,6 @@ 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:
@@ -341,7 +318,6 @@ 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}",
@@ -449,7 +425,6 @@ 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:
@@ -802,36 +777,13 @@ 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]:
"""
Build the App definition before serializing it as YAML or a resource archive.
Export app
: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
@@ -859,12 +811,11 @@ 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 export_data
return yaml.dump(export_data, allow_unicode=True)
@classmethod
def _append_workflow_export_data(
@@ -875,7 +826,6 @@ class AppDslService:
include_secret: bool,
session: Session,
workflow_id: str | None = None,
resource_exporter: AgentPackageResourceExporter | None = None,
):
"""
Append workflow export data
@@ -894,7 +844,6 @@ 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,264 +1,101 @@
"""Package App DSLs and their node-bound Agent resources."""
"""Package ordinary App DSLs while retaining the existing Roster Agent format."""
from __future__ import annotations
import hashlib
import io
import re
import tempfile
import zipfile
import zlib
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import Any, BinaryIO, Literal, Self, cast
from typing import BinaryIO, Literal, cast
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from pydantic import BaseModel, ConfigDict, Field, ValidationError
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.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_entities import RosterAgentPackageApp, RosterAgentPackageExport
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; Apps retain DSL import semantics."""
"""Reuse the bounded ZIP reader; ordinary Apps retain DSL import semantics."""
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
def read_dsl(self, source: BinaryIO) -> str | None:
"""Return an ordinary DSL, or rewind a Roster package for its importer."""
try:
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())]
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
)
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,
)
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 (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)
@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:
def export(self, *, dsl: str, name: str) -> RosterAgentPackageExport:
payload = dsl.encode("utf-8")
if len(payload) > DSL_MAX_SIZE:
raise RosterAgentPackageTooLargeError("App package DSL exceeds the size limit")
archive = cast(BinaryIO, tempfile.SpooledTemporaryFile(max_size=16 * 1024 * 1024, mode="w+b")) # noqa: SIM115
manifest = AppPackageManifest(
format="dify.app",
format_version=1,
apps=[
RosterAgentPackageApp(path="app.yaml", size=len(payload), sha256=hashlib.sha256(payload).hexdigest())
],
)
archive = io.BytesIO()
try:
with zipfile.ZipFile(archive, "w", compression=zipfile.ZIP_DEFLATED) as package:
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("manifest.yaml", yaml.safe_dump(manifest.model_dump(mode="json")))
package.writestr("app.yaml", payload)
size = archive.tell()
archive.seek(0)
# Validate exported containers against the same limits as imports.
validated = self.read_package(archive)
if validated is None:
raise InvalidRosterAgentPackageError("Expected an App package")
validated.close()
self.read_dsl(archive)
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

@@ -51,6 +51,7 @@ from services.enterprise import rbac_service as enterprise_rbac_service
from services.enterprise.enterprise_service import EnterpriseService
from services.openapi.visibility import apply_openapi_gate, is_openapi_visible
from services.rbac_agent_access_service import initialize_agent_rbac_access
from services.resource_access_token_service import ResourceAccessTokenService
from services.system_feature_service import SystemFeatureService
from services.tag_service import TagService
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
@@ -1173,6 +1174,7 @@ class AppService:
tenant_id=app.tenant_id,
app_id=app.id,
)
ResourceAccessTokenService.delete_relations_for_app(app_id=app.id, session=session)
session.delete(app)
session.commit()

View File

@@ -6,30 +6,12 @@ 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 RedisClientWrapper, redis_client
from extensions.ext_redis import 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

@@ -90,6 +90,7 @@ from services.external_knowledge_service import ExternalDatasetService
from services.feature_service import FeatureService
from services.file_service import FileService
from services.rag_pipeline.rag_pipeline import RagPipelineService
from services.resource_access_token_service import ResourceAccessTokenService
from services.tag_service import TagService
from services.vector_service import VectorService
from tasks.add_document_to_index_task import add_document_to_index_task
@@ -1365,6 +1366,7 @@ class DatasetService:
# silently degrade to unrestricted (access-all) once its last binding is gone.
dataset_api_key_service.delete_keys_scoped_only_to(session, str(dataset.id))
ResourceAccessTokenService.delete_relations_for_dataset(dataset_id=dataset.id, session=session)
session.delete(dataset)
session.commit()
return True

View File

@@ -5,8 +5,6 @@ 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
@@ -38,27 +36,6 @@ 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]
@@ -77,26 +54,12 @@ class FileService:
filename: str,
content: bytes,
mimetype: str,
user: Account | EndUser | FileUploadActor,
user: Account | EndUser,
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()
@@ -129,12 +92,7 @@ class FileService:
# generate file key
file_uuid = str(uuid.uuid4())
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
resource_tenant_id = tenant_id if tenant_id is not None else extract_tenant_id(user)
file_key = "upload_files/" + (resource_tenant_id or "") + "/" + file_uuid + "." + extension
@@ -150,7 +108,7 @@ class FileService:
size=file_size,
extension=extension,
mime_type=mimetype,
created_by_role=creator_role,
created_by_role=(CreatorUserRole.ACCOUNT if isinstance(user, Account) else CreatorUserRole.END_USER),
created_by=user.id,
created_at=naive_utc_now(),
used=False,
@@ -167,46 +125,6 @@ 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(
*,
@@ -225,13 +143,7 @@ class FileService:
extension: str,
default_file_size_limit: int | None = None,
) -> int:
"""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.
"""
"""Return the size an extension is allowed, in bytes."""
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, FileUploadActor
from services.file_service import FileService
@dataclass(frozen=True, slots=True)
@@ -87,19 +87,9 @@ class RemoteFileService:
self,
*,
url: str,
user: Account | EndUser | FileUploadActor,
user: Account | EndUser,
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,467 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from uuid import uuid4
from sqlalchemy import delete, func, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, Unauthorized
from libs.datetime_utils import naive_utc_now
from models.dataset import Dataset
from models.model import App
from models.resource_access_token import (
ResourceAccessToken,
ResourceAccessTokenRelation,
ResourceAccessTokenResourceType,
)
TOKEN_PREFIX = "sk-"
MASK_PREFIX_LENGTH = 8
MASK_SUFFIX_LENGTH = 4
@dataclass(frozen=True)
class ResourceAccessTokenResource:
type: ResourceAccessTokenResourceType
id: str
@dataclass(frozen=True)
class ResourceAccessTokenRow:
token_id: str
relation_id: str
name: str
track_id: str
token: str | None
masked_token: str
resource_type: ResourceAccessTokenResourceType
resource_id: str
resource_name: str
created_at: datetime
last_used_at: datetime | None
@dataclass(frozen=True)
class ResourceAccessTokenCreateResult:
token_id: str
token: str
rows: list[ResourceAccessTokenRow]
class ResourceAccessTokenService:
@classmethod
def create(
cls,
*,
tenant_id: str,
created_by: str,
name: str,
resources: list[ResourceAccessTokenResource],
session: Session,
) -> ResourceAccessTokenCreateResult:
name = cls._normalize_name(name)
if not resources:
raise BadRequest("At least one resource is required.")
token = ResourceAccessToken(
tenant_id=tenant_id,
name=name,
track_id=cls._generate_track_id(session),
token=cls._generate_token(session),
created_by=created_by,
)
session.add(token)
session.flush()
seen: set[tuple[ResourceAccessTokenResourceType, str]] = set()
for resource in resources:
key = (resource.type, resource.id)
if key in seen:
continue
seen.add(key)
cls._ensure_resource_available(tenant_id=tenant_id, resource=resource, session=session)
relation = ResourceAccessTokenRelation(
token_id=token.id,
resource_type=resource.type,
app_id=resource.id if resource.type == ResourceAccessTokenResourceType.APP else None,
dataset_id=resource.id if resource.type == ResourceAccessTokenResourceType.KNOWLEDGE else None,
)
session.add(relation)
session.commit()
rows = cls.list_rows(tenant_id=tenant_id, token_id=token.id, include_token=True, session=session)
return ResourceAccessTokenCreateResult(token_id=token.id, token=token.token, rows=rows)
@classmethod
def list_rows(
cls,
*,
tenant_id: str,
session: Session,
page: int = 1,
limit: int = 20,
token_id: str | None = None,
include_token: bool = False,
) -> list[ResourceAccessTokenRow]:
stmt = (
select(ResourceAccessToken, ResourceAccessTokenRelation, App.name, Dataset.name)
.join(ResourceAccessTokenRelation, ResourceAccessTokenRelation.token_id == ResourceAccessToken.id)
.outerjoin(App, App.id == ResourceAccessTokenRelation.app_id)
.outerjoin(Dataset, Dataset.id == ResourceAccessTokenRelation.dataset_id)
.where(ResourceAccessToken.tenant_id == tenant_id)
.order_by(ResourceAccessToken.created_at.desc(), ResourceAccessTokenRelation.created_at.desc())
)
if token_id:
stmt = stmt.where(ResourceAccessToken.id == token_id)
else:
token_ids = (
select(ResourceAccessToken.id)
.where(ResourceAccessToken.tenant_id == tenant_id)
.order_by(ResourceAccessToken.created_at.desc())
.offset(max(page - 1, 0) * limit)
.limit(limit)
)
stmt = stmt.where(ResourceAccessToken.id.in_(token_ids))
return [
cls._row_from_models(token, relation, app_name, dataset_name, include_token=include_token)
for token, relation, app_name, dataset_name in session.execute(stmt).all()
]
@classmethod
def count_rows(cls, *, tenant_id: str, session: Session) -> int:
return (
session.scalar(
select(func.count(ResourceAccessToken.id)).where(ResourceAccessToken.tenant_id == tenant_id)
)
or 0
)
@classmethod
def update(
cls,
*,
tenant_id: str,
token_id: str,
name: str,
resources: list[ResourceAccessTokenResource] | None = None,
session: Session,
) -> list[ResourceAccessTokenRow]:
token = cls._get_token(tenant_id=tenant_id, token_id=token_id, session=session)
token.name = cls._normalize_name(name)
if resources is not None:
if not resources:
raise BadRequest("At least one resource is required.")
seen: set[tuple[ResourceAccessTokenResourceType, str]] = set()
desired_resources: list[ResourceAccessTokenResource] = []
for resource in resources:
key = (resource.type, resource.id)
if key in seen:
continue
seen.add(key)
cls._ensure_resource_available(tenant_id=tenant_id, resource=resource, session=session)
desired_resources.append(resource)
existing_relations = session.scalars(
select(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token.id)
).all()
existing_by_key = {cls._relation_key(relation): relation for relation in existing_relations}
desired_keys = {(resource.type, resource.id) for resource in desired_resources}
for key, relation in existing_by_key.items():
if key not in desired_keys:
session.delete(relation)
for resource in desired_resources:
key = (resource.type, resource.id)
if key in existing_by_key:
continue
session.add(
ResourceAccessTokenRelation(
token_id=token.id,
resource_type=resource.type,
app_id=resource.id if resource.type == ResourceAccessTokenResourceType.APP else None,
dataset_id=resource.id if resource.type == ResourceAccessTokenResourceType.KNOWLEDGE else None,
)
)
session.commit()
return cls.list_rows(tenant_id=tenant_id, token_id=token_id, session=session)
@classmethod
def delete_relation(cls, *, tenant_id: str, token_id: str, relation_id: str, session: Session) -> None:
token = cls._get_token(tenant_id=tenant_id, token_id=token_id, session=session)
relation = session.scalar(
select(ResourceAccessTokenRelation).where(
ResourceAccessTokenRelation.id == relation_id,
ResourceAccessTokenRelation.token_id == token.id,
)
)
if relation is None:
raise NotFound("Resource access token relation not found.")
session.execute(
delete(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token.id)
)
session.delete(token)
session.commit()
@classmethod
def delete_relations_for_app(cls, *, app_id: str, session: Session) -> None:
token_ids = cls._delete_relations(
resource_type=ResourceAccessTokenResourceType.APP,
resource_id_field=ResourceAccessTokenRelation.app_id,
resource_id=app_id,
session=session,
)
cls._delete_orphan_tokens(token_ids=token_ids, session=session)
@classmethod
def delete_relations_for_dataset(cls, *, dataset_id: str, session: Session) -> None:
token_ids = cls._delete_relations(
resource_type=ResourceAccessTokenResourceType.KNOWLEDGE,
resource_id_field=ResourceAccessTokenRelation.dataset_id,
resource_id=dataset_id,
session=session,
)
cls._delete_orphan_tokens(token_ids=token_ids, session=session)
@classmethod
def validate_app_access(cls, *, token: str, app: App, session: Session) -> ResourceAccessToken:
access_token = cls._get_by_token(token=token, session=session)
relation = session.scalar(
select(ResourceAccessTokenRelation).where(
ResourceAccessTokenRelation.token_id == access_token.id,
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.APP,
ResourceAccessTokenRelation.app_id == app.id,
)
)
if relation is None:
raise Forbidden("Resource access token is not allowed to access this app.")
access_token.last_used_at = naive_utc_now()
session.commit()
return access_token
@classmethod
def resolve_app_for_service_api(
cls,
*,
token: str,
requested_app_id: str | None,
session: Session,
) -> App:
access_token = cls._get_by_token(token=token, session=session)
stmt = select(ResourceAccessTokenRelation).where(
ResourceAccessTokenRelation.token_id == access_token.id,
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.APP,
)
if requested_app_id:
stmt = stmt.where(ResourceAccessTokenRelation.app_id == requested_app_id)
relations = session.scalars(stmt).all()
if not relations:
raise Forbidden("Resource access token is not allowed to access this app.")
if len(relations) > 1:
raise BadRequest("App ID is required when a resource access token is bound to multiple apps.")
app_id = relations[0].app_id
if app_id is None:
raise Forbidden("Resource access token is not allowed to access this app.")
app = session.get(App, app_id)
if app is None:
raise Forbidden("The app no longer exists.")
access_token.last_used_at = naive_utc_now()
session.commit()
return app
@classmethod
def validate_dataset_access(cls, *, token: str, dataset_id: str, session: Session) -> ResourceAccessToken:
access_token = cls._get_by_token(token=token, session=session)
relation = session.scalar(
select(ResourceAccessTokenRelation).where(
ResourceAccessTokenRelation.token_id == access_token.id,
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.KNOWLEDGE,
ResourceAccessTokenRelation.dataset_id == dataset_id,
)
)
if relation is None:
raise Forbidden("Resource access token is not allowed to access this knowledge base.")
access_token.last_used_at = naive_utc_now()
session.commit()
return access_token
@classmethod
def resolve_tenant_for_dataset_service_api(
cls,
*,
token: str,
dataset_id: str | None,
session: Session,
) -> str:
access_token = cls._get_by_token(token=token, session=session)
stmt = select(ResourceAccessTokenRelation).where(
ResourceAccessTokenRelation.token_id == access_token.id,
ResourceAccessTokenRelation.resource_type == ResourceAccessTokenResourceType.KNOWLEDGE,
)
if dataset_id:
stmt = stmt.where(ResourceAccessTokenRelation.dataset_id == dataset_id)
relation = session.scalar(stmt.limit(1))
if relation is None:
raise Forbidden("Resource access token is not allowed to access this knowledge base.")
access_token.last_used_at = naive_utc_now()
session.commit()
return access_token.tenant_id
@staticmethod
def is_resource_access_token(token: str) -> bool:
return token.startswith(TOKEN_PREFIX)
@staticmethod
def mask_token(token: str) -> str:
if len(token) <= MASK_PREFIX_LENGTH + MASK_SUFFIX_LENGTH:
return token
return f"{token[:MASK_PREFIX_LENGTH]}...{token[-MASK_SUFFIX_LENGTH:]}"
@classmethod
def _get_by_token(cls, *, token: str, session: Session) -> ResourceAccessToken:
access_token = session.scalar(select(ResourceAccessToken).where(ResourceAccessToken.token == token))
if access_token is None:
raise Unauthorized("Resource access token is invalid.")
return access_token
@classmethod
def _get_token(cls, *, tenant_id: str, token_id: str, session: Session) -> ResourceAccessToken:
token = session.scalar(
select(ResourceAccessToken).where(
ResourceAccessToken.id == token_id,
ResourceAccessToken.tenant_id == tenant_id,
)
)
if token is None:
raise NotFound("Resource access token not found.")
return token
@staticmethod
def _normalize_name(name: str) -> str:
normalized = name.strip()
if not normalized:
raise BadRequest("Name is required.")
return normalized
@classmethod
def _generate_token(cls, session: Session) -> str:
while True:
value = f"{TOKEN_PREFIX}{uuid4()}"
if not session.scalar(select(ResourceAccessToken.id).where(ResourceAccessToken.token == value)):
return value
@classmethod
def _generate_track_id(cls, session: Session) -> str:
while True:
value = uuid4().hex
if not session.scalar(select(ResourceAccessToken.id).where(ResourceAccessToken.track_id == value)):
return value
@staticmethod
def _ensure_resource_available(
*,
tenant_id: str,
resource: ResourceAccessTokenResource,
session: Session,
) -> None:
if resource.type == ResourceAccessTokenResourceType.APP:
app = session.scalar(select(App).where(App.id == resource.id, App.tenant_id == tenant_id))
if app is None:
raise NotFound("App not found.")
if app.status != "normal" or not app.enable_api:
raise Forbidden("App API access is not enabled.")
return
dataset = session.scalar(select(Dataset).where(Dataset.id == resource.id, Dataset.tenant_id == tenant_id))
if dataset is None:
raise NotFound("Knowledge base not found.")
if not dataset.enable_api:
raise Forbidden("Knowledge base API access is not enabled.")
@staticmethod
def _delete_relations(
*,
resource_type: ResourceAccessTokenResourceType,
resource_id_field,
resource_id: str,
session: Session,
) -> list[str]:
token_ids = session.scalars(
select(ResourceAccessTokenRelation.token_id).where(
ResourceAccessTokenRelation.resource_type == resource_type,
resource_id_field == resource_id,
)
).all()
if token_ids:
session.execute(
delete(ResourceAccessTokenRelation).where(
ResourceAccessTokenRelation.resource_type == resource_type,
resource_id_field == resource_id,
)
)
return list(token_ids)
@staticmethod
def _delete_orphan_tokens(*, token_ids: list[str], session: Session) -> None:
for token_id in set(token_ids):
remaining_count = (
session.scalar(
select(func.count(ResourceAccessTokenRelation.id)).where(
ResourceAccessTokenRelation.token_id == token_id,
)
)
or 0
)
if remaining_count == 0:
session.execute(delete(ResourceAccessToken).where(ResourceAccessToken.id == token_id))
@staticmethod
def _relation_key(
relation: ResourceAccessTokenRelation,
) -> tuple[ResourceAccessTokenResourceType, str]:
resource_id = (
relation.app_id
if relation.resource_type == ResourceAccessTokenResourceType.APP
else relation.dataset_id
)
assert resource_id is not None
return relation.resource_type, resource_id
@classmethod
def _row_from_models(
cls,
token: ResourceAccessToken,
relation: ResourceAccessTokenRelation,
app_name: str | None,
dataset_name: str | None,
*,
include_token: bool,
) -> ResourceAccessTokenRow:
resource_id = (
relation.app_id
if relation.resource_type == ResourceAccessTokenResourceType.APP
else relation.dataset_id
)
assert resource_id is not None
return ResourceAccessTokenRow(
token_id=token.id,
relation_id=relation.id,
name=token.name,
track_id=token.track_id,
token=token.token if include_token else None,
masked_token=cls.mask_token(token.token),
resource_type=relation.resource_type,
resource_id=resource_id,
resource_name=app_name or dataset_name or "",
created_at=token.created_at,
last_used_at=token.last_used_at,
)

View File

@@ -1,48 +0,0 @@
"""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

@@ -1,66 +0,0 @@
"""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

@@ -1,95 +0,0 @@
"""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
from typing import Any, NotRequired
from celery import shared_task
from sqlalchemy import select
@@ -46,6 +46,8 @@ 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)
@@ -101,6 +103,7 @@ def _build_generator_args(trigger_data: TriggerData) -> WorkflowGeneratorArgsDic
return {
"inputs": dict(trigger_data.inputs),
"files": list(trigger_data.files),
"_skip_prepare_user_inputs": True,
}
@@ -147,9 +150,13 @@ def _execute_workflow_common(
# Execute workflow using WorkflowAppGenerator
generator = WorkflowAppGenerator()
# Adapt trigger inputs and files for the generator.
# Prepare args matching AppGenerateService.generate format
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

@@ -52,6 +52,7 @@ from models.workflow import (
)
from repositories.factory import DifyAPIRepositoryFactory
from services.api_token_service import ApiTokenCache
from services.resource_access_token_service import ResourceAccessTokenService
logger = logging.getLogger(__name__)
@@ -66,6 +67,7 @@ def remove_app_and_related_data_task(self, tenant_id: str, app_id: str):
_delete_app_site(tenant_id, app_id)
_delete_app_mcp_servers(tenant_id, app_id)
_delete_app_api_tokens(tenant_id, app_id)
_delete_app_resource_access_token_relations(app_id)
_delete_installed_apps(tenant_id, app_id)
_delete_app_stars(tenant_id, app_id)
_delete_recommended_apps(tenant_id, app_id)
@@ -164,6 +166,12 @@ def _delete_app_api_tokens(tenant_id: str, app_id: str):
)
def _delete_app_resource_access_token_relations(app_id: str):
with session_factory.get_session_maker()() as session:
ResourceAccessTokenService.delete_relations_for_app(app_id=app_id, session=session)
session.commit()
def _delete_installed_apps(tenant_id: str, app_id: str):
def del_installed_app(session: Session, installed_app_id: str):
session.execute(

View File

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

View File

@@ -3,12 +3,11 @@ import zipfile
from collections.abc import Callable
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import ANY, Mock
from unittest.mock import 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
@@ -324,10 +323,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=yaml.safe_load(dsl))
export = Mock(return_value=dsl)
session = object()
monkeypatch.setattr(app_module, "db", SimpleNamespace(session=lambda: session))
monkeypatch.setattr(app_module.AppDslService, "export_data", export)
monkeypatch.setattr(app_module.AppDslService, "export_dsl", export)
with app.test_request_context():
response = unwrap(app_module.AppExportApi.get)(
app_module.AppExportApi(),
@@ -337,14 +336,9 @@ 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"]
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)
assert AppPackageService().read_dsl(io.BytesIO(response.get_data())) == dsl
response.close()
export.assert_called_once_with(
app_model=model, session=ANY, include_secret=True, workflow_id="revision-1", resource_exporter=ANY
)
export.assert_called_once_with(app_model=model, session=session, include_secret=True, workflow_id="revision-1")
@pytest.mark.parametrize("from_url", [False, True])
@@ -386,11 +380,8 @@ 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,
package=ANY,
import_module.AppImportPayload(mode="yaml-content", yaml_content=dsl, name="Renamed", app_id=app_id), account
)
assert import_dsl.call_args.kwargs["package"].archive.closed
agent_import.assert_not_called()

View File

@@ -10,19 +10,25 @@ from uuid import uuid4
import pytest
from flask import Flask, request
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, NotFound
from werkzeug.exceptions import Forbidden, InternalServerError, 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
from controllers.console.explore.trial import TextToSpeechRequest
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 core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.errors.error import (
ModelCurrentlyNotSupportError,
@@ -36,17 +42,26 @@ 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
from models.model import App, AppMode, Site, UploadFile
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 tests.unit_tests.model_factories import make_app
from services.errors.llm import InvokeRateLimitError
from tests.unit_tests.model_factories import make_app, make_upload_file
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")
@@ -69,6 +84,18 @@ 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"
@@ -92,6 +119,16 @@ 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"}
@@ -178,6 +215,654 @@ 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()
@@ -678,6 +1363,30 @@ 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

@@ -1,559 +0,0 @@
"""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

@@ -1,570 +0,0 @@
"""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() -> None:
def test_trial_feature_enable_disabled():
@trial_feature_enable
def view() -> str:
def view():
return "ok"
services = MagicMock()
services.recommended_app_queries.is_trial_enabled.return_value = False
with patch("controllers.console.explore.trial_app_admission.application_services", return_value=services):
with patch("controllers.console.explore.wraps.application_services", return_value=services):
with pytest.raises(TrialAppFeatureDisabledError) as exc_info:
view()
@@ -260,14 +260,14 @@ def test_trial_feature_enable_disabled() -> None:
}
def test_trial_feature_enable_enabled() -> None:
def test_trial_feature_enable_enabled():
@trial_feature_enable
def view() -> str:
def view():
return "ok"
services = MagicMock()
services.recommended_app_queries.is_trial_enabled.return_value = True
with patch("controllers.console.explore.trial_app_admission.application_services", return_value=services):
with patch("controllers.console.explore.wraps.application_services", return_value=services):
assert view() == "ok"

View File

@@ -0,0 +1,16 @@
from types import SimpleNamespace
import pytest
from werkzeug.exceptions import Forbidden
from controllers.console.resource_access_token import _require_owner
from models.account import TenantAccountRole
def test_require_owner_allows_owner():
_require_owner(SimpleNamespace(current_role=TenantAccountRole.OWNER))
def test_require_owner_rejects_non_owner():
with pytest.raises(Forbidden):
_require_owner(SimpleNamespace(current_role=TenantAccountRole.ADMIN))

View File

@@ -1,219 +0,0 @@
"""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

@@ -0,0 +1,347 @@
from contextlib import nullcontext
from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy import delete
from sqlalchemy.orm import Session
from werkzeug.exceptions import Forbidden, Unauthorized
from controllers.openapi.auth.requirements import CheckScope, CheckSubject
from controllers.openapi.auth.resource_access import authenticate_resource_token
from controllers.openapi.auth.router import subject_router
from controllers.openapi.auth.spec import EndpointSpec
from controllers.openapi.auth.subjects import AccountSubject, ResourceAccessSubject
from libs.oauth_bearer import Scope, TokenType
from models.account import Tenant
from models.enums import CreatorUserRole
from models.model import App, AppMode, AppStar, EndUser
from models.resource_access_token import (
ResourceAccessToken,
ResourceAccessTokenRelation,
ResourceAccessTokenResourceType,
)
pytestmark = pytest.mark.parametrize(
"sqlite_session",
[(Tenant, App, AppStar, ResourceAccessToken, ResourceAccessTokenRelation)],
indirect=True,
)
@pytest.fixture
def resource_fixture(sqlite_session: Session):
tenant = Tenant(name="Token workspace")
tenant.id = str(uuid4())
app = App(
id=str(uuid4()), tenant_id=tenant.id, name="Bound app", mode=AppMode.CHAT, enable_api=True, enable_site=False
)
token = ResourceAccessToken(
id=str(uuid4()), tenant_id=tenant.id, name="CLI", token="sk-test", track_id="test", created_by=str(uuid4())
)
relation = ResourceAccessTokenRelation(
token_id=token.id, resource_type=ResourceAccessTokenResourceType.APP, app_id=app.id
)
sqlite_session.add_all([tenant, app, token, relation])
sqlite_session.commit()
flask_app = Flask(__name__)
flask_app.login_manager = MagicMock()
with (
patch(
"controllers.openapi.auth.resource_access.session_factory.create_session",
side_effect=lambda: nullcontext(sqlite_session),
),
patch("controllers.openapi.auth.resource_access.enforce_bearer_rate_limit"),
patch(
"controllers.openapi.auth.subjects.EndUserService.get_or_create_end_user_by_type",
return_value=MagicMock(spec=EndUser),
) as end_user,
patch("controllers.openapi.auth.pipelines._mount_flask_login"),
patch("controllers.openapi.auth.router.assert_license_valid"),
):
yield flask_app, app.id, token.id, tenant.id, end_user
def call(flask_app, app_id=None, workspace_id=None, scope=Scope.APPS_RUN, allowed=None):
path = "/apps" + (f"/{app_id}" if app_id else "")
if workspace_id:
path += f"?workspace_id={workspace_id}"
with flask_app.test_request_context(path, headers={"Authorization": "Bearer sk-test"}):
from flask import request
request.view_args = {"app_id": app_id} if app_id else {}
@subject_router.guard(
EndpointSpec(
requirements=(
CheckSubject(allowed=(AccountSubject,) if allowed else (ResourceAccessSubject,)),
CheckScope(scope),
)
)
)
def view(*, ctx):
return ctx
return view()
def test_bound_app_uses_machine_end_user_without_account(resource_fixture):
flask_app, app_id, token_id, tenant_id, end_user = resource_fixture
data = call(flask_app, app_id)
assert data.subject.caller_role == CreatorUserRole.END_USER
assert data.subject.account_id is None
assert data.app.id == app_id
end_user.assert_called_once()
assert end_user.call_args.kwargs["user_id"] == f"resource-token:{token_id}"
assert end_user.call_args.kwargs["tenant_id"] == tenant_id
def test_rejects_unbound_app_before_creating_caller(resource_fixture):
flask_app, _, _, _, end_user = resource_fixture
with pytest.raises(Forbidden, match="resource_not_authorized"):
call(flask_app, str(uuid4()))
end_user.assert_not_called()
def test_rejects_other_workspace(resource_fixture):
flask_app, _, _, _, _ = resource_fixture
with pytest.raises(Forbidden, match="resource_not_authorized"):
call(flask_app, workspace_id=str(uuid4()), scope=Scope.APPS_READ)
def test_binding_revocation_is_immediate(resource_fixture, sqlite_session):
flask_app, app_id, token_id, _, _ = resource_fixture
call(flask_app, app_id)
sqlite_session.execute(delete(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token_id))
sqlite_session.commit()
with pytest.raises(Forbidden):
call(flask_app, app_id)
def test_token_revocation_is_immediate(resource_fixture, sqlite_session):
flask_app, app_id, token_id, _, _ = resource_fixture
call(flask_app, app_id)
sqlite_session.execute(delete(ResourceAccessToken).where(ResourceAccessToken.id == token_id))
sqlite_session.commit()
with pytest.raises(Unauthorized):
call(flask_app, app_id)
def test_disabled_app_is_rejected(resource_fixture, sqlite_session):
flask_app, app_id, _, _, _ = resource_fixture
sqlite_session.get(App, app_id).enable_api = False
sqlite_session.commit()
with pytest.raises(Forbidden):
call(flask_app, app_id)
def test_app_discovery_keeps_explicit_empty_binding_set(resource_fixture, sqlite_session):
flask_app, _, token_id, tenant_id, _ = resource_fixture
sqlite_session.execute(delete(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token_id))
sqlite_session.commit()
data = call(flask_app, workspace_id=tenant_id, scope=Scope.APPS_READ)
assert data.resource_app_ids == frozenset()
def test_account_only_routes_reject_resource_token(resource_fixture):
flask_app, _, _, _, _ = resource_fixture
with pytest.raises(Forbidden, match="unsupported_token_type"):
call(flask_app, scope=Scope.WORKSPACE_WRITE, allowed=frozenset({TokenType.OAUTH_ACCOUNT}))
def test_invalid_token_is_unauthorized(resource_fixture):
flask_app, _, _, _, _ = resource_fixture
with flask_app.test_request_context("/"):
with pytest.raises(Unauthorized):
authenticate_resource_token("sk-invalid")
def test_archived_workspace_is_rejected(resource_fixture, sqlite_session):
flask_app, app_id, _, tenant_id, _ = resource_fixture
sqlite_session.get(Tenant, tenant_id).status = "archive"
sqlite_session.commit()
with pytest.raises(Forbidden):
call(flask_app, app_id)
def test_resource_token_has_workspace_read_scope(resource_fixture):
flask_app, _, _, tenant_id, _ = resource_fixture
with flask_app.test_request_context("/"):
identity = authenticate_resource_token("sk-test")
assert identity.scopes == frozenset({Scope.WORKSPACE_READ, Scope.APPS_READ, Scope.APPS_RUN})
assert str(call(flask_app, scope=Scope.WORKSPACE_READ).workspace.id) == tenant_id
def test_workspace_list_returns_only_token_tenant(resource_fixture, sqlite_session):
from controllers.openapi.workspaces import WorkspacesApi
flask_app, _, _, tenant_id, _ = resource_fixture
other_tenant = Tenant(name="Other workspace")
other_tenant.id = str(uuid4())
sqlite_session.add(other_tenant)
sqlite_session.commit()
with flask_app.test_request_context("/workspaces", headers={"Authorization": "Bearer sk-test"}):
result, status = WorkspacesApi().get()
assert status == 200
assert len(result["workspaces"]) == 1
assert result["workspaces"][0] == {
"id": tenant_id,
"name": "Token workspace",
"role": "",
"status": "normal",
"current": True,
}
@pytest.mark.parametrize("endpoint", ["detail", "members", "switch"])
def test_other_workspace_endpoints_reject_resource_token(resource_fixture, endpoint):
from controllers.openapi.workspaces import WorkspaceByIdApi, WorkspaceMembersApi, WorkspaceSwitchApi
flask_app, _, _, tenant_id, _ = resource_fixture
methods = {
"detail": WorkspaceByIdApi().get,
"members": WorkspaceMembersApi().get,
"switch": WorkspaceSwitchApi().post,
}
with flask_app.test_request_context("/workspaces", headers={"Authorization": "Bearer sk-test"}):
with pytest.raises(Forbidden, match="unsupported_token_type"):
methods[endpoint](workspace_id=tenant_id)
def test_stop_task_rejects_other_token_owner(resource_fixture):
import inspect
from werkzeug.exceptions import NotFound
from controllers.openapi.app_run import AppRunTaskStopApi
flask_app, app_id, _, _, end_user = resource_fixture
data = call(flask_app, app_id)
end_user.return_value.id = "machine-user"
with (
patch("controllers.openapi.app_run.redis_client") as redis,
patch("controllers.openapi.app_run.AppQueueManager.set_stop_flag_no_user_check") as stop,
patch("controllers.openapi.app_run.GraphEngineManager") as engine,
):
redis.get.return_value = b"end-user-other-user"
with pytest.raises(NotFound):
inspect.unwrap(AppRunTaskStopApi.post)(AppRunTaskStopApi(), data, app_id, "task")
stop.assert_not_called()
engine.assert_not_called()
redis.get.return_value = b"end-user-machine-user"
inspect.unwrap(AppRunTaskStopApi.post)(AppRunTaskStopApi(), data, app_id, "task")
stop.assert_called_once_with("task")
@pytest.mark.parametrize("search_unbound", [False, True])
def test_app_list_filters_unbound_apps_before_pagination(resource_fixture, sqlite_session, search_unbound):
import inspect
from controllers.openapi._models import AppListQuery
from controllers.openapi.apps import AppListApi
flask_app, app_id, _, tenant_id, _ = resource_fixture
unbound = App(
id=str(uuid4()), tenant_id=tenant_id, name="Unbound app", mode=AppMode.CHAT, enable_api=True, enable_site=False
)
sqlite_session.add(unbound)
sqlite_session.commit()
with flask_app.test_request_context("/"):
data = call(flask_app, workspace_id=tenant_id, scope=Scope.APPS_READ)
result = inspect.unwrap(AppListApi.get)(
AppListApi(),
data,
query=AppListQuery(workspace_id=tenant_id, name=unbound.id if search_unbound else None),
)
assert result.total == (0 if search_unbound else 1)
assert [app.id for app in result.data] == ([] if search_unbound else [app_id])
def test_resource_token_cannot_gain_workspace_write_scope(resource_fixture):
flask_app, _, _, _, end_user = resource_fixture
with pytest.raises(Forbidden, match="insufficient_scope"):
call(flask_app, scope=Scope.WORKSPACE_WRITE)
end_user.assert_not_called()
def test_cross_tenant_binding_does_not_authorize_app(resource_fixture, sqlite_session):
flask_app, app_id, _, _, end_user = resource_fixture
other_tenant = Tenant(name="Other workspace")
other_tenant.id = str(uuid4())
sqlite_session.add(other_tenant)
sqlite_session.get(App, app_id).tenant_id = other_tenant.id
sqlite_session.commit()
with pytest.raises(Forbidden, match="resource_not_authorized"):
call(flask_app, app_id)
end_user.assert_not_called()
def test_bound_app_describe_uses_new_endpoint_contract(resource_fixture):
from flask import request
from controllers.openapi.apps import AppDescribeApi
flask_app, app_id, _, _, _ = resource_fixture
with flask_app.test_request_context(f"/apps/{app_id}?fields=info", headers={"Authorization": "Bearer sk-test"}):
request.view_args = {"app_id": app_id}
result, status = AppDescribeApi().get(app_id=app_id)
assert status == 200
assert result["info"]["id"] == app_id
def test_successful_request_records_token_usage(resource_fixture, sqlite_session):
flask_app, _, token_id, _, _ = resource_fixture
assert sqlite_session.get(ResourceAccessToken, token_id).last_used_at is None
call(flask_app, scope=Scope.WORKSPACE_READ)
assert sqlite_session.get(ResourceAccessToken, token_id).last_used_at is not None
def test_resource_token_works_when_oauth_is_disabled(resource_fixture, config_overrides):
config_overrides(ENABLE_OAUTH_BEARER=False)
flask_app, app_id, _, _, _ = resource_fixture
with patch("controllers.openapi.auth.router.get_authenticator") as oauth:
ctx = call(flask_app, app_id)
assert ctx.app.id == app_id
oauth.assert_not_called()
@pytest.mark.parametrize("route", ["upload", "events", "form_get", "form_submit"])
@pytest.mark.parametrize("bound", [True, False], ids=["bound", "unbound"])
def test_run_companion_routes_enforce_resource_bindings(resource_fixture, route, bound):
from flask import request
from controllers.openapi.files import AppFileUploadApi
from controllers.openapi.human_input_form import (
OpenApiWorkflowHumanInputFormApi,
OpenApiWorkflowHumanInputFormSubmitApi,
)
from controllers.openapi.workflow_events import OpenApiWorkflowEventsApi
handlers = {
"upload": AppFileUploadApi.post,
"events": OpenApiWorkflowEventsApi.get,
"form_get": OpenApiWorkflowHumanInputFormApi.get,
"form_submit": OpenApiWorkflowHumanInputFormSubmitApi.post,
}
flask_app, app_id, _, _, end_user = resource_fixture
handler = MagicMock(return_value="admitted")
# Exercise the production endpoint's entire authorization pipeline without
# invoking file storage, workflow streaming, or form submission side effects.
guarded = subject_router.guard(handlers[route].__spec__)(handler)
with (
flask_app.test_request_context("/apps", headers={"Authorization": "Bearer sk-test"}),
patch("controllers.openapi.human_input_form.HumanInputService") as forms,
):
forms.return_value.get_form_by_token.return_value = None
request.view_args = {"app_id": app_id if bound else str(uuid4()), "form_token": "form"}
if bound:
assert guarded() == "admitted"
ctx = handler.call_args.kwargs["ctx"]
assert ctx.app.id == app_id
assert ctx.end_user is end_user.return_value
else:
with pytest.raises(Forbidden, match="resource_not_authorized"):
guarded()
handler.assert_not_called()
end_user.assert_not_called()

View File

@@ -101,7 +101,9 @@ def test_a_dead_licence_403s_an_unauthenticated_caller_on_every_route_in_enterpr
view()
def test_a_disabled_bearer_feature_503s_before_the_bearer_is_read(
@pytest.mark.parametrize("token", [None, "dfoa_test", "dfoe_test", "unknown"])
def test_a_disabled_oauth_feature_503s_before_authentication(
token: str | None,
app: Flask,
config_overrides: Callable[..., None],
monkeypatch: pytest.MonkeyPatch,
@@ -110,10 +112,11 @@ def test_a_disabled_bearer_feature_503s_before_the_bearer_is_read(
the device endpoints do instead of a 500 from the unbound authenticator.
"""
config_overrides(ENABLE_OAUTH_BEARER=False)
monkeypatch.setattr(f"{ROUTER}.extract_bearer", never_reached)
monkeypatch.setattr(f"{ROUTER}.get_authenticator", never_reached)
view = _guard(_nothing)
with app.test_request_context("/openapi/v1/account"):
headers = {"Authorization": f"Bearer {token}"} if token else {}
with app.test_request_context("/openapi/v1/account", headers=headers):
with pytest.raises(ServiceUnavailable, match="bearer_auth_disabled"):
view()

View File

@@ -58,7 +58,9 @@ def test_stop_task_calls_queue_manager_and_graph_engine(app: Flask, monkeypatch:
api = AppRunTaskStopApi()
with app.test_request_context("/openapi/v1/apps/app-1/tasks/task-1:stop", method="POST"):
result = api.post.__handler__(api, _SealableContext(), app_id="app-1", task_id="task-1")
result = api.post.__handler__(
api, _SealableContext(subject=SimpleNamespace()), app_id="app-1", task_id="task-1"
)
queue_mock.set_stop_flag_no_user_check.assert_called_once_with("task-1")
graph_instance.send_stop_command.assert_called_once_with("task-1")

View File

@@ -88,7 +88,7 @@ from controllers.openapi.auth.requirements import (
Requirement,
)
from controllers.openapi.auth.spec import EndpointSpec
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject
from controllers.openapi.auth.subjects import AccountSubject, ExternalSsoSubject, ResourceAccessSubject
from controllers.openapi.human_input_form import CheckFormSurface
from enums import DeploymentEdition, WebAppAccessMode
from libs.oauth_bearer import (
@@ -741,9 +741,12 @@ DECLARED: dict[str, tuple[Requirement, ...]] = {
"account.sessions.revoke_self": _REQ_ACCOUNT_FULL,
"account.sessions.list": _REQ_ACCOUNT_FULL,
"account.sessions.revoke_one": _REQ_ACCOUNT_FULL,
"apps.describe": _REQ_APP_DESCRIBE,
"apps.list": _REQ_ACCOUNT_APPS_READ_MEMBER,
"workspaces.list": _REQ_ACCOUNT_WORKSPACE_READ,
"apps.describe": (CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), *_REQ_APP_DESCRIBE[1:]),
"apps.list": (CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)), *_REQ_ACCOUNT_APPS_READ_MEMBER[1:]),
"workspaces.list": (
CheckSubject(allowed=(AccountSubject, ResourceAccessSubject)),
*_REQ_ACCOUNT_WORKSPACE_READ[1:],
),
"workspaces.describe": _REQ_ACCOUNT_WORKSPACE_READ,
"workspaces.switch": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
"workspaces.members.list": _REQ_ACCOUNT_WORKSPACE_READ_MEMBER,
@@ -754,12 +757,24 @@ DECLARED: dict[str, tuple[Requirement, ...]] = {
"app_dsl.import_confirm": _REQ_DSL_WORKSPACE,
"app_dsl.export": _REQ_DSL_APP,
"app_dsl.check_dependencies": _REQ_DSL_APP,
"app_run.run": _REQ_RUN,
"app_run.stop": _REQ_RUN,
"files.upload": _REQ_FILES,
"human_input_form.get": _REQ_RUN_FORM,
"human_input_form.submit": _REQ_RUN_FORM,
"workflow_events.stream": _REQ_RUN,
"app_run.run": (CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)), *_REQ_RUN[1:]),
"app_run.stop": (CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)), *_REQ_RUN[1:]),
"files.upload": (
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
*_REQ_FILES[1:],
),
"human_input_form.get": (
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
*_REQ_RUN_FORM[1:],
),
"human_input_form.submit": (
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
*_REQ_RUN_FORM[1:],
),
"workflow_events.stream": (
CheckSubject(allowed=(AccountSubject, ExternalSsoSubject, ResourceAccessSubject)),
*_REQ_RUN[1:],
),
"permitted_external.list": _REQ_EXTERNAL_LIST,
"permitted_external.describe": _REQ_EXTERNAL_DESCRIBE,
}

View File

@@ -30,6 +30,11 @@ from models.account import TenantAccountRole
from models.dataset import Dataset, RateLimitLog
from models.enums import ApiTokenType
from models.model import ApiToken, App, AppMode, DatasetApiTokenBinding, IconType
from models.resource_access_token import (
ResourceAccessToken,
ResourceAccessTokenRelation,
ResourceAccessTokenResourceType,
)
from tests.unit_tests.config_override import config_overrides_context
@@ -223,6 +228,59 @@ class TestValidateAppToken:
assert result["app_id"] == app_model.id
assert account.current_tenant_id == tenant.id
@patch("controllers.service_api.wraps.user_logged_in")
@patch("controllers.service_api.wraps.current_app")
@pytest.mark.parametrize(
"sqlite_session",
[(App, ResourceAccessToken, ResourceAccessTokenRelation, Tenant, Account, TenantAccountJoin)],
indirect=True,
)
def test_resource_access_token_allows_bound_app(
self,
mock_current_app,
mock_user_logged_in,
app: Flask,
sqlite_session: Session,
):
_configure_current_app_mock(mock_current_app)
tenant, account, _ = _persist_workspace(sqlite_session)
app_model = _app_model(tenant_id=tenant.id)
access_token = ResourceAccessToken(
tenant_id=tenant.id,
name="Production integration",
track_id="a" * 32,
token="sk-00000000-0000-0000-0000-000000000000",
created_by=account.id,
)
sqlite_session.add_all([app_model, access_token])
sqlite_session.flush()
sqlite_session.add(
ResourceAccessTokenRelation(
token_id=access_token.id,
resource_type=ResourceAccessTokenResourceType.APP,
app_id=app_model.id,
dataset_id=None,
)
)
sqlite_session.commit()
@validate_app_token
def protected_view(app_model):
return {"success": True, "app_id": app_model.id}
with (
app.test_request_context(
"/",
method="GET",
headers={"Authorization": f"Bearer {access_token.token}"},
),
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
):
result = protected_view()
assert result["app_id"] == app_model.id
assert sqlite_session.get(ResourceAccessToken, access_token.id).last_used_at is not None
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@pytest.mark.parametrize("sqlite_session", [(App,)], indirect=True)
def test_app_not_found_raises_forbidden(self, mock_validate_token, app: Flask, sqlite_session: Session):
@@ -695,6 +753,62 @@ class TestValidateDatasetToken:
assert result["tenant_id"] == tenant.id
assert account.current_tenant_id == tenant.id
@patch("controllers.service_api.wraps.user_logged_in")
@patch("controllers.service_api.wraps.current_app")
@pytest.mark.parametrize(
"sqlite_session",
[(Dataset, ResourceAccessToken, ResourceAccessTokenRelation, Tenant, Account, TenantAccountJoin)],
indirect=True,
)
def test_resource_access_token_allows_bound_dataset(
self,
mock_current_app,
mock_user_logged_in,
app: Flask,
sqlite_session: Session,
):
_configure_current_app_mock(mock_current_app)
tenant, account, _ = _persist_workspace(sqlite_session)
dataset = Dataset(
id=str(uuid.uuid4()),
tenant_id=tenant.id,
name="Customer Support",
created_by=account.id,
enable_api=True,
)
access_token = ResourceAccessToken(
tenant_id=tenant.id,
name="Production integration",
track_id="b" * 32,
token="sk-00000000-0000-0000-0000-000000000001",
created_by=account.id,
)
sqlite_session.add_all([dataset, access_token])
sqlite_session.flush()
sqlite_session.add(
ResourceAccessTokenRelation(
token_id=access_token.id,
resource_type=ResourceAccessTokenResourceType.KNOWLEDGE,
app_id=None,
dataset_id=dataset.id,
)
)
sqlite_session.commit()
@validate_dataset_token
def protected_view(tenant_id, dataset_id=None):
return {"success": True, "tenant_id": tenant_id, "dataset_id": dataset_id}
with (
app.test_request_context("/", method="GET", headers={"Authorization": f"Bearer {access_token.token}"}),
patch("controllers.service_api.wraps.db.session", _session_proxy(sqlite_session)),
):
result = protected_view(dataset_id=dataset.id)
assert result["tenant_id"] == tenant.id
assert result["dataset_id"] == dataset.id
assert sqlite_session.get(ResourceAccessToken, access_token.id).last_used_at is not None
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
def test_dataset_not_found_raises_not_found(self, mock_validate_token, app: Flask, sqlite_session: Session):

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 WorkflowAppGenerator
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY, 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,7 +39,6 @@ def _workflow(
kind=kind,
version="1",
created_by="creator",
graph={"nodes": [{"id": "start", "data": {"type": "start", "title": "Start", "variables": []}}], "edges": []},
)
@@ -124,6 +123,24 @@ 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 WorkflowAppGenerator
from core.app.apps.workflow.app_generator import SKIP_PREPARE_USER_INPUTS_KEY, 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,6 +264,12 @@ 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()
@@ -485,95 +491,79 @@ class TestWorkflowAppGeneratorHandleResponse:
class TestWorkflowAppGeneratorGenerate:
@pytest.fixture
def generation(self, monkeypatch: pytest.MonkeyPatch, sqlite_generator_session: Session):
"""Keep input preparation real and stop at the execution boundary."""
@pytest.mark.usefixtures("sqlite_generator_session")
def test_generate_skips_prepare_inputs_when_flag_set(
self,
monkeypatch: pytest.MonkeyPatch,
sqlite_session: Session,
):
generator = WorkflowAppGenerator()
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 = _persist_app(sqlite_session)
workflow = _persist_workflow(sqlite_session)
user = _persist_end_user(sqlite_session)
@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(
{
"nodes": [
{"id": "first-trigger", "data": {"type": node_type, "title": "First trigger"}},
{"id": "selected-trigger", "data": {"type": node_type, "title": "Selected trigger"}},
],
"edges": [],
}
app_config = WorkflowUIBasedAppConfig(
tenant_id=TENANT_ID,
app_id=APP_ID,
app_mode=AppMode.WORKFLOW,
additional_features=AppAdditionalFeatures(),
variables=[],
workflow_id=WORKFLOW_ID,
)
inputs = {"event": {"message": "hello", "items": [1, 2]}, "enabled": False}
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,),
{
"__init__": lambda self, app_id=None, user_id=None: (
setattr(self, "app_id", app_id) or setattr(self, "user_id", user_id)
)
},
)
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})
result = generator.generate(
app_model=app,
workflow=workflow,
user=user,
args={"inputs": inputs},
invoke_from=invoke_from,
args={"inputs": {}, SKIP_PREPARE_USER_INPUTS_KEY: True},
invoke_from=InvokeFrom.WEB_APP,
streaming=False,
root_node_id=root_node_id,
call_depth=0,
)
assert result == {"ok": True}
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()
assert len(repository_session_makers) == 2
assert all(factory.kw["bind"] is sqlite_session.get_bind() for factory in repository_session_makers)
class TestWorkflowAppGeneratorResume:

View File

@@ -3,8 +3,6 @@ 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
@@ -137,32 +135,3 @@ 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,7 +162,6 @@ 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
@@ -213,7 +212,6 @@ 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

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