Compare commits

...
Author SHA1 Message Date
GareArc bc68e02711 feat(openapi): distinguish expired OAuth bearer from invalid token
Previously an expired OAuth bearer and an unknown/invalid one both
surfaced as an indistinguishable generic 401 (and an invalid token
actually leaked a 500), so a client could not tell "session expired,
re-authenticate" apart from "never authenticated."

The resolver now raises a distinct TokenExpiredError for expired DB
rows and records a separate `expired` negative-cache marker, so a
retry within the negative-cache TTL still reports expiry instead of
collapsing into a generic miss. The auth pipeline maps the two domain
errors to unified OpenApiError responses: SessionExpired (code
`token_expired`) and InvalidBearer (code `unauthorized`), both 401.
This also fixes the latent 500 on invalid bearers.

The `token_expired` code is synced through the contract codegen into
the generated types/zod, and the difyctl error mapper branches the
401 on it. The CLI `expired_token` taxonomy member (RFC 8628
device-flow code expiry) is merged into `token_expired`; the RFC 8628
wire value is unchanged.

Closes WTA-1062
2026-06-28 21:45:13 -07:00
Escape0707andGitHub eb4ec93cea chore: migrate account role test to testcontainers (#38010) 2026-06-26 05:51:14 +00:00
Xiyuan ChenandGitHub fa6f4b0ea5 refactor(api): enforce enums over string literals on openapi surface (#38009) 2026-06-26 05:48:21 +00:00
267b34caaf fix(web): keep HITL input-field save button visible in the edit dialog (#38007)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-26 05:40:51 +00:00
yyhandGitHub 6ab1dd06ac fix: isolate node selector keyboard events (#37998) 2026-06-26 05:38:10 +00:00
Stephen ZhouandGitHub 113d6d7e00 refactor: align deployment detail state ownership (#38008) 2026-06-26 05:37:21 +00:00
Mohammed AqibGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a66b1de477 refactor: Use pytest caplog instead of logger patching in test_spec.py (#37997)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-26 04:48:22 +00:00
Stephen ZhouandGitHub 16b698b54d refactor: simplify deployment access ownership (#37994) 2026-06-26 04:10:56 +00:00
yyhandGitHub 4cd8b8c733 fix(web): make marketplace URL builder SSR-safe (#37944) 2026-06-26 03:35:22 +00:00
677ab01806 refactor(tests): use caplog in workflow collaboration service tests (#37468) (#37991)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 03:14:06 +00:00
Stephen ZhouandGitHub f4e832f35c fix: decouple deployment access control dialog (#37936) 2026-06-26 02:16:29 +00:00
kurokoboGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1c5e1280cb feat(webapp): display app description on chat and text-generation app screens (#37345)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-26 02:03:16 +00:00
-LAN-GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
0035d90e36 fix(api): require edit access for trace config changes (#37973)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-26 00:51:39 +00:00
Xiyuan ChenandGitHub 2047e0dc12 fix(cli): use a(n) article in difyctl prerelease warning (#37976) 2026-06-26 00:49:42 +00:00
chaririandGitHub 2e9c0a3c7a refactor(api): migrate response contract tooling to BaseModel (#37950) 2026-06-26 00:11:04 +00:00
ojasarora.ethandGitHub a246dc8b17 ci: emit pyrefly diagnostics as GitHub workflow commands (#37974) 2026-06-25 18:27:46 +00:00
ojasarora.ethandGitHub bb921bcc45 test: replace logger mock with caplog in workflow collaboration test (#37971) 2026-06-25 16:45:59 +00:00
QuantumGhostandGitHub 4f4ac27de2 chore(api): surface pyrefly output on type-check failures (#37934) 2026-06-25 13:47:10 +00:00
Yunlu WenandGitHub 3aa26fb637 chore: bump versions to 1.15.0 (#37929) 2026-06-25 12:02:38 +00:00
Wu TianweiandGitHub 1d2cc1e475 feat: add hydration support and fallback for Countdown component (#37943) 2026-06-25 10:40:48 +00:00
林玮 (Jade Lin)andGitHub aa37c1d833 fix(api): tongyi credential compatibility (#37942) 2026-06-25 10:25:24 +00:00
yyhandGitHub 48dfbd60d6 fix: polish tool provider selection and detail drawer (#37940) 2026-06-25 10:07:32 +00:00
Mr_xieGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
e90c7ab8a7 refactor(tests): replace logger mocks with caplog (#37468) (#37922)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-25 09:48:43 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
40119fef44 feat: update monitor permission key and plugin permission key (#37937)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-25 09:41:38 +00:00
72a03c2d6a refactor(test): replace SimpleNamespace with real UploadFile model in test_dataset_models.py (#37935)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-25 09:35:24 +00:00
Wu TianweiandGitHub affdc89f84 feat: enhance app permissions and access controls (#37933) 2026-06-25 09:28:58 +00:00
QuantumGhostandGitHub b33e8f0ddb fix: improve error handling for workflow execution (#37919) 2026-06-25 07:36:34 +00:00
FFXNGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Copilot Autofix powered by AI
8f74e176ca fix: remove redundant snippets permissions (#37921)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-06-25 07:27:16 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
b9bcf31c72 chore(i18n): sync translations with en-US (#37916)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-06-25 07:07:23 +00:00
GatewayandGitHub abf2986299 build(deps): update Bleach sanitizer security fix (#37860) 2026-06-25 06:32:48 +00:00
Wu TianweiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
599d92ef6b fix: Fix frontend rbac issues (#37872)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-25 05:51:39 +00:00
JingyiandGitHub 93dd955deb docs: add Dify Cloud support contact (#37913) 2026-06-25 05:19:31 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
75909ce10e chore(i18n): sync translations with en-US (#37891)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-06-25 03:24:37 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
d93989bfc0 chore: add type to test (#37876)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-25 03:01:09 +00:00
31a50a3b20 fix: eagerly validate conversation before generator to prevent hanging (#37224)
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
2026-06-25 02:57:33 +00:00
Stephen ZhouandGitHub 3d8316333f fix: keep body background consistent on overscroll (#37909) 2026-06-25 02:20:39 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
9fc2925b00 feat: delete member delete rbac binding (#37904)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-25 02:08:07 +00:00
JingyiandGitHub d349e892f4 fix: respect legacy plugin permissions without RBAC (#37903) 2026-06-25 01:18:40 +00:00
Willow LopezGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Asuka Minato
2483c091aa refactor: replace logger patches with pytest caplog in tests (#37890)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-06-24 21:40:06 +00:00
Stephen ZhouandGitHub a421362847 chore(web): prune unused i18n translations (#37888) 2026-06-24 16:13:22 +00:00
4964359961 refactor(tests): use caplog in web login tests (#37889)
Co-authored-by: lin-hongkuan <lin-hongkuan@users.noreply.github.com>
2026-06-24 14:44:01 +00:00
Stephen ZhouGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1b81ac033f chore(knip): add mdx support clean unused code (#37882)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-24 12:30:11 +00:00
JoelandGitHub 2eb564696e fix: banner has ui problem in small screen (#37879) 2026-06-24 11:19:33 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
d87764b0f8 feat: filter dataset operator and add miss permission key (#37867)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-24 09:56:28 +00:00
JiangandGitHub d135dab241 fix: Lindorm vector store errors caused by the update of opensearch-py (#37862) 2026-06-24 09:54:06 +00:00
Stephen ZhouGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
ea1aa2fecd chore(web): remove unused frontend code (#37866)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-24 09:25:52 +00:00
yyhandGitHub 86b73ba205 fix: align switch focus outline (#37870) 2026-06-24 09:16:40 +00:00
fe62177ba5 fix: add is_cloud_only for templates (#37846)
Co-authored-by: Joel <iamjoel007@gmail.com>
2026-06-24 08:29:54 +00:00
ce6297bed2 chore(deps): bump dep versions for CVE fixes (#37861)
Co-authored-by: QuantumGhost <obelisk.reg+git@gmail.com>
2026-06-24 08:22:55 +00:00
L1nSn0wandGitHub 94d365ea5e feat: surface separated-mode LLM reasoning in CLI and workflow run preview (#37828) 2026-06-24 08:02:28 +00:00
Xiyuan ChenandGitHub 32dc9ff2d9 fix(cli): make app-info cache resilient to corrupt entries + route errors through structured envelope (WTA-257) (#37852) 2026-06-24 07:39:26 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2112115962 chore: make AccountService.load_user use passed session (#37764)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-24 07:29:12 +00:00
Stephen ZhouGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
f665bcac95 chore(web): clean up unused production code (#37854)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-24 07:10:17 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
347b02d318 chore: rm all mock_logger (#37786)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-24 07:10:02 +00:00
Wu TianweiandGitHub eb19140986 chore: Update permission keys and descriptions for multiple languages to simplify application and dataset management phrases (#37859) 2026-06-24 06:24:38 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>
f8a9605417 chore(i18n): sync translations with en-US (#37855)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
2026-06-24 05:54:21 +00:00
JingyiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>yyh
4573aaa717 fix(web): polish onboarding main nav and preferences tab (#37844)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
2026-06-24 05:46:53 +00:00
67a5eacf2d refactor: pass session as parameter in knowledge_retrieval_inner_service and agent_app_feature_service (#37639)
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-06-24 05:34:41 +00:00
jashwanth_reddy_gummulaGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2cde7e4a94 fix: resolve DetachedInstanceError via session management refactoring (#37847)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-24 05:33:12 +00:00
JingyiandGitHub 4086f5f2d9 fix(web): align marketplace i18n terminology (#37837) 2026-06-24 05:18:07 +00:00
Myshkin451andGitHub 31c08faded refactor: inject session into audio TTS (#37849) 2026-06-24 04:49:48 +00:00
Stephen ZhouandGitHub 1c1b20aa46 refactor(web): simplify deployment action state (#37851) 2026-06-24 04:39:52 +00:00
Wu TianweiandGitHub 24dd7ea3a8 fix: add RBAC feature toggle to environment configuration (#37853) 2026-06-24 04:18:59 +00:00
zyssyz123andGitHub 1eafbf9763 fix(agent): save workflow agents as console roster apps (#37848) 2026-06-24 03:35:12 +00:00
kunalGitHubkunalj1-archAsuka Minatoautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
d0b2239c60 refactor: accept db.session explicitly in ApiKeyAuthService (#37832)
Co-authored-by: kunalj1-arch <kunal.j1@turing.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-24 01:20:47 +00:00
Stephen ZhouandGitHub 50b3228bc7 refactor(web): reuse infinite scroll hook in deployments (#37825) 2026-06-23 14:22:09 +00:00
5b453069d1 refactor: for db.session feedback service.export feedbacks (#37763)
Co-authored-by: kunalj1-arch <kunal.j1@turing.com>
Co-authored-by: Asuka Minato <i@asukaminato.eu.org>
2026-06-23 14:01:40 +00:00
Stephen ZhouandGitHub acf6d0ddc9 refactor(web): simplify deployment async ownership (#37823) 2026-06-23 13:20:23 +00:00
Stephen ZhouandGitHub d8ed874dc7 refactor(web): manage deployment async state with atoms (#37819) 2026-06-23 12:50:16 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
ed500761c8 chore(i18n): sync translations with en-US (#37760)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-06-23 12:41:44 +00:00
zyssyz123GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
26b0ff2a01 feat(agent): copy roster agent into workflow inline agent (#37813)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 12:28:29 +00:00
Stephen ZhouandGitHub b56e5f74e7 fix(web): stabilize deployment state hydration (#37818) 2026-06-23 11:07:15 +00:00
林玮 (Jade Lin)andGitHub 4ac8c5a30e fix(agent): inject env inline per-command instead of persisting workspace file (#37815) 2026-06-23 10:46:11 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
be3677f145 fix: fix logic (#37812)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 10:11:52 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
d1a3f31f15 chore(i18n): sync translations with en-US (#37802)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-06-23 10:05:42 +00:00
Stephen ZhouandGitHub ac06deba20 refactor(web): sync deployment route state from next route (#37811) 2026-06-23 09:49:25 +00:00
L1nSn0wGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
725e4da29d feat(chatflow): stream LLM reasoning to a live thinking panel (#37460)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 09:49:01 +00:00
Yunlu WenandGitHub c2a554da93 fix(api): disable gunicorn control sock (#37806) 2026-06-23 09:21:13 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
db9b899321 chore: compatiable old role update (#37804)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 09:08:08 +00:00
ToughGuysDeservePinkandGitHub b98ba99b23 fix: correct misleading password length validation message (#37796) 2026-06-23 09:00:16 +00:00
林玮 (Jade Lin)andGitHub 04d226384f fix(agent): agent composer publish validation (#37803) 2026-06-23 08:48:40 +00:00
Stephen ZhouandGitHub 0fcaf92d67 fix(web): polish main nav and deployment tooltip styles (#37800) 2026-06-23 08:10:28 +00:00
FFXNGitHubgemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>Copilot Autofix powered by AIautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a554891bbd fix: snippet history detail includes input fields (#37797)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 08:08:12 +00:00
Xiyuan ChenandGitHub d764b16d06 feat(cli): prepare for alpha release (#37794) 2026-06-23 08:07:15 +00:00
盐粒 YanliGitHubyyhJoelautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>yyh
f4fdbeba76 feat(agent-v2): sync nightly updates to main (2026-06-22) (#37651)
Co-authored-by: yyh <yuanyouhuilyz@gmail.com>
Co-authored-by: Joel <iamjoel007@gmail.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
2026-06-23 08:05:16 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
56b0b57ff7 feat: support query catalog add billing enabled (#37791)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 07:34:58 +00:00
Stephen ZhouandGitHub 99c3d7d0f0 refactor(web): consolidate deployment state atoms (#37783) 2026-06-23 07:26:55 +00:00
Byron.wangGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
cf1ebdadf5 feat(retention): add V2 workflow run archive bundlesa (#37747)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 15:20:25 +08:00
zyssyz123GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a3309cd857 fix: support agent duplicate role and skill file preview (#37788)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 06:35:26 +00:00
Myshkin451andGitHub 7fc8eed716 refactor: pass session into hit testing service (#37785) 2026-06-23 06:21:38 +00:00
Xiyuan ChenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
b3e5f29421 fix(app): derive get-app --mode whitelist from listable app types (#37761)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 04:40:05 +00:00
zyssyz123GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
26639e0923 feat: add agent debug conversation refresh API (#37784)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 04:34:13 +00:00
Yunlu WenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
7852c273e4 refactor: improve stream close 2 (#37106)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 03:32:33 +00:00
wangxiaoleiandGitHub ef54229d6f chore: add ENTERPRISE_RBAC_API_URL check and update permission key (#37777) 2026-06-23 03:26:06 +00:00
Stephen ZhouGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
855bb32306 refactor: adapt docs links for product prefixes (#37565)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-23 03:24:29 +00:00
Stephen ZhouandGitHub f380bbaa10 refactor(web): consolidate create release state (#37765) 2026-06-23 02:17:54 +00:00
zyssyz123GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
b67a04aa22 fix: isolate agent debug conversations by account (#37766)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 02:04:23 +00:00
JingyiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
ab11083c2d fix(web): restore contact us support menu (#37774)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-23 01:04:17 +00:00
0cc27dd401 chore: not use request.scoped session (#37421)
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-06-22 19:38:24 +00:00
zyssyz123GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
7d2f25df8e feat(agent): add roster service api access (#37759)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-06-22 12:37:27 +00:00
Stephen ZhouandGitHub e3d0320826 refactor(web): manage create release form with Jotai (#37762) 2026-06-22 12:26:12 +00:00
EvanandGitHub 29b95d3ced fix: replace mock_logger with caplog in tests (#37757) 2026-06-22 11:26:07 +00:00
2033 changed files with 39806 additions and 71746 deletions
+6 -5
View File
@@ -102,11 +102,11 @@ describe('ComponentName', () => {
})
})
// Props tests (REQUIRED)
// Props tests (REQUIRED when props change observable behavior)
describe('Props', () => {
it('should apply custom className', () => {
render(<Component className="custom" />)
expect(screen.getByRole('button')).toHaveClass('custom')
it('should disable the action when disabled', () => {
render(<Component disabled />)
expect(screen.getByRole('button')).toBeDisabled()
})
})
@@ -220,6 +220,7 @@ Every test should clearly separate:
### 2. Black-Box Testing
- Test observable behavior, not implementation details
- Test product contracts, not cosmetic implementation. Do not add or expand unit tests only to lock pure style classes, spacing, colors, backgrounds, or layout micro-adjustments. Cover visual-only fixes with browser/manual verification, screenshots, or E2E/visual checks when risk justifies it. Add unit tests only when the change affects user-observable behavior, accessibility semantics, state, data flow, routing, or a stable component API contract.
- Use semantic queries (`getByRole` with accessible `name`, `getByLabelText`, `getByPlaceholderText`, `getByText`, and scoped `within(...)`)
- Treat `getByTestId` as a last resort. If a control cannot be found by role/name, label, landmark, or dialog scope, fix the component accessibility first instead of adding or relying on `data-testid`.
- Remove production `data-testid` attributes when semantic selectors can cover the behavior. Keep them only for non-visual mocked boundaries, editor/browser shims such as Monaco, canvas/chart output, or third-party widgets with no accessible DOM in the test environment.
@@ -273,7 +274,7 @@ it('should disable input when isReadOnly is true')
### Always Required (All Components)
1. **Rendering**: Component renders without crashing
1. **Props**: Required props, optional props, default values
1. **Props**: Required props, optional props, default values that change observable behavior. Do not test pass-through styling props such as `className` unless they are an explicit, stable component API whose absence would break a real integration contract.
1. **Edge Cases**: null, undefined, empty values, boundary conditions
### Conditional (When Present)
+89 -92
View File
@@ -1,128 +1,125 @@
---
name: how-to-write-component
description: React/TypeScript component style guide. Use when writing, refactoring, or reviewing React components, especially around abstraction choices, props typing, state boundaries, shared local state with Jotai atoms, API types, query/mutation contracts, navigation, memoization, wrappers, and empty-state handling.
description: Use when writing, refactoring, or reviewing React/TypeScript components in Dify web, especially decisions about component ownership, props/types, URL/query state, Jotai state, async state, generated API contracts, queries/mutations, overlays, effects, navigation, performance, and empty states.
---
# How To Write A Component
Use this as the decision guide for React/TypeScript component structure. Existing code is reference material, not automatic precedent; when it conflicts with these rules, adapt the approach instead of reproducing the violation.
Use this as the component decision guide for Dify web. Existing code is reference material, not automatic precedent; if touched code violates these rules, adapt it and fix equivalent patterns in the same feature branch.
## First Decisions
| Question | Default | Promote or extract only when |
| --- | --- | --- |
| Where should code live? | Keep it local to the feature workflow, route, or owner. | Multiple verticals need the same stable primitive. |
| How should route/tab folders be named? | Match the current route segment, tab name, or user-visible surface. | Keep a historical or broader parent only when it still owns multiple surfaces. |
| Who owns state, data, and handlers? | The lowest component that uses them. | A parent coordinates shared loading, errors, empty UI, selection, submission, navigation, or one consistent snapshot. |
| Should this become Jotai state? | Keep synchronous UI/form state in component or DOM state. | Siblings need one source of truth, the value drives atoms, or scoped workflow state must survive hidden/unmounted steps. |
| Should URL state enter Jotai? | Let Next.js route params and `nuqs` own URL state and updates. | Query atoms or shared derived atoms need a read-only bridge hydrated at the route/surface boundary. |
| Should this query/mutation become an atom? | Use TanStack Query hooks at the lowest owner. | It reads atom state, feeds derived atoms, or participates in shared Jotai workflow orchestration. |
| Should this be a helper/wrapper? | Prefer direct readable code at the use site. | The name captures a stable domain rule or the wrapper owns real behavior, validation, state, error handling, or semantics. |
| Is an Effect needed? | No. Derive during render or handle the user action in the event handler. | It synchronizes with an external system such as browser APIs, subscriptions, timers, analytics, or imperative DOM/non-React widgets. |
## Core Defaults
- Search before adding UI, hooks, helpers, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Group code by feature workflow, route, or ownership area: components, hooks, local types, query helpers, atoms, constants, and small utilities should live near the code that changes with them.
- Promote code to shared only when multiple verticals need the same stable primitive. Otherwise keep it local and compose shared primitives inside the owning feature.
- Prefer local code and purpose-named helpers over catch-all utility modules; do not group workflow-specific defaults, validation, payload shaping, or metadata merging in a generic utils file just because they share a DTO.
- Keep source/default selection, validation, and payload shaping close to the workflow that owns the behavior. Do not extract a shared helper just because two flows read the same DTO when their priority order, fallback behavior, or submit semantics differ.
- Prefer direct, readable conditionals at the use site for small branch-specific decisions, especially form source selection and request payload assembly. Extract only when the helper name captures a stable domain rule and removes repeated complexity without hiding flow-specific behavior.
- When fixing an invalid pattern, scan the touched feature or branch for equivalent patterns and fix them together.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind guidance.
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices.
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
- Keep source/default selection, validation, dirty checks, and payload shaping close to the workflow that owns submit behavior. Do not hide flow-specific priority order, fallback behavior, or submit semantics in generic utilities.
- Prefer direct conditionals for small branch-specific decisions, especially form source selection and request payload assembly.
- Loading states for page sections, cards, lists, tables, forms, and drawers should be skeletons scoped to the content being loaded. Use spinners only for small inline busy indicators.
## Feature Workflow Layout
## Layout And Ownership
- State-heavy wizards, drawers, modals, and secondary workflows work best as a small feature surface with route/entry files, a single feature-local state file, and feature-local UI.
- Keep `ui/` shallow with owner files that map to the workflow's real composition boundaries and major visual regions.
- Owner files contain the section components, field components, skeletons, and one-off helper components that belong to their visual region.
- Folders represent groups of related files with a shared owner and a stable reason to change together.
- The entry file handles route integration, provider wiring, close behavior, and feature surface mounting. The composition owner handles high-level workflow branching, and the closest visual owner handles section branching.
- State-heavy wizards, drawers, modals, and secondary workflows can be a small feature surface: an entry file, one feature-local state file when Jotai is actually needed, and shallow `ui/` owners that match real visual regions.
- The entry file handles route integration, provider wiring, close behavior, and surface mounting. The composition owner handles high-level workflow branching. The closest visual owner handles section branching.
- When a page or tab maps to a route segment, name its feature folder after that route/tab surface instead of a stale parent grouping. Remove misleading intermediate folders when only one surface remains.
- When a tab folder grows into several independent sections or action areas, split the first level by product/visual owners. Keep the root for the entry component and cross-owner state, colocate tests with the owner folder, and put truly shared local UI under a specifically named `components/` file.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data; TanStack Query deduplicates and shares cache.
- Pass stable domain identity across boundaries. Do not forward derived presentation state when the receiver can derive it from its own data source.
- A component that owns a visual surface should also own data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in Query and API flow.
- Do not replace prop drilling with one large view-model hook threaded through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child, menu, or row own the action.
## Ownership
## Feature-Scoped Jotai
- Put local state, queries, mutations, handlers, and derived UI data in the lowest component that uses them. Extract a purpose-built owner component only when the logic has no natural home.
- Repeated TanStack query calls in sibling components are acceptable when each component independently consumes the data. Do not hoist a query only because it is duplicated; TanStack Query handles deduplication and cache sharing.
- Hoist state, queries, or callbacks to a parent only when the parent consumes the data, coordinates shared loading/error/empty UI, needs one consistent snapshot, or owns a workflow spanning children.
- Pass stable domain identity across boundaries; avoid forwarding derived presentation state when the receiver can derive it from its own data source. A component that owns a visual surface should also own the data access, loading, empty, and error states for content rendered inside it unless a parent truly coordinates that state.
- Loading states for visual surfaces should use skeleton placeholders scoped to the content that is actually loading, with shape, density, and dimensions close to the final UI. Avoid generic loading text or centered spinners for page sections, cards, lists, tables, forms, and drawers; reserve spinners for small inline busy indicators such as an in-progress status icon.
- Avoid prop drilling. One pass-through layer is acceptable; repeated forwarding means ownership should move down or into feature-scoped Jotai UI state. Keep server/cache state in query and API data flow.
- Do not replace prop drilling with one top-level hook that returns a large view model and then thread that object through section props. Move each hook, query, derived value, and handler to the concrete section that consumes it, or use feature-scoped Jotai atoms for simple shared form/UI state when siblings need the same source of truth.
- When using feature-scoped Jotai state for a form, drawer, or other secondary surface, scope the store to that surface instance when stale cross-instance state is possible. Initialize stable config at the owning boundary, then let descendants read only the atoms or purpose-named hooks they actually need.
- Keep callbacks in a parent only for workflow coordination such as form submission, shared selection, batch behavior, or navigation. Otherwise let the child or row own its action.
- Prefer uncontrolled DOM state and CSS variables before adding controlled props.
## Feature-Scoped Jotai State
- A module's feature-local state lives in one state file for Jotai-backed features: primitive atoms, query atoms, derived atoms, write-only action atoms, mutation atoms, submission orchestration, provider exports, and optional scope configuration.
- Atom order in the state file follows the dependency graph: types/constants, editable primitives, query atoms, query-data derived atoms, readiness/business derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
- Derived atom names read as business facts. Write atom names read as user or workflow commands.
- UI components read and write the exact atom they use with `useAtomValue` or `useSetAtom`. Repeated workflow semantics live in named derived atoms or write atoms.
- Non-query derived atoms return a narrow value with a clear domain name. Query atoms expose the TanStack Query result object so loading, error, fetch, and pagination state stay attached to the query contract.
- Write-only atoms own state transitions that update multiple primitives, reset dependent state, guard stale async work, or advance the workflow.
- `jotai-tanstack-query` atoms use the same QueryClient as the React Query provider. Query atoms belong in feature state when atoms are the feature's local state surface.
- Jotai scope is an optional instance-isolation tool for secondary surfaces with independent local state. Query atoms keep shared cache behavior through the shared QueryClient.
- A Jotai-backed feature has one feature-local state file for shared primitive atoms, query atoms, derived atoms, write-only actions, mutation atoms, submission orchestration, provider exports, and optional scope configuration.
- Keep component-owned synchronous UI state local even inside Jotai features: dialog open flags, menus/popovers, confirmations, field drafts, and selected local options usually belong in component state.
- Use uncontrolled `@langgenius/dify-ui/form` and `@langgenius/dify-ui/field` controls for edit/create forms whose fields are read only at submit time. Initialize query-backed defaults with `defaultValue` and keyed remounts.
- Promote form state to atoms only when another component must react to in-progress values, a draft must survive unmount/remount in the scoped workflow, or multiple steps share the same editable draft before submit.
- Treat `useParams`, route args, and `nuqs` query state as framework-owned state. When atom logic needs those values, hydrate primitive atoms at the route or surface boundary, such as with `useHydrateAtoms(..., { dangerouslyForceHydrate: true })`; keep URL updates in the route/query-state APIs instead of write atoms.
- Within a route-owned feature, choose one source for route identity. If route params are bridged into feature atoms, use that bridge consistently for route-derived queries and actions instead of also threading the same route id through page, tab, and section props.
- For async work tied to atom state, use `atomWithQuery` or `atomWithMutation`; write atoms should update only the inputs that drive those atoms. This applies to pure frontend async work as well as network requests, so do not hand-roll loading/error/in-flight state with `useState` or `useRef` for atom-orchestrated async behavior. For component-owned remote work, use `useQuery` or `useMutation` directly.
- Row-local async state belongs to the row owner unless it participates in a shared Jotai workflow or needs atom-scoped reset semantics.
- Leave query and mutation atoms unscoped so they keep shared QueryClient cache and invalidation behavior. Scope resettable primitives and explicit hydration tuples; scope a derived atom only when every dependency should be private to that surface.
- For scoped primitives that are always hydrated by `ScopeProvider`, prefer `atomWithLazy<T>(() => { throw new Error(...) })` when consumers should see a non-null type.
- Order state files by dependency graph: types/constants, primitives, query atoms, query-data derived atoms, business/readiness derived atoms, write actions, mutation atoms, submission orchestration, provider exports.
- Name derived atoms as business facts and write atoms as user or workflow commands. Components should read or write the exact atom they need with `useAtomValue` or `useSetAtom`.
- Menu/dialog `open` state usually stays local, but a scoped atom is acceptable when a composed menu plus secondary surface would otherwise pass confusing `open`/`onClose` props through unrelated layers. Scope that primitive with the surface instance so reset behavior stays local.
- Keep independent dialog lifecycles separate. Avoid one discriminated "current action dialog" atom when dialogs have separate open state, loading guards, or reset behavior.
## Components, Props, And Types
- Type component signatures directly; do not use `FC` or `React.FC`.
- Prefer `function` for top-level components and module helpers. Use arrow functions for local callbacks, handlers, and lambda-style APIs.
- Prefer named exports. Use default exports only where the framework requires them, such as Next.js route files.
- Avoid barrel files that only re-export secondary owners. `index.tsx` is acceptable for a route/tab entry component; import header controls, switches, sections, and row owners from their concrete owner files.
- Type simple one-off props inline. Use a named `Props` type only when reused, exported, complex, or clearer.
- Use API-generated or API-returned types at component boundaries. Keep small UI conversion helpers and one-off UI extensions beside the component that needs them.
- Do not create type aliases that only rename another type. Use an alias only when it encodes a real UI concept, refinement, or reusable local contract.
- Name values by their domain role and backend API contract, and keep that name stable across the call chain, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Keep fallback and invariant checks at the lowest component that already handles that state; avoid defensive fallbacks that mask impossible states.
- Do not extract fallback helpers whose only behavior is hiding missing display data. The component that renders the surface owns the empty, disabled, hidden, or placeholder state.
- Avoid `common.tsx` buckets for shared UI. Use a feature-local `components/` folder with concrete filenames that describe the shared role.
- Do not create type aliases that only rename another type. Use aliases only for real UI concepts, refinements, or reusable local contracts.
- Name values by their domain role and backend API contract, especially persistent IDs and route params. Normalize framework or route params at the boundary.
- Put fallback and invariant checks in the lowest component that already handles that state. Do not extract helpers whose only behavior is hiding missing display data.
## Generated API Contracts
## Generated API And Nullable Data
- Treat generated contracts as authoritative at API, query, mutation, cache, and service boundaries. For enterprise APIs, use `packages/contracts/generated/enterprise/*`.
- Do not hand-write local request/response/reply/page/cache-data types that mirror generated DTOs. Import or infer the generated type.
- Do not widen generated fields or enums for compatibility. Normalize legacy input at the boundary, then return the generated field type.
- Do not repair generated or API-returned contract fields in components unless the API contract or product requirement says they need normalization. Treat enums, statuses, and presence flags as exact contract values.
- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Do not add local enum constants or parallel frontend enum/status layers unless they model real product state not represented by the API. Presentation-only tone maps should be keyed by the generated enum.
- Normalize or coerce only at a real boundary, such as user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters. Preserve user-entered values when whitespace or formatting can be meaningful.
- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `undefined` or `null` until the final boundary that requires a string.
- Local UI models are fine for presentation, form state, select options, or guarded required-field refinements. Name them as UI concepts, not generated DTO mirrors.
- Required-value refinements are allowed only after same-branch filtering or early return. Prefer nullable-tolerant props for render-only data.
- When a component needs a stricter shape than a generated DTO, refine once at the API/query-to-UI boundary into a purpose-named UI type instead of hiding missing fields with generic fallback or coercion helpers.
## Nullable API Data
- Prefer nullable-tolerant call boundaries. Pass API-returned types through for render-only rows, and let the component render fallback, disabled state, or nothing.
- Narrow only where a real value is required, such as mutation params, route hrefs, select values, or query input. Build that target model with `flatMap`, a local loop, or an early return so the required value is captured in the same branch.
- If design says a field is the display value, use that field. Only the final component should decide whether a nullable display value renders a placeholder, hides content, or disables an action.
- Do not wrap required arrays or fields in null-fallback helpers. Use empty collection fallbacks only for not-yet-loaded query data or genuinely nullable collections at the owning render boundary.
- Do not drop rows only to satisfy props or React keys; use a stable fallback key when possible.
- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by a narrowing filter.
- Avoid truthiness type guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after those filters.
- Use type guards only for meaningful domain or runtime validation, such as enum membership, object shape, or a reusable business invariant.
- Do not hand-write DTO mirrors, widen generated fields/enums, or add parallel frontend enum/status layers unless they model product state not represented by the API.
- Use generated enum objects and union types directly in props, comparisons, status logic, and i18n keys. Presentation-only tone maps should be keyed by generated enums.
- Normalize or coerce only at real boundaries: user-entered forms, search, URL/query params, file names, DOM IDs, or legacy adapters.
- Do not coerce nullable or optional API strings to `''` in query, derived model, or payload-building code. Keep `null` or `undefined` until the final boundary requiring a string.
- Do not use `value || undefined` for mutation fields where `''` means "clear this value". Trim or normalize at the form boundary, then preserve intentional empty strings.
- Prefer nullable-tolerant render props for API-returned rows. Narrow only where a real value is required, such as mutation params, route hrefs, select values, query input, or required React keys.
- Build required values in the same branch that proves them, using `flatMap`, a local loop, or an early return. Avoid truthiness guards, `filter(Boolean)`, `filter(item => item.id)`, and `!` after filters.
- Use conditional spreads or explicit pushes for conditional array items instead of `undefined` placeholders followed by narrowing filters.
- Empty collection fallbacks are for not-yet-loaded query data or genuinely nullable collections at the owning render boundary, not for hiding required API fields.
## Queries And Mutations
- Keep `web/contract/*` as the single source of truth for API shape; follow existing domain/router patterns and the `{ params, query?, body? }` input shape.
- Consume queries directly with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename `queryOptions()` or `mutationOptions()`. Extract a small `queryOptions` helper only when repeated call-site options justify it.
- Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
- For TanStack cache data, use generated or query-derived types; do not create local wrappers for `getQueryData` or `getQueriesData`.
- For generated oRPC `queryOptions()` / `infiniteOptions()`, do not pass `skipToken` as `input`; keep a valid placeholder input shape and use `enabled` to gate missing required params because the OpenAPI codec encodes input eagerly.
- Consume mutations directly with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))`; use oRPC clients as `mutationFn` only for custom flows.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`; components may add UI feedback callbacks, but should not own shared invalidation rules.
- Keep `web/contract/*` as the API shape source of truth and follow the `{ params, query?, body? }` input shape.
- Consume generated queries with `useQuery(consoleQuery.xxx.queryOptions(...))` or `useQuery(marketplaceQuery.xxx.queryOptions(...))`.
- If a generated query input comes from an atom, including a route-identity bridge atom, keep the query in `atomWithQuery`; do not unwrap the atom in a component just to call `useQuery`.
- Consume owner-local mutations with `useMutation(consoleQuery.xxx.mutationOptions(...))` or `useMutation(marketplaceQuery.xxx.mutationOptions(...))` when pending/error state is not consumed by feature atoms.
- In `atomWithQuery`, `atomWithInfiniteQuery`, and `atomWithMutation`, return generated `queryOptions()`, `infiniteOptions()`, or `mutationOptions()` directly. Pass `enabled`, `retry`, `placeholderData`, `select`, and pagination options into the generated call instead of spreading options into a hand-built object.
- For generated oRPC options with missing required input, branch the whole input with `input: condition ? validInput : skipToken` and `enabled: Boolean(condition)`. Never place `skipToken` inside a nested placeholder payload or coerce required IDs to `''`.
- When prefetch and render use the same request, extract local query options or a query-options atom so `prefetchQuery` and `useQuery`/`atomWithQuery` share the exact options.
- For custom query or mutation functions, wrap options with TanStack `queryOptions(...)` or `mutationOptions(...)`.
- Do not extract generated `queryOptions(...)` into a helper solely to share input construction; extract only when prefetch/render must share exact options or the helper owns real domain behavior.
- Avoid pass-through hooks and thin `web/service/use-*` wrappers that only rename generated options. Keep feature hooks for real orchestration, workflow state, or shared domain behavior.
- Put shared cache behavior in `createTanstackQueryUtils(...experimental_defaults...)`. Component or atom callbacks may handle local toasts, closing dialogs, and navigation, but should not replace shared invalidation or patch shared server state locally.
- For overlays that may open heavier secondary content, prefetch from the trigger/menu open event with `queryClient.prefetchQuery(queryOptions)` when `onOpenChange` is available. Do not mount hidden subscribers just to warm cache.
- Do not use deprecated `useInvalid` or `useReset`.
- Prefer `mutate(...)`; use `mutateAsync(...)` only when Promise semantics are required, and wrap awaited calls in `try/catch`.
## Component Boundaries
## Boundaries And Overlays
- Use the first level below a page or tab to organize independent page sections when it adds real structure. This layer is layout/semantic first, not automatically the data owner.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Do not expand a child component's responsibility just because its data is useful nearby; keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
- Split deeper components by the data and state each layer actually needs. Each component should access only necessary data, and ownership should stay at the lowest consumer.
- Use the first level below a page or tab to organize independent page sections when it adds structure or the root folder becomes noisy. This layer is layout/semantic first, not automatically the data owner.
- Treat component names, semantic roles, and user- or design-marked visual regions as boundary constraints. Keep adjacent UI as a sibling owner or introduce a correctly named broader owner.
- Keep cohesive forms, menu bodies, and one-off helpers local unless they need their own state, reuse, or semantic boundary.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component that owns the trigger, open state, and hidden content when it would obscure the parent flow.
- Preserve composability by separating behavior ownership from layout ownership. A dropdown action may own its trigger, open state, and menu content; the caller owns placement such as slots, offsets, and alignment.
- Avoid unnecessary DOM hierarchy. Do not add wrapper elements unless they provide layout, semantics, accessibility, state ownership, or integration with a library API; prefer fragments or styling an existing element when possible.
- Avoid shallow wrappers, hook-to-props adapter components, layout-only render-prop wrappers, and prop renaming unless the wrapper adds validation, orchestration, error handling, state ownership, or a real semantic boundary. If a component only calls a hook and forwards every returned field to one child, move the hook into that child or make the wrapper own a real surface.
- Separate hidden secondary surfaces from the trigger's main flow. For dialogs, dropdowns, popovers, and similar branches, extract a small local component when hidden content would obscure the parent.
- Preserve composability by separating behavior ownership from placement ownership: an action can own trigger/open/menu content while the caller owns slots, offsets, and alignment.
- When a dialog, dropdown, or popover accepts controlled `open`, mount it unconditionally unless unmounting is required for performance or reset semantics. Use keyed scope or local state reset instead of `{open && <Surface />}` wrappers.
- When opening a dialog from a menu item, keep the menu and dialog as sibling surfaces. Let the menu command open the dialog, and mount the dialog outside menu popup content.
- For dialogs and alert dialogs, keep the root responsible for `open` wiring and put query/mutation hooks inside the content component when work should mount only after the overlay opens.
- Prefer uncontrolled overlay roots when the library can own open state. Use `onOpenChange` for side effects and CSS/data selectors for open-state styling.
- Avoid wrapper DOM unless it provides layout, semantics, accessibility, state ownership, or library integration. Avoid shallow wrappers, hook-to-props adapters, layout-only render props, children pass-through wrappers, and prop renaming unless they add real behavior or a real boundary.
## You Might Not Need An Effect
- Use Effects only to synchronize with external systems such as browser APIs, non-React widgets, subscriptions, timers, analytics that must run because the component was shown, or imperative DOM integration.
- Do not use Effects to transform props or state for rendering. Calculate derived values during render, and use `useMemo` only when the calculation is actually expensive.
- Do not use Effects to handle user actions. Put action-specific logic in the event handler where the cause is known.
- Do not use Effects to copy one state value into another state value representing the same concept. Pick one source of truth and derive the rest during render.
- Do not reset or adjust state from props with an Effect. Prefer a `key` reset, storing a stable ID and deriving the selected object, or guarded same-component render-time adjustment when truly necessary.
- Prefer framework data APIs or TanStack Query for data fetching instead of writing request Effects in components.
- If an Effect still seems necessary, first name the external system it synchronizes with. If there is no external system, remove the Effect and restructure the state or event flow.
## Navigation And Performance
## Effects, Navigation, And Performance
- Use Effects only to synchronize with external systems. Do not use Effects to transform props/state for rendering, handle user actions, copy state, reset state from props, or fetch data.
- For forms initialized from query data, prefer keyed remounts or surface-entry atom hydration over Effects that copy query data into form state.
- Prefer framework data APIs or TanStack Query for data fetching.
- Prefer `Link` for normal navigation. Use router APIs only for command-flow side effects such as mutation success, guarded redirects, or form submission.
- Before reaching for `memo`, first try moving changing state down to the smallest component that actually uses it so unrelated sibling trees stay untouched.
- If changing state must wrap other content, lift the unchanged content up and pass it as `children` so the stateful wrapper can update without React visiting that subtree.
- Before using `memo`, move changing state down to the smallest component that uses it. If state must wrap stable content, lift the stable content up and pass it as `children`.
- Avoid `memo`, `useMemo`, and `useCallback` unless there is a clear performance reason.
+21
View File
@@ -21,6 +21,7 @@ env:
DIFY_WEB_IMAGE_NAME: ${{ vars.DIFY_WEB_IMAGE_NAME || 'langgenius/dify-web' }}
DIFY_API_IMAGE_NAME: ${{ vars.DIFY_API_IMAGE_NAME || 'langgenius/dify-api' }}
DIFY_AGENT_IMAGE_NAME: ${{ vars.DIFY_AGENT_IMAGE_NAME || 'langgenius/dify-agent-backend' }}
DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME: ${{ vars.DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME || 'langgenius/dify-agent-local-sandbox' }}
jobs:
build:
@@ -74,6 +75,20 @@ jobs:
file: "dify-agent/Dockerfile"
platform: linux/arm64
runs_on: depot-ubuntu-24.04-4
- service_name: "build-agent-local-sandbox-amd64"
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
artifact_context: "local-sandbox"
build_context: "{{defaultContext}}:dify-agent"
file: "docker/local-sandbox/Dockerfile"
platform: linux/amd64
runs_on: depot-ubuntu-24.04-4
- service_name: "build-agent-local-sandbox-arm64"
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
artifact_context: "local-sandbox"
build_context: "{{defaultContext}}:dify-agent"
file: "docker/local-sandbox/Dockerfile"
platform: linux/arm64
runs_on: depot-ubuntu-24.04-4
steps:
- name: Prepare
@@ -139,6 +154,9 @@ jobs:
- service_name: "validate-agent-amd64"
build_context: "{{defaultContext}}"
file: "dify-agent/Dockerfile"
- service_name: "validate-agent-local-sandbox-amd64"
build_context: "{{defaultContext}}:dify-agent"
file: "docker/local-sandbox/Dockerfile"
steps:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
@@ -167,6 +185,9 @@ jobs:
- service_name: "merge-agent-images"
image_name_env: "DIFY_AGENT_IMAGE_NAME"
context: "agent"
- service_name: "merge-agent-local-sandbox-images"
image_name_env: "DIFY_AGENT_LOCAL_SANDBOX_IMAGE_NAME"
context: "local-sandbox"
steps:
- name: Download digests
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+10
View File
@@ -53,6 +53,8 @@ jobs:
- name: Run Type Checks
if: steps.changed-files.outputs.any_changed == 'true'
env:
PYREFLY_OUTPUT_FORMAT: github
run: make type-check-core
- name: Dotenv check
@@ -105,6 +107,14 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
run: vp run knip
- name: Web dead code check production
if: steps.changed-files.outputs.any_changed == 'true'
run: vp run knip:production
- name: Web production unused declarations check
if: steps.changed-files.outputs.any_changed == 'true'
run: vp run knip:production-unused-check
ts-common-style:
name: TS Common
runs-on: depot-ubuntu-24.04
+1 -1
View File
@@ -116,7 +116,7 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly
## Using Dify
- **Cloud <br/>**
We host a [Dify Cloud](https://dify.ai) service for anyone to try with zero setup. It provides all the capabilities of the self-deployed version, and includes 200 free GPT-4 calls in the sandbox plan.
We host a [Dify Cloud](https://dify.ai) service for anyone to try with zero setup. It provides all the capabilities of the self-deployed version, and includes 200 free GPT-4 calls in the sandbox plan. If you run into issues with Dify Cloud, [contact our Cloud support team](mailto:cloud@dify.ai?subject=%5BGitHub%5DDify%20Cloud%20Support).
- **Self-hosting Dify Community Edition<br/>**
Quickly get Dify running in your environment with this [starter guide](#quick-start).
+3 -1
View File
@@ -36,6 +36,9 @@ FILES_ACCESS_TIMEOUT=300
# Collaboration mode toggle
ENABLE_COLLABORATION_MODE=true
# Learn app feature toggle
ENABLE_LEARN_APP=true
# Access token expiration time in minutes
ACCESS_TOKEN_EXPIRE_MINUTES=60
@@ -768,7 +771,6 @@ EVENT_BUS_REDIS_CHANNEL_TYPE=pubsub
# Whether to use Redis cluster mode while use redis as event bus.
# It's highly recommended to enable this for large deployments.
EVENT_BUS_REDIS_USE_CLUSTERS=false
EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS=2000
# Whether to Enable human input timeout check task
ENABLE_HUMAN_INPUT_TIMEOUT_TASK=true
+17 -6
View File
@@ -78,6 +78,13 @@ def _filter_snapshot_to_specs(
return CompositorSessionSnapshot(schema_version=snapshot.schema_version, layers=filtered_layers)
def _shell_layer_deps(*, include_drive: bool) -> dict[str, str]:
deps = {"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID}
if include_drive:
deps["drive"] = DIFY_DRIVE_LAYER_ID
return deps
class AgentBackendModelConfig(BaseModel):
"""API-side model/plugin selection before it is converted to Dify Agent layers."""
@@ -263,6 +270,7 @@ class AgentBackendRunRequestBuilder:
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.drive_config,
)
@@ -329,14 +337,15 @@ class AgentBackendRunRequestBuilder:
)
if run_input.include_shell:
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
# the agent server can mint per-command Agent Stub env (back proxy);
# Sandboxed bash workspace (dify.shell). Depends on execution_context
# so the agent server can mint per-command Agent Stub env, and on
# drive when present so that env points at /mnt/drive/<drive_ref>.
# shellctl connection itself is server-injected.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
metadata=run_input.metadata,
config=run_input.shell_config or DifyShellLayerConfig(),
)
@@ -460,6 +469,7 @@ class AgentBackendRunRequestBuilder:
RunLayerSpec(
name=DIFY_DRIVE_LAYER_ID,
type=DIFY_DRIVE_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
metadata=run_input.metadata,
config=run_input.drive_config,
)
@@ -528,14 +538,15 @@ class AgentBackendRunRequestBuilder:
)
if run_input.include_shell:
# Sandboxed bash workspace (dify.shell). Depends on execution_context so
# the agent server can mint per-command Agent Stub env (back proxy);
# Sandboxed bash workspace (dify.shell). Depends on execution_context
# so the agent server can mint per-command Agent Stub env, and on
# drive when present so that env points at /mnt/drive/<drive_ref>.
# shellctl connection itself is server-injected.
layers.append(
RunLayerSpec(
name=DIFY_SHELL_LAYER_ID,
type=DIFY_SHELL_LAYER_TYPE_ID,
deps={"execution_context": DIFY_EXECUTION_CONTEXT_LAYER_ID},
deps=_shell_layer_deps(include_drive=run_input.drive_config is not None),
metadata=run_input.metadata,
config=run_input.shell_config or DifyShellLayerConfig(),
)
+2
View File
@@ -25,6 +25,7 @@ from .plugin import (
from .rbac import migrate_member_roles_to_rbac
from .retention import (
archive_workflow_runs,
archive_workflow_runs_plan,
clean_expired_messages,
clean_workflow_runs,
cleanup_orphaned_draft_variables,
@@ -51,6 +52,7 @@ from .vector import (
__all__ = [
"add_qdrant_index",
"archive_workflow_runs",
"archive_workflow_runs_plan",
"backfill_plugin_auto_upgrade",
"clean_expired_messages",
"clean_workflow_runs",
+4 -3
View File
@@ -25,7 +25,7 @@ def reset_password(email, new_password, password_confirm):
return
normalized_email = email.strip().lower()
account = AccountService.get_account_by_email_with_case_fallback(email.strip())
account = AccountService.get_account_by_email_with_case_fallback(db.session, email.strip())
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
@@ -67,7 +67,7 @@ def reset_email(email, new_email, email_confirm):
return
normalized_new_email = new_email.strip().lower()
account = AccountService.get_account_by_email_with_case_fallback(email.strip())
account = AccountService.get_account_by_email_with_case_fallback(db.session, email.strip())
if not account:
click.echo(click.style(f"Account not found for email: {email}", fg="red"))
@@ -133,8 +133,9 @@ def create_tenant(email: str, language: str | None = None, name: str | None = No
password=new_password,
language=language,
create_workspace_required=False,
session=db.session,
)
TenantService.create_owner_tenant_if_not_exist(account, name)
TenantService.create_owner_tenant_if_not_exist(account, name, session=db.session)
click.echo(
click.style(
+544 -95
View File
@@ -12,10 +12,160 @@ from services.clear_free_plan_tenant_expired_logs import ClearFreePlanTenantExpi
from services.retention.conversation.messages_clean_policy import create_message_clean_policy
from services.retention.conversation.messages_clean_service import MessagesCleanService
from services.retention.workflow_run.clear_free_plan_expired_workflow_run_logs import WorkflowRunCleanup
from services.retention.workflow_run.tenant_prefix import tenant_prefix_condition
from tasks.remove_app_and_related_data_task import delete_draft_variables_batch
logger = logging.getLogger(__name__)
_HEX_PREFIXES = tuple("0123456789abcdef")
class WorkflowRunArchivePlanRow(TypedDict):
tenant_prefix: str
total_tenants: int
workflow_runs: int
workflow_node_executions: int
paid_tenants: int
unpaid_tenants: int
class WorkflowRunArchiveTenantPlan(TypedDict):
archive_tenant_ids: list[str] | None
paid_tenant_ids: list[str]
unpaid_tenant_ids: list[str]
def _parse_tenant_prefixes(prefixes: str | None) -> list[str]:
if not prefixes:
return []
parsed = []
for raw_prefix in prefixes.split(","):
prefix = raw_prefix.strip().lower()
if not prefix:
continue
if len(prefix) != 1 or prefix not in _HEX_PREFIXES:
raise click.UsageError("--tenant-prefixes must be a comma-separated list of hex digits, e.g. 0,1,a,f.")
parsed.append(prefix)
return sorted(set(parsed))
def _get_archive_candidate_tenant_ids_by_prefix(
prefix: str,
*,
start_from: datetime.datetime | None,
end_before: datetime.datetime,
) -> list[str]:
from graphon.enums import WorkflowExecutionStatus
from models.workflow import WorkflowRun
from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver
conditions = [
WorkflowRun.created_at < end_before,
WorkflowRun.status.in_(WorkflowExecutionStatus.ended_values()),
WorkflowRun.type.in_(WorkflowRunArchiver.ARCHIVED_TYPE),
tenant_prefix_condition(WorkflowRun.tenant_id, prefix),
]
if start_from is not None:
conditions.append(WorkflowRun.created_at >= start_from)
tenant_ids = db.session.scalars(
sa.select(WorkflowRun.tenant_id).where(*conditions).distinct().order_by(WorkflowRun.tenant_id)
).all()
return list(tenant_ids)
def _filter_paid_workflow_archive_tenant_ids(tenant_ids: list[str]) -> tuple[list[str], list[str]]:
from configs import dify_config
from enums.cloud_plan import CloudPlan
from services.billing_service import BillingService
tenant_ids = sorted(set(tenant_ids))
if not tenant_ids:
return [], []
if not dify_config.BILLING_ENABLED:
return tenant_ids, []
plans = BillingService.get_plan_bulk_with_cache(tenant_ids)
paid_tenant_ids = [
tenant_id
for tenant_id in tenant_ids
if plans.get(tenant_id) and plans[tenant_id].get("plan") in (CloudPlan.PROFESSIONAL, CloudPlan.TEAM)
]
unpaid_tenant_ids = sorted(set(tenant_ids) - set(paid_tenant_ids))
return paid_tenant_ids, unpaid_tenant_ids
def _resolve_archive_tenant_ids_from_plan(
*,
tenant_ids: str | None,
tenant_prefixes: list[str],
start_from: datetime.datetime | None,
end_before: datetime.datetime,
) -> WorkflowRunArchiveTenantPlan:
"""
Resolve the archive tenant scope once before scanning workflow_runs.
Prefix rollout should use the tenant list collected by the same planning path, then archive by
tenant_id IN (...). Scanning workflow_runs with a tenant prefix range in every archive run is too expensive on
the large production table this command is meant to shrink.
"""
if tenant_ids:
requested_tenant_ids = [tid.strip() for tid in tenant_ids.split(",") if tid.strip()]
elif tenant_prefixes:
requested_tenant_ids = []
for prefix in tenant_prefixes:
requested_tenant_ids.extend(
_get_archive_candidate_tenant_ids_by_prefix(
prefix,
start_from=start_from,
end_before=end_before,
)
)
else:
return WorkflowRunArchiveTenantPlan(
archive_tenant_ids=None,
paid_tenant_ids=[],
unpaid_tenant_ids=[],
)
paid_tenant_ids, unpaid_tenant_ids = _filter_paid_workflow_archive_tenant_ids(requested_tenant_ids)
return WorkflowRunArchiveTenantPlan(
archive_tenant_ids=paid_tenant_ids,
paid_tenant_ids=paid_tenant_ids,
unpaid_tenant_ids=unpaid_tenant_ids,
)
def _resolve_archive_time_range(
*,
before_days: int,
from_days_ago: int | None,
to_days_ago: int | None,
start_from: datetime.datetime | None,
end_before: datetime.datetime | None,
) -> tuple[int, datetime.datetime | None, datetime.datetime | None]:
if (start_from is None) ^ (end_before is None):
raise click.UsageError("--start-from and --end-before must be provided together.")
if (from_days_ago is None) ^ (to_days_ago is None):
raise click.UsageError("--from-days-ago and --to-days-ago must be provided together.")
if from_days_ago is not None and to_days_ago is not None:
if start_from or end_before:
raise click.UsageError("Choose either day offsets or explicit dates, not both.")
if from_days_ago <= to_days_ago:
raise click.UsageError("--from-days-ago must be greater than --to-days-ago.")
now = datetime.datetime.now()
start_from = now - datetime.timedelta(days=from_days_ago)
end_before = now - datetime.timedelta(days=to_days_ago)
before_days = 0
if start_from and end_before and start_from >= end_before:
raise click.UsageError("--start-from must be earlier than --end-before.")
return before_days, start_from, end_before
@click.command("clear-free-plan-tenant-expired-logs", help="Clear free plan tenant expired logs.")
@click.option("--days", prompt=True, help="The days to clear free plan tenant expired logs.", default=30)
@@ -139,11 +289,143 @@ def clean_workflow_runs(
)
@click.command(
"archive-workflow-runs-plan",
help="Plan workflow run archive rollout by tenant ID first hex digit.",
)
@click.option("--before-days", default=90, show_default=True, help="Plan runs older than N days.")
@click.option(
"--from-days-ago",
default=None,
type=click.IntRange(min=0),
help="Lower bound in days ago (older). Must be paired with --to-days-ago.",
)
@click.option(
"--to-days-ago",
default=None,
type=click.IntRange(min=0),
help="Upper bound in days ago (newer). Must be paired with --from-days-ago.",
)
@click.option(
"--start-from",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
default=None,
help="Plan runs created at or after this timestamp (UTC if no timezone).",
)
@click.option(
"--end-before",
type=click.DateTime(formats=["%Y-%m-%d", "%Y-%m-%dT%H:%M:%S"]),
default=None,
help="Plan runs created before this timestamp (UTC if no timezone).",
)
@click.option(
"--include-archived",
is_flag=True,
help="Compatibility no-op for V2 bundle archive; plan counts source rows in the requested window.",
)
def archive_workflow_runs_plan(
before_days: int,
from_days_ago: int | None,
to_days_ago: int | None,
start_from: datetime.datetime | None,
end_before: datetime.datetime | None,
include_archived: bool,
):
"""
Print the 16 tenant-prefix rollout rows used to choose archive execution order.
Counts use the same workflow run eligibility as archive-workflow-runs: ended runs,
supported workflow types, and the requested created_at window. V2 bundle archive
does not maintain per-run archive logs, so this plan reports source-table volume.
"""
from graphon.enums import WorkflowExecutionStatus
from models.workflow import WorkflowNodeExecutionModel, WorkflowRun
from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver
before_days, start_from, end_before = _resolve_archive_time_range(
before_days=before_days,
from_days_ago=from_days_ago,
to_days_ago=to_days_ago,
start_from=start_from,
end_before=end_before,
)
plan_end_before = end_before or datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=before_days)
if include_archived:
click.echo(click.style("--include-archived is a no-op for V2 bundle archive plans.", fg="yellow"))
rows: list[WorkflowRunArchivePlanRow] = []
for prefix in _HEX_PREFIXES:
tenant_ids = _get_archive_candidate_tenant_ids_by_prefix(
prefix,
start_from=start_from,
end_before=plan_end_before,
)
total_tenants = len(tenant_ids)
paid_tenant_ids, unpaid_tenant_ids = _filter_paid_workflow_archive_tenant_ids(tenant_ids)
run_conditions = [
WorkflowRun.created_at < plan_end_before,
WorkflowRun.status.in_(WorkflowExecutionStatus.ended_values()),
WorkflowRun.type.in_(WorkflowRunArchiver.ARCHIVED_TYPE),
tenant_prefix_condition(WorkflowRun.tenant_id, prefix),
]
if start_from is not None:
run_conditions.append(WorkflowRun.created_at >= start_from)
workflow_runs = (
db.session.scalar(sa.select(sa.func.count()).select_from(WorkflowRun).where(*run_conditions)) or 0
)
candidate_runs = sa.select(WorkflowRun.id).where(*run_conditions).subquery()
workflow_node_executions = (
db.session.scalar(
sa.select(sa.func.count())
.select_from(WorkflowNodeExecutionModel)
.join(candidate_runs, WorkflowNodeExecutionModel.workflow_run_id == candidate_runs.c.id)
)
or 0
)
rows.append(
WorkflowRunArchivePlanRow(
tenant_prefix=prefix,
total_tenants=total_tenants,
workflow_runs=workflow_runs,
workflow_node_executions=workflow_node_executions,
paid_tenants=len(paid_tenant_ids),
unpaid_tenants=len(unpaid_tenant_ids),
)
)
click.echo(
click.style(
f"Workflow archive plan for runs before {plan_end_before.isoformat()}"
f"{f' and at/after {start_from.isoformat()}' if start_from else ''}.",
fg="white",
)
)
click.echo("tenant_prefix,total_tenants,workflow_runs,workflow_node_executions,paid_tenants,unpaid_tenants")
for row in rows:
click.echo(
f"{row['tenant_prefix']},{row['total_tenants']},{row['workflow_runs']},"
f"{row['workflow_node_executions']},{row['paid_tenants']},{row['unpaid_tenants']}"
)
ordered_rows = sorted(
rows,
key=lambda row: (row["workflow_runs"] + row["workflow_node_executions"], row["tenant_prefix"]),
)
click.echo("suggested_execution_order=" + ",".join(row["tenant_prefix"] for row in ordered_rows))
@click.command(
"archive-workflow-runs",
help="Archive workflow runs for paid plan tenants to S3-compatible storage.",
)
@click.option("--tenant-ids", default=None, help="Optional comma-separated tenant IDs for grayscale rollout.")
@click.option(
"--tenant-prefixes",
default=None,
help="Optional comma-separated tenant ID first hex digits for rollout waves, e.g. 0,1,a,f.",
)
@click.option("--before-days", default=90, show_default=True, help="Archive runs older than N days.")
@click.option(
"--from-days-ago",
@@ -169,13 +451,36 @@ def clean_workflow_runs(
default=None,
help="Archive runs created before this timestamp (UTC if no timezone).",
)
@click.option("--batch-size", default=100, show_default=True, help="Batch size for processing.")
@click.option("--workers", default=1, show_default=True, type=int, help="Concurrent workflow runs to archive.")
@click.option("--batch-size", default=100, show_default=True, help="Maximum workflow runs per archive bundle.")
@click.option(
"--workers",
default=1,
show_default=True,
type=int,
help="Reserved; bundle archive currently runs serially.",
)
@click.option(
"--run-shard-index",
default=None,
type=click.IntRange(min=0),
help="Zero-based workflow run shard index for parallel cron jobs. Must be paired with --run-shard-total.",
)
@click.option(
"--run-shard-total",
default=None,
type=click.IntRange(min=1, max=16),
help="Total workflow run shard count for parallel cron jobs. Must be paired with --run-shard-index.",
)
@click.option("--limit", default=None, type=int, help="Maximum number of runs to archive.")
@click.option("--dry-run", is_flag=True, help="Preview without archiving.")
@click.option("--delete-after-archive", is_flag=True, help="Delete runs and related data after archiving.")
@click.option(
"--delete-after-archive",
is_flag=True,
help="Not supported by bundle archive; use a separate bundle delete workflow after validation.",
)
def archive_workflow_runs(
tenant_ids: str | None,
tenant_prefixes: str | None,
before_days: int,
from_days_ago: int | None,
to_days_ago: int | None,
@@ -183,6 +488,8 @@ def archive_workflow_runs(
end_before: datetime.datetime | None,
batch_size: int,
workers: int,
run_shard_index: int | None,
run_shard_total: int | None,
limit: int | None,
dry_run: bool,
delete_after_archive: bool,
@@ -190,14 +497,19 @@ def archive_workflow_runs(
"""
Archive workflow runs for paid plan tenants older than the specified days.
This command archives the following tables to storage:
This command writes V2 tenant/month/shard archive bundles. Each bundle contains Parquet snapshots from:
- workflow_runs
- workflow_app_logs
- workflow_node_executions
- workflow_node_execution_offload
- workflow_pauses
- workflow_pause_reasons
- workflow_trigger_logs
The workflow_runs and workflow_app_logs tables are preserved for UI listing.
Source database rows are always preserved by archive. Deletion must be handled by
a separate bundle-level delete workflow after manifest, checksum, row-count, and
restore-sampling validation. In --dry-run mode, no storage or database writes
happen; the command estimates per-table Parquet bytes and object size instead.
"""
from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver
@@ -209,32 +521,58 @@ def archive_workflow_runs(
)
)
if (start_from is None) ^ (end_before is None):
click.echo(click.style("start-from and end-before must be provided together.", fg="red"))
return
if (from_days_ago is None) ^ (to_days_ago is None):
click.echo(click.style("from-days-ago and to-days-ago must be provided together.", fg="red"))
return
if from_days_ago is not None and to_days_ago is not None:
if start_from or end_before:
click.echo(click.style("Choose either day offsets or explicit dates, not both.", fg="red"))
return
if from_days_ago <= to_days_ago:
click.echo(click.style("from-days-ago must be greater than to-days-ago.", fg="red"))
return
now = datetime.datetime.now()
start_from = now - datetime.timedelta(days=from_days_ago)
end_before = now - datetime.timedelta(days=to_days_ago)
before_days = 0
if start_from and end_before and start_from >= end_before:
click.echo(click.style("start-from must be earlier than end-before.", fg="red"))
try:
before_days, start_from, end_before = _resolve_archive_time_range(
before_days=before_days,
from_days_ago=from_days_ago,
to_days_ago=to_days_ago,
start_from=start_from,
end_before=end_before,
)
parsed_tenant_prefixes = _parse_tenant_prefixes(tenant_prefixes)
except click.UsageError as e:
click.echo(click.style(e.message, fg="red"))
return
plan_end_before = end_before or datetime.datetime.now(datetime.UTC) - datetime.timedelta(days=before_days)
if workers < 1:
click.echo(click.style("workers must be at least 1.", fg="red"))
return
if (run_shard_index is None) ^ (run_shard_total is None):
click.echo(click.style("run-shard-index and run-shard-total must be provided together.", fg="red"))
return
if run_shard_index is not None and run_shard_total is not None and run_shard_index >= run_shard_total:
click.echo(click.style("run-shard-index must be less than run-shard-total.", fg="red"))
return
if delete_after_archive:
click.echo(click.style("delete-after-archive is not supported by bundle archive.", fg="red"))
return
try:
tenant_plan = _resolve_archive_tenant_ids_from_plan(
tenant_ids=tenant_ids,
tenant_prefixes=parsed_tenant_prefixes,
start_from=start_from,
end_before=plan_end_before,
)
except Exception:
logger.exception("Failed to resolve workflow archive tenant plan")
click.echo(click.style("Failed to resolve workflow archive tenant plan.", fg="red"))
return
planned_tenant_ids = tenant_plan["archive_tenant_ids"]
planned_paid_tenant_ids = tenant_plan["paid_tenant_ids"] if planned_tenant_ids is not None else None
paid_tenants = len(tenant_plan["paid_tenant_ids"])
unpaid_tenants = len(tenant_plan["unpaid_tenant_ids"])
if planned_tenant_ids is not None:
click.echo(
click.style(
f"Resolved archive tenant plan: paid_tenants={paid_tenants}, unpaid_tenants={unpaid_tenants}.",
fg="white",
)
)
if not planned_tenant_ids:
click.echo(click.style("No paid tenants matched the archive plan; nothing to archive.", fg="yellow"))
return
archiver = WorkflowRunArchiver(
days=before_days,
@@ -242,7 +580,11 @@ def archive_workflow_runs(
start_from=start_from,
end_before=end_before,
workers=workers,
tenant_ids=[tid.strip() for tid in tenant_ids.split(",")] if tenant_ids else None,
tenant_ids=planned_tenant_ids,
tenant_prefixes=parsed_tenant_prefixes,
paid_tenant_ids=planned_paid_tenant_ids,
run_shard_index=run_shard_index,
run_shard_total=run_shard_total,
limit=limit,
dry_run=dry_run,
delete_after_archive=delete_after_archive,
@@ -252,7 +594,9 @@ def archive_workflow_runs(
click.style(
f"Summary: processed={summary.total_runs_processed}, archived={summary.runs_archived}, "
f"skipped={summary.runs_skipped}, failed={summary.runs_failed}, "
f"time={summary.total_elapsed_time:.2f}s",
f"bundles_archived={summary.bundles_archived}, bundles_skipped={summary.bundles_skipped}, "
f"bundles_failed={summary.bundles_failed}, "
f"object_size_bytes={summary.total_object_size_bytes}, time={summary.total_elapsed_time:.2f}s",
fg="cyan",
)
)
@@ -268,6 +612,52 @@ def archive_workflow_runs(
)
def _echo_bundle_archive_operation_summary(summary) -> None:
status = "completed successfully" if summary.bundles_failed == 0 else "completed with failures"
fg = "green" if summary.bundles_failed == 0 else "red"
click.echo(
click.style(
f"{summary.operation} {status}. "
f"bundles_success={summary.bundles_succeeded} bundles_failed={summary.bundles_failed} "
f"runs={summary.runs_processed} rows={summary.rows_processed} "
f"archive_bytes={summary.archive_bytes} duration={summary.elapsed_time:.2f}s "
f"validation_time={summary.validation_time:.2f}s "
f"runs_per_second={summary.runs_per_second:.2f} rows_per_second={summary.rows_per_second:.2f} "
f"bytes_per_second={summary.bytes_per_second:.2f}",
fg=fg,
)
)
click.echo(click.style("table,row_count", fg="white"))
for table_name in [
"workflow_runs",
"workflow_app_logs",
"workflow_node_executions",
"workflow_node_execution_offload",
"workflow_pauses",
"workflow_pause_reasons",
"workflow_trigger_logs",
]:
click.echo(f"{table_name},{summary.table_counts.get(table_name, 0)}")
for result in summary.results:
if result.success:
click.echo(
click.style(
f" bundle={result.bundle_id} tenant={result.tenant_id} runs={result.run_count} "
f"rows={result.row_count} archive_bytes={result.archive_bytes} "
f"time={result.elapsed_time:.2f}s validation={result.validation_time:.2f}s",
fg="white",
)
)
else:
click.echo(
click.style(
f" failed bundle={result.bundle_id} tenant={result.tenant_id} "
f"object_prefix={result.object_prefix} error={result.error}",
fg="red",
)
)
@click.command(
"restore-workflow-runs",
help="Restore archived workflow runs from S3-compatible storage.",
@@ -290,8 +680,8 @@ def archive_workflow_runs(
default=None,
help="Optional upper bound (exclusive) for created_at; must be paired with --start-from.",
)
@click.option("--workers", default=1, show_default=True, type=int, help="Concurrent workflow runs to restore.")
@click.option("--limit", type=int, default=100, show_default=True, help="Maximum number of runs to restore.")
@click.option("--workers", default=1, show_default=True, type=int, help="V1 --run-id compatibility only.")
@click.option("--limit", type=int, default=100, show_default=True, help="Maximum number of V2 bundles to restore.")
@click.option("--dry-run", is_flag=True, help="Preview without restoring.")
def restore_workflow_runs(
tenant_ids: str | None,
@@ -303,15 +693,18 @@ def restore_workflow_runs(
dry_run: bool,
):
"""
Restore an archived workflow run from storage to the database.
Restore archived workflow runs from storage to the database.
This restores the following tables:
Batch restore uses V2 bundle metadata and validates archive objects before writing source rows. This restores:
- workflow_runs
- workflow_app_logs
- workflow_node_executions
- workflow_node_execution_offload
- workflow_pauses
- workflow_pause_reasons
- workflow_trigger_logs
"""
from services.retention.workflow_run.bundle_archive_maintenance import WorkflowRunBundleArchiveMaintenance
from services.retention.workflow_run.restore_archived_workflow_run import WorkflowRunRestore
parsed_tenant_ids = None
@@ -335,39 +728,46 @@ def restore_workflow_runs(
)
)
restorer = WorkflowRunRestore(dry_run=dry_run, workers=workers)
if run_id:
restorer = WorkflowRunRestore(dry_run=dry_run, workers=workers)
results = [restorer.restore_by_run_id(run_id)]
else:
assert start_from is not None
assert end_before is not None
results = restorer.restore_batch(
parsed_tenant_ids,
start_date=start_from,
end_date=end_before,
limit=limit,
)
end_time = datetime.datetime.now(datetime.UTC)
elapsed = end_time - start_time
end_time = datetime.datetime.now(datetime.UTC)
elapsed = end_time - start_time
successes = sum(1 for result in results if result.success)
failures = len(results) - successes
successes = sum(1 for result in results if result.success)
failures = len(results) - successes
if failures == 0:
click.echo(
click.style(
f"Restore completed successfully. success={successes} duration={elapsed}",
fg="green",
if failures == 0:
click.echo(
click.style(
f"Restore completed successfully. success={successes} duration={elapsed}",
fg="green",
)
)
)
else:
click.echo(
click.style(
f"Restore completed with failures. success={successes} failed={failures} duration={elapsed}",
fg="red",
else:
click.echo(
click.style(
f"Restore completed with failures. success={successes} failed={failures} duration={elapsed}",
fg="red",
)
)
return
if workers != 1:
click.echo(
click.style("--workers is ignored for V2 bundle restore; bundles are processed serially.", fg="yellow")
)
assert start_from is not None
assert end_before is not None
bundle_restorer = WorkflowRunBundleArchiveMaintenance(dry_run=dry_run, strict_content_validation=True)
summary = bundle_restorer.restore_batch(
tenant_ids=parsed_tenant_ids,
start_date=start_from,
end_date=end_before,
limit=limit,
)
_echo_bundle_archive_operation_summary(summary)
return
@click.command(
@@ -392,8 +792,20 @@ def restore_workflow_runs(
default=None,
help="Optional upper bound (exclusive) for created_at; must be paired with --start-from.",
)
@click.option("--limit", type=int, default=100, show_default=True, help="Maximum number of runs to delete.")
@click.option("--limit", type=int, default=100, show_default=True, help="Maximum number of V2 bundles to delete.")
@click.option("--dry-run", is_flag=True, help="Preview without deleting.")
@click.option(
"--skip-bad-archives",
is_flag=True,
help="Continue batch deletion when one archive object fails validation.",
)
@click.option(
"--restore-sample-interval",
type=int,
default=0,
show_default=True,
help="Run restore dry-run after every N successful deletes; 0 disables restore sampling.",
)
def delete_archived_workflow_runs(
tenant_ids: str | None,
run_id: str | None,
@@ -401,10 +813,16 @@ def delete_archived_workflow_runs(
end_before: datetime.datetime | None,
limit: int,
dry_run: bool,
skip_bad_archives: bool,
restore_sample_interval: int,
):
"""
Delete archived workflow runs from the database.
Batch delete uses V2 bundle metadata and validates object existence, manifest schema, object size, checksum, row
counts, and source/archive content checksums before deleting source rows. `--run-id` keeps the V1 per-run path.
"""
from services.retention.workflow_run.bundle_archive_maintenance import WorkflowRunBundleArchiveMaintenance
from services.retention.workflow_run.delete_archived_workflow_run import ArchivedWorkflowRunDeletion
parsed_tenant_ids = None
@@ -417,6 +835,8 @@ def delete_archived_workflow_runs(
raise click.UsageError("--start-from and --end-before must be provided together.")
if run_id is None and (start_from is None or end_before is None):
raise click.UsageError("--start-from and --end-before are required for batch delete.")
if restore_sample_interval < 0:
raise click.BadParameter("restore-sample-interval must be >= 0")
start_time = datetime.datetime.now(datetime.UTC)
target_desc = f"workflow run {run_id}" if run_id else "workflow runs"
@@ -427,56 +847,85 @@ def delete_archived_workflow_runs(
)
)
deleter = ArchivedWorkflowRunDeletion(dry_run=dry_run)
if run_id:
results = [deleter.delete_by_run_id(run_id)]
else:
assert start_from is not None
assert end_before is not None
results = deleter.delete_batch(
parsed_tenant_ids,
start_date=start_from,
end_date=end_before,
limit=limit,
deleter = ArchivedWorkflowRunDeletion(
dry_run=dry_run,
skip_bad_archives=skip_bad_archives,
restore_sample_interval=restore_sample_interval,
)
results = [deleter.delete_by_run_id(run_id)]
for result in results:
if result.success:
click.echo(
click.style(
f"{'[DRY RUN] Would delete' if dry_run else 'Deleted'} "
f"workflow run {result.run_id} (tenant={result.tenant_id}, "
f"archive_key={result.archive_key}, counts={result.validated_counts})",
fg="green",
)
)
if result.restore_sampled:
sample_status = "passed" if result.restore_sample_success else "failed"
click.echo(
click.style(
f" restore dry-run sample {sample_status} for workflow run {result.run_id}",
fg="green" if result.restore_sample_success else "red",
)
)
else:
click.echo(
click.style(
f"Failed to delete workflow run {result.run_id}: {result.error}",
fg="red",
)
)
click.echo(
click.style(
" runbook: pause this delete window, verify archive storage object and manifest/checksum, "
"retry the same run after fixing storage or DB drift, or rerun with --skip-bad-archives "
"to quarantine this run and continue the batch.",
fg="yellow",
)
)
for result in results:
if result.success:
end_time = datetime.datetime.now(datetime.UTC)
elapsed = end_time - start_time
successes = sum(1 for result in results if result.success)
failures = len(results) - successes
if failures == 0:
click.echo(
click.style(
f"{'[DRY RUN] Would delete' if dry_run else 'Deleted'} "
f"workflow run {result.run_id} (tenant={result.tenant_id})",
f"Delete completed successfully. success={successes} duration={elapsed}",
fg="green",
)
)
else:
click.echo(
click.style(
f"Failed to delete workflow run {result.run_id}: {result.error}",
f"Delete completed with failures. success={successes} failed={failures} duration={elapsed}",
fg="red",
)
)
return
end_time = datetime.datetime.now(datetime.UTC)
elapsed = end_time - start_time
successes = sum(1 for result in results if result.success)
failures = len(results) - successes
if failures == 0:
click.echo(
click.style(
f"Delete completed successfully. success={successes} duration={elapsed}",
fg="green",
)
)
else:
click.echo(
click.style(
f"Delete completed with failures. success={successes} failed={failures} duration={elapsed}",
fg="red",
)
)
if restore_sample_interval:
click.echo(click.style("--restore-sample-interval is ignored for V2 bundle delete.", fg="yellow"))
assert start_from is not None
assert end_before is not None
bundle_deleter = WorkflowRunBundleArchiveMaintenance(
dry_run=dry_run,
strict_content_validation=True,
stop_on_error=not skip_bad_archives,
)
summary = bundle_deleter.delete_batch(
tenant_ids=parsed_tenant_ids,
start_date=start_from,
end_date=end_before,
limit=limit,
)
_echo_bundle_archive_operation_summary(summary)
def _find_orphaned_draft_variables(batch_size: int = 1000) -> list[str]:
+12
View File
@@ -1073,6 +1073,12 @@ class MailConfig(BaseSettings):
default=None,
)
class HomepageConfig(BaseSettings):
"""
Configuration for homepage feature toggles exposed through system features.
"""
ENABLE_TRIAL_APP: bool = Field(
description="Enable trial app",
default=False,
@@ -1083,6 +1089,11 @@ class MailConfig(BaseSettings):
default=False,
)
ENABLE_LEARN_APP: bool = Field(
description="Enable Learn App",
default=True,
)
class RagEtlConfig(BaseSettings):
"""
@@ -1489,6 +1500,7 @@ class FeatureConfig(
EndpointConfig,
FileAccessConfig,
FileUploadConfig,
HomepageConfig,
HttpConfig,
InnerAPIConfig,
IndexingConfig,
-19
View File
@@ -2,7 +2,6 @@ from typing import Literal, Protocol, cast
from urllib.parse import quote_plus, urlunparse
from pydantic import AliasChoices, Field
from pydantic.types import NonNegativeInt
from pydantic_settings import BaseSettings
@@ -71,24 +70,6 @@ class RedisPubSubConfig(BaseSettings):
default=600,
)
PUBSUB_LISTENER_JOIN_TIMEOUT_MS: NonNegativeInt = Field(
validation_alias=AliasChoices("EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS", "PUBSUB_LISTENER_JOIN_TIMEOUT_MS"),
description=(
"Maximum time (milliseconds) that ``Subscription.close()`` waits for its listener thread to "
"finish before returning. Bounds the tail latency between a terminal event being delivered to "
"an SSE client and the response stream actually closing.\n\n"
"The listener thread blocks on a polling read (XREAD BLOCK for streams, get_message timeout "
"for pubsub/sharded) with a fixed 1s window, so close() naturally has to wait up to ~1s for "
"the thread to notice the subscription was closed. Setting this lower (e.g. 100) lets close() "
"return promptly while the daemon listener thread cleans itself up on the next poll "
"boundary - safe because the listener holds no critical state and exits within one poll "
"window. Setting it higher (e.g. 5000) gives the listener more grace before close() gives up "
"and logs a warning. Default 2000ms preserves the pre-change behaviour.\n\n"
"Also accepts ENV: EVENT_BUS_LISTENER_JOIN_TIMEOUT_MS."
),
default=2000,
)
def _build_default_pubsub_url(self) -> str:
defaults = _redis_defaults(self)
if not defaults.REDIS_HOST or not defaults.REDIS_PORT:
+36 -4
View File
@@ -28,9 +28,9 @@ from libs.login import login_required
from models.model import App, AppMode
from services.agent.composer_service import AgentComposerService
from services.agent.composer_validator import ComposerConfigValidator
from services.entities.agent_entities import ComposerSavePayload
from services.entities.agent_entities import ComposerSavePayload, WorkflowComposerCopyFromRosterPayload
register_schema_models(console_ns, ComposerSavePayload)
register_schema_models(console_ns, ComposerSavePayload, WorkflowComposerCopyFromRosterPayload)
register_response_schema_models(
console_ns,
AgentAppComposerResponse,
@@ -91,6 +91,38 @@ class WorkflowAgentComposerApi(Resource):
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/copy-from-roster")
class WorkflowAgentComposerCopyFromRosterApi(Resource):
@console_ns.expect(console_ns.models[WorkflowComposerCopyFromRosterPayload.__name__])
@console_ns.response(
200,
"Workflow roster agent copied to inline agent",
console_ns.models[WorkflowAgentComposerResponse.__name__],
)
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_user_id
@with_current_tenant_id
def post(self, tenant_id: str, account_id: str, app_model: App, node_id: str):
payload = WorkflowComposerCopyFromRosterPayload.model_validate(console_ns.payload or {})
return dump_response(
WorkflowAgentComposerResponse,
AgentComposerService.copy_workflow_composer_from_roster(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
account_id=account_id,
source_agent_id=payload.source_agent_id,
source_snapshot_id=payload.source_snapshot_id,
idempotency_key=payload.idempotency_key,
),
)
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/validate")
class WorkflowAgentComposerValidateApi(Resource):
@console_ns.expect(console_ns.models[ComposerSavePayload.__name__])
@@ -104,7 +136,7 @@ class WorkflowAgentComposerValidateApi(Resource):
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
ComposerConfigValidator.validate_publish_payload(payload)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
@@ -238,7 +270,7 @@ class AgentComposerValidateApi(Resource):
def post(self, tenant_id: str, agent_id: UUID):
_resolve_agent_app_id(tenant_id=tenant_id, agent_id=agent_id)
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
ComposerConfigValidator.validate_publish_payload(payload)
findings = AgentComposerService.collect_validation_findings(
tenant_id=tenant_id,
payload=payload,
+211 -16
View File
@@ -3,16 +3,17 @@ from uuid import UUID
from flask import abort, request
from flask_restx import Resource
from pydantic import AliasChoices, BaseModel, Field, field_validator
from sqlalchemy import func, select
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.agent.app_helpers import resolve_agent_app_model
from controllers.console.apikey import ApiKeyItem, ApiKeyList, BaseApiKeyListResource, BaseApiKeyResource
from controllers.console.app.app import (
AppDetailWithSite as GenericAppDetailWithSite,
)
from controllers.console.app.app import (
AppListQuery,
CopyAppPayload,
_normalize_app_list_query_args,
)
from controllers.console.app.app import (
@@ -25,9 +26,13 @@ from controllers.console.app.app import (
UpdateAppPayload as GenericUpdateAppPayload,
)
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
enterprise_license_required,
is_admin_or_owner_required,
rbac_permission_required,
setup_required,
with_current_tenant_id,
with_current_user,
@@ -49,7 +54,8 @@ from libs.datetime_utils import parse_time_range
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.model import IconType
from models.enums import ApiTokenType
from models.model import ApiToken, App, IconType
from services.agent.errors import AgentNotFoundError
from services.agent.observability_service import (
AgentLogQueryParams,
@@ -103,6 +109,46 @@ class AgentAppUpdatePayload(GenericUpdateAppPayload):
return role
class AgentAppCopyPayload(BaseModel):
name: str | None = Field(default=None, description="Name for the copied agent")
description: str | None = Field(default=None, description="Description for the copied agent", max_length=400)
role: str | None = Field(default=None, description="Role for the copied agent", max_length=255)
icon_type: IconType | None = Field(default=None, description="Icon type")
icon: str | None = Field(default=None, description="Icon")
icon_background: str | None = Field(default=None, description="Icon background color")
@field_validator("role")
@classmethod
def validate_role(cls, value: str | None) -> str | None:
if value is None:
return None
role = value.strip()
if not role:
raise ValueError("Agent role is required when provided.")
return role
class AgentApiStatusPayload(BaseModel):
enable_api: bool = Field(..., description="Enable or disable Agent service API")
class AgentApiAccessResponse(BaseModel):
enabled: bool
service_api_base_url: str
streaming_only: bool = True
chat_endpoint: str
stop_endpoint: str
conversations_endpoint: str
messages_endpoint: str
files_upload_endpoint: str
parameters_endpoint: str
info_endpoint: str
meta_endpoint: str
api_rpm: int
api_rph: int
api_key_count: int
class AgentAppPublishedReferenceResponse(BaseModel):
app_id: str
app_name: str
@@ -200,6 +246,10 @@ class AgentAppDetailWithSite(GenericAppDetailWithSite):
active_config_is_published: bool = False
class AgentDebugConversationRefreshResponse(BaseModel):
debug_conversation_id: str
class AgentAppPagination(GenericAppPagination):
data: list[AgentAppPartial] = Field( # type: ignore[assignment] # pyrefly: ignore[bad-override-mutable-attribute]
validation_alias=AliasChoices("items", "data")
@@ -210,7 +260,8 @@ register_schema_models(
console_ns,
AgentAppCreatePayload,
AgentAppUpdatePayload,
CopyAppPayload,
AgentAppCopyPayload,
AgentApiStatusPayload,
AgentInviteOptionsQuery,
AgentLogsQuery,
AgentStatisticsQuery,
@@ -221,9 +272,11 @@ register_schema_models(
register_response_schema_models(
console_ns,
AgentAppPagination,
AgentApiAccessResponse,
AgentAppPublishedReferenceResponse,
AgentAppDetailWithSite,
AgentAppPartial,
AgentDebugConversationRefreshResponse,
AgentConfigSnapshotDetailResponse,
AgentConfigSnapshotListResponse,
AgentConfigSnapshotRestoreResponse,
@@ -241,7 +294,7 @@ def _agent_roster_service() -> AgentRosterService:
return AgentRosterService(db.session)
def _serialize_agent_app_detail(app_model) -> dict:
def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict:
"""Serialize an Agent App detail using roster-only DTOs.
`/agent` responses are roster-shaped rather than raw app-shaped: `id`
@@ -264,7 +317,11 @@ def _serialize_agent_app_detail(app_model) -> dict:
payload.pop("bound_agent_id", None)
payload["app_id"] = str(app_model.id)
payload["id"] = agent.id
payload["debug_conversation_id"] = agent.debug_conversation_id
payload["debug_conversation_id"] = roster_service.get_or_create_agent_app_debug_conversation_id(
tenant_id=app_model.tenant_id,
agent_id=agent.id,
account_id=current_user.id,
)
payload["role"] = agent.role or ""
payload["active_config_is_published"] = roster_service.active_config_is_published(
tenant_id=app_model.tenant_id,
@@ -273,7 +330,7 @@ def _serialize_agent_app_detail(app_model) -> dict:
return payload
def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str, current_user: Account) -> dict:
"""Serialize Agent App lists with roster-shaped items.
Each item starts from the shared App list shape, then drops
@@ -296,6 +353,11 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents_by_app_id.values()],
)
debug_conversation_ids_by_agent_id = roster_service.load_or_create_agent_app_debug_conversation_ids_by_agent_id(
tenant_id=tenant_id,
agents=list(agents_by_app_id.values()),
account_id=current_user.id,
)
payload = AgentAppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json")
for item in payload["data"]:
app_id = item["id"]
@@ -304,7 +366,7 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict:
if agent:
item["app_id"] = app_id
item["id"] = agent.id
item["debug_conversation_id"] = agent.debug_conversation_id
item["debug_conversation_id"] = debug_conversation_ids_by_agent_id.get(agent.id)
item["role"] = agent.role or ""
item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False)
published_references = published_references_by_agent_id.get(agent.id, [])
@@ -329,6 +391,38 @@ def _resolve_agent_app_model(*, tenant_id: str, agent_id: UUID):
return resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
def _agent_api_key_count(app_id: str) -> int:
return (
db.session.scalar(
select(func.count(ApiToken.id)).where(
ApiToken.type == ApiTokenType.APP,
ApiToken.app_id == app_id,
)
)
or 0
)
def _serialize_agent_api_access(app_model: App) -> dict:
base_url = app_model.api_base_url
response = AgentApiAccessResponse(
enabled=bool(app_model.enable_api),
service_api_base_url=base_url,
chat_endpoint=f"{base_url}/chat-messages",
stop_endpoint=f"{base_url}/chat-messages/{{task_id}}/stop",
conversations_endpoint=f"{base_url}/conversations",
messages_endpoint=f"{base_url}/messages",
files_upload_endpoint=f"{base_url}/files/upload",
parameters_endpoint=f"{base_url}/parameters",
info_endpoint=f"{base_url}/info",
meta_endpoint=f"{base_url}/meta",
api_rpm=app_model.api_rpm or 0,
api_rph=app_model.api_rph or 0,
api_key_count=_agent_api_key_count(str(app_model.id)),
)
return response.model_dump(mode="json")
def _agent_observability_service() -> AgentObservabilityService:
return AgentObservabilityService(db.session)
@@ -380,7 +474,11 @@ class AgentAppListApi(Resource):
empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[])
return empty.model_dump(mode="json")
return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id)
return _serialize_agent_app_pagination(
app_pagination,
tenant_id=current_tenant_id,
current_user=current_user,
)
@console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__])
@console_ns.response(201, "Agent app created successfully", console_ns.models[AgentAppDetailWithSite.__name__])
@@ -405,7 +503,7 @@ class AgentAppListApi(Resource):
)
app = AppService().create_app(current_tenant_id, params, current_user)
return _serialize_agent_app_detail(app), 201
return _serialize_agent_app_detail(app, current_user=current_user), 201
@console_ns.route("/agent/<uuid:agent_id>")
@@ -415,10 +513,11 @@ class AgentAppApi(Resource):
@login_required
@account_initialization_required
@enterprise_license_required
@with_current_user
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
def get(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _serialize_agent_app_detail(app_model)
return _serialize_agent_app_detail(app_model, current_user=current_user)
@console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__])
@console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__])
@@ -428,8 +527,9 @@ class AgentAppApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def put(self, tenant_id: str, agent_id: UUID):
def put(self, tenant_id: str, current_user: Account, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
args = AgentAppUpdatePayload.model_validate(console_ns.payload)
args_dict: AppService.ArgsDict = {
@@ -443,7 +543,7 @@ class AgentAppApi(Resource):
"role": args.role,
}
updated = AppService().update_app(app_model, args_dict)
return _serialize_agent_app_detail(updated)
return _serialize_agent_app_detail(updated, current_user=current_user)
@console_ns.response(204, "Agent app deleted successfully")
@console_ns.response(403, "Insufficient permissions")
@@ -458,9 +558,34 @@ class AgentAppApi(Resource):
return "", 204
@console_ns.route("/agent/<uuid:agent_id>/debug-conversation/refresh")
class AgentDebugConversationRefreshApi(Resource):
@console_ns.response(
200,
"Agent debug conversation refreshed",
console_ns.models[AgentDebugConversationRefreshResponse.__name__],
)
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
debug_conversation_id = _agent_roster_service().refresh_agent_app_debug_conversation_id(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
)
return AgentDebugConversationRefreshResponse(debug_conversation_id=debug_conversation_id).model_dump(
mode="json"
)
@console_ns.route("/agent/<uuid:agent_id>/copy")
class AgentAppCopyApi(Resource):
@console_ns.expect(console_ns.models[CopyAppPayload.__name__])
@console_ns.expect(console_ns.models[AgentAppCopyPayload.__name__])
@console_ns.response(201, "Agent app copied successfully", console_ns.models[AgentAppDetailWithSite.__name__])
@console_ns.response(403, "Insufficient permissions")
@console_ns.response(400, "Invalid request parameters")
@@ -471,18 +596,88 @@ class AgentAppCopyApi(Resource):
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, current_user: Account, agent_id: UUID):
args = CopyAppPayload.model_validate(console_ns.payload or {})
args = AgentAppCopyPayload.model_validate(console_ns.payload or {})
copied_app = _agent_roster_service().duplicate_agent_app(
tenant_id=tenant_id,
agent_id=str(agent_id),
account=current_user,
name=args.name,
description=args.description,
role=args.role,
icon_type=args.icon_type,
icon=args.icon,
icon_background=args.icon_background,
)
return _serialize_agent_app_detail(copied_app), 201
return _serialize_agent_app_detail(copied_app, current_user=current_user), 201
@console_ns.route("/agent/<uuid:agent_id>/api-access")
class AgentApiAccessApi(Resource):
@console_ns.response(200, "Agent service API access", console_ns.models[AgentApiAccessResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return _serialize_agent_api_access(app_model)
@console_ns.route("/agent/<uuid:agent_id>/api-enable")
class AgentApiStatusApi(Resource):
@console_ns.expect(console_ns.models[AgentApiStatusPayload.__name__])
@console_ns.response(200, "Agent service API status updated", console_ns.models[AgentApiAccessResponse.__name__])
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@is_admin_or_owner_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
@with_current_tenant_id
def post(self, tenant_id: str, agent_id: UUID):
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
args = AgentApiStatusPayload.model_validate(console_ns.payload)
app_model = AppService().update_app_api_status(app_model, args.enable_api)
return _serialize_agent_api_access(app_model)
@console_ns.route("/agent/<uuid:agent_id>/api-keys")
class AgentApiKeyListApi(BaseApiKeyListResource):
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
token_prefix = "app-"
@console_ns.response(200, "Agent service API keys", console_ns.models[ApiKeyList.__name__])
@with_current_tenant_id
def get(self, tenant_id: str, agent_id: UUID) -> dict[str, object]:
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return dump_response(ApiKeyList, self._get_api_key_list(str(app_model.id), tenant_id))
@console_ns.response(201, "Agent service API key created", console_ns.models[ApiKeyItem.__name__])
@console_ns.response(400, "Maximum keys exceeded")
@with_current_tenant_id
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
def post(self, tenant_id: str, agent_id: UUID) -> tuple[dict[str, object], int]:
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
return dump_response(ApiKeyItem, self._create_api_key(str(app_model.id), tenant_id)), 201
@console_ns.route("/agent/<uuid:agent_id>/api-keys/<uuid:api_key_id>")
class AgentApiKeyApi(BaseApiKeyResource):
resource_type = ApiTokenType.APP
resource_model = App
resource_id_field = "app_id"
@console_ns.response(204, "Agent service API key deleted")
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_RELEASE_AND_VERSION)
def delete(self, tenant_id: str, current_user: Account, agent_id: UUID, api_key_id: UUID) -> tuple[str, int]:
app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id)
self._delete_api_key(str(app_model.id), str(api_key_id), tenant_id, current_user)
return "", 204
@console_ns.route("/agent/invite-options")
+31 -58
View File
@@ -1,4 +1,3 @@
import logging
from typing import Any
from uuid import UUID
@@ -30,7 +29,6 @@ from fields.base import ResponseModel
from libs.helper import uuid_value
from libs.login import login_required
from models import Account
from models.agent_config_entities import AgentFileRefConfig, AgentSkillRefConfig
from models.model import App, AppMode, UploadFile
from services.agent.composer_service import AgentComposerService
from services.agent.skill_package_service import SkillManifest, SkillPackageError
@@ -49,8 +47,6 @@ from services.agent_drive_service import (
)
from services.agent_service import AgentService
logger = logging.getLogger(__name__)
_WORKFLOW_AGENT_DRIVE_APP_MODES = [AppMode.WORKFLOW, AppMode.ADVANCED_CHAT]
_AGENT_SKILL_UPLOAD_PARAMS = {
"file": {
@@ -130,8 +126,16 @@ class AgentLogResponse(ResponseModel):
files: list[Any] = Field(default_factory=list)
class AgentUploadedSkillResponse(ResponseModel):
name: str
description: str
path: str
skill_md_key: str
archive_key: str | None = None
class AgentSkillUploadResponse(ResponseModel):
skill: AgentSkillRefConfig
skill: AgentUploadedSkillResponse
manifest: SkillManifest
@@ -145,13 +149,11 @@ class AgentDriveFileResponse(ResponseModel):
class AgentDriveFileCommitResponse(ResponseModel):
file: AgentDriveFileResponse
config_version_id: str | None = None
class AgentDriveDeleteResponse(ResponseModel):
result: str
removed_keys: list[str] = Field(default_factory=list)
config_version_id: str | None = None
register_schema_models(console_ns, AgentLogQuery, AgentDriveFilePayload, AgentDriveDeleteFileByAgentQuery)
@@ -161,6 +163,7 @@ register_response_schema_models(
AgentDriveFileCommitResponse,
AgentDriveFileResponse,
AgentLogResponse,
AgentUploadedSkillResponse,
AgentSkillUploadResponse,
SkillToolInferenceResult,
)
@@ -242,24 +245,6 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
return {"code": exc.code, "message": exc.message}, exc.status_code
row = committed[0]
file_ref = AgentFileRefConfig.model_validate(
{
"id": row["key"],
"name": upload_file.name,
"file_id": upload_file.id,
"drive_key": row["key"],
"type": row.get("mime_type"),
"size": row.get("size"),
}
)
config_version_id = AgentComposerService.add_drive_file_ref(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
file_ref=file_ref,
app_id=app_model.id,
node_id=node_id,
)
return {
"file": {
"name": upload_file.name,
@@ -268,7 +253,6 @@ def _commit_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
"size": row.get("size"),
"mime_type": row.get("mime_type"),
},
"config_version_id": config_version_id,
}, 201
@@ -283,24 +267,17 @@ def _delete_drive_file_for_app(*, current_user: Account, app_model: App, allow_n
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
config_version_id = AgentComposerService.remove_drive_refs(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
file_key=key,
app_id=app_model.id,
node_id=node_id,
)
removed_keys: list[str] = []
try:
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, key=key)
result = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[DriveCommitItem(key=key, file_ref=None)],
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
except Exception:
# Soul-first ordering: the ref is already gone; orphan KV rows are
# harmless and an idempotent DELETE retry cleans them.
logger.exception("agent drive delete failed for key %s (soul already updated)", key)
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
removed_keys = [item["key"] for item in result if item.get("removed")]
return {"result": "success", "removed_keys": removed_keys}
def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, allow_node_id: bool = True):
@@ -312,22 +289,20 @@ def _delete_skill_for_app(*, current_user: Account, app_model: App, slug: str, a
if "/" in slug or not slug.strip():
return {"code": "drive_key_invalid", "message": "skill slug must be a single path segment"}, 400
config_version_id = AgentComposerService.remove_drive_refs(
tenant_id=app_model.tenant_id,
agent_id=agent_id,
account_id=current_user.id,
skill_slug=slug,
app_id=app_model.id,
node_id=node_id,
)
removed_keys: list[str] = []
try:
removed_keys = AgentDriveService().delete(tenant_id=app_model.tenant_id, agent_id=agent_id, prefix=f"{slug}/")
result = AgentDriveService().commit(
tenant_id=app_model.tenant_id,
user_id=current_user.id,
agent_id=agent_id,
items=[
DriveCommitItem(key=f"{slug}/SKILL.md", file_ref=None),
DriveCommitItem(key=f"{slug}/.DIFY-SKILL-FULL.zip", file_ref=None),
],
)
except AgentDriveError as exc:
return {"code": exc.code, "message": exc.message}, exc.status_code
except Exception:
logger.exception("agent drive delete failed for skill %s (soul already updated)", slug)
return {"result": "success", "removed_keys": removed_keys, "config_version_id": config_version_id}
removed_keys = [item["key"] for item in result if item.get("removed")]
return {"result": "success", "removed_keys": removed_keys}
def _infer_skill_tools_for_app(*, app_model: App, slug: str):
@@ -455,7 +430,7 @@ class AgentDriveFilesApi(Resource):
return _commit_drive_file_for_app(current_user=current_user, app_model=app_model)
@console_ns.doc("delete_agent_drive_file")
@console_ns.doc(description="Delete one drive file by key; soul ref first, then the KV row (ENG-625 D5)")
@console_ns.doc(description="Delete one drive file by key via drive commit-null semantics")
@console_ns.doc(params={"app_id": "Application ID", **query_params_from_model(AgentDriveDeleteFileQuery)})
@console_ns.response(200, "File removed", console_ns.models[AgentDriveDeleteResponse.__name__])
@setup_required
@@ -486,9 +461,7 @@ class AgentSkillByAgentApi(Resource):
@console_ns.route("/apps/<uuid:app_id>/agent/skills/<string:slug>")
class AgentSkillApi(Resource):
@console_ns.doc("delete_agent_skill")
@console_ns.doc(
description="Delete a standardized skill: soul ref first, then the <slug>/ drive prefix (ENG-625 D5)"
)
@console_ns.doc(description="Delete a standardized skill by removing its known drive keys via commit-null")
@console_ns.doc(
params={
"app_id": "Application ID",
@@ -91,7 +91,10 @@ class AgentAppFeatureConfigResource(Resource):
args = AgentAppFeaturesPayload.model_validate(console_ns.payload or {})
new_app_model_config = AgentAppFeatureConfigService.update_features(
app_model=app_model, account=current_user, config=args.model_dump(exclude_none=True), session=db.session
app_model=app_model,
account=current_user,
config=args.model_dump(exclude_none=True),
session=db.session,
)
app_model_config_was_updated.send(app_model, app_model_config=new_app_model_config)
+5 -2
View File
@@ -19,6 +19,7 @@ from controllers.console.wraps import (
rbac_permission_required,
setup_required,
)
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.annotation_fields import (
Annotation,
@@ -388,7 +389,9 @@ class AnnotationUpdateDeleteApi(Resource):
update_args["answer"] = args.answer
if args.question is not None:
update_args["question"] = args.question
annotation = AppAnnotationService.update_app_annotation_directly(update_args, str(app_id), str(annotation_id))
annotation = AppAnnotationService.update_app_annotation_directly(
update_args, str(app_id), str(annotation_id), db.session
)
return Annotation.model_validate(annotation, from_attributes=True).model_dump(mode="json")
@setup_required
@@ -398,7 +401,7 @@ class AnnotationUpdateDeleteApi(Resource):
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_EDIT)
@console_ns.response(204, "Annotation deleted successfully")
def delete(self, app_id: UUID, annotation_id: UUID):
AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id))
AppAnnotationService.delete_app_annotation(str(app_id), str(annotation_id), db.session)
return "", 204
+1 -1
View File
@@ -1094,7 +1094,7 @@ class AppTraceApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
@get_app_model
def post(self, app_model: App):
# add app trace
+2
View File
@@ -30,6 +30,7 @@ from controllers.console.wraps import (
setup_required,
)
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import login_required
from models import App, AppMode
@@ -142,6 +143,7 @@ class ChatMessageTextApi(Resource):
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session,
text=payload.text,
voice=payload.voice,
message_id=payload.message_id,
+47 -4
View File
@@ -40,12 +40,15 @@ from core.errors.error import (
QuotaExceededError,
)
from core.helper.trace_id_helper import get_external_trace_id
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from libs.helper import uuid_value
from libs.login import login_required
from models import Account
from models.model import App, AppMode
from services.agent.errors import AgentNotFoundError
from services.agent.roster_service import AgentRosterService
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.errors.llm import InvokeRateLimitError
@@ -191,10 +194,11 @@ class ChatMessageApi(Resource):
@account_initialization_required
@edit_permission_required
@with_current_user
@with_current_tenant_id
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT])
def post(self, current_user: Account, app_model: App):
return _create_chat_message(current_user=current_user, app_model=app_model)
def post(self, current_tenant_id: str, current_user: Account, app_model: App):
return _create_chat_message(current_tenant_id=current_tenant_id, current_user=current_user, app_model=app_model)
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
@@ -215,7 +219,12 @@ class AgentChatMessageApi(Resource):
@with_current_tenant_id
def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _create_chat_message(current_user=current_user, app_model=app_model)
return _create_chat_message(
current_tenant_id=current_tenant_id,
current_user=current_user,
app_model=app_model,
agent_id=str(agent_id),
)
@console_ns.route("/apps/<uuid:app_id>/chat-messages/<string:task_id>/stop")
@@ -249,11 +258,45 @@ class AgentChatMessageStopApi(Resource):
return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id)
def _create_chat_message(*, current_user: Account, app_model: App):
def _resolve_current_user_agent_debug_conversation_id(
*, current_tenant_id: str, current_user: Account, app_model: App, agent_id: str | None
) -> str:
roster_service = AgentRosterService(db.session)
if agent_id:
return roster_service.get_or_create_agent_app_debug_conversation_id(
tenant_id=current_tenant_id,
agent_id=agent_id,
account_id=current_user.id,
)
agent = roster_service.get_app_backing_agent(tenant_id=current_tenant_id, app_id=str(app_model.id))
if agent is None:
raise AgentNotFoundError()
return roster_service.get_or_create_agent_app_debug_conversation_id(
tenant_id=current_tenant_id,
agent_id=agent.id,
account_id=current_user.id,
)
def _create_chat_message(
*, current_user: Account, app_model: App, current_tenant_id: str | None = None, agent_id: str | None = None
):
raw_payload = console_ns.payload or {}
args_model = ChatMessagePayload.model_validate(raw_payload)
args = args_model.model_dump(exclude_none=True, by_alias=True)
if AppMode.value_of(app_model.mode) == AppMode.AGENT:
debug_conversation_id = _resolve_current_user_agent_debug_conversation_id(
current_tenant_id=current_tenant_id or app_model.tenant_id,
current_user=current_user,
app_model=app_model,
agent_id=agent_id,
)
if args_model.conversation_id and args_model.conversation_id != debug_conversation_id:
raise NotFound("Conversation Not Exists.")
args["conversation_id"] = debug_conversation_id
streaming = _resolve_debugger_chat_streaming(
app_mode=AppMode.value_of(app_model.mode),
response_mode=args_model.response_mode,
+30 -13
View File
@@ -53,6 +53,7 @@ from libs.login import login_required
from models.account import Account
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
from services.conversation_service import ConversationService
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
from services.message_service import MessageService, attach_message_extra_contents
@@ -166,12 +167,16 @@ register_schema_models(
ChatMessagesQuery,
MessageFeedbackPayload,
FeedbackExportQuery,
)
register_response_schema_models(
console_ns,
AnnotationCountResponse,
SuggestedQuestionsResponse,
MessageDetailResponse,
MessageInfiniteScrollPaginationResponse,
SimpleResultResponse,
TextFileResponse,
)
register_response_schema_models(console_ns, SimpleResultResponse, TextFileResponse)
@console_ns.route("/apps/<uuid:app_id>/chat-messages")
@@ -186,10 +191,11 @@ class ChatMessageListApi(Resource):
@account_initialization_required
@setup_required
@edit_permission_required
@with_current_user
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
def get(self, app_model: App):
return _list_chat_messages(app_model=app_model)
def get(self, current_user: Account, app_model: App):
return _list_chat_messages(app_model=app_model, current_user=current_user)
@console_ns.route("/agent/<uuid:agent_id>/chat-messages")
@@ -205,10 +211,11 @@ class AgentChatMessageListApi(Resource):
@setup_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT)
@with_current_user
@with_current_tenant_id
def get(self, current_tenant_id: str, agent_id: UUID):
def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID):
app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id)
return _list_chat_messages(app_model=app_model)
return _list_chat_messages(app_model=app_model, current_user=current_user)
@console_ns.route("/apps/<uuid:app_id>/feedbacks")
@@ -338,8 +345,8 @@ class MessageFeedbackExportApi(Resource):
try:
export_data = FeedbackService.export_feedbacks(
db.session(),
app_id=app_model.id,
app_model.id,
session=db.session(),
from_source=args.from_source,
rating=args.rating,
has_comment=args.has_comment,
@@ -390,14 +397,24 @@ class AgentMessageApi(Resource):
return _get_message_detail(app_model=app_model, message_id=message_id)
def _list_chat_messages(*, app_model: App):
def _list_chat_messages(*, app_model: App, current_user: Account | None = None):
args = ChatMessagesQuery.model_validate(request.args.to_dict())
conversation = db.session.scalar(
select(Conversation)
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
.limit(1)
)
if AppMode.value_of(app_model.mode) == AppMode.AGENT and current_user is not None:
try:
conversation = ConversationService.get_conversation(
app_model=app_model,
conversation_id=args.conversation_id,
user=current_user,
)
except ConversationNotExistsError:
raise NotFound("Conversation Not Exists.")
else:
conversation = db.session.scalar(
select(Conversation)
.where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id)
.limit(1)
)
if not conversation:
raise NotFound("Conversation Not Exists.")
+11 -1
View File
@@ -13,6 +13,7 @@ from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
rbac_permission_required,
setup_required,
)
@@ -70,7 +71,7 @@ class TraceAppConfigApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
@get_app_model
def get(self, app_model: App):
args = TraceProviderQuery.model_validate(request.args.to_dict(flat=True)) # type: ignore
@@ -95,9 +96,12 @@ class TraceAppConfigApi(Resource):
console_ns.models[TraceAppConfigResponse.__name__],
)
@console_ns.response(400, "Invalid request parameters or configuration already exists")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
@get_app_model
def post(self, app_model: App):
"""Create a new trace app configuration"""
@@ -125,9 +129,12 @@ class TraceAppConfigApi(Resource):
console_ns.models[TraceAppConfigResponse.__name__],
)
@console_ns.response(400, "Invalid request parameters or configuration not found")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
@get_app_model
def patch(self, app_model: App):
"""Update an existing trace app configuration"""
@@ -149,9 +156,12 @@ class TraceAppConfigApi(Resource):
@console_ns.doc(params=query_params_from_model(TraceProviderQuery))
@console_ns.response(204, "Tracing configuration deleted successfully")
@console_ns.response(400, "Invalid request parameters or configuration not found")
@console_ns.response(403, "Insufficient permissions")
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TRACING_CONFIG)
@get_app_model
def delete(self, app_model: App):
"""Delete an existing trace app configuration"""
@@ -181,7 +181,7 @@ class WorkflowAppLogApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_LOG_AND_ANNOTATION)
@get_app_model(mode=[AppMode.WORKFLOW])
def get(self, app_model: App):
"""
@@ -225,7 +225,7 @@ class WorkflowArchivedLogApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_MONITOR)
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_LOG_AND_ANNOTATION)
@get_app_model(mode=[AppMode.WORKFLOW])
def get(self, app_model: App):
"""
@@ -17,6 +17,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.member_fields import AccountWithRole
from libs.helper import build_avatar_url, dump_response, to_timestamp
@@ -489,7 +490,7 @@ class WorkflowCommentMentionUsersApi(Resource):
current_tenant = current_user.current_tenant # need the tenant object here
if current_tenant is None:
raise ValueError("current tenant is required")
members = TenantService.get_tenant_members(current_tenant)
members = TenantService.get_tenant_members(current_tenant, session=db.session)
users = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
response = WorkflowCommentMentionUsersPayload(users=users)
return response.model_dump(mode="json"), 200
+7 -3
View File
@@ -89,7 +89,9 @@ class ActivateCheckApi(Resource):
workspaceId = args.workspace_id
token = args.token
invitation = RegisterService.get_invitation_with_case_fallback(workspaceId, args.email, token)
invitation = RegisterService.get_invitation_with_case_fallback(
workspaceId, args.email, token, session=db.session
)
if invitation:
data = invitation.get("data", {})
tenant = invitation.get("tenant", None)
@@ -137,7 +139,9 @@ class ActivateApi(Resource):
args = ActivatePayload.model_validate(console_ns.payload)
normalized_request_email = args.email.lower() if args.email else None
invitation = RegisterService.get_invitation_with_case_fallback(args.workspace_id, args.email, args.token)
invitation = RegisterService.get_invitation_with_case_fallback(
args.workspace_id, args.email, args.token, session=db.session
)
if invitation is None:
raise AlreadyActivateError()
@@ -184,6 +188,6 @@ class ActivateApi(Resource):
account.status = AccountStatus.ACTIVE
account.initialized_at = naive_utc_now()
TenantService.switch_tenant(account, tenant.id)
TenantService.switch_tenant(account, tenant.id, session=db.session)
return {"result": "success"}
@@ -5,6 +5,7 @@ from pydantic import BaseModel, Field
from controllers.common.fields import SimpleResultResponse
from controllers.common.schema import register_response_schema_models, register_schema_models
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.login import login_required
from services.auth.api_key_auth_service import ApiKeyAuthService
@@ -58,7 +59,7 @@ class ApiKeyAuthDataSource(Resource):
@account_initialization_required
@with_current_tenant_id
def get(self, current_tenant_id: str):
data_source_api_key_bindings = ApiKeyAuthService.get_provider_auth_list(current_tenant_id)
data_source_api_key_bindings = ApiKeyAuthService.get_provider_auth_list(db.session(), current_tenant_id)
if data_source_api_key_bindings:
return {
"sources": [
@@ -83,7 +84,7 @@ class ApiKeyAuthDataSourceBinding(Resource):
@login_required
@account_initialization_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
@console_ns.expect(console_ns.models[ApiKeyAuthBindingPayload.__name__])
@with_current_tenant_id
def post(self, current_tenant_id: str):
@@ -92,7 +93,7 @@ class ApiKeyAuthDataSourceBinding(Resource):
data = payload.model_dump()
ApiKeyAuthService.validate_api_key_auth_args(data)
try:
ApiKeyAuthService.create_provider_auth(current_tenant_id, data)
ApiKeyAuthService.create_provider_auth(db.session(), current_tenant_id, data)
except Exception as e:
raise ApiKeyAuthFailedError(str(e))
return {"result": "success"}, 200
@@ -109,6 +110,6 @@ class ApiKeyAuthDataSourceBindingDelete(Resource):
@with_current_tenant_id
def delete(self, current_tenant_id: str, binding_id: UUID):
# The role of the current user in the table must be admin or owner
ApiKeyAuthService.delete_provider_auth(current_tenant_id, str(binding_id))
ApiKeyAuthService.delete_provider_auth(db.session(), current_tenant_id, str(binding_id))
return "", 204
@@ -15,6 +15,7 @@ from controllers.console.auth.error import (
InvalidTokenError,
PasswordMismatchError,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from libs.helper import EmailStr, extract_remote_ip
from libs.helper import timezone as validate_timezone_string
@@ -100,7 +101,7 @@ class EmailRegisterSendEmailApi(Resource):
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(normalized_email):
raise AccountInFreezeError()
account = AccountService.get_account_by_email_with_case_fallback(args.email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, args.email)
token = AccountService.send_email_register_email(email=normalized_email, account=account, language=language)
return {"result": "success", "data": token}
@@ -175,7 +176,7 @@ class EmailRegisterResetApi(Resource):
email = register_data.get("email", "")
normalized_email = email.lower()
account = AccountService.get_account_by_email_with_case_fallback(email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
if account:
raise EmailAlreadyInUseError()
@@ -186,7 +187,7 @@ class EmailRegisterResetApi(Resource):
timezone=args.timezone,
language=args.language,
)
token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
token_pair = AccountService.login(account=account, session=db.session, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(normalized_email)
return {"result": "success", "data": token_pair.model_dump()}
@@ -205,6 +206,7 @@ class EmailRegisterResetApi(Resource):
password=password,
interface_language=get_valid_language(language),
timezone=timezone,
session=db.session,
)
except AccountRegisterError:
raise AccountInFreezeError()
@@ -82,7 +82,7 @@ class ForgotPasswordSendEmailApi(Resource):
else:
language = "en-US"
account = AccountService.get_account_by_email_with_case_fallback(args.email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, args.email)
token = AccountService.send_reset_password_email(
account=account,
@@ -180,7 +180,7 @@ class ForgotPasswordResetApi(Resource):
password_hashed = hash_password(args.new_password, salt)
email = reset_data.get("email", "")
account = AccountService.get_account_by_email_with_case_fallback(email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
if account:
account = db.session.merge(account)
@@ -198,10 +198,10 @@ class ForgotPasswordResetApi(Resource):
# Create workspace if needed
if (
not TenantService.get_join_tenants(account)
not TenantService.get_join_tenants(account, session=db.session)
and FeatureService.get_system_features().is_allow_create_workspace
):
tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
tenant = TenantService.create_tenant(f"{account.name}'s Workspace", session=db.session)
TenantService.create_tenant_member(tenant, account, db.session, role="owner")
account.current_tenant = tenant
tenant_was_created.send(tenant)
+14 -11
View File
@@ -119,7 +119,9 @@ class LoginApi(Resource):
invite_token = args.invite_token
invitation_data: InvitationDetailDict | None = None
if invite_token:
invitation_data = RegisterService.get_invitation_with_case_fallback(None, request_email, invite_token)
invitation_data = RegisterService.get_invitation_with_case_fallback(
None, request_email, invite_token, session=db.session
)
if invitation_data is None:
invite_token = None
@@ -145,7 +147,7 @@ class LoginApi(Resource):
_log_console_login_failure(email=normalized_email, reason=LoginFailureReason.INVALID_CREDENTIALS)
raise AuthenticationFailedError() from exc
# SELF_HOSTED only have one workspace
tenants = TenantService.get_join_tenants(account)
tenants = TenantService.get_join_tenants(account, session=db.session)
if len(tenants) == 0:
system_features = FeatureService.get_system_features()
@@ -157,7 +159,7 @@ class LoginApi(Resource):
"data": "workspace not found, please contact system admin to invite you to join in a workspace",
}
token_pair = AccountService.login(account=account, ip_address=extract_remote_ip(request))
token_pair = AccountService.login(account=account, session=db.session, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(normalized_email)
# Create response with cookies instead of returning tokens in body
@@ -291,7 +293,7 @@ class EmailCodeLoginApi(Resource):
_log_console_login_failure(email=user_email, reason=LoginFailureReason.ACCOUNT_IN_FREEZE)
raise AccountInFreezeError()
if account:
tenants = TenantService.get_join_tenants(account)
tenants = TenantService.get_join_tenants(account, session=db.session)
if not tenants:
workspaces = FeatureService.get_system_features().license.workspaces
if not workspaces.is_available():
@@ -299,7 +301,7 @@ class EmailCodeLoginApi(Resource):
if not FeatureService.get_system_features().is_allow_create_workspace:
raise NotAllowedCreateWorkspace()
else:
new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace", session=db.session)
TenantService.create_tenant_member(new_tenant, account, db.session, role="owner")
account.current_tenant = new_tenant
tenant_was_created.send(new_tenant)
@@ -311,6 +313,7 @@ class EmailCodeLoginApi(Resource):
name=user_email,
interface_language=get_valid_language(language),
timezone=args.timezone,
session=db.session,
)
except WorkSpaceNotAllowedCreateError:
raise NotAllowedCreateWorkspace()
@@ -319,7 +322,7 @@ class EmailCodeLoginApi(Resource):
raise AccountInFreezeError()
except WorkspacesLimitExceededError:
raise WorkspacesLimitExceeded()
token_pair = AccountService.login(account, ip_address=extract_remote_ip(request))
token_pair = AccountService.login(account, session=db.session, ip_address=extract_remote_ip(request))
AccountService.reset_login_error_rate_limit(user_email)
# Create response with cookies instead of returning tokens in body
@@ -343,7 +346,7 @@ class RefreshTokenApi(Resource):
return {"result": "fail", "message": "No refresh token provided"}, 401
try:
new_token_pair = AccountService.refresh_token(refresh_token)
new_token_pair = AccountService.refresh_token(refresh_token, session=db.session)
# Create response with new cookies
response = make_response({"result": "success"})
@@ -358,22 +361,22 @@ class RefreshTokenApi(Resource):
def _get_account_with_case_fallback(email: str):
account = AccountService.get_user_through_email(email)
account = AccountService.get_user_through_email(email, session=db.session)
if account or email == email.lower():
return account
return AccountService.get_user_through_email(email.lower())
return AccountService.get_user_through_email(email.lower(), session=db.session)
def _authenticate_account_with_case_fallback(
original_email: str, normalized_email: str, password: str, invite_token: str | None
):
try:
return AccountService.authenticate(original_email, password, invite_token)
return AccountService.authenticate(original_email, password, invite_token, session=db.session)
except services.errors.account.AccountPasswordError:
if original_email == normalized_email:
raise
return AccountService.authenticate(normalized_email, password, invite_token)
return AccountService.authenticate(normalized_email, password, invite_token, session=db.session)
def _log_console_login_failure(*, email: str, reason: LoginFailureReason) -> None:
+7 -5
View File
@@ -195,7 +195,7 @@ class OAuthCallback(Resource):
db.session.commit()
try:
TenantService.create_owner_tenant_if_not_exist(account)
TenantService.create_owner_tenant_if_not_exist(account, session=db.session)
except Unauthorized:
return redirect(f"{dify_config.CONSOLE_WEB_URL}/signin?message=Workspace not found.")
except WorkSpaceNotAllowedCreateError:
@@ -206,6 +206,7 @@ class OAuthCallback(Resource):
token_pair = AccountService.login(
account=account,
session=db.session,
ip_address=extract_remote_ip(request),
)
@@ -224,7 +225,7 @@ def _get_account_by_openid_or_email(provider: str, user_info: OAuthUserInfo) ->
account: Account | None = Account.get_by_openid(provider, user_info.id)
if not account:
account = AccountService.get_account_by_email_with_case_fallback(user_info.email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, user_info.email)
return account
@@ -240,12 +241,12 @@ def _generate_account(
oauth_new_user = False
if account:
tenants = TenantService.get_join_tenants(account)
tenants = TenantService.get_join_tenants(account, session=db.session)
if not tenants:
if not FeatureService.get_system_features().is_allow_create_workspace:
raise WorkSpaceNotAllowedCreateError()
else:
new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace")
new_tenant = TenantService.create_tenant(f"{account.name}'s Workspace", session=db.session)
TenantService.create_tenant_member(new_tenant, account, db.session, role="owner")
account.current_tenant = new_tenant
tenant_was_created.send(new_tenant)
@@ -272,9 +273,10 @@ def _generate_account(
provider=provider,
language=interface_language,
timezone=timezone,
session=db.session,
)
# Link account
AccountService.link_account_integrate(provider, user_info.id, account)
AccountService.link_account_integrate(provider, user_info.id, account, session=db.session)
return account, oauth_new_user
@@ -26,6 +26,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.dataset_fields import (
dataset_detail_fields,
@@ -390,6 +391,7 @@ class ExternalKnowledgeHitTestingApi(Resource):
try:
response = HitTestingService.external_retrieve(
session=db.session,
dataset=dataset,
query=payload.query,
account=current_user,
@@ -18,6 +18,7 @@ from core.errors.error import (
ProviderTokenNotInitError,
QuotaExceededError,
)
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs.login import resolve_account_fallback
from models.account import Account
@@ -115,6 +116,7 @@ class DatasetsHitTestingBase:
try:
current_user, _ = resolve_account_fallback(current_user, current_tenant_id)
response = HitTestingService.retrieve(
session=db.session,
dataset=dataset,
query=cast(str, args.get("query")),
account=current_user,
+10 -7
View File
@@ -17,6 +17,7 @@ from controllers.console.wraps import (
with_current_tenant_id,
with_current_user,
)
from extensions.ext_database import db
from fields.dataset_fields import (
DatasetMetadataBuiltInFieldsResponse,
DatasetMetadataListResponse,
@@ -65,7 +66,9 @@ class DatasetMetadataCreateApi(Resource):
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
metadata = MetadataService.create_metadata(dataset_id_str, metadata_args, current_user, current_tenant_id)
metadata = MetadataService.create_metadata(
db.session(), dataset_id_str, metadata_args, current_user, current_tenant_id
)
return dump_response(DatasetMetadataResponse, metadata), 201
@setup_required
@@ -81,7 +84,7 @@ class DatasetMetadataCreateApi(Resource):
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
raise NotFound("Dataset not found.")
metadata = MetadataService.get_dataset_metadatas(dataset)
metadata = MetadataService.get_dataset_metadatas(db.session(), dataset)
return dump_response(DatasetMetadataListResponse, metadata), 200
@@ -108,7 +111,7 @@ class DatasetMetadataApi(Resource):
DatasetService.check_dataset_permission(dataset, current_user)
metadata = MetadataService.update_metadata_name(
dataset_id_str, metadata_id_str, name, current_user, current_tenant_id
db.session(), dataset_id_str, metadata_id_str, name, current_user, current_tenant_id
)
return dump_response(DatasetMetadataResponse, metadata), 200
@@ -127,7 +130,7 @@ class DatasetMetadataApi(Resource):
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
MetadataService.delete_metadata(dataset_id_str, metadata_id_str)
MetadataService.delete_metadata(db.session(), dataset_id_str, metadata_id_str)
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
return "", 204
@@ -166,9 +169,9 @@ class DatasetMetadataBuiltInFieldActionApi(Resource):
match action:
case "enable":
MetadataService.enable_built_in_field(dataset)
MetadataService.enable_built_in_field(db.session(), dataset)
case "disable":
MetadataService.disable_built_in_field(dataset)
MetadataService.disable_built_in_field(db.session(), dataset)
# Frontend callers only await success and invalidate metadata caches; no response body is consumed.
return "", 204
@@ -195,7 +198,7 @@ class DocumentMetadataEditApi(Resource):
metadata_args = MetadataOperationData.model_validate(console_ns.payload or {})
MetadataService.update_documents_metadata(dataset, metadata_args, current_user)
MetadataService.update_documents_metadata(db.session(), dataset, metadata_args, current_user)
# Frontend callers only await success and invalidate caches; no response body is consumed.
return "", 204
@@ -222,7 +222,7 @@ class DatasourceAuth(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.DATASET, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
@with_current_tenant_id
def post(self, current_tenant_id: str, provider_id: str):
payload = DatasourceCredentialPayload.model_validate(console_ns.payload or {})
+8 -1
View File
@@ -20,6 +20,7 @@ from controllers.console.app.error import (
)
from controllers.console.explore.wraps import InstalledAppResource
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from models.model import InstalledApp
from services.audio_service import AudioService
@@ -99,7 +100,13 @@ class ChatTextApi(InstalledAppResource):
text = payload.text
voice = payload.voice
response = AudioService.transcript_tts(app_model=app_model, text=text, voice=voice, message_id=message_id)
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session,
text=text,
voice=voice,
message_id=message_id,
)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
logger.exception("App model config broken.")
@@ -85,6 +85,7 @@ def _published_app_filter():
class InstalledAppInfoResponse(ResponseModel):
id: str
name: str | None = None
description: str | None = None
mode: str | None = None
icon_type: str | None = None
icon: str | None = None
@@ -123,6 +124,7 @@ class InstalledAppResponse(ResponseModel):
return {
"id": _safe_primitive(getattr(value, "id", "")) or "",
"name": _safe_primitive(getattr(value, "name", None)),
"description": _safe_primitive(getattr(value, "description", None)),
"mode": _safe_primitive(getattr(value, "mode", None)),
"icon_type": _safe_primitive(getattr(value, "icon_type", None)),
"icon": _safe_primitive(getattr(value, "icon", None)),
@@ -175,7 +177,7 @@ class InstalledAppsListApi(Resource):
if current_user.current_tenant is None:
raise ValueError("current_user.current_tenant must not be None")
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant)
current_user.role = TenantService.get_user_role(current_user, current_user.current_tenant, session=db.session)
installed_app_list: list[dict[str, Any]] = []
for installed_app, app_model in installed_apps:
installed_app_list.append(
+9 -5
View File
@@ -27,7 +27,11 @@ from controllers.console.wraps import with_current_user
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from fields.conversation_fields import ResultResponse
from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem, SuggestedQuestionsResponse
from fields.message_fields import (
ExploreMessageInfiniteScrollPagination,
ExploreMessageListItem,
SuggestedQuestionsResponse,
)
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from models import Account
@@ -56,7 +60,7 @@ register_schema_models(console_ns, MessageListQuery, MessageFeedbackPayload, Mor
register_response_schema_models(
console_ns,
GeneratedAppResponse,
MessageInfiniteScrollPagination,
ExploreMessageInfiniteScrollPagination,
ResultResponse,
SuggestedQuestionsResponse,
)
@@ -68,7 +72,7 @@ register_response_schema_models(
)
class MessageListApi(InstalledAppResource):
@console_ns.doc(params=query_params_from_model(MessageListQuery))
@console_ns.response(200, "Success", console_ns.models[MessageInfiniteScrollPagination.__name__])
@console_ns.response(200, "Success", console_ns.models[ExploreMessageInfiniteScrollPagination.__name__])
@with_current_user
def get(self, current_user: Account, installed_app: InstalledApp):
app_model = installed_app.app
@@ -88,9 +92,9 @@ class MessageListApi(InstalledAppResource):
str(args.first_id) if args.first_id else None,
args.limit,
)
adapter = TypeAdapter(MessageListItem)
adapter = TypeAdapter(ExploreMessageListItem)
items = [adapter.validate_python(message, from_attributes=True) for message in pagination.data]
return MessageInfiniteScrollPagination(
return ExploreMessageInfiniteScrollPagination(
limit=pagination.limit,
has_more=pagination.has_more,
data=items,
+7 -1
View File
@@ -419,7 +419,13 @@ class TrialChatTextApi(TrialAppResource):
app_id = app_model.id
user_id = current_user.id
response = AudioService.transcript_tts(app_model=app_model, text=text, voice=voice, message_id=message_id)
response = AudioService.transcript_tts(
app_model=app_model,
session=db.session,
text=text,
voice=voice,
message_id=message_id,
)
RecommendedAppService.add_trial_app_record(db.session, app_id, user_id)
return response
except services.errors.app_model_config.AppModelConfigBrokenError:
+1 -1
View File
@@ -50,7 +50,7 @@ def get_init_status() -> InitStatusResponse:
@only_edition_self_hosted
def validate_init_password(payload: InitValidatePayload) -> InitValidateResponse:
"""Validate initialization password."""
tenant_count = TenantService.get_tenant_count()
tenant_count = TenantService.get_tenant_count(session=db.session)
if tenant_count > 0:
raise AlreadySetupError()
+2 -1
View File
@@ -79,7 +79,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
if get_setup_status():
raise AlreadySetupError()
tenant_count = TenantService.get_tenant_count()
tenant_count = TenantService.get_tenant_count(session=db.session)
if tenant_count > 0:
raise AlreadySetupError()
@@ -94,6 +94,7 @@ def setup_system(payload: SetupRequestPayload) -> SetupResponse:
password=payload.password,
ip_address=extract_remote_ip(request),
language=payload.language,
session=db.session,
)
return SetupResponse(result="success")
@@ -80,6 +80,13 @@ class SnippetDraftConfigResponse(BaseModel):
parallel_depth_limit: int
class SnippetWorkflowPaginationResponse(BaseModel):
items: list[SnippetWorkflowResponse]
page: int
limit: int
has_more: bool
register_schema_models(
console_ns,
SnippetDraftSyncPayload,
@@ -98,6 +105,7 @@ register_response_schema_models(
SimpleResultResponse,
SnippetDraftConfigResponse,
SnippetWorkflowResponse,
SnippetWorkflowPaginationResponse,
WorkflowPublishResponse,
WorkflowPaginationResponse,
WorkflowRestoreResponse,
@@ -329,7 +337,7 @@ class SnippetPublishedAllWorkflowApi(Resource):
@console_ns.response(
200,
"Published workflows retrieved successfully",
console_ns.models[WorkflowPaginationResponse.__name__],
console_ns.models[SnippetWorkflowPaginationResponse.__name__],
)
@setup_required
@login_required
@@ -350,7 +358,7 @@ class SnippetPublishedAllWorkflowApi(Resource):
limit=args.limit,
)
return WorkflowPaginationResponse.model_validate(
response = SnippetWorkflowPaginationResponse.model_validate(
{
"items": workflows,
"page": args.page,
@@ -359,6 +367,9 @@ class SnippetPublishedAllWorkflowApi(Resource):
},
from_attributes=True,
).model_dump(mode="json")
for item in response["items"]:
item["input_fields"] = snippet.input_fields_list
return response
@console_ns.route("/snippets/<uuid:snippet_id>/workflows/<string:workflow_id>/restore")
+2 -1
View File
@@ -4,6 +4,7 @@ from typing import cast
from flask import Request as FlaskRequest
from extensions.ext_database import db
from extensions.ext_socketio import sio
from libs.passport import PassportService
from libs.token import extract_access_token
@@ -43,7 +44,7 @@ def socket_connect(sid, environ, auth):
return False
with sio.app.app_context():
user = AccountService.load_logged_in_account(account_id=user_id)
user = AccountService.load_logged_in_account(account_id=user_id, session=db.session)
if not user:
logging.warning("Socket connect rejected: user not found (user_id=%s, sid=%s)", user_id, sid)
return False
@@ -5,6 +5,7 @@ from sqlalchemy import select
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import Forbidden
from configs import dify_config
from extensions.ext_database import db
from libs.login import current_account_with_tenant
from models.account import TenantPluginPermission
@@ -17,6 +18,9 @@ def plugin_permission_required(
def interceptor[**P, R](view: Callable[P, R]) -> Callable[P, R]:
@wraps(view)
def decorated(*args: P.args, **kwargs: P.kwargs) -> R:
if dify_config.RBAC_ENABLED:
return view(*args, **kwargs)
current_user, current_tenant_id = current_account_with_tenant()
user = current_user
tenant_id = current_tenant_id
+16 -9
View File
@@ -328,7 +328,7 @@ class AccountNameApi(Resource):
def post(self, current_user: Account):
payload = console_ns.payload or {}
args = AccountNamePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, name=args.name)
updated_account = AccountService.update_account(current_user, session=db.session, name=args.name)
return _serialize_account(updated_account)
@@ -374,7 +374,7 @@ class AccountAvatarApi(Resource):
payload = console_ns.payload or {}
args = AccountAvatarPayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, avatar=args.avatar)
updated_account = AccountService.update_account(current_user, session=db.session, avatar=args.avatar)
return _serialize_account(updated_account)
@@ -391,7 +391,9 @@ class AccountInterfaceLanguageApi(Resource):
payload = console_ns.payload or {}
args = AccountInterfaceLanguagePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, interface_language=args.interface_language)
updated_account = AccountService.update_account(
current_user, session=db.session, interface_language=args.interface_language
)
return _serialize_account(updated_account)
@@ -408,7 +410,9 @@ class AccountInterfaceThemeApi(Resource):
payload = console_ns.payload or {}
args = AccountInterfaceThemePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, interface_theme=args.interface_theme)
updated_account = AccountService.update_account(
current_user, session=db.session, interface_theme=args.interface_theme
)
return _serialize_account(updated_account)
@@ -425,7 +429,7 @@ class AccountTimezoneApi(Resource):
payload = console_ns.payload or {}
args = AccountTimezonePayload.model_validate(payload)
updated_account = AccountService.update_account(current_user, timezone=args.timezone)
updated_account = AccountService.update_account(current_user, session=db.session, timezone=args.timezone)
return _serialize_account(updated_account)
@@ -443,7 +447,8 @@ class AccountPasswordApi(Resource):
args = AccountPasswordPayload.model_validate(payload)
try:
AccountService.update_account_password(current_user, args.password, args.new_password)
assert args.password is not None
AccountService.update_account_password(current_user, args.password, args.new_password, session=db.session)
except ServiceCurrentPasswordIncorrectError:
raise CurrentPasswordIncorrectError()
@@ -731,7 +736,7 @@ class ChangeEmailResetApi(Resource):
if AccountService.is_account_in_freeze(normalized_new_email):
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_new_email):
if not AccountService.check_email_unique(normalized_new_email, session=db.session):
raise EmailAlreadyInUseError()
reset_data = AccountService.get_change_email_data(args.token)
@@ -755,7 +760,9 @@ class ChangeEmailResetApi(Resource):
# legitimately verified token.
AccountService.revoke_change_email_token(args.token)
updated_account = AccountService.update_account_email(current_user, email=normalized_new_email)
updated_account = AccountService.update_account_email(
current_user, email=normalized_new_email, session=db.session
)
AccountService.send_change_email_completed_notify_email(
email=normalized_new_email,
@@ -775,6 +782,6 @@ class CheckEmailUnique(Resource):
normalized_email = args.email.lower()
if AccountService.is_account_in_freeze(normalized_email):
raise AccountInFreezeError()
if not AccountService.check_email_unique(normalized_email):
if not AccountService.check_email_unique(normalized_email, session=db.session):
raise EmailAlreadyInUseError()
return {"result": "success"}
@@ -169,7 +169,7 @@ class EndpointCollectionApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -198,7 +198,7 @@ class DeprecatedEndpointCreateApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -290,7 +290,7 @@ class EndpointItemApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -310,7 +310,7 @@ class EndpointItemApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -340,7 +340,7 @@ class DeprecatedEndpointDeleteApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -371,7 +371,7 @@ class DeprecatedEndpointUpdateApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -394,7 +394,7 @@ class EndpointEnableApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
@@ -422,7 +422,7 @@ class EndpointDisableApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user_id
@with_current_tenant_id
+17 -10
View File
@@ -131,7 +131,7 @@ def _normalize_invitee_emails(emails: list[str]) -> list[str]:
def _count_new_member_invites(tenant_id: str, emails: list[str]) -> int:
new_member_count = 0
for email in emails:
account = AccountService.get_account_by_email_with_case_fallback(email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
if not account:
new_member_count += 1
continue
@@ -186,7 +186,7 @@ class MemberListApi(Resource):
current_user, _ = current_account_with_tenant()
if not current_user.current_tenant:
raise ValueError("No current tenant")
members = TenantService.get_tenant_members(current_user.current_tenant)
members = TenantService.get_tenant_members(current_user.current_tenant, session=db.session)
if dify_config.RBAC_ENABLED:
member_ids = [member.id for member in members]
member_roles = enterprise_rbac_service.RBACService.MemberRoles.batch_get(
@@ -273,6 +273,7 @@ class MemberInviteEmailApi(Resource):
language=interface_language,
role=invitee_role,
inviter=inviter,
session=db.session,
)
encoded_invitee_email = parse.quote(invitee_email)
invitation_results.append(
@@ -317,7 +318,9 @@ class MemberCancelInviteApi(Resource):
abort(404)
else:
try:
TenantService.remove_member_from_tenant(current_user.current_tenant, member, current_user)
TenantService.remove_member_from_tenant(
current_user.current_tenant, member, current_user, session=db.session
)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, 400
except services.errors.account.NoPermissionError as e:
@@ -360,7 +363,9 @@ class MemberUpdateRoleApi(Resource):
try:
assert member is not None, "Member not found"
TenantService.update_member_role(current_user.current_tenant, member, new_role, current_user)
TenantService.update_member_role(
current_user.current_tenant, member, new_role, current_user, session=db.session
)
except services.errors.account.CannotOperateSelfError as e:
return {"code": "cannot-operate-self", "message": str(e)}, 400
except services.errors.account.NoPermissionError as e:
@@ -387,7 +392,7 @@ class DatasetOperatorMemberListApi(Resource):
def get(self, current_user: Account):
if not current_user.current_tenant:
raise ValueError("No current tenant")
members = TenantService.get_dataset_operator_members(current_user.current_tenant)
members = TenantService.get_dataset_operator_members(current_user.current_tenant, session=db.session)
member_models = TypeAdapter(list[AccountWithRole]).validate_python(members, from_attributes=True)
response = AccountWithRoleList(accounts=member_models)
return response.model_dump(mode="json"), 200
@@ -413,7 +418,7 @@ class SendOwnerTransferEmailApi(Resource):
# check if the current user is the owner of the workspace
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_owner(current_user, current_user.current_tenant):
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session):
raise NotOwnerError()
if args.language is not None and args.language == "zh-Hans":
@@ -448,7 +453,7 @@ class OwnerTransferCheckApi(Resource):
# check if the current user is the owner of the workspace
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_owner(current_user, current_user.current_tenant):
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session):
raise NotOwnerError()
user_email = current_user.email
@@ -494,7 +499,7 @@ class OwnerTransfer(Resource):
# check if the current user is the owner of the workspace
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_owner(current_user, current_user.current_tenant):
if not TenantService.is_owner(current_user, current_user.current_tenant, session=db.session):
raise NotOwnerError()
if current_user.id == str(member_id):
@@ -516,12 +521,14 @@ class OwnerTransfer(Resource):
if not current_user.current_tenant:
raise ValueError("No current tenant")
if not TenantService.is_member(member, current_user.current_tenant):
if not TenantService.is_member(member, current_user.current_tenant, session=db.session):
raise MemberNotInTenantError()
try:
assert member is not None, "Member not found"
TenantService.update_member_role(current_user.current_tenant, member, "owner", current_user)
TenantService.update_member_role(
current_user.current_tenant, member, "owner", current_user, session=db.session
)
AccountService.send_new_owner_transfer_notify_email(
account=member,
@@ -169,7 +169,7 @@ class ModelProviderCredentialApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str, provider: str):
@@ -244,7 +244,7 @@ class ModelProviderCredentialSwitchApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str, provider: str):
@@ -326,7 +326,7 @@ class PreferredProviderTypeUpdateApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, provider: str):
+2 -2
View File
@@ -395,7 +395,7 @@ class ModelProviderModelCredentialApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_CREATE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, tenant_id: str, provider: str):
@@ -481,7 +481,7 @@ class ModelProviderModelCredentialSwitchApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str, provider: str):
+16 -2
View File
@@ -469,6 +469,7 @@ class PluginDebuggingKeyApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_DEBUG, resource_required=False)
@plugin_permission_required(debug_required=True)
@with_current_tenant_id
def get(self, tenant_id: str):
@@ -614,6 +615,7 @@ class PluginUploadFromPkgApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
@@ -634,6 +636,7 @@ class PluginUploadFromGithubApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
@@ -653,6 +656,7 @@ class PluginUploadFromBundleApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
@@ -673,6 +677,7 @@ class PluginInstallFromPkgApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
@@ -693,6 +698,7 @@ class PluginInstallFromGithubApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
@@ -719,6 +725,7 @@ class PluginInstallFromMarketplaceApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
@@ -739,6 +746,7 @@ class PluginFetchMarketplacePkgApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def get(self, tenant_id: str):
@@ -764,6 +772,7 @@ class PluginFetchManifestApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_INSTALL, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def get(self, tenant_id: str):
@@ -862,6 +871,7 @@ class PluginUpgradeFromMarketplaceApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
@@ -884,6 +894,7 @@ class PluginUpgradeFromGithubApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
@@ -911,6 +922,7 @@ class PluginUninstallApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_DELETE, resource_required=False)
@plugin_permission_required(install_required=True)
@with_current_tenant_id
def post(self, tenant_id: str):
@@ -978,7 +990,7 @@ class PluginFetchDynamicSelectOptionsApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_MODEL_CONFIG, resource_required=False)
@account_initialization_required
@with_current_user
@with_current_tenant_id
@@ -1041,10 +1053,11 @@ class PluginChangeAutoUpgradeApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False)
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account):
if not user.is_admin_or_owner:
if not dify_config.RBAC_ENABLED and not user.is_admin_or_owner:
raise Forbidden()
args = ParserAutoUpgradeChange.model_validate(console_ns.payload)
@@ -1097,6 +1110,7 @@ class PluginAutoUpgradeExcludePluginApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.PLUGIN_PREFERENCES, resource_required=False)
@with_current_tenant_id
def post(self, tenant_id: str):
# exclude one single plugin
+16 -14
View File
@@ -201,21 +201,23 @@ def _legacy_workspace_roles(
This keeps the new `/rbac/roles` endpoint compatible with the original
Dify role model when enterprise RBAC is disabled.
"""
legacy_roles = [
svc.RBACRole(
id=role_name,
tenant_id="",
type=svc.RBACRoleType.WORKSPACE.value,
category="global_system_default",
name=role_name,
description="",
is_builtin=True,
permission_keys=list(dict.fromkeys(_LEGACY_ROLE_PERMISSION_KEYS[role_name])),
role_tag="owner" if role_name == "owner" else "",
legacy_roles = []
for role_name in ("owner", "admin", "editor", "normal", "dataset_operator"):
if not dify_config.DATASET_OPERATOR_ENABLED and role_name == "dataset_operator":
continue
legacy_roles.append(
svc.RBACRole(
id=role_name,
tenant_id="",
type=svc.RBACRoleType.WORKSPACE.value,
category="global_system_default",
name=role_name,
description="",
is_builtin=True,
permission_keys=list(dict.fromkeys(_LEGACY_ROLE_PERMISSION_KEYS[role_name])),
role_tag="owner" if role_name == "owner" else "",
)
)
for role_name in ("owner", "admin", "editor", "normal", "dataset_operator")
]
if not include_owner:
legacy_roles = [r for r in legacy_roles if r.name != "owner"]
@@ -971,7 +971,7 @@ class ToolBuiltinProviderSetDefaultApi(Resource):
@setup_required
@login_required
@is_admin_or_owner_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_MANAGE, resource_required=False)
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.CREDENTIAL_USE, resource_required=False)
@account_initialization_required
@with_current_tenant_id
def post(self, current_tenant_id: str, provider: str):
@@ -1070,6 +1070,7 @@ class ToolProviderMCPApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
@with_current_user
@with_current_tenant_id
def post(self, tenant_id: str, user: Account):
@@ -1125,6 +1126,7 @@ class ToolProviderMCPApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
@with_current_tenant_id
def put(self, current_tenant_id: str):
payload = MCPProviderUpdatePayload.model_validate(console_ns.payload or {})
@@ -1178,6 +1180,7 @@ class ToolProviderMCPApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
@with_current_tenant_id
def delete(self, current_tenant_id: str):
payload = MCPProviderDeletePayload.model_validate(console_ns.payload or {})
@@ -1196,6 +1199,7 @@ class ToolMCPAuthApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
@with_current_tenant_id
def post(self, tenant_id: str):
payload = MCPAuthPayload.model_validate(console_ns.payload or {})
@@ -1300,6 +1304,7 @@ class ToolMCPUpdateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.WORKSPACE, RBACPermission.MCP_MANAGE, resource_required=False)
@with_current_tenant_id
def get(self, tenant_id: str, provider_id: str):
with sessionmaker(db.engine).begin() as session:
@@ -325,10 +325,10 @@ class TenantApi(Resource):
raise ValueError("No current tenant")
if tenant.status == TenantStatus.ARCHIVE:
tenants = TenantService.get_join_tenants(current_user)
tenants = TenantService.get_join_tenants(current_user, session=db.session)
# if there is any tenant, switch to the first one
if len(tenants) > 0:
TenantService.switch_tenant(current_user, tenants[0].id)
TenantService.switch_tenant(current_user, tenants[0].id, session=db.session)
tenant = tenants[0]
# else, raise Unauthorized
else:
@@ -351,7 +351,7 @@ class SwitchWorkspaceApi(Resource):
# check if tenant_id is valid, 403 if not
try:
TenantService.switch_tenant(current_user, args.tenant_id)
TenantService.switch_tenant(current_user, args.tenant_id, session=db.session)
except Exception:
raise AccountNotLinkTenantError("Account not link tenant")
@@ -1,9 +1,10 @@
"""Inner API endpoint for tenant-scoped knowledge retrieval.
"""Plugin inner API endpoint for tenant-scoped knowledge retrieval.
This controller is a thin HTTP wrapper around
``services.knowledge_retrieval_inner_service.InnerKnowledgeRetrievalService``.
It intentionally keeps authorization simple: shared inner API key plus
tenant-scoped app/dataset validation in the service layer.
It uses the plugin inner API key because dify-agent calls this endpoint through
the same trusted Dify API bridge as other agent/plugin inner calls; tenant-scoped
app/dataset validation remains in the service layer.
"""
from flask_restx import Resource
@@ -11,8 +12,9 @@ from pydantic import ValidationError
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.inner_api import inner_api_ns
from controllers.inner_api.wraps import inner_api_only
from controllers.inner_api.wraps import plugin_inner_api_only
from core.workflow.nodes.knowledge_retrieval import exc as retrieval_exc
from extensions.ext_database import db
from libs.exception import BaseHTTPException
from services.entities.knowledge_retrieval_inner import InnerKnowledgeRetrieveRequest, InnerKnowledgeRetrieveResponse
from services.errors.knowledge_retrieval import ExternalKnowledgeRetrievalError, InnerKnowledgeRetrievalServiceError
@@ -48,7 +50,7 @@ register_response_schema_models(inner_api_ns, InnerKnowledgeRetrieveResponse)
class InnerKnowledgeRetrieveApi(Resource):
"""Retrieve knowledge from one or more datasets within the caller tenant."""
@inner_api_only
@plugin_inner_api_only
@inner_api_ns.doc("inner_knowledge_retrieve")
@inner_api_ns.doc(description="Retrieve knowledge for trusted internal callers")
@inner_api_ns.expect(inner_api_ns.models[InnerKnowledgeRetrieveRequest.__name__])
@@ -60,9 +62,8 @@ class InnerKnowledgeRetrieveApi(Resource):
@inner_api_ns.doc(
responses={
400: "Invalid request body",
401: "Unauthorized - invalid inner API key",
403: "Caller tenant does not own the requested resource",
404: "App or dataset not found",
404: "Invalid plugin inner API key, app not found, or dataset not found",
422: "Invalid retrieval configuration",
429: "Knowledge retrieval rate limited",
502: "External knowledge retrieval failed",
@@ -81,7 +82,7 @@ class InnerKnowledgeRetrieveApi(Resource):
) from exc
try:
response = InnerKnowledgeRetrievalService().retrieve(payload)
response = InnerKnowledgeRetrievalService().retrieve(payload, session=db.session)
except InnerKnowledgeRetrievalServiceError as exc:
raise InnerKnowledgeRetrievalHttpError(
error_code=exc.error_code,
@@ -1,10 +1,12 @@
"""Inner API for the agent drive (agent 网盘) control plane — ENG-591.
"""Inner API for the agent drive (agent 网盘) control plane.
Two endpoints, called by the dify-agent server (not the sandbox) with the inner
API key. The drive ref is the URL segment ``agent-<agent_id>``; the path-like
file key travels in the query/body, never as a URL path segment (so its ``/``
characters do not collide with routing). Drive-owned semantics: tenant scoped,
no user-level FileAccessScope.
These endpoints are called by the dify-agent server (not the sandbox) with the
inner API key. The drive ref is the URL segment ``agent-<agent_id>``; the
path-like file key travels in the query/body, never as a URL path segment (so
its ``/`` characters do not collide with routing). Drive-owned semantics:
tenant scoped, no user-level FileAccessScope. Commit still canonicalizes the
trusted execution-context user through the same EndUser lookup as plugin file
upload before validating ToolFile ownership.
"""
from flask import request
@@ -13,6 +15,7 @@ from pydantic import BaseModel, ValidationError
from controllers.console.wraps import setup_required
from controllers.inner_api import inner_api_ns
from controllers.inner_api.plugin.wraps import get_user
from controllers.inner_api.wraps import plugin_inner_api_only
from services.agent_drive_service import (
AgentDriveError,
@@ -56,6 +59,24 @@ class AgentDriveManifestApi(Resource):
return {"items": items}
@inner_api_ns.route("/drive/<string:drive_ref>/skills")
class AgentDriveSkillsApi(Resource):
@setup_required
@plugin_inner_api_only
@inner_api_ns.doc("agent_drive_skills")
@inner_api_ns.doc(description="List the skill catalog of an agent drive")
def get(self, drive_ref: str):
try:
agent_id = parse_agent_drive_ref(drive_ref)
tenant_id = (request.args.get("tenant_id") or "").strip()
if not tenant_id:
raise AgentDriveError("missing_tenant_id", "tenant_id is required", status_code=400)
items = AgentDriveService().list_skills(tenant_id=tenant_id, agent_id=agent_id)
except AgentDriveError as exc:
return _error_response(exc)
return {"items": items}
@inner_api_ns.route("/drive/<string:drive_ref>/commit")
class AgentDriveCommitApi(Resource):
@setup_required
@@ -69,9 +90,10 @@ class AgentDriveCommitApi(Resource):
body = _CommitRequest.model_validate(request.get_json(silent=True) or {})
except ValidationError as exc:
raise AgentDriveError("invalid_request", str(exc), status_code=400) from exc
user = get_user(body.tenant_id, body.user_id)
items = AgentDriveService().commit(
tenant_id=body.tenant_id,
user_id=body.user_id,
user_id=user.id,
agent_id=agent_id,
items=body.items,
)
@@ -47,7 +47,7 @@ class EnterpriseWorkspace(Resource):
if account is None:
return {"message": "owner account not found."}, 404
tenant = TenantService.create_tenant(args.name, is_from_dashboard=True)
tenant = TenantService.create_tenant(args.name, is_from_dashboard=True, session=db.session)
TenantService.create_tenant_member(tenant, account, db.session, role="owner")
tenant_was_created.send(tenant)
@@ -84,7 +84,7 @@ class EnterpriseWorkspaceNoOwnerEmail(Resource):
def post(self):
args = WorkspaceOwnerlessPayload.model_validate(inner_api_ns.payload or {})
tenant = TenantService.create_tenant(args.name, is_from_dashboard=True)
tenant = TenantService.create_tenant(args.name, is_from_dashboard=True, session=db.session)
tenant_was_created.send(tenant)
+2 -1
View File
@@ -20,7 +20,7 @@ openapi_ns = Namespace("openapi", description="User-scoped operations", path="/"
# Register response/query models BEFORE importing controller modules so that
# @openapi_ns.response / @openapi_ns.expect decorators can resolve model names.
from controllers.common.fields import EventStreamResponse
from controllers.common.fields import EventStreamResponse, SimpleResultResponse
from controllers.common.schema import register_enum_models, register_response_schema_models, register_schema_models
from controllers.openapi._models import (
AccountPayload,
@@ -95,6 +95,7 @@ register_response_schema_models(
openapi_ns,
ErrorBody,
EventStreamResponse,
SimpleResultResponse,
UsageInfo,
MessageMetadata,
AppListRow,
+14
View File
@@ -34,6 +34,7 @@ class OpenApiErrorCode(StrEnum):
# transport-generic (resolved from HTTP status for plain werkzeug raises)
BAD_REQUEST = "bad_request"
UNAUTHORIZED = "unauthorized"
TOKEN_EXPIRED = "token_expired"
FORBIDDEN = "forbidden"
NOT_FOUND = "not_found"
METHOD_NOT_ALLOWED = "method_not_allowed"
@@ -223,6 +224,19 @@ class OpenApiErrorFormatter:
return isinstance(part, (str, int)) and not isinstance(part, bool)
class InvalidBearer(OpenApiError): # noqa: N818
code = 401
error_code = OpenApiErrorCode.UNAUTHORIZED
description = "Invalid or unknown bearer token."
class SessionExpired(OpenApiError): # noqa: N818
code = 401
error_code = OpenApiErrorCode.TOKEN_EXPIRED
description = "Your session has expired."
hint = "Re-authenticate to continue (e.g. re-run your login command)."
class FilenameNotExists(OpenApiError): # noqa: N818
code = 400
error_code = OpenApiErrorCode.FILENAME_NOT_EXISTS
+29 -4
View File
@@ -2,7 +2,8 @@
from __future__ import annotations
from typing import Any, Literal
from enum import StrEnum
from typing import Any, Final, Literal
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
@@ -13,6 +14,30 @@ from models.model import AppMode
MAX_PAGE_LIMIT = 200
class SupportedAppType(StrEnum):
"""App types the ``app`` usage face (``get app``) lists and filters.
A curated subset of :class:`AppMode`: the real, user-facing app categories.
Excludes runtime-only mode tags that are not standalone apps
(``rag-pipeline`` is a knowledge ``Pipeline``; ``channel`` is unused) and the
roster-owned ``agent`` type (surfaced through the roster, not this list).
Members reference ``AppMode.*.value`` so the subset relationship is
type-checked: dropping a member from ``AppMode`` breaks this at import.
This is the single source for the listable set params, filters, and the
generated CLI whitelist all derive from it.
"""
COMPLETION = AppMode.COMPLETION.value
CHAT = AppMode.CHAT.value
ADVANCED_CHAT = AppMode.ADVANCED_CHAT.value
WORKFLOW = AppMode.WORKFLOW.value
AGENT_CHAT = AppMode.AGENT_CHAT.value
SUPPORTED_APP_TYPES: Final[tuple[AppMode, ...]] = tuple(AppMode(t.value) for t in SupportedAppType)
class UsageInfo(BaseModel):
prompt_tokens: int = 0
completion_tokens: int = 0
@@ -279,12 +304,12 @@ class AppDescribeQuery(BaseModel):
class AppListQuery(BaseModel):
"""mode is a closed enum."""
"""mode is a closed enum of listable app types."""
workspace_id: UUIDStr
page: int = Field(1, ge=1)
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
mode: AppMode | None = None
mode: SupportedAppType | None = None
name: str | None = Field(None, max_length=200)
@@ -335,7 +360,7 @@ class PermittedExternalAppsListQuery(BaseModel):
page: int = Field(1, ge=1)
limit: int = Field(20, ge=1, le=MAX_PAGE_LIMIT)
mode: AppMode | None = None
mode: SupportedAppType | None = None
name: str | None = Field(None, max_length=200)
+3 -2
View File
@@ -3,7 +3,7 @@
from __future__ import annotations
import logging
from collections.abc import Callable, Iterator
from collections.abc import Callable, Generator
from contextlib import contextmanager
from typing import Any
@@ -61,7 +61,7 @@ logger = logging.getLogger(__name__)
@contextmanager
def _translate_service_errors() -> Iterator[None]:
def _translate_service_errors() -> Generator[None, None, None]:
try:
yield
except WorkflowNotFoundError as ex:
@@ -166,6 +166,7 @@ class AppRunApi(Resource):
surface="apps",
)
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(stream_obj)
+15 -3
View File
@@ -16,6 +16,7 @@ from controllers.openapi import openapi_ns
from controllers.openapi._contract import accepts, returns
from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA, build_input_schema, resolve_app_config
from controllers.openapi._models import (
SUPPORTED_APP_TYPES,
AppDescribeInfo,
AppDescribeQuery,
AppDescribeResponse,
@@ -24,12 +25,13 @@ from controllers.openapi._models import (
AppListRow,
)
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, RBACRequirement
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
from controllers.service_api.app.error import AppUnavailableError
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from models import App
from models.enums import AppStatus
from models.model import AppMode
from services.account_service import TenantService
from services.app_service import AppListParams, AppService
@@ -37,6 +39,11 @@ from services.app_service import AppListParams, AppService
_ALLOWED_DESCRIBE_FIELDS: frozenset[str] = frozenset({"info", "parameters", "input_schema"})
def _is_listable(app: App) -> bool:
"""Whether the openapi app face exposes this app (curated, listable types only)."""
return app.mode in SUPPORTED_APP_TYPES
_EMPTY_PARAMETERS: dict[str, Any] = {
"opening_statement": None,
"suggested_questions": [],
@@ -160,7 +167,9 @@ class AppListApi(Resource):
# an empty set or list means the caller has no accessible apps.
# End-users bypass RBAC here — their access is controlled by scope upstream.
apply_rbac_filter = (
dify_config.RBAC_ENABLED and auth_data.caller_kind != "end_user" and auth_data.account_id is not None
dify_config.RBAC_ENABLED
and auth_data.caller_kind != CallerKind.END_USER
and auth_data.account_id is not None
)
access_filter = AppAccessFilter.unrestricted()
if apply_rbac_filter:
@@ -171,6 +180,8 @@ class AppListApi(Resource):
app: App | None = AppService.get_visible_app_by_id(db.session, str(parsed_uuid))
if app is None or str(app.tenant_id) != workspace_id:
return empty
if not _is_listable(app):
return empty
# Apply RBAC visibility to the UUID fast-path the same way the service
# layer does for paginated queries (id in accessible set OR own app).
if apply_rbac_filter and not access_filter.is_app_accessible(
@@ -195,7 +206,7 @@ class AppListApi(Resource):
limit=query.limit,
mode=query.mode.value if query.mode else "all", # type:ignore
name=query.name,
status="normal",
status=AppStatus.NORMAL,
# Visibility gate pushed into the query — pagination.total stays
# consistent across pages because invisible rows never count.
openapi_visible=True,
@@ -223,6 +234,7 @@ class AppListApi(Resource):
workspace_name=tenant_name,
)
for r in pagination.items
if _is_listable(r)
]
env = AppListResponse(
@@ -25,6 +25,7 @@ from controllers.openapi.auth.data import AuthData, Edition
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from models import App
from models.enums import AppStatus
from services.account_service import TenantService
from services.app_service import AppService
from services.enterprise.app_permitted_service import list_permitted_apps
@@ -62,7 +63,7 @@ class PermittedExternalAppsListApi(Resource):
items: list[AppListRow] = []
for app_id in page_result.app_ids:
app = apps_by_id.get(app_id)
if not app or app.status != "normal":
if not app or app.status != AppStatus.NORMAL:
continue
tenant = tenants_by_id.get(str(app.tenant_id))
items.append(
+7 -3
View File
@@ -2,7 +2,6 @@ from __future__ import annotations
import uuid
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, ConfigDict, Field
from werkzeug.exceptions import InternalServerError
@@ -21,6 +20,11 @@ class Edition(StrEnum):
SAAS = "saas"
class CallerKind(StrEnum):
ACCOUNT = "account"
END_USER = "end_user"
def current_edition() -> Edition:
if dify_config.EDITION == "CLOUD":
return Edition.SAAS
@@ -78,9 +82,9 @@ class AuthData(BaseModel):
tenant_role: TenantAccountRole | None = None
caller: Account | EndUser | None = None
caller_kind: Literal["account", "end_user"] | None = None
caller_kind: CallerKind | None = None
def require_app_context(self) -> tuple[App, Account | EndUser, Literal["account", "end_user"]]:
def require_app_context(self) -> tuple[App, Account | EndUser, CallerKind]:
if self.app is None or self.caller is None or self.caller_kind is None:
raise InternalServerError("pipeline_invariant_violated: app context missing")
return self.app, self.caller, self.caller_kind
+9 -1
View File
@@ -17,6 +17,7 @@ from flask_login import user_logged_in
from werkzeug.exceptions import Forbidden, NotFound, Unauthorized
from controllers.openapi._audit import emit_wrong_surface
from controllers.openapi._errors import InvalidBearer, SessionExpired
from controllers.openapi.auth.data import (
AuthData,
Edition,
@@ -28,7 +29,9 @@ from controllers.openapi.auth.data import (
from controllers.openapi.auth.flow import When
from libs.oauth_bearer import (
AuthContext,
InvalidBearerError,
Scope,
TokenExpiredError,
TokenType,
extract_bearer,
get_authenticator,
@@ -217,7 +220,12 @@ class PipelineRouter:
if not token:
raise Unauthorized("bearer required")
identity = get_authenticator().authenticate(token)
try:
identity = get_authenticator().authenticate(token)
except TokenExpiredError:
raise SessionExpired()
except InvalidBearerError:
raise InvalidBearer()
if allowed_token_types is not None and identity.token_type not in allowed_token_types:
emit_wrong_surface(
+7 -7
View File
@@ -5,10 +5,10 @@ import uuid
from flask import request
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound, Unauthorized
from controllers.openapi.auth.data import AuthData
from controllers.openapi.auth.data import AuthData, CallerKind
from extensions.ext_database import db
from models.account import TenantStatus
from models.enums import EndUserType
from models.account import AccountStatus, TenantStatus
from models.enums import AppStatus, EndUserType
from services.account_service import AccountService, TenantService
from services.app_service import AppService
from services.end_user_service import EndUserService
@@ -24,7 +24,7 @@ def load_app(data: AuthData) -> None:
except ValueError:
raise NotFound("app not found")
app = AppService.get_app_by_id(db.session, app_id)
if not app or app.status != "normal":
if not app or app.status != AppStatus.NORMAL:
raise NotFound("app not found")
data.app = app
@@ -65,7 +65,7 @@ def load_account(data: AuthData) -> None:
if data.tenant:
account.current_tenant = data.tenant
data.caller = account
data.caller_kind = "account"
data.caller_kind = CallerKind.ACCOUNT
def load_workspace_role(data: AuthData) -> None:
@@ -73,7 +73,7 @@ def load_workspace_role(data: AuthData) -> None:
return
if data.tenant is None or data.account_id is None:
return
if data.caller is not None and getattr(data.caller, "status", None) != "active":
if data.caller is not None and getattr(data.caller, "status", None) != AccountStatus.ACTIVE:
return
role = TenantService.get_account_role_in_tenant(db.session, str(data.account_id), str(data.tenant.id))
if role is None:
@@ -91,7 +91,7 @@ def resolve_external_user(data: AuthData) -> None:
user_id=data.external_identity.email,
)
data.caller = end_user
data.caller_kind = "end_user"
data.caller_kind = CallerKind.END_USER
def load_app_access_mode(data: AuthData) -> None:
+2 -2
View File
@@ -5,7 +5,7 @@ from werkzeug.exceptions import Forbidden, NotFound, UnprocessableEntity
from configs import dify_config
from controllers.common.wraps import enforce_rbac_access
from controllers.openapi.auth.data import AuthData
from controllers.openapi.auth.data import AuthData, CallerKind
from extensions.ext_database import db
from libs.oauth_bearer import Scope, TokenType
from services.account_service import AccountService, TenantService
@@ -58,7 +58,7 @@ def check_rbac_permission(data: AuthData) -> None:
if not dify_config.RBAC_ENABLED:
return
# Only account callers are subject to RBAC; end_user access is scope-controlled.
if data.caller_kind != "account":
if data.caller_kind != CallerKind.ACCOUNT:
return
if data.account_id is None or data.tenant is None:
raise Forbidden("rbac context missing")
+2 -2
View File
@@ -22,7 +22,7 @@ from controllers.openapi._contract import accepts, returns
from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch
from controllers.openapi._models import FormSubmitResponse, HumanInputFormDefinitionResponse
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, RBACRequirement
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
from core.workflow.human_input_policy import (
HumanInputSurface,
is_recipient_type_allowed_for_surface,
@@ -98,7 +98,7 @@ class OpenApiWorkflowHumanInputFormApi(Resource):
submission_user_id: str | None = None
submission_end_user_id: str | None = None
if caller_kind == "account":
if caller_kind == CallerKind.ACCOUNT:
submission_user_id = caller.id
else:
submission_end_user_id = caller.id
+2 -2
View File
@@ -22,7 +22,7 @@ from controllers.common.schema import query_params_from_model
from controllers.common.wraps import RBACPermission, RBACResourceScope
from controllers.openapi import openapi_ns
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData, RBACRequirement
from controllers.openapi.auth.data import AuthData, CallerKind, RBACRequirement
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
@@ -70,7 +70,7 @@ class OpenApiWorkflowEventsApi(Resource):
if workflow_run.app_id != app_model.id:
raise NotFound("Workflow run not found")
if caller_kind == "account":
if caller_kind == CallerKind.ACCOUNT:
if workflow_run.created_by_role != CreatorUserRole.ACCOUNT or workflow_run.created_by != caller.id:
raise NotFound("Workflow run not found")
else:
+6 -5
View File
@@ -128,7 +128,7 @@ class WorkspaceSwitchApi(Resource):
account = _load_account(auth_data.account_id)
try:
TenantService.switch_tenant(account, workspace_id)
TenantService.switch_tenant(account, workspace_id, session=db.session)
except AccountNotLinkTenantError:
raise NotFound("workspace not found")
@@ -152,7 +152,7 @@ class WorkspaceMembersApi(Resource):
@accepts(query=MemberListQuery)
def get(self, workspace_id: str, *, auth_data: AuthData, query: MemberListQuery):
tenant = _load_tenant(workspace_id)
members = TenantService.get_tenant_members(tenant)
members = TenantService.get_tenant_members(tenant, session=db.session)
total = len(members)
start = (query.page - 1) * query.limit
page_items = members[start : start + query.limit]
@@ -184,6 +184,7 @@ class WorkspaceMembersApi(Resource):
language=None,
role=body.role,
inviter=inviter,
session=db.session,
)
except AccountAlreadyInTenantError as exc:
raise BadRequest(str(exc))
@@ -193,7 +194,7 @@ class WorkspaceMembersApi(Resource):
raise BadRequest(str(exc))
normalized_email = body.email.lower()
member = AccountService.get_account_by_email_with_case_fallback(normalized_email)
member = AccountService.get_account_by_email_with_case_fallback(db.session, normalized_email)
if member is None:
# invite_new_member just created or fetched this account.
raise RuntimeError("invited member missing from DB after invite")
@@ -232,7 +233,7 @@ class WorkspaceMemberApi(Resource):
raise NotFound("member not found")
try:
TenantService.remove_member_from_tenant(tenant, member, operator)
TenantService.remove_member_from_tenant(tenant, member, operator, session=db.session)
except CannotOperateSelfError as exc:
raise BadRequest(str(exc))
except NoPermissionError as exc:
@@ -266,7 +267,7 @@ class WorkspaceMemberRoleApi(Resource):
raise NotFound("member not found")
try:
TenantService.update_member_role(tenant, member, body.role, operator)
TenantService.update_member_role(tenant, member, body.role, operator, session=db.session)
except CannotOperateSelfError as exc:
raise BadRequest(str(exc))
except NoPermissionError as exc:
@@ -10,6 +10,7 @@ from controllers.common.schema import query_params_from_model, register_response
from controllers.console.wraps import edit_permission_required
from controllers.service_api import service_api_ns
from controllers.service_api.wraps import validate_app_token
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.annotation_fields import Annotation, AnnotationList
from fields.base import ResponseModel
@@ -281,7 +282,9 @@ class AnnotationUpdateDeleteApi(Resource):
"""Update an existing annotation."""
payload = AnnotationCreatePayload.model_validate(service_api_ns.payload or {})
update_args: UpdateAnnotationArgs = {"question": payload.question, "answer": payload.answer}
annotation = AppAnnotationService.update_app_annotation_directly(update_args, app_model.id, str(annotation_id))
annotation = AppAnnotationService.update_app_annotation_directly(
update_args, app_model.id, str(annotation_id), db.session
)
response = Annotation.model_validate(annotation, from_attributes=True)
return response.model_dump(mode="json")
@@ -310,5 +313,5 @@ class AnnotationUpdateDeleteApi(Resource):
@edit_permission_required
def delete(self, app_model: App, annotation_id: UUID):
"""Delete an annotation."""
AppAnnotationService.delete_app_annotation(app_model.id, str(annotation_id))
AppAnnotationService.delete_app_annotation(app_model.id, str(annotation_id), db.session)
return "", 204
+45 -2
View File
@@ -2,6 +2,7 @@ from typing import Any, cast
from flask_restx import Resource
from pydantic import Field
from sqlalchemy import select
from controllers.common.fields import Parameters
from controllers.common.schema import register_response_schema_models
@@ -9,7 +10,11 @@ from controllers.service_api import service_api_ns
from controllers.service_api.app.error import AppUnavailableError
from controllers.service_api.wraps import validate_app_token
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from extensions.ext_database import db
from fields.base import ResponseModel
from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode
from services.app_service import AppService
@@ -29,6 +34,40 @@ class AppMetaResponse(ResponseModel):
register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, AppInfoResponse)
def _get_agent_app_feature_dict_and_user_input_form(app_model: App) -> tuple[dict[str, Any], list[dict[str, Any]]]:
app_model_config = app_model.app_model_config
features_dict = cast(dict[str, Any], app_model_config.to_dict()) if app_model_config is not None else {}
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == app_model.tenant_id,
Agent.app_id == app_model.id,
Agent.scope == AgentScope.ROSTER,
Agent.source == AgentSource.AGENT_APP,
Agent.status == AgentStatus.ACTIVE,
)
.limit(1)
)
if agent is None or not agent.active_config_snapshot_id:
raise AppUnavailableError()
snapshot = db.session.scalar(
select(AgentConfigSnapshot)
.where(
AgentConfigSnapshot.tenant_id == app_model.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == agent.active_config_snapshot_id,
)
.limit(1)
)
if snapshot is None:
raise AppUnavailableError()
agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)
return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables)
@service_api_ns.route("/parameters")
class AppParameterApi(Resource):
"""Resource for app variables."""
@@ -61,12 +100,16 @@ class AppParameterApi(Resource):
Returns the input form parameters and configuration for the application.
"""
if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
features_dict: dict[str, Any]
user_input_form: list[dict[str, Any]]
if app_model.mode == AppMode.AGENT:
features_dict, user_input_form = _get_agent_app_feature_dict_and_user_input_form(app_model)
elif app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app_model.workflow
if workflow is None:
raise AppUnavailableError()
features_dict: dict[str, Any] = workflow.features_dict
features_dict = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app_model.app_model_config
+7 -1
View File
@@ -23,6 +23,7 @@ from controllers.service_api.app.error import (
from controllers.service_api.schema import binary_response, expect_with_user, multipart_file_params
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from models.model import App, EndUser
from services.audio_service import AudioService
@@ -177,7 +178,12 @@ class TextApi(Resource):
text = payload.text
voice = payload.voice
response = AudioService.transcript_tts(
app_model=app_model, text=text, voice=voice, end_user=end_user.external_user_id, message_id=message_id
app_model=app_model,
session=db.session,
text=text,
voice=voice,
end_user=end_user.external_user_id,
message_id=message_id,
)
return response
@@ -8,6 +8,7 @@ from controllers.common.controller_schemas import MetadataUpdatePayload
from controllers.common.schema import register_response_schema_models, register_schema_model, register_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.wraps import DatasetApiResource, cloud_edition_billing_rate_limit_check
from extensions.ext_database import db
from fields.dataset_fields import (
DatasetMetadataActionResponse,
DatasetMetadataBuiltInFieldsResponse,
@@ -85,7 +86,7 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource):
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
metadata = MetadataService.create_metadata(dataset_id_str, metadata_args)
metadata = MetadataService.create_metadata(db.session(), dataset_id_str, metadata_args)
return dump_response(DatasetMetadataResponse, metadata), 201
@service_api_ns.doc(
@@ -118,7 +119,7 @@ class DatasetMetadataCreateServiceApi(DatasetApiResource):
dataset = DatasetService.get_dataset(dataset_id_str)
if dataset is None:
raise NotFound("Dataset not found.")
metadata = MetadataService.get_dataset_metadatas(dataset)
metadata = MetadataService.get_dataset_metadatas(db.session(), dataset)
return dump_response(DatasetMetadataListResponse, metadata), 200
@@ -158,7 +159,7 @@ class DatasetMetadataServiceApi(DatasetApiResource):
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
metadata = MetadataService.update_metadata_name(dataset_id_str, metadata_id_str, payload.name)
metadata = MetadataService.update_metadata_name(db.session(), dataset_id_str, metadata_id_str, payload.name)
return dump_response(DatasetMetadataResponse, metadata), 200
@service_api_ns.doc(
@@ -193,7 +194,7 @@ class DatasetMetadataServiceApi(DatasetApiResource):
raise NotFound("Dataset not found.")
DatasetService.check_dataset_permission(dataset, current_user)
MetadataService.delete_metadata(dataset_id_str, metadata_id_str)
MetadataService.delete_metadata(db.session(), dataset_id_str, metadata_id_str)
return "", 204
@@ -263,9 +264,9 @@ class DatasetMetadataBuiltInFieldActionServiceApi(DatasetApiResource):
match action:
case "enable":
MetadataService.enable_built_in_field(dataset)
MetadataService.enable_built_in_field(db.session(), dataset)
case "disable":
MetadataService.disable_built_in_field(dataset)
MetadataService.disable_built_in_field(db.session(), dataset)
return dump_response(DatasetMetadataActionResponse, {"result": "success"}), 200
@@ -309,6 +310,6 @@ class DocumentMetadataEditServiceApi(DatasetApiResource):
metadata_args = MetadataOperationData.model_validate(service_api_ns.payload or {})
MetadataService.update_documents_metadata(dataset, metadata_args)
MetadataService.update_documents_metadata(db.session(), dataset, metadata_args)
return dump_response(DatasetMetadataActionResponse, {"result": "success"}), 200
+7 -1
View File
@@ -22,6 +22,7 @@ from controllers.web.error import (
)
from controllers.web.wraps import WebApiResource
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs.helper import uuid_value
from models.model import App, EndUser
@@ -130,7 +131,12 @@ class TextApi(WebApiResource):
text = payload.text
voice = payload.voice
response = AudioService.transcript_tts(
app_model=app_model, text=text, voice=voice, end_user=end_user.external_user_id, message_id=message_id
app_model=app_model,
session=db.session,
text=text,
voice=voice,
end_user=end_user.external_user_id,
message_id=message_id,
)
return response
+7
View File
@@ -32,6 +32,7 @@ from libs.helper import uuid_value
from models.model import App, AppMode, EndUser
from services.app_generate_service import AppGenerateService
from services.app_task_service import AppTaskService
from services.conversation_service import ConversationService
from services.errors.llm import InvokeRateLimitError
logger = logging.getLogger(__name__)
@@ -202,6 +203,12 @@ class ChatApi(WebApiResource):
args["auto_generate_name"] = False
try:
# Eagerly validate conversation to avoid hanging on invalid conversation_id
if payload.conversation_id:
ConversationService.get_conversation(
app_model=app_model, conversation_id=payload.conversation_id, user=end_user
)
response = AppGenerateService.generate(
app_model=app_model, user=end_user, args=args, invoke_from=InvokeFrom.WEB_APP, streaming=streaming
)
+2 -2
View File
@@ -69,7 +69,7 @@ class ForgotPasswordSendEmailApi(Resource):
else:
language = "en-US"
account = AccountService.get_account_by_email_with_case_fallback(request_email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, request_email)
if account is None:
raise AuthenticationFailedError()
else:
@@ -168,7 +168,7 @@ class ForgotPasswordResetApi(Resource):
email = reset_data.get("email", "")
account = AccountService.get_account_by_email_with_case_fallback(email)
account = AccountService.get_account_by_email_with_case_fallback(db.session, email)
if account:
account = db.session.merge(account)
@@ -4,7 +4,7 @@ from collections.abc import Mapping, Sequence
from typing import Any, cast
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm import Session
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfig
from core.app.apps.base_app_queue_manager import AppQueueManager
@@ -22,7 +22,7 @@ from core.app.entities.queue_entities import (
from core.app.features.annotation_reply.annotation_reply import AnnotationReplyFeature
from core.app.layers.conversation_variable_persist_layer import ConversationVariablePersistenceLayer
from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
from core.db.session_factory import session_factory
from core.db.session_factory import create_session, session_factory
from core.moderation.base import ModerationError
from core.moderation.input_moderation import InputModeration
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
@@ -107,7 +107,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
workflow_execution_id=self.application_generate_entity.workflow_run_id,
)
with Session(db.engine, expire_on_commit=False) as session:
with create_session() as session:
app_record = session.scalar(select(App).where(App.id == app_config.app_id))
if not app_record:
@@ -204,6 +204,8 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
trace_session_id=self.application_generate_entity.extras.get("trace_session_id"),
)
# Release the Flask scoped session before workflow execution so a checked-out DB connection
# is not held for the lifetime of the graph run.
db.session.close()
# RUN WORKFLOW
@@ -368,7 +370,7 @@ class AdvancedChatAppRunner(WorkflowBasedAppRunner):
:return: List of conversation variables ready for use
"""
with sessionmaker(bind=db.engine).begin() as session:
with create_session() as session, session.begin():
existing_variables = self._load_existing_conversation_variables(session)
if not existing_variables:
@@ -41,6 +41,7 @@ from core.app.entities.queue_entities import (
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
QueuePingEvent,
QueueReasoningChunkEvent,
QueueRetrieverResourcesEvent,
QueueStopEvent,
QueueTextChunkEvent,
@@ -62,6 +63,7 @@ from core.app.entities.task_entities import (
MessageAudioStreamResponse,
MessageEndStreamResponse,
PingStreamResponse,
ReasoningChunkStreamResponse,
StreamResponse,
WorkflowPauseStreamResponse,
WorkflowTaskState,
@@ -473,6 +475,17 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
self._workflow_response_converter.fetch_files_from_node_outputs(event.outputs or {})
)
# Collect terminal reasoning (separated mode) per LLM node id for persistence. This is the
# authoritative source (outputs.reasoning_content), decoupled from the live delta stream.
# Accumulate across iteration/loop passes (same node_id) to match the live stream, which
# appends every pass under the same key — overwriting would keep only the last pass.
if event.node_type == BuiltinNodeTypes.LLM:
reasoning_content = (event.outputs or {}).get("reasoning_content")
if isinstance(reasoning_content, str) and reasoning_content:
self._task_state.metadata.reasoning[event.node_id] = (
self._task_state.metadata.reasoning.get(event.node_id, "") + reasoning_content
)
node_finish_resp = self._workflow_response_converter.workflow_node_finish_to_stream_response(
event=event,
task_id=self._application_generate_entity.task_id,
@@ -535,6 +548,27 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
answer=delta_text, message_id=self._message_id, from_variable_selector=event.from_variable_selector
)
def _handle_reasoning_chunk_event(
self, event: QueueReasoningChunkEvent, **kwargs
) -> Generator[StreamResponse, None, None]:
"""Handle out-of-band reasoning chunk events.
Pure emit: reasoning is streamed on its own channel and never written to the
answer. The terminal marker (is_final) may carry an empty reasoning string, in
which case it is still forwarded as the "thinking finished" signal.
"""
if not event.reasoning and not event.is_final:
return
yield ReasoningChunkStreamResponse(
task_id=self._application_generate_entity.task_id,
data=ReasoningChunkStreamResponse.Data(
message_id=self._message_id,
reasoning=event.reasoning,
node_id=event.from_node_id,
is_final=event.is_final,
),
)
def _handle_iteration_start_event(
self, event: QueueIterationStartEvent, **kwargs
) -> Generator[StreamResponse, None, None]:
@@ -872,6 +906,7 @@ class AdvancedChatAppGenerateTaskPipeline(GraphRuntimeStateSupport):
QueuePingEvent: self._handle_ping_event,
QueueErrorEvent: self._handle_error_event,
QueueTextChunkEvent: self._handle_text_chunk_event,
QueueReasoningChunkEvent: self._handle_reasoning_chunk_event,
# Workflow events
QueueWorkflowStartedEvent: self._handle_workflow_started_event,
QueueWorkflowSucceededEvent: self._handle_workflow_succeeded_event,
@@ -21,6 +21,7 @@ from core.app.app_config.entities import (
EasyUIBasedAppModelConfigFrom,
PromptTemplateEntity,
)
from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode, AppModelConfig, AppModelConfigDict, Conversation
@@ -98,8 +99,7 @@ class AgentAppConfigManager(BaseAppConfigManager):
# pipeline's bookkeeping (token counting, persistence).
base["prompt_type"] = PromptTemplateEntity.PromptType.SIMPLE.value
base["pre_prompt"] = agent_soul.prompt.system_prompt or ""
# Agent App takes the user message directly; no completion-style inputs form.
base.setdefault("user_input_form", [])
base["user_input_form"] = agent_app_variables_to_user_input_form(agent_soul.app_variables)
return base
@@ -0,0 +1,37 @@
from __future__ import annotations
from collections.abc import Sequence
from typing import Any
from models.agent_config_entities import AppVariableConfig
def agent_app_variables_to_user_input_form(app_variables: Sequence[AppVariableConfig]) -> list[dict[str, Any]]:
"""Project Agent Soul app variables into the legacy service-API parameter form."""
user_input_form: list[dict[str, Any]] = []
for variable in app_variables:
form_type = _form_type_for_agent_variable(variable.type)
form_item: dict[str, Any] = {
"label": variable.name,
"variable": variable.name,
"required": variable.required,
}
if variable.default is not None:
form_item["default"] = variable.default
user_input_form.append({form_type: form_item})
return user_input_form
def _form_type_for_agent_variable(variable_type: str) -> str:
normalized = variable_type.strip().lower()
if normalized in {"number", "integer", "float"}:
return "number"
if normalized in {"boolean", "bool"}:
return "checkbox"
if normalized in {"paragraph", "long_text", "multiline"}:
return "paragraph"
return "text-input"
__all__ = ["agent_app_variables_to_user_input_form"]
@@ -37,6 +37,7 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import (
from core.workflow.nodes.agent_v2.runtime_request_builder import (
append_runtime_warnings,
build_ask_human_layer_config,
build_drive_aware_soul_mention_resolver,
build_drive_layer_config,
build_knowledge_layer_config,
build_shell_layer_config,
@@ -123,9 +124,19 @@ class AgentAppRuntimeRequestBuilder:
}
drive_config = None
soul_prompt_resolver = build_soul_mention_resolver(agent_soul)
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent_id)
drive_config, drive_warnings = build_drive_layer_config(
agent_soul,
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent_id,
)
append_runtime_warnings(metadata, drive_warnings)
soul_prompt_resolver = build_drive_aware_soul_mention_resolver(
agent_soul,
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent_id,
)
knowledge_config = build_knowledge_layer_config(agent_soul)
request = self._request_builder.build_for_agent_app(
@@ -154,9 +165,7 @@ class AgentAppRuntimeRequestBuilder:
),
# ENG-616: expand slash-menu mention tokens to canonical names so
# no frontend-internal {{#…#}} marker ever reaches the model.
agent_soul_prompt=expand_prompt_mentions(
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
).strip()
agent_soul_prompt=expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
or None,
user_prompt=context.user_query,
tools=tools_layer,
@@ -188,7 +197,7 @@ class AgentAppRuntimeRequestBuilder:
def _plugin_daemon_plugin_id(*, plugin_id: str, model_provider: str) -> str:
"""Return the transport plugin id expected by plugin-daemon headers."""
if plugin_id.count("/") == 1:
return plugin_id
return plugin_id.split(":", 1)[0].split("@", 1)[0]
if plugin_id:
return ModelProviderID(plugin_id).plugin_id
return ModelProviderID(model_provider).plugin_id
+14 -7
View File
@@ -12,10 +12,10 @@ from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom
from core.app.apps.base_app_runner import AppRunner
from core.app.entities.app_invoke_entities import AgentChatAppGenerateEntity
from core.app.entities.queue_entities import QueueAnnotationReplyEvent
from core.db.session_factory import create_session
from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_manager import ModelInstance
from core.moderation.base import ModerationError
from extensions.ext_database import db
from graphon.model_runtime.entities.llm_entities import LLMMode
from graphon.model_runtime.entities.model_entities import ModelFeature, ModelPropertyKey
from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel
@@ -47,7 +47,10 @@ class AgentChatAppRunner(AppRunner):
app_config = application_generate_entity.app_config
app_config = cast(AgentChatAppConfig, app_config)
app_stmt = select(App).where(App.id == app_config.app_id)
app_record = db.session.scalar(app_stmt)
with create_session() as session:
app_record = session.scalar(app_stmt)
if app_record:
session.expunge(app_record)
if not app_record:
raise ValueError("App not found")
@@ -185,14 +188,18 @@ class AgentChatAppRunner(AppRunner):
if {ModelFeature.MULTI_TOOL_CALL, ModelFeature.TOOL_CALL}.intersection(model_schema.features or []):
agent_entity.strategy = AgentEntity.Strategy.FUNCTION_CALLING
conversation_stmt = select(Conversation).where(Conversation.id == conversation.id)
conversation_result = db.session.scalar(conversation_stmt)
if conversation_result is None:
raise ValueError("Conversation not found")
msg_stmt = select(Message).where(Message.id == message.id)
message_result = db.session.scalar(msg_stmt)
with create_session() as session:
conversation_result = session.scalar(conversation_stmt)
if conversation_result is None:
raise ValueError("Conversation not found")
message_result = session.scalar(msg_stmt)
if message_result is not None:
session.expunge(message_result)
session.expunge(conversation_result)
if message_result is None:
raise ValueError("Message not found")
db.session.close()
runner_cls: type[FunctionCallAgentRunner] | type[CotChatAgentRunner] | type[CotCompletionAgentRunner]
# start agent runner
+7 -1
View File
@@ -11,6 +11,7 @@ from core.app.entities.app_invoke_entities import (
)
from core.app.entities.queue_entities import QueueAnnotationReplyEvent
from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler
from core.db.session_factory import create_session
from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_manager import ModelInstance
from core.moderation.base import ModerationError
@@ -46,7 +47,10 @@ class ChatAppRunner(AppRunner):
app_config = application_generate_entity.app_config
app_config = cast(ChatAppConfig, app_config)
stmt = select(App).where(App.id == app_config.app_id)
app_record = db.session.scalar(stmt)
with create_session() as session:
app_record = session.scalar(stmt)
if app_record:
session.expunge(app_record)
if not app_record:
raise ValueError("App not found")
@@ -216,6 +220,8 @@ class ChatAppRunner(AppRunner):
model=application_generate_entity.model_conf.model,
)
# Release the Flask scoped session before LLM streaming so a checked-out DB connection
# is not held for the lifetime of the provider response.
db.session.close()
invoke_result = model_instance.invoke_llm(
+7 -1
View File
@@ -10,6 +10,7 @@ from core.app.entities.app_invoke_entities import (
CompletionAppGenerateEntity,
)
from core.callback_handler.index_tool_callback_handler import DatasetIndexToolCallbackHandler
from core.db.session_factory import create_session
from core.model_manager import ModelInstance
from core.moderation.base import ModerationError
from core.rag.retrieval.dataset_retrieval import DatasetRetrieval
@@ -39,7 +40,10 @@ class CompletionAppRunner(AppRunner):
app_config = application_generate_entity.app_config
app_config = cast(CompletionAppConfig, app_config)
stmt = select(App).where(App.id == app_config.app_id)
app_record = db.session.scalar(stmt)
with create_session() as session:
app_record = session.scalar(stmt)
if app_record:
session.expunge(app_record)
if not app_record:
raise ValueError("App not found")
@@ -174,6 +178,8 @@ class CompletionAppRunner(AppRunner):
model=application_generate_entity.model_conf.model,
)
# Release the Flask scoped session before LLM streaming so a checked-out DB connection
# is not held for the lifetime of the provider response.
db.session.close()
invoke_result = model_instance.invoke_llm(
+27 -24
View File
@@ -3,6 +3,7 @@ import time
from typing import cast
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.apps.pipeline.pipeline_config_manager import PipelineConfig
@@ -14,12 +15,12 @@ from core.app.entities.app_invoke_entities import (
build_dify_run_context,
)
from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
from core.db.session_factory import create_session
from core.repositories.factory import WorkflowExecutionRepository, WorkflowNodeExecutionRepository
from core.workflow.node_factory import DifyGraphInitContext, DifyNodeFactory, get_default_root_node_id
from core.workflow.system_variables import build_bootstrap_variables, build_system_variables
from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool
from core.workflow.workflow_entry import WorkflowEntry
from extensions.ext_database import db
from graphon.enums import WorkflowType
from graphon.graph import Graph
from graphon.graph_events import GraphEngineEvent, GraphRunFailedEvent
@@ -83,22 +84,24 @@ class PipelineRunner(WorkflowBasedAppRunner):
user_from = self._resolve_user_from(invoke_from)
user_id = None
if invoke_from in {InvokeFrom.WEB_APP, InvokeFrom.SERVICE_API}:
end_user = db.session.get(EndUser, self.application_generate_entity.user_id)
if end_user:
user_id = end_user.session_id
else:
user_id = self.application_generate_entity.user_id
with create_session() as session:
if invoke_from in {InvokeFrom.WEB_APP, InvokeFrom.SERVICE_API}:
end_user = session.get(EndUser, self.application_generate_entity.user_id)
if end_user:
user_id = end_user.session_id
else:
user_id = self.application_generate_entity.user_id
pipeline = db.session.get(Pipeline, app_config.app_id)
if not pipeline:
raise ValueError("Pipeline not found")
pipeline = session.get(Pipeline, app_config.app_id)
if not pipeline:
raise ValueError("Pipeline not found")
workflow = self.get_workflow(pipeline=pipeline, workflow_id=app_config.workflow_id)
if not workflow:
raise ValueError("Workflow not initialized")
workflow = self.get_workflow(session=session, pipeline=pipeline, workflow_id=app_config.workflow_id)
if not workflow:
raise ValueError("Workflow not initialized")
db.session.close()
session.expunge(pipeline)
session.expunge(workflow)
# if only single iteration run is requested
if self.application_generate_entity.single_iteration_run or self.application_generate_entity.single_loop_run:
@@ -208,12 +211,12 @@ class PipelineRunner(WorkflowBasedAppRunner):
)
self._handle_event(workflow_entry, event)
def get_workflow(self, pipeline: Pipeline, workflow_id: str) -> Workflow | None:
def get_workflow(self, session: Session, pipeline: Pipeline, workflow_id: str) -> Workflow | None:
"""
Get workflow
"""
# fetch workflow by workflow_id
workflow = db.session.scalar(
workflow = session.scalar(
select(Workflow)
.where(Workflow.tenant_id == pipeline.tenant_id, Workflow.app_id == pipeline.id, Workflow.id == workflow_id)
.limit(1)
@@ -298,11 +301,11 @@ class PipelineRunner(WorkflowBasedAppRunner):
"""
if isinstance(event, GraphRunFailedEvent):
if document_id and dataset_id:
document = db.session.scalar(
select(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).limit(1)
)
if document:
document.indexing_status = "error"
document.error = event.error or "Unknown error"
db.session.add(document)
db.session.commit()
with create_session() as session, session.begin():
document = session.scalar(
select(Document).where(Document.id == document_id, Document.dataset_id == dataset_id).limit(1)
)
if document:
document.indexing_status = "error"
document.error = event.error or "Unknown error"
session.add(document)
@@ -31,6 +31,7 @@ from core.app.entities.queue_entities import (
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
QueuePingEvent,
QueueReasoningChunkEvent,
QueueStopEvent,
QueueTextChunkEvent,
QueueWorkflowFailedEvent,
@@ -47,6 +48,7 @@ from core.app.entities.task_entities import (
MessageAudioEndStreamResponse,
MessageAudioStreamResponse,
PingStreamResponse,
ReasoningChunkStreamResponse,
StreamResponse,
TextChunkStreamResponse,
WorkflowAppBlockingResponse,
@@ -571,6 +573,22 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
yield self._text_chunk_to_stream_response(delta_text, from_variable_selector=event.from_variable_selector)
def _handle_reasoning_chunk_event(
self, event: QueueReasoningChunkEvent, **kwargs
) -> Generator[StreamResponse, None, None]:
"""Handle reasoning chunk events."""
# is_final with empty reasoning is still forwarded as the "thinking finished" signal
if not event.reasoning and not event.is_final:
return
yield ReasoningChunkStreamResponse(
task_id=self._application_generate_entity.task_id,
data=ReasoningChunkStreamResponse.Data(
reasoning=event.reasoning,
node_id=event.from_node_id,
is_final=event.is_final,
),
)
def _handle_agent_log_event(self, event: QueueAgentLogEvent, **kwargs) -> Generator[StreamResponse, None, None]:
"""Handle agent log events."""
yield self._workflow_response_converter.handle_agent_log(
@@ -600,6 +618,7 @@ class WorkflowAppGenerateTaskPipeline(GraphRuntimeStateSupport):
QueuePingEvent: self._handle_ping_event,
QueueErrorEvent: self._handle_error_event,
QueueTextChunkEvent: self._handle_text_chunk_event,
QueueReasoningChunkEvent: self._handle_reasoning_chunk_event,
# Workflow events
QueueWorkflowStartedEvent: self._handle_workflow_started_event,
QueueWorkflowSucceededEvent: self._handle_workflow_succeeded_event,
+12
View File
@@ -24,6 +24,7 @@ from core.app.entities.queue_entities import (
QueueNodeRetryEvent,
QueueNodeStartedEvent,
QueueNodeSucceededEvent,
QueueReasoningChunkEvent,
QueueRetrieverResourcesEvent,
QueueTextChunkEvent,
QueueWorkflowFailedEvent,
@@ -74,6 +75,7 @@ from graphon.graph_events import (
NodeRunLoopNextEvent,
NodeRunLoopStartedEvent,
NodeRunLoopSucceededEvent,
NodeRunReasoningChunkEvent,
NodeRunRetrieverResourceEvent,
NodeRunRetryEvent,
NodeRunStartedEvent,
@@ -576,6 +578,16 @@ class WorkflowBasedAppRunner:
in_loop_id=event.in_loop_id,
)
)
case NodeRunReasoningChunkEvent():
self._publish_event(
QueueReasoningChunkEvent(
reasoning=event.chunk,
from_node_id=event.node_id,
is_final=event.is_final,
in_iteration_id=event.in_iteration_id,
in_loop_id=event.in_loop_id,
)
)
case NodeRunRetrieverResourceEvent():
self._publish_event(
QueueRetrieverResourcesEvent(
+21
View File
@@ -40,6 +40,7 @@ class QueueEvent(StrEnum):
NODE_FAILED = "node_failed"
NODE_EXCEPTION = "node_exception"
RETRIEVER_RESOURCES = "retriever_resources"
REASONING_CHUNK = "reasoning_chunk"
ANNOTATION_REPLY = "annotation_reply"
AGENT_THOUGHT = "agent_thought"
MESSAGE_FILE = "message_file"
@@ -197,6 +198,26 @@ class QueueTextChunkEvent(AppQueueEvent):
"""loop id if node is in loop"""
class QueueReasoningChunkEvent(AppQueueEvent):
"""
QueueReasoningChunkEvent entity
Out-of-band reasoning (chain-of-thought) delta from an LLM node in "separated"
mode. It never touches the answer; it is emitted on a dedicated channel.
"""
event: QueueEvent = QueueEvent.REASONING_CHUNK
reasoning: str
from_node_id: str | None = None
"""id of the LLM node that produced this reasoning"""
is_final: bool = False
"""marks the terminal reasoning chunk for the node run (may carry empty reasoning)"""
in_iteration_id: str | None = None
"""iteration id if node is in iteration"""
in_loop_id: str | None = None
"""loop id if node is in loop"""
class QueueAgentMessageEvent(AppQueueEvent):
"""
QueueMessageEvent entity
+27
View File
@@ -27,6 +27,9 @@ class TaskStateMetadata(BaseModel):
annotation_reply: AnnotationReply | None = None
retriever_resources: Sequence[RetrievalSourceMetadata] = Field(default_factory=list)
usage: LLMUsage | None = None
reasoning: dict[str, str] = Field(default_factory=dict)
"""reasoning_content per LLM node id (separated mode), accumulated across iteration/loop
passes for that node; persisted to message_metadata"""
class TaskState(BaseModel):
@@ -85,6 +88,7 @@ class StreamEvent(StrEnum):
LOOP_COMPLETED = "loop_completed"
TEXT_CHUNK = "text_chunk"
TEXT_REPLACE = "text_replace"
REASONING_CHUNK = "reasoning_chunk"
AGENT_LOG = "agent_log"
HUMAN_INPUT_REQUIRED = "human_input_required"
HUMAN_INPUT_FORM_FILLED = "human_input_form_filled"
@@ -726,6 +730,29 @@ class TextChunkStreamResponse(StreamResponse):
data: Data
class ReasoningChunkStreamResponse(StreamResponse):
"""
ReasoningChunkStreamResponse entity
Out-of-band reasoning (chain-of-thought) delta, parallel to text_chunk. Only
emitted in "separated" mode; the answer/message stream stays free of <think>.
"""
class Data(BaseModel):
"""
Data entity
"""
# chat apps set this; workflow runs have no message
message_id: str | None = None
reasoning: str
node_id: str | None = None
is_final: bool = False
event: StreamEvent = StreamEvent.REASONING_CHUNK
data: Data
class TextReplaceStreamResponse(StreamResponse):
"""
TextReplaceStreamResponse entity
+2 -1
View File
@@ -240,7 +240,8 @@ class HostingConfiguration:
if len(quotas) > 0:
credentials = {
"dashscope_api_key": dify_config.HOSTED_TONGYI_API_KEY,
"use_international_endpoint": dify_config.HOSTED_TONGYI_USE_INTERNATIONAL_ENDPOINT,
# SNP-494: keep temporary compatibility with tongyi plugin string credential checks.
"use_international_endpoint": str(dify_config.HOSTED_TONGYI_USE_INTERNATIONAL_ENDPOINT).lower(),
}
return HostingProvider(enabled=True, credentials=credentials, quota_unit=quota_unit, quotas=quotas)
+78 -19
View File
@@ -3,7 +3,6 @@ from collections.abc import Generator, Mapping
from typing import Any, cast
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
@@ -13,10 +12,19 @@ from core.app.apps.completion.app_generator import CompletionAppGenerator
from core.app.apps.workflow.app_generator import WorkflowAppGenerator
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig
from core.db.session_factory import create_session
from core.plugin.backwards_invocation.base import BaseBackwardsInvocation
from extensions.ext_database import db
from models import Account
from models.model import App, AppMode, EndUser
from models import Account, TenantAccountJoin
from models.model import (
App,
AppMode,
AppModelConfig,
AppModelConfigDict,
EndUser,
load_annotation_reply_config,
)
from models.workflow import Workflow
from services.end_user_service import EndUserService
@@ -30,18 +38,18 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
"""Retrieve app parameters."""
if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}:
workflow = app.workflow
workflow = cls._get_workflow(app)
if workflow is None:
raise ValueError("unexpected app type")
features_dict: dict[str, Any] = workflow.features_dict
user_input_form = workflow.user_input_form(to_old_structure=True)
else:
app_model_config = app.app_model_config
if app_model_config is None:
app_model_config_dict = cls._get_app_model_config_dict(app)
if app_model_config_dict is None:
raise ValueError("unexpected app type")
features_dict = cast(dict[str, Any], app_model_config.to_dict())
features_dict = cast(dict[str, Any], app_model_config_dict)
user_input_form = features_dict.get("user_input_form", [])
@@ -68,7 +76,7 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
if not user_id:
user = EndUserService.get_or_create_end_user(app)
else:
user = cls._get_user(user_id)
user = cls._get_user(user_id, app)
conversation_id = conversation_id or ""
@@ -79,7 +87,10 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
return cls.invoke_chat_app(app, user, conversation_id, query, stream, inputs, files)
case AppMode.WORKFLOW:
return cls.invoke_workflow_app(app, user, stream, inputs, files)
workflow = cls._get_workflow(app)
if not workflow:
raise ValueError("unexpected app type")
return cls.invoke_workflow_app(app, workflow, user, stream, inputs, files)
case AppMode.COMPLETION:
return cls.invoke_completion_app(app, user, stream, inputs, files)
case _:
@@ -101,7 +112,7 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
"""
match app.mode:
case AppMode.ADVANCED_CHAT:
workflow = app.workflow
workflow = cls._get_workflow(app)
if not workflow:
raise ValueError("unexpected app type")
@@ -158,6 +169,7 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
def invoke_workflow_app(
cls,
app: App,
workflow: Workflow,
user: EndUser | Account,
stream: bool,
inputs: Mapping,
@@ -166,10 +178,6 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
"""
invoke workflow app
"""
workflow = app.workflow
if not workflow:
raise ValueError("unexpected app type")
pause_config = PauseStateLayerConfig(
session_factory=db.engine,
state_owner_user_id=workflow.created_by,
@@ -207,16 +215,26 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
)
@classmethod
def _get_user(cls, user_id: str) -> EndUser | Account:
def _get_user(cls, user_id: str, app: App) -> EndUser | Account:
"""
get the user by user id
"""
with Session(db.engine, expire_on_commit=False) as session:
stmt = select(EndUser).where(EndUser.id == user_id)
with create_session() as session:
stmt = select(EndUser).where(
EndUser.id == user_id,
EndUser.tenant_id == app.tenant_id,
EndUser.app_id == app.id,
)
user = session.scalar(stmt)
if not user:
stmt = select(Account).where(Account.id == user_id)
stmt = select(Account).where(
Account.id == user_id,
Account.id == TenantAccountJoin.account_id,
TenantAccountJoin.tenant_id == app.tenant_id,
)
user = session.scalar(stmt)
if user:
session.expunge(user)
if not user:
raise ValueError("user not found")
@@ -229,7 +247,10 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
get app
"""
try:
app = db.session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant_id).limit(1))
with create_session() as session:
app = session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant_id).limit(1))
if app:
session.expunge(app)
except Exception:
raise ValueError("app not found")
@@ -237,3 +258,41 @@ class PluginAppBackwardsInvocation(BaseBackwardsInvocation):
raise ValueError("app not found")
return app
@classmethod
def _get_workflow(cls, app: App) -> Workflow | None:
"""
get workflow without relying on App.workflow's request-scoped session property
"""
if not app.workflow_id:
return None
with create_session() as session:
workflow = session.scalar(
select(Workflow)
.where(Workflow.id == app.workflow_id, Workflow.tenant_id == app.tenant_id, Workflow.app_id == app.id)
.limit(1)
)
if workflow:
session.expunge(workflow)
return workflow
@classmethod
def _get_app_model_config_dict(cls, app: App) -> AppModelConfigDict | None:
"""
get app model config features without relying on request-scoped session-backed model properties
"""
if not app.app_model_config_id:
return None
with create_session() as session:
app_model_config = session.scalar(
select(AppModelConfig)
.where(AppModelConfig.id == app.app_model_config_id, AppModelConfig.app_id == app.id)
.limit(1)
)
if app_model_config is None:
return None
annotation_reply = load_annotation_reply_config(session, app_model_config.app_id)
return app_model_config.to_dict(annotation_reply=annotation_reply)
@@ -226,7 +226,7 @@ class ParagraphIndexProcessor(BaseIndexProcessor):
all_multimodal_documents.append(file_document)
doc.attachments = attachments
else:
account = AccountService.load_user(document.created_by)
account = AccountService.load_user(document.created_by, db.session)
if not account:
raise ValueError("Invalid account")
doc.attachments = self._get_content_files(doc, current_user=account)
@@ -291,7 +291,7 @@ class ParentChildIndexProcessor(BaseIndexProcessor):
attachments.append(file_document)
doc.attachments = attachments
else:
account = AccountService.load_user(document.created_by)
account = AccountService.load_user(document.created_by, db.session)
if not account:
raise ValueError("Invalid account")
doc.attachments = self._get_content_files(doc, current_user=account)
+17
View File
@@ -22,33 +22,50 @@ class RBACPermission(StrEnum):
APP_VIEW_LAYOUT = "app_view_layout"
APP_TEST_AND_RUN = "app_test_and_run"
APP_PREVIEW = "app_preview"
APP_CREATE_AND_MANAGEMENT = "app_create_and_management"
APP_RELEASE_AND_VERSION = "app_release_and_version"
APP_IMPORT_EXPORT_DSL = "app_import_export_dsl"
APP_EDIT = "app_edit"
APP_MONITOR = "app_monitor"
APP_TRACING_CONFIG = "app_tracing_config"
APP_LOG_AND_ANNOTATION = "app_log_and_annotation"
APP_DELETE = "app_delete"
APP_ACCESS_CONFIG = "app_access_config"
DATASET_PREVIEW = "dataset_preview"
DATASET_READONLY = "dataset_readonly"
DATASET_EDIT = "dataset_edit"
DATASET_CREATE_AND_MANAGEMENT = "dataset_create_and_management"
DATASET_PIPELINE_TEST = "dataset_pipeline_test"
DATASET_DOCUMENT_DOWNLOAD = "dataset_document_download"
DATASET_RETRIEVAL_RECALL = "dataset_retrieval_recall"
DATASET_USE = "dataset_use"
DATASET_DELETE_FILE = "dataset_delete_file"
DATASET_PIPELINE_RELEASE = "dataset_pipeline_release"
DATASET_DELETE = "dataset_delete"
DATASET_ACCESS_CONFIG = "dataset_access_config"
DATASET_API_KEY_MANAGE = "dataset_api_key_manage"
DATASET_EXTERNAL_CONNECT = "dataset_external_connect"
DATASET_IMPORT_EXPORT_DSL = "dataset_import_export_dsl"
WORKSPACE_MEMBER_MANAGE = "workspace_member_manage"
WORKSPACE_ROLE_MANAGE = "workspace_role_manage"
API_EXTENSION_MANAGE = "api_extension_manage"
CUSTOMIZATION_MANAGE = "customization_manage"
SNIPPETS_CREATE_AND_MODIFY = "snippets_create_and_modify"
SNIPPETS_MANAGE = "snippets_management"
PLUGIN_INSTALL = "plugin_install"
PLUGIN_PREFERENCES = "plugin_preferences"
PLUGIN_MODEL_CONFIG = "plugin_model_config"
PLUGIN_MANAGE = "plugin_manage"
PLUGIN_DELETE = "plugin_delete"
PLUGIN_DEBUG = "plugin_debug"
CREDENTIAL_USE = "credential_use"
CREDENTIAL_CREATE = "credential_create"
CREDENTIAL_MANAGE = "credential_manage"
TOOL_MANAGE = "tool_manage"
@@ -16,9 +16,6 @@ SUPPORTED_AGENT_BACKEND_FEATURES = frozenset(
"knowledge",
"env",
"sandbox",
# ENG-623: exposed at runtime as the dify.drive declaration layer
# (an index the agent pulls through the back proxy).
"skills_files",
# ENG-635: human involvement is exposed at runtime as the dify.ask_human
# deferred tool; a call pauses via the existing HITL form mechanism.
"human",
@@ -32,11 +29,7 @@ RESERVED_AGENT_BACKEND_FEATURES = frozenset(
)
def build_runtime_feature_manifest(
agent_soul: AgentSoulConfig,
*,
drive_manifest_enabled: bool = False,
) -> dict[str, Any]:
def build_runtime_feature_manifest(agent_soul: AgentSoulConfig) -> dict[str, Any]:
"""Describe PRD capabilities supported by or still reserved from Agent backend runtime."""
warnings: list[dict[str, str]] = []
soul_dump = agent_soul.model_dump(mode="json", exclude_none=True, exclude_defaults=True)
@@ -54,38 +47,10 @@ def build_runtime_feature_manifest(
}
)
has_skills_files = bool(agent_soul.skills_files.skills or agent_soul.skills_files.files)
if has_skills_files and not drive_manifest_enabled:
warnings.append(
{
"section": "agent_soul.skills_files",
"code": "drive_manifest_disabled",
"message": (
"skills_files is configured but AGENT_DRIVE_MANIFEST_ENABLED is off; "
"the drive declaration layer is not injected into this run."
),
}
)
for skill in agent_soul.skills_files.skills:
if not skill.skill_md_key:
warnings.append(
{
"section": "agent_soul.skills_files",
"code": "skill_ref_dangling",
"message": (
f"skill_ref_dangling: skill '{skill.name or skill.id or 'unknown'}' has no drive key; "
"re-standardize it to expose it at runtime."
),
}
)
reserved_status = dict.fromkeys(sorted(RESERVED_AGENT_BACKEND_FEATURES), "reserved_not_executed")
reserved_status["knowledge"] = (
"supported_by_knowledge_layer" if list_configured_knowledge_dataset_ids(agent_soul) else "not_configured"
)
reserved_status["skills_files"] = (
"supported_by_drive_manifest" if drive_manifest_enabled else "drive_manifest_disabled"
)
reserved_status["tools.dify_tools"] = "supported_when_config_valid"
reserved_status["tools.cli_tools"] = "supported_by_shell_bootstrap"
reserved_status["env"] = "supported_by_shell_bootstrap"
@@ -7,7 +7,6 @@ from typing import Any, Literal, Protocol, assert_never, cast
from agenton.compositor import CompositorSessionSnapshot
from dify_agent.layers.ask_human import DifyAskHumanLayerConfig
from dify_agent.layers.drive import (
DifyDriveFileConfig,
DifyDriveLayerConfig,
DifyDriveSkillConfig,
)
@@ -55,10 +54,13 @@ from models.agent_config_entities import (
)
from models.provider_ids import ModelProviderID
from services.agent.prompt_mentions import (
MentionKind,
build_node_job_mention_resolver,
build_soul_mention_resolver,
expand_prompt_mentions,
parse_prompt_mentions,
)
from services.agent_drive_service import AgentDriveService, decode_drive_mention_ref
from .output_failure_orchestrator import retry_idempotency_key
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
@@ -153,9 +155,6 @@ class WorkflowAgentRuntimeRequestBuilder:
expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
or "Run this workflow Agent Node for the current run."
)
soul_prompt = expand_prompt_mentions(
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
).strip()
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
try:
@@ -182,9 +181,20 @@ class WorkflowAgentRuntimeRequestBuilder:
}
drive_config: DifyDriveLayerConfig | None = None
soul_prompt_resolver = build_soul_mention_resolver(agent_soul)
if dify_config.AGENT_DRIVE_MANIFEST_ENABLED:
drive_config, drive_warnings = build_drive_layer_config(agent_soul, agent_id=context.agent.id)
drive_config, drive_warnings = build_drive_layer_config(
agent_soul,
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent.id,
)
append_runtime_warnings(metadata, drive_warnings)
soul_prompt_resolver = build_drive_aware_soul_mention_resolver(
agent_soul,
tenant_id=context.dify_context.tenant_id,
agent_id=context.agent.id,
)
soul_prompt = expand_prompt_mentions(agent_soul.prompt.system_prompt, soul_prompt_resolver).strip()
knowledge_config = build_knowledge_layer_config(agent_soul)
request = self._request_builder.build_for_workflow_node(
@@ -255,7 +265,7 @@ class WorkflowAgentRuntimeRequestBuilder:
def _plugin_daemon_plugin_id(*, plugin_id: str, model_provider: str) -> str:
"""Return the transport plugin id expected by plugin-daemon headers."""
if plugin_id.count("/") == 1:
return plugin_id
return plugin_id.split(":", 1)[0].split("@", 1)[0]
if plugin_id:
return ModelProviderID(plugin_id).plugin_id
return ModelProviderID(model_provider).plugin_id
@@ -292,10 +302,7 @@ class WorkflowAgentRuntimeRequestBuilder:
"agent_config_snapshot_id": context.snapshot.id,
"binding_id": context.binding.id,
"workflow_node_job_mode": node_job.mode.value,
"runtime_support": build_runtime_feature_manifest(
agent_soul,
drive_manifest_enabled=dify_config.AGENT_DRIVE_MANIFEST_ENABLED,
),
"runtime_support": build_runtime_feature_manifest(agent_soul),
}
def _build_workflow_context_prompt(
@@ -603,76 +610,107 @@ def append_runtime_warnings(metadata: dict[str, Any], warnings: list[dict[str, s
existing.extend(warnings)
def build_drive_aware_soul_mention_resolver(
agent_soul: AgentSoulConfig,
*,
tenant_id: str,
agent_id: str,
):
"""Resolve skill/file mentions against the agent drive and everything else via Agent Soul."""
base_resolver = build_soul_mention_resolver(agent_soul)
drive_service = AgentDriveService()
skill_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id)
skill_names_by_key = {skill["skill_md_key"]: skill["name"] for skill in skill_catalog}
drive_keys = {item["key"] for item in drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)}
def _resolve(mention: object) -> str | None:
if not hasattr(mention, "kind") or not hasattr(mention, "ref_id"):
return None
kind = cast(MentionKind, mention.kind)
ref_id = cast(str, mention.ref_id)
label = cast(str | None, getattr(mention, "label", None))
if kind == MentionKind.SKILL:
decoded_key = decode_drive_mention_ref(ref_id)
return skill_names_by_key.get(decoded_key) or label or decoded_key
if kind == MentionKind.FILE:
decoded_key = decode_drive_mention_ref(ref_id)
if decoded_key in drive_keys:
return decoded_key.rsplit("/", 1)[-1]
return label or decoded_key
return base_resolver(cast(Any, mention))
return _resolve
def build_drive_layer_config(
agent_soul: AgentSoulConfig,
*,
tenant_id: str,
agent_id: str | None,
) -> tuple[DifyDriveLayerConfig | None, list[dict[str, str]]]:
"""Catalog the soul's drive-backed Skills & Files into the dify.drive declaration.
"""Derive drive runtime catalog + prompt-mentioned eager-pull keys from the drive."""
Returns ``(config, warnings)`` ``config is None`` means nothing to inject
(no skills/files configured, or no agent identity to address the drive by).
Refs that predate standardization (no drive key) are skipped with a warning
instead of failing the run, so historic souls keep running.
"""
skill_refs = agent_soul.skills_files.skills
file_refs = agent_soul.skills_files.files
if not skill_refs and not file_refs:
return None, []
warnings: list[dict[str, str]] = []
mentioned_drive_refs = [
decode_drive_mention_ref(mention.ref_id)
for mention in parse_prompt_mentions(agent_soul.prompt.system_prompt)
if mention.kind in {MentionKind.SKILL, MentionKind.FILE}
]
ordered_mentions = list(dict.fromkeys(ref for ref in mentioned_drive_refs if ref))
if not agent_id:
if not ordered_mentions:
return None, []
return None, [
{
"section": "agent_soul.prompt.system_prompt",
"code": "drive_ref_dangling",
"message": "drive mentions are configured but the run has no bound agent to address a drive by.",
}
]
drive_service = AgentDriveService()
skills_catalog = drive_service.list_skills(tenant_id=tenant_id, agent_id=agent_id)
manifest_items = drive_service.manifest(tenant_id=tenant_id, agent_id=agent_id)
manifest_by_key = {item["key"]: item for item in manifest_items}
skill_keys = {skill["skill_md_key"] for skill in skills_catalog}
warnings: list[dict[str, str]] = []
mentioned_skill_keys: list[str] = []
mentioned_file_keys: list[str] = []
for drive_key in ordered_mentions:
if drive_key in skill_keys:
mentioned_skill_keys.append(drive_key)
continue
if drive_key in manifest_by_key:
mentioned_file_keys.append(drive_key)
continue
warnings.append(
{
"section": "agent_soul.skills_files",
"code": "skill_ref_dangling",
"message": "skills_files is configured but the run has no bound agent to address a drive by.",
"section": "agent_soul.prompt.system_prompt",
"code": "mention_target_missing",
"message": f"drive mention '{drive_key}' has no matching drive entry.",
}
)
return None, warnings
skills: list[DifyDriveSkillConfig] = []
for skill in skill_refs:
if not skill.skill_md_key:
warnings.append(
{
"section": "agent_soul.skills_files",
"code": "skill_ref_dangling",
"message": (
f"skill_ref_dangling: skill '{skill.name or skill.id or 'unknown'}' has no drive key; "
"re-standardize it to expose it at runtime."
),
}
)
continue
skills.append(
DifyDriveSkillConfig(
name=skill.name or skill.skill_md_key.split("/", 1)[0],
description=skill.description or "",
skill_md_key=skill.skill_md_key,
archive_key=skill.full_archive_key,
)
skills = [
DifyDriveSkillConfig(
path=skill["path"],
name=skill["name"],
description=skill["description"],
skill_md_key=skill["skill_md_key"],
archive_key=skill["archive_key"],
)
for skill in skills_catalog
]
files: list[DifyDriveFileConfig] = []
for file in file_refs:
if not file.drive_key:
# Plain upload references (pre-ENG-625) are not drive-backed; they are
# simply invisible to the manifest rather than a defect worth warning on.
continue
size = file.get("size")
files.append(
DifyDriveFileConfig(
name=file.name or file.drive_key.rsplit("/", 1)[-1],
key=file.drive_key,
size=size if isinstance(size, int) else None,
mime_type=file.type,
)
)
if not skills and not files:
return None, warnings
return DifyDriveLayerConfig(drive_ref=f"agent-{agent_id}", skills=skills, files=files), warnings
return (
DifyDriveLayerConfig(
drive_ref=f"agent-{agent_id}",
skills=skills,
mentioned_skill_keys=mentioned_skill_keys,
mentioned_file_keys=mentioned_file_keys,
),
warnings,
)
def _cli_tool_enabled(item: object) -> bool:
@@ -35,7 +35,6 @@ class WorkflowAgentNodeValidator:
"soul",
"prompt",
"system_prompt",
"skills_files",
"skills",
"files",
"tools",
+96 -20
View File
@@ -2,8 +2,8 @@
This checker intentionally stays conservative. It only reports a hard schema
mismatch when both sides are statically known for the same 2xx status code:
a documented ``@ns.response(..., Model)`` and an actual ``dump_response(Model, ...)``
or ``Model.model_validate(...).model_dump()`` return.
a documented ``@ns.response(..., Model)`` and an actual ``dump_response(Model, ...)``,
``Model(...).model_dump()``, or ``Model.model_validate(...).model_dump()`` return.
Raw dictionaries, raw lists, ``None`` responses, streaming helpers, missing
response schemas, and returns with non-literal status codes are classified as
@@ -28,6 +28,7 @@ from typing import Any, Literal
HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put"}
NO_BODY_STATUSES = {HTTPStatus.NO_CONTENT.value, HTTPStatus.RESET_CONTENT.value, HTTPStatus.NOT_MODIFIED.value}
DEFAULT_CONTROLLER_DIRS = ("controllers/console", "controllers/service_api", "controllers/web")
IGNORE_COMMENT_MARKERS = ("response-contract:ignore",)
type Classification = Literal["valid", "mismatch", "unknown", "refactorable"]
type ActualKind = Literal[
@@ -41,6 +42,7 @@ type ActualKind = Literal[
"unknown",
]
type MethodNode = ast.FunctionDef | ast.AsyncFunctionDef
type ModelValueSource = Literal["constructor", "model_validate"]
HTTP_STATUS_NAMES = {status.name: status.value for status in HTTPStatus}
HTTP_STATUS_NAMES.update({f"HTTP_{status.value}_{status.name}": status.value for status in HTTPStatus})
@@ -109,18 +111,22 @@ class VariableAssignmentSummary:
"""Track whether a local name is safe to treat as one specific response model."""
known_models: set[str] = field(default_factory=set)
known_sources: set[ModelValueSource] = field(default_factory=set)
has_unknown_assignment: bool = False
def add_known(self, model: str) -> None:
def add_known(self, model: str, source: ModelValueSource) -> None:
self.known_models.add(model)
self.known_sources.add(source)
def add_unknown(self) -> None:
self.has_unknown_assignment = True
def single_known_model(self) -> str | None:
def single_known_model(self) -> tuple[str, ModelValueSource] | None:
if self.has_unknown_assignment or len(self.known_models) != 1:
return None
return next(iter(self.known_models))
model = next(iter(self.known_models))
source: ModelValueSource = "constructor" if self.known_sources == {"constructor"} else "model_validate"
return model, source
def dotted_name(node: ast.AST) -> str | None:
@@ -249,6 +255,12 @@ def model_name_from_model_validate_call(node: ast.AST) -> str | None:
return None
def model_value_from_model_validate_call(node: ast.AST) -> tuple[str, ModelValueSource] | None:
if model_name := model_name_from_model_validate_call(node):
return model_name, "model_validate"
return None
def model_name_from_constructor_call(node: ast.AST) -> str | None:
if not isinstance(node, ast.Call):
return None
@@ -257,6 +269,12 @@ def model_name_from_constructor_call(node: ast.AST) -> str | None:
return None
def model_value_from_constructor_call(node: ast.AST) -> tuple[str, ModelValueSource] | None:
if model_name := model_name_from_constructor_call(node):
return model_name, "constructor"
return None
def model_name_from_model_dump(node: ast.AST) -> str | None:
if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute) or node.func.attr != "model_dump":
return None
@@ -272,6 +290,10 @@ def model_name_from_model_value(node: ast.AST) -> str | None:
return model_name_from_model_validate_call(node) or model_name_from_constructor_call(node)
def model_value_from_model_value(node: ast.AST) -> tuple[str, ModelValueSource] | None:
return model_value_from_model_validate_call(node) or model_value_from_constructor_call(node)
def model_name_from_dump_response(node: ast.AST) -> str | None:
if not isinstance(node, ast.Call):
return None
@@ -287,7 +309,7 @@ def model_name_from_dump_response(node: ast.AST) -> str | None:
def actual_kind_from_expr(
expr: ast.AST | None, variable_models: dict[str, str] | None = None
expr: ast.AST | None, variable_models: dict[str, tuple[str, ModelValueSource]] | None = None
) -> tuple[ActualKind, str | None]:
if expr is None:
return "none", None
@@ -299,10 +321,14 @@ def actual_kind_from_expr(
if isinstance(expr, ast.Call) and isinstance(expr.func, ast.Attribute) and expr.func.attr == "model_dump":
dumped_value = expr.func.value
if isinstance(dumped_value, ast.Name) and variable_models:
# A variable dump can match today, but it bypasses dump_response and
# is easier to drift; keep it visible as refactorable.
model_name = variable_models.get(dumped_value.id)
if model_name:
model_assignment = variable_models.get(dumped_value.id)
if model_assignment:
model_name, source = model_assignment
if source == "constructor":
return "model", model_name
# A variable dump from model_validate can match today, but it
# bypasses dump_response and is easier to drift; keep it visible
# as refactorable.
return "model_dump_variable", model_name
model_dump_model = model_name_from_model_dump(expr)
@@ -325,7 +351,9 @@ def actual_kind_from_expr(
return "unknown", None
def actual_response_from_return(return_node: ast.Return, variable_models: dict[str, str]) -> ActualResponse:
def actual_response_from_return(
return_node: ast.Return, variable_models: dict[str, tuple[str, ModelValueSource]]
) -> ActualResponse:
status: int | None = 200
body_expr = return_node.value
@@ -363,18 +391,21 @@ def target_names(target: ast.AST) -> Iterable[str]:
def record_assignment(
assignments: defaultdict[str, VariableAssignmentSummary], targets: Iterable[str], model_name: str | None
assignments: defaultdict[str, VariableAssignmentSummary],
targets: Iterable[str],
model_assignment: tuple[str, ModelValueSource] | None,
) -> None:
for target in targets:
if model_name is None:
if model_assignment is None:
# Once a name receives an unknown value, later model_dump() calls on it
# are no longer a reliable signal for the returned schema.
assignments[target].add_unknown()
else:
assignments[target].add_known(model_name)
model_name, source = model_assignment
assignments[target].add_known(model_name, source)
def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]:
def variable_model_assignments_for_method(method: MethodNode) -> dict[str, tuple[str, ModelValueSource]]:
"""Infer local variables that are unambiguously assigned one response model."""
assignments: defaultdict[str, VariableAssignmentSummary] = defaultdict(VariableAssignmentSummary)
@@ -385,10 +416,10 @@ def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]:
record_assignment(
assignments,
(name for target in targets for name in target_names(target)),
model_name_from_model_value(value),
model_value_from_model_value(value),
)
case ast.AnnAssign(target=target, value=value) if value is not None:
record_assignment(assignments, target_names(target), model_name_from_model_value(value))
record_assignment(assignments, target_names(target), model_value_from_model_value(value))
case ast.AugAssign(target=target) | ast.For(target=target) | ast.AsyncFor(target=target):
# Mutation and loop targets overwrite prior values with runtime-dependent data.
record_assignment(assignments, target_names(target), None)
@@ -399,9 +430,13 @@ def variable_model_assignments_for_method(method: MethodNode) -> dict[str, str]:
case ast.ExceptHandler(name=name) if name:
assignments[name].add_unknown()
case ast.NamedExpr(target=target, value=value):
record_assignment(assignments, target_names(target), model_name_from_model_value(value))
record_assignment(assignments, target_names(target), model_value_from_model_value(value))
return {name: model for name, summary in assignments.items() if (model := summary.single_known_model()) is not None}
return {
name: assignment
for name, summary in assignments.items()
if (assignment := summary.single_known_model()) is not None
}
def actual_responses_for_method(method: MethodNode) -> list[ActualResponse]:
@@ -545,13 +580,52 @@ def iter_controller_files(paths: Iterable[Path]) -> Iterable[Path]:
yield from sorted(child for child in path.rglob("*.py") if child.is_file())
def node_start_lineno(node: ast.ClassDef | MethodNode) -> int:
decorator_lines = [decorator.lineno for decorator in node.decorator_list]
if decorator_lines:
return min(decorator_lines)
return node.lineno
def line_has_ignore_marker(line: str) -> bool:
_, marker, comment = line.partition("#")
if not marker:
return False
normalized = comment.lower()
return any(ignore_marker in normalized for ignore_marker in IGNORE_COMMENT_MARKERS)
def node_has_ignore_comment(lines: Sequence[str], node: ast.ClassDef | MethodNode) -> bool:
start = node_start_lineno(node)
end = node.end_lineno or node.lineno
if any(line_has_ignore_marker(line) for line in lines[start - 1 : end]):
return True
line_index = start - 2
while line_index >= 0:
stripped = lines[line_index].strip()
if not stripped:
line_index -= 1
continue
if not stripped.startswith("#"):
break
if line_has_ignore_marker(lines[line_index]):
return True
line_index -= 1
return False
def checks_for_file(file_path: Path, repo_root: Path) -> list[ContractCheck]:
module = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
source = file_path.read_text(encoding="utf-8")
lines = source.splitlines()
module = ast.parse(source, filename=str(file_path))
checks: list[ContractCheck] = []
for node in module.body:
if not isinstance(node, ast.ClassDef):
continue
if node_has_ignore_comment(lines, node):
continue
class_routes = routes_from_decorators(node.decorator_list)
class_documented = response_docs_from_decorators(node.decorator_list)
@@ -559,6 +633,8 @@ def checks_for_file(file_path: Path, repo_root: Path) -> list[ContractCheck]:
for item in node.body:
if not isinstance(item, ast.FunctionDef | ast.AsyncFunctionDef) or item.name not in HTTP_METHODS:
continue
if node_has_ignore_comment(lines, item):
continue
routes = routes_from_decorators(item.decorator_list) or class_routes
if not routes:
+1
View File
@@ -124,6 +124,7 @@ else
exec python -m app
else
exec gunicorn \
--no-control-socket \
--bind "${DIFY_BIND_ADDRESS:-0.0.0.0}:${DIFY_PORT:-5001}" \
--workers ${SERVER_WORKER_AMOUNT:-1} \
--worker-class ${SERVER_WORKER_CLASS:-geventwebsocket.gunicorn.workers.GeventWebSocketWorker} \
+2
View File
@@ -5,6 +5,7 @@ def init_app(app: DifyApp):
from commands import (
add_qdrant_index,
archive_workflow_runs,
archive_workflow_runs_plan,
backfill_plugin_auto_upgrade,
clean_expired_messages,
clean_workflow_runs,
@@ -72,6 +73,7 @@ def init_app(app: DifyApp):
setup_datasource_oauth_client,
transform_datasource_credentials,
install_rag_pipeline_plugins,
archive_workflow_runs_plan,
archive_workflow_runs,
delete_archived_workflow_runs,
restore_workflow_runs,
+2 -2
View File
@@ -84,7 +84,7 @@ def load_user_from_request(request_from_flask_login: Request) -> LoginUser | Non
if not user_id:
raise Unauthorized("Invalid Authorization token.")
logged_in_account = AccountService.load_logged_in_account(account_id=user_id)
logged_in_account = AccountService.load_logged_in_account(account_id=user_id, session=db.session)
return logged_in_account
elif request.blueprint == "openapi":
# Account-branch device-flow approval routes (approve / deny /
@@ -103,7 +103,7 @@ def load_user_from_request(request_from_flask_login: Request) -> LoginUser | Non
source = decoded.get("token_source")
if source or not user_id:
return None
return AccountService.load_logged_in_account(account_id=user_id)
return AccountService.load_logged_in_account(account_id=user_id, session=db.session)
elif request.blueprint == "web":
app_code = request.headers.get(HEADER_NAME_APP_CODE)
webapp_token = extract_webapp_passport(app_code, request) if app_code else None

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