Compare commits

..
Author SHA1 Message Date
L1nSn0w abb2ea82d7 feat(cli): add amp, openclaw, qoder, windsurf, hermes to skills install registry
Extend the agent registry with five verified entries. Amp and OpenClaw
read the shared ~/.agents/skills directory that Codex already uses, so
the installer now groups agents by resolved target and writes a shared
path once (dry-run merges the agent names into a single line).

Paths verified against each tool's documentation:
- amp:      probe ~/.config/amp      -> shared ~/.agents/skills
- openclaw: probe ~/.openclaw        -> shared ~/.agents/skills
- qoder:    probe ~/.qoder           -> ~/.qoder/skills
- windsurf: probe ~/.codeium/windsurf -> ~/.codeium/windsurf/skills
- hermes:   probe ~/.hermes          -> ~/.hermes/skills
2026-07-13 11:57:51 +08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
408cd12835 chore(deps): bump the storage group in /api with 5 updates (#38825)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 11:32:27 +08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ca46d07ea8 build(deps): bump soupsieve from 2.8 to 2.8.4 in /api (#38631)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 11:32:06 +08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e721801b37 chore(deps): bump the google group in /api with 2 updates (#38811)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 11:31:53 +08:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f03648435a chore(deps): bump flask-cors from 6.0.2 to 6.0.5 in /api in the flask group (#38810)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 11:31:37 +08:00
JoelandGitHub 41683013e3 fix: marketplace page header jitter when it in sticky status (#38832) 2026-07-13 03:19:10 +00:00
Asuka MinatoandGitHub 0e84444809 test: use sqlite3 session in test_data_migration_commands (#38672) 2026-07-13 03:04:37 +00:00
Asuka MinatoandGitHub b893b53f0a test: use sqlite3 session in test_plugin_tool (#38781) 2026-07-13 03:03:12 +00:00
Asuka MinatoandGitHub 4c1c92eb2f test: use sqlite3 session in test_remove_app_and_related_data_task (#38778) 2026-07-13 03:02:30 +00:00
非法操作GitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
7311f1ba6d chore: sandbox plan should not execution special workflow (#38662)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-13 02:55:39 +00:00
Wu TianweiandGitHub f4dfac7214 fix: update role selector to use max height for scroll area (#38831) 2026-07-13 02:38:15 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
6d8e390c95 chore(deps): bump the github-actions-dependencies group with 4 updates (#38807)
Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-13 01:39:28 +00:00
Stephen ZhouGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
a84c2d36a3 style: format with vp fmt (#38803)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-12 15:57:46 +00:00
Yufeng HeGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
fde08d24fe fix: eagerly validate conversation on service-api and explore chat endpoints (#38801)
Signed-off-by: Yufeng He <[email protected]>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-12 15:19:07 +00:00
Stephen ZhouGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
b54107e9b9 chore: migrate formatting to vp fmt (#38705)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-12 14:59:00 +00:00
yyhGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
594c21d98c fix(web): split model selection and settings actions (#38797)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-12 14:24:27 +00:00
yyhandGitHub 6e59c723c9 fix(agent-v2): restore keyboard access to row actions (#38792) 2026-07-12 14:23:49 +00:00
yyhandGitHub 4efab8d7a3 chore(dify-ui): enforce strict ESLint rules (#38793) 2026-07-12 14:23:39 +00:00
yyhandGitHub 2a2193dabb fix(web): correct knowledge dialog draft state (#38794) 2026-07-12 14:23:28 +00:00
yyhandGitHub 1246aa7f3a refactor(web): normalize z-index layering (#38796) 2026-07-12 14:23:14 +00:00
yyhandGitHub 3defd50709 fix(web): improve marketplace install dialog accessibility (#38798) 2026-07-12 14:18:39 +00:00
yyhandGitHub 6e5ba18d65 refactor(dify-ui): standardize primitive boundary names (#38669) 2026-07-10 14:17:10 +00:00
Stephen ZhouandGitHub e3a08b40f2 test(web): centralize i18n mocks (#38670) 2026-07-10 14:08:33 +00:00
Stephen ZhouandGitHub 6f47950fe2 perf(web): lazy-load Vite plugins (#38663) 2026-07-10 11:13:09 +00:00
林玮 (Jade Lin)andGitHub 4043adacd7 fix(trigger): return 429 instead of 500 when API quota is exceeded (#38664) 2026-07-10 10:57:50 +00:00
Stephen ZhouGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
b9fde89781 refactor(web): migrate i18n to selector optimize mode (#38657)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-10 09:35:31 +00:00
github-actions[bot]GitHubclaude[bot] <41898282+claude[bot]@users.noreply.github.com>yyh
d612307658 chore(i18n): sync translations with en-US (#38658)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: yyh <[email protected]>
2026-07-10 09:34:41 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
7e05f28a46 feat: create app init rbac scope to all and add workspace members. (#38636)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-10 09:31:41 +00:00
林玮 (Jade Lin)andGitHub 44fb074359 fix(trigger): surface webhook trigger quota exceeded as 429 response (#38656) 2026-07-10 08:50:25 +00:00
无能的迪拜巧克力andGitHub 775ed70bac feat(web): refine web app customization options (#38650) 2026-07-10 08:35:26 +00:00
KVOJJJinandGitHub 3c8e0e2113 fix(web): support punctuation in markdown form field names (#38651) 2026-07-10 07:08:07 +00:00
yyhandGitHub 489e77658e fix(web): simplify infotip icon variants (#38644) 2026-07-10 06:37:36 +00:00
JoelandGitHub 4a8c6773a3 fix: final answer in new agent would clear after sse if agent thought has answer (#38649) 2026-07-10 06:33:26 +00:00
Asuka MinatoandGitHub d0f0134314 test: use sqlite3 session (#38641) 2026-07-10 06:11:24 +00:00
zyssyz123andGitHub 9b3650e2ca fix(agent): surface knowledge retrieval failures (#38640) 2026-07-10 04:40:46 +00:00
KVOJJJinandGitHub 0038bcc4bb fix(web): agent tool credentials mismatch (#38637) 2026-07-10 04:35:52 +00:00
yyhandGitHub b325597d29 test(e2e): consume agent service API SSE incrementally (#38634) 2026-07-10 04:26:04 +00:00
Stephen ZhouandGitHub b7255e6f27 chore: migrate js-yaml to v5 (#38639) 2026-07-10 04:03:30 +00:00
yyhandGitHub c3fb5344cb fix: simplify help menu upgrade item (#38635) 2026-07-10 03:58:27 +00:00
Yunlu WenGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Copilot Autofix powered by AI
888bea9911 feat: ee agent sandbox support (#38528)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
2026-07-10 03:56:55 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2da37a9416 test: Mock to sqlite3 (#38499)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-10 03:49:06 +00:00
chaririGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
f902551d0c chore(api): Prohibit new direct SQLAlchemy in controller (#38624)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-10 03:10:27 +00:00
DresdenandGitHub faba726a45 chore: Replace @patch logger.warning with caplog fixture in credit_pool_service test (#38626) 2026-07-10 03:08:17 +00:00
wangxiaoleiandGitHub 7da3d7074a chore: remove unused param (#38628) 2026-07-10 02:20:16 +00:00
Stephen ZhouGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
f9dd37420b chore: update deps (#38627)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-10 02:07:57 +00:00
yyhandGitHub eb74946b25 fix(web): save agent configure draft on route leave (#38620) 2026-07-09 13:55:34 +00:00
QuantumGhostandGitHub b5e35fc2fc fix: remove hardcoded sandbox path in configuration file (#38618) 2026-07-09 13:30:56 +00:00
wangxiaoleiandGitHub 7bfbb2bbe8 fix: fix auth prefix duplicate (#38616) 2026-07-09 12:55:58 +00:00
QuantumGhostandGitHub d177998255 chore: Bump version to 1.16.0-rc1 (#38600) 2026-07-09 11:42:09 +00:00
wangxiaoleiGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
74ee665af6 fix: fix miss session param (#38612)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-09 11:26:16 +00:00
Stephen ZhouandGitHub d655153a3d fix(web): update docs links (#38591) 2026-07-09 11:25:31 +00:00
zyssyz123GitHub盐粒 YanliyyhJoelautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>QuantumGhostyyh盐粒 Yanli
925f97be20 feat: daily sync (#38593)
Co-authored-by: 盐粒 Yanli <[email protected]>
Co-authored-by: yyh <[email protected]>
Co-authored-by: Joel <[email protected]>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: QuantumGhost <[email protected]>
Co-authored-by: yyh <[email protected]>
Co-authored-by: 盐粒 Yanli <[email protected]>
2026-07-09 10:35:15 +00:00
9252d81826 docs: remove Dify Premium on AWS Marketplace section from all READMEs (#38607)
Co-authored-by: Claude Fable 5 <[email protected]>
2026-07-09 10:09:59 +00:00
Harsh KashyapGitHubHarsh Kashyapautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>Harsh Kashyap
9d5819a9c1 fix(api): ignore invalid utf8 cache payloads (#37835)
Co-authored-by: Harsh Kashyap <[email protected]>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Harsh Kashyap <[email protected]>
2026-07-09 09:52:49 +00:00
Ingram ZGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
99e3b1a401 fix: harden workflow archive DB retries (#38170)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-09 09:46:28 +00:00
Asuka MinatoGitHubautofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
45957225cd chore: batch example #38419 (#38474)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-09 09:14:52 +00:00
6481 changed files with 241505 additions and 194911 deletions
@@ -28,9 +28,12 @@ Flag:
- Missing `package.json#exports` entry for a new primitive.
- Internal package imports using workspace subpaths instead of relative paths.
- Exported props using internal-only types that consumers cannot import from the component subpath.
- Canonical primitive boundaries or their associated public types using a redundant `Root` suffix when no higher-level convenience component exists in the same subpath.
Consumers use subpath exports such as `@langgenius/dify-ui/button`.
Canonical boundaries use the primitive name and matching public types (`Select` / `SelectProps`). Keep `Root` only to distinguish a low-level anatomy root from a higher-level convenience component (`CheckboxRoot` / `Checkbox`); implementation aliases should still show their Base UI source (`BaseSelect.Root.Props`).
## Props And State
Flag:
@@ -45,17 +48,17 @@ Flag:
Prefer Base UI/Dify UI data attributes and CSS variables for visual state: `data-open`, `data-checked`, `data-disabled`, `data-highlighted`, `data-popup-open`, `group-data-*`, `peer-data-*`, `has-[:focus-visible]`, and primitive CSS variables such as anchor width or transform origin. Use JS conditional classes for product/business state that the primitive does not expose.
For non-string `Select` and `RadioGroup` values, prefer explicit domain generics at the root and at child value carriers. JSX children do not inherit the parent generic, so `RadioGroup<PromptMode>` should compose with `Radio<PromptMode>`, `RadioRoot<PromptMode>`, or option values from a typed collection. For `Select`, prefer the Base UI `items` collection pattern for typed value-to-label rendering, and flag string coercion helpers used only to recover display labels.
For non-string `Select` and `RadioGroup` values, prefer explicit domain generics at the root and at child value carriers. JSX children do not inherit the parent generic, so `RadioGroup<PromptMode>` should compose with `Radio<PromptMode>`, `RadioItem<PromptMode>`, or option values from a typed collection. For `Select`, prefer the Base UI `items` collection pattern for typed value-to-label rendering, and flag string coercion helpers used only to recover display labels.
## Forms
Flag:
- Form-like UI using unrelated `Input` and `Button` pieces without a submit boundary.
- Text-like fields not composed through `FieldRoot`, `FieldLabel`, and `FieldControl` when using Dify UI form semantics.
- Text-like fields not composed through `Field`, `FieldLabel`, and `FieldControl` when using Dify UI form semantics.
- Select fields using `FieldLabel` instead of `SelectLabel`.
- Slider fields using a generic label instead of `SliderLabel`.
- Checkbox/radio groups missing `FieldsetRoot` and `FieldsetLegend`.
- Checkbox/radio groups missing `Fieldset` and `FieldsetLegend`.
- Field errors or descriptions rendered without `FieldDescription` / `FieldError` relationships.
`Form` is the submit boundary. Dify UI form primitives are not a form state-management framework; business validation and schema-driven behavior belong in `web/`.
+3 -3
View File
@@ -35,7 +35,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -97,7 +97,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
@@ -148,7 +148,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
python-version: "3.12"
+14 -2
View File
@@ -39,11 +39,19 @@ jobs:
with:
files: |
web/**
cli/**
e2e/**
packages/**
sdks/nodejs-client/**
package.json
pnpm-lock.yaml
pnpm-workspace.yaml
.nvmrc
vite.config.ts
eslint.config.mjs
.vscode/**
.github/workflows/autofix.yml
.github/workflows/style.yml
- name: Check api inputs
if: github.event_name != 'merge_group'
id: api-changes
@@ -78,7 +86,7 @@ jobs:
python-version: "3.11"
- if: github.event_name != 'merge_group'
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Generate Docker Compose
if: github.event_name != 'merge_group' && steps.docker-compose-changes.outputs.any_changed == 'true'
@@ -161,7 +169,11 @@ jobs:
- name: ESLint autofix
if: github.event_name != 'merge_group' && steps.web-changes.outputs.any_changed == 'true'
run: |
vp exec eslint --concurrency=2 --prune-suppressions --quiet || true
vp exec eslint --fix --concurrency=2 --prune-suppressions --quiet || true
- name: Format frontend files
if: github.event_name != 'merge_group' && (steps.web-changes.outputs.any_changed == 'true' || steps.frontend-contract-changes.outputs.any_changed == 'true')
run: vp fmt
- if: github.event_name != 'merge_group'
uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
+2 -2
View File
@@ -19,7 +19,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
python-version: "3.12"
@@ -69,7 +69,7 @@ jobs:
persist-credentials: false
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
python-version: "3.12"
+1 -1
View File
@@ -9,6 +9,6 @@ jobs:
pull-requests: write
runs-on: depot-ubuntu-24.04
steps:
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
- uses: actions/labeler@b8dd2d9be0f68b860e7dae5dae7d772984eacd6d # v6.2.0
with:
sync-labels: true
+4
View File
@@ -53,8 +53,12 @@ jobs:
filters: |
api:
- 'api/**'
- 'scripts/ast_grep_guard.py'
- 'scripts/check_no_new_getattr.py'
- 'scripts/check_no_new_controller_sqlalchemy.py'
- 'scripts/lint_controller_sqlalchemy.py'
- 'scripts/ast_grep_rules/no_new_getattr.yml'
- 'scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml'
- '.github/workflows/style.yml'
- '.github/workflows/main-ci.yml'
- '.github/workflows/api-tests.yml'
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
@@ -24,7 +24,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup Python & UV
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
fetch-depth: 0
- name: Setup Python & UV
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with:
days-before-issue-stale: 15
days-before-issue-close: 3
+18 -3
View File
@@ -34,14 +34,18 @@ jobs:
with:
files: |
api/**
scripts/ast_grep_guard.py
scripts/check_no_new_getattr.py
scripts/check_no_new_controller_sqlalchemy.py
scripts/lint_controller_sqlalchemy.py
scripts/ast_grep_rules/no_new_getattr.yml
scripts/ast_grep_rules/no_new_controller_sqlalchemy.yml
.github/workflows/style.yml
.github/workflows/main-ci.yml
- name: Setup UV and Python
if: steps.changed-files.outputs.any_changed == 'true'
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: false
python-version: "3.12"
@@ -63,6 +67,10 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
run: uv run --project api python scripts/check_no_new_getattr.py --base-rev "${{ inputs.base-rev }}"
- name: Run No New Controller SQLAlchemy Guard
if: steps.changed-files.outputs.any_changed == 'true'
run: uv run --project api python scripts/check_no_new_controller_sqlalchemy.py --base-rev "${{ inputs.base-rev }}"
- name: Run Type Checks
if: steps.changed-files.outputs.any_changed == 'true'
env:
@@ -154,7 +162,10 @@ jobs:
pnpm-lock.yaml
pnpm-workspace.yaml
.nvmrc
vite.config.ts
eslint.config.mjs
.vscode/**
.github/workflows/autofix.yml
.github/workflows/style.yml
.github/actions/setup-web/**
@@ -162,15 +173,19 @@ jobs:
if: steps.changed-files.outputs.any_changed == 'true'
uses: ./.github/actions/setup-web
- name: Format check
if: steps.changed-files.outputs.any_changed == 'true'
run: vp fmt --check
- name: Restore ESLint cache
if: steps.changed-files.outputs.any_changed == 'true'
id: eslint-cache-restore
uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: .eslintcache
key: ${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
key: ${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'packages/dify-ui/eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-${{ github.sha }}
restore-keys: |
${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-
${{ runner.os }}-eslint-${{ hashFiles('pnpm-lock.yaml', 'eslint.config.mjs', 'packages/dify-ui/eslint.config.mjs', 'web/eslint.config.mjs', 'web/eslint.constants.mjs', 'web/plugins/eslint/**') }}-
- name: Style check
if: steps.changed-files.outputs.any_changed == 'true'
+3 -3
View File
@@ -158,7 +158,7 @@ jobs:
- name: Run Claude Code for Translation Sync
if: steps.context.outputs.CHANGED_FILES != ''
uses: anthropics/claude-code-action@558b1d6cab4085c7753fe402c10bef0fbb92ac7a # v1.0.165
uses: anthropics/claude-code-action@e90deca47693f9457b72f2b53c17d7c445a87342 # v1.0.171
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
github_token: ${{ secrets.GITHUB_TOKEN }}
@@ -223,7 +223,7 @@ jobs:
- Match the existing terminology and register used by each locale.
- Prefer one Edit per file when stable, but prioritize correctness over batching.
6. Verify only the edited files.
- Run `vp run dify-web#lint:fix --quiet -- <relative edited i18n file paths under web/>`
- Run `vp fmt <repo-relative edited paths such as web/i18n/...>`
- Run `vp run dify-web#i18n:check ${{ steps.context.outputs.FILE_ARGS }} ${{ steps.context.outputs.LANG_ARGS }}`
- If verification fails, fix the remaining problems before continuing.
7. Stop after the scoped locale files are updated and verification passes.
@@ -322,7 +322,7 @@ jobs:
'## Verification',
'',
`- \`vp run dify-web#i18n:check --file ${process.env.FILES_IN_SCOPE} --lang ${process.env.TARGET_LANGS}\``,
`- \`vp run dify-web#lint:fix --quiet -- <edited i18n files under web/>\``,
`- \`vp fmt <repo-relative edited paths such as web/i18n/...>\``,
'',
'## Notes',
'',
+1 -1
View File
@@ -36,7 +36,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
+1 -1
View File
@@ -33,7 +33,7 @@ jobs:
remove_tool_cache: true
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
python-version: ${{ matrix.python-version }}
+6 -6
View File
@@ -1,10 +1,6 @@
name: Web Full-Stack E2E
on:
push:
branches:
- feat/agent-v2-e2e-test
workflow_call:
inputs:
run-external-runtime:
@@ -37,7 +33,7 @@ jobs:
uses: ./.github/actions/setup-web
- name: Setup UV and Python
uses: astral-sh/setup-uv@d31148d669074a8d0a63714ba94f3201e7020bc3 # v8.3.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
with:
enable-cache: true
python-version: "3.12"
@@ -48,6 +44,10 @@ jobs:
- name: Install API dependencies
run: uv sync --project api --dev
- name: Run E2E support unit tests
working-directory: ./e2e
run: vp run test:unit
- name: Install Playwright browser
working-directory: ./e2e
run: vp run e2e:install
@@ -63,7 +63,7 @@ jobs:
run: vp run e2e:full
- name: Run external runtime E2E tests
if: ${{ inputs.run-external-runtime || github.ref == 'refs/heads/feat/agent-v2-e2e-test' }}
if: ${{ inputs.run-external-runtime }}
working-directory: ./e2e
env:
E2E_ADMIN_EMAIL: [email protected]
+29 -36
View File
@@ -1,54 +1,47 @@
{
"cucumber.features": [
"e2e/features/**/*.feature",
],
"cucumber.glue": [
"e2e/features/**/*.ts",
],
"cucumber.features": ["e2e/features/**/*.feature"],
"cucumber.glue": ["e2e/features/**/*.ts"],
"tailwindCSS.experimental.configFile": "web/app/styles/globals.css",
// Auto fix
// Format
"[javascript]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[javascriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[typescript]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[typescriptreact]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[json]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[jsonc]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[markdown]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[mdx]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[yaml]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[toml]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[css]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[scss]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[less]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[html]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[vue]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[svelte]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[graphql]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"[handlebars]": { "editor.defaultFormatter": "oxc.oxc-vscode" },
"editor.formatOnSave": true,
"editor.formatOnSaveMode": "file",
"oxc.fmt.configPath": "./vite.config.ts",
// Lint fix
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit",
"source.fixAll.eslint": "explicit"
},
// Silent the stylistic rules in your IDE, but still auto fix them
"eslint.rules.customizations": [
{ "rule": "style/*", "severity": "off", "fixable": true },
{ "rule": "format/*", "severity": "off", "fixable": true },
{ "rule": "*-indent", "severity": "off", "fixable": true },
{ "rule": "*-spacing", "severity": "off", "fixable": true },
{ "rule": "*-spaces", "severity": "off", "fixable": true },
{ "rule": "*-order", "severity": "off", "fixable": true },
{ "rule": "*-dangle", "severity": "off", "fixable": true },
{ "rule": "*-newline", "severity": "off", "fixable": true },
{ "rule": "*quotes", "severity": "off", "fixable": true },
{ "rule": "*semi", "severity": "off", "fixable": true }
],
// Enable eslint for all supported languages
// Enable ESLint for linted languages
"eslint.validate": [
"javascript",
"javascriptreact",
"typescript",
"typescriptreact",
"vue",
"html",
"markdown",
"json",
"jsonc",
"yaml",
"toml",
"xml",
"gql",
"graphql",
"astro",
"svelte",
"css",
"less",
"scss",
"pcss",
"postcss"
"toml"
]
}
+1 -1
View File
@@ -31,7 +31,7 @@ The codebase is split into:
## Language Style
- **Python**: Keep type hints on functions and attributes, and implement relevant special methods (e.g., `__repr__`, `__str__`). Prefer `TypedDict` over `dict` or `Mapping` for type safety and better code documentation.
- **TypeScript**: Use the strict config, rely on ESLint (`pnpm lint:fix` preferred) plus `pnpm type-check`, and avoid `any` types.
- **TypeScript**: Use the strict config, format with `vp fmt`, run ESLint for code-quality checks and fixes, run `pnpm type-check`, and avoid `any` types.
## General Practices
-2
View File
@@ -125,8 +125,6 @@ All of Dify's offerings come with corresponding APIs, so you could effortlessly
- **Dify for enterprise / organizations<br/>**
We provide additional enterprise-centric features. [Send us an email](mailto:[email protected]?subject=%5BGitHub%5DBusiness%20License%20Inquiry) to discuss your enterprise needs. <br/>
> For startups and small businesses using AWS, check out [Dify Premium on AWS Marketplace](https://aws.amazon.com/marketplace/pp/prodview-t22mebxzwjhu6) and deploy it to your own AWS VPC with one click. It's an affordable AMI offering with the option to create apps with custom logo and branding.
## Staying ahead
Star Dify on GitHub and be instantly notified of new releases.
+15 -2
View File
@@ -54,8 +54,21 @@ class AgentBackendRunFailedError(AgentBackendError):
run_id: str
detail: Any
reason: str | None
source_event_id: str | None
def __init__(self, run_id: str, detail: Any) -> None:
def __init__(
self,
run_id: str,
detail: Any,
*,
message: str | None = None,
reason: str | None = None,
source_event_id: str | None = None,
) -> None:
self.run_id = run_id
self.detail = detail
super().__init__(f"Agent backend run failed: {run_id}")
self.reason = reason
self.source_event_id = source_event_id
display_message = message or f"Agent backend run failed: {run_id}"
super().__init__(f"{display_message} (agent_run_id={run_id})")
+4 -4
View File
@@ -212,9 +212,7 @@ def migrate_member_roles_to_rbac(
account_id=owner_account_id,
member_account_ids=[account_id for account_id, _ in batch],
)
current_roles_by_account_id = {
item.account_id: {str(role.id) for role in item.roles} for item in current_roles
}
current_roles_by_account_id = {item.account_id: {role.id for role in item.roles} for item in current_roles}
replace_jobs: list[tuple[str, str]] = []
for member_account_id, legacy_role in batch:
@@ -468,7 +466,9 @@ def migrate_dataset_permissions_to_rbac(
"account_id": operator_account_id,
"dataset_id": current_dataset_id,
"target_account_id": member_account_id,
"payload": replace_user_access_policies_payload.model_dump(mode="json"),
"payload": replace_user_access_policies_payload.model_dump(
mode="json", exclude_unset=True
),
},
},
}
+122 -36
View File
@@ -1,10 +1,12 @@
import datetime
import logging
import time
from collections.abc import Callable
from typing import TypedDict
import click
import sqlalchemy as sa
from sqlalchemy.orm import Session, sessionmaker
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
@@ -12,6 +14,7 @@ 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.db_retry import run_with_db_retry
from services.retention.workflow_run.tenant_prefix import tenant_prefix_condition
from tasks.remove_app_and_related_data_task import delete_draft_variables_batch
@@ -35,6 +38,12 @@ class WorkflowRunArchiveTenantPlan(TypedDict):
unpaid_tenant_ids: list[str]
class WorkflowRunArchivePrefixStats(TypedDict):
tenant_ids: list[str]
workflow_runs: int
workflow_node_executions: int
def _normalize_utc_datetime(value: datetime.datetime) -> datetime.datetime:
if value.tzinfo is None:
return value.replace(tzinfo=datetime.UTC)
@@ -57,6 +66,7 @@ def _parse_tenant_prefixes(prefixes: str | None) -> list[str]:
def _get_archive_candidate_tenant_ids_by_prefix(
session: Session,
prefix: str,
*,
start_from: datetime.datetime | None,
@@ -75,7 +85,7 @@ def _get_archive_candidate_tenant_ids_by_prefix(
if start_from is not None:
conditions.append(WorkflowRun.created_at >= start_from)
tenant_ids = db.session.scalars(
tenant_ids = session.scalars(
sa.select(WorkflowRun.tenant_id).where(*conditions).distinct().order_by(WorkflowRun.tenant_id)
).all()
return list(tenant_ids)
@@ -102,8 +112,80 @@ def _filter_paid_workflow_archive_tenant_ids(tenant_ids: list[str]) -> tuple[lis
return paid_tenant_ids, unpaid_tenant_ids
def _run_archive_command_db_retry[T](operation_name: str, operation: Callable[[], T]) -> T:
return run_with_db_retry(operation_name, operation, logger=logger)
def _get_archive_candidate_tenant_ids_with_retry(
session_maker: sessionmaker[Session],
prefix: str,
*,
start_from: datetime.datetime | None,
end_before: datetime.datetime,
) -> list[str]:
def fetch_tenant_ids() -> list[str]:
with session_maker() as session:
return _get_archive_candidate_tenant_ids_by_prefix(
session,
prefix,
start_from=start_from,
end_before=end_before,
)
return _run_archive_command_db_retry(f"workflow archive tenant resolve for prefix {prefix}", fetch_tenant_ids)
def _get_archive_plan_prefix_stats(
session_maker: sessionmaker[Session],
prefix: str,
*,
start_from: datetime.datetime | None,
end_before: datetime.datetime,
) -> WorkflowRunArchivePrefixStats:
from graphon.enums import WorkflowExecutionStatus
from models.workflow import WorkflowNodeExecutionModel, WorkflowRun
from services.retention.workflow_run.archive_paid_plan_workflow_run import WorkflowRunArchiver
def fetch_prefix_stats() -> WorkflowRunArchivePrefixStats:
with session_maker() as session:
tenant_ids = _get_archive_candidate_tenant_ids_by_prefix(
session,
prefix,
start_from=start_from,
end_before=end_before,
)
run_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:
run_conditions.append(WorkflowRun.created_at >= start_from)
workflow_runs = (
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 = (
session.scalar(
sa.select(sa.func.count())
.select_from(WorkflowNodeExecutionModel)
.join(candidate_runs, WorkflowNodeExecutionModel.workflow_run_id == candidate_runs.c.id)
)
or 0
)
return WorkflowRunArchivePrefixStats(
tenant_ids=tenant_ids,
workflow_runs=workflow_runs,
workflow_node_executions=workflow_node_executions,
)
return _run_archive_command_db_retry(f"workflow archive plan for prefix {prefix}", fetch_prefix_stats)
def _resolve_archive_tenant_ids_from_plan(
*,
session_maker: sessionmaker[Session],
tenant_ids: str | None,
tenant_prefixes: list[str],
start_from: datetime.datetime | None,
@@ -122,7 +204,8 @@ def _resolve_archive_tenant_ids_from_plan(
requested_tenant_ids = []
for prefix in tenant_prefixes:
requested_tenant_ids.extend(
_get_archive_candidate_tenant_ids_by_prefix(
_get_archive_candidate_tenant_ids_with_retry(
session_maker,
prefix,
start_from=start_from,
end_before=end_before,
@@ -143,6 +226,21 @@ def _resolve_archive_tenant_ids_from_plan(
)
def _safe_remove_scoped_session(context: str) -> None:
try:
db.session.remove()
except Exception:
logger.warning("Ignoring DB scoped-session cleanup error after %s", context, exc_info=True)
try:
db.session.registry.clear()
except Exception:
logger.warning("Ignoring DB scoped-session registry cleanup error after %s", context, exc_info=True)
try:
db.engine.dispose()
except Exception:
logger.warning("Ignoring DB engine dispose error after %s", context, exc_info=True)
def _resolve_archive_time_range(
*,
before_days: int,
@@ -349,10 +447,6 @@ def archive_workflow_runs_plan(
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,
@@ -364,37 +458,25 @@ def archive_workflow_runs_plan(
if include_archived:
click.echo(click.style("--include-archived is a no-op for V2 bundle archive plans.", fg="yellow"))
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
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,
)
try:
prefix_stats = _get_archive_plan_prefix_stats(
session_maker,
prefix,
start_from=start_from,
end_before=plan_end_before,
)
except Exception as exc:
logger.exception("Failed to build workflow archive plan for prefix %s", prefix)
raise click.ClickException(f"Failed to build workflow archive plan for prefix {prefix}.") from exc
tenant_ids = prefix_stats["tenant_ids"]
workflow_runs = prefix_stats["workflow_runs"]
workflow_node_executions = prefix_stats["workflow_node_executions"]
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,
@@ -574,17 +656,18 @@ def archive_workflow_runs(
)
)
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
try:
tenant_plan = _resolve_archive_tenant_ids_from_plan(
session_maker=session_maker,
tenant_ids=tenant_ids,
tenant_prefixes=parsed_tenant_prefixes,
start_from=start_from,
end_before=plan_end_before,
)
except Exception:
except Exception as exc:
logger.exception("Failed to resolve workflow archive tenant plan")
click.echo(click.style("Failed to resolve workflow archive tenant plan.", fg="red"))
return
raise click.ClickException("Failed to resolve workflow archive tenant plan.") from exc
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
@@ -616,7 +699,10 @@ def archive_workflow_runs(
dry_run=dry_run,
delete_after_archive=delete_after_archive,
)
summary = archiver.run()
try:
summary = archiver.run()
finally:
_safe_remove_scoped_session("archive workflow run command")
click.echo(
click.style(
f"Summary: processed={summary.total_runs_processed}, archived={summary.runs_archived}, "
+10
View File
@@ -42,6 +42,7 @@ from controllers.console.wraps import (
from core.ops.ops_trace_manager import OpsTraceManager
from core.rag.entities import PreProcessingRule, Rule, Segmentation
from core.rag.retrieval.retrieval_methods import RetrievalMethod
from core.rbac import RBACResourceWhitelistScope
from core.trigger.constants import TRIGGER_NODE_TYPES
from extensions.ext_database import db
from fields.base import ResponseModel
@@ -68,6 +69,7 @@ from services.entities.knowledge_entities.knowledge_entities import (
WeightVectorSetting,
)
from services.feature_service import FeatureService
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
ALLOW_CREATE_APP_MODES = ["chat", "agent-chat", "advanced-chat", "workflow", "completion"]
@@ -645,6 +647,14 @@ class AppListApi(Resource):
app_service = AppService()
app = app_service.create_app(current_tenant_id, params, current_user, session=db.session())
if dify_config.RBAC_ENABLED:
enterprise_rbac_service.RBACService.AppAccess.replace_whitelist(
tenant_id=str(current_tenant_id),
account_id=current_user.id,
app_id=str(app.id),
payload=enterprise_rbac_service.ReplaceMemberBindings(scope=RBACResourceWhitelistScope.ALL),
)
initialize_created_app_rbac_access_task.delay(current_tenant_id, current_user.id, app.id)
permission_keys_map = enterprise_rbac_service.RBACService.AppPermissions.batch_get(
str(current_tenant_id),
current_user.id,
@@ -36,6 +36,7 @@ from models import Account
from models.model import AppMode, InstalledApp
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
from .. import console_ns
@@ -188,6 +189,15 @@ class ChatApi(InstalledAppResource):
db.session.commit()
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=current_user,
session=db.session(),
)
response = AppGenerateService.generate(
session=session,
app_model=app_model,
+11 -9
View File
@@ -18,6 +18,7 @@ from extensions.ext_database import db
from libs.login import current_account_with_tenant, login_required
from models import Account
from services.enterprise import rbac_service as svc
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
class _RBACRoleList(svc.Paginated[svc.RBACRole]):
@@ -596,14 +597,15 @@ class RBACAppWhitelistApi(Resource):
def put(self, app_id):
tenant_id, account_id = _current_ids()
request = _payload(_ResourceAccessScopeRequest)
return _dump(
svc.RBACService.AppAccess.replace_whitelist(
tenant_id,
account_id,
str(app_id),
svc.ReplaceMemberBindings(scope=request.scope.value),
)
result = svc.RBACService.AppAccess.replace_whitelist(
tenant_id,
account_id,
str(app_id),
svc.ReplaceMemberBindings(scope=request.scope.value),
)
if dify_config.RBAC_ENABLED and request.scope is RBACResourceWhitelistScope.ALL:
initialize_created_app_rbac_access_task.delay(tenant_id, account_id, str(app_id))
return _dump(result)
@console_ns.route("/workspaces/current/rbac/apps/<uuid:app_id>/user-access-policies")
@@ -630,8 +632,8 @@ class RBACAppUserAccessPolicyAssignmentApi(Resource):
svc.RBACService.AppAccess.replace_user_access_policies(
tenant_id,
account_id,
str(app_id),
str(target_account_id),
app_id,
target_account_id,
payload,
)
)
@@ -40,12 +40,14 @@ from core.errors.error import (
QuotaExceededError,
)
from core.helper.trace_id_helper import get_external_trace_id, get_trace_session_id, omit_trace_session_id_from_payload
from extensions.ext_database import db
from graphon.model_runtime.errors.invoke import InvokeError
from libs import helper
from libs.helper import UUIDStrOrEmpty
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.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
@@ -377,6 +379,15 @@ class ChatApi(Resource):
streaming = _resolve_agent_app_streaming(app_mode=app_mode, response_mode=payload.response_mode)
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,
session=db.session(),
)
response = AppGenerateService.generate(
session=session,
app_model=app_model,
+6
View File
@@ -31,6 +31,12 @@ class NotWorkflowAppError(BaseHTTPException):
code = 400
class WorkflowVersionExecutionNotAllowedError(BaseHTTPException):
error_code = "workflow_version_execution_not_allowed"
description = "Workflow version execution is not available on your current plan. Please upgrade to a paid plan."
code = 403
class ConversationCompletedError(BaseHTTPException):
error_code = "conversation_completed"
description = "The conversation has ended. Please start a new conversation."
@@ -11,6 +11,7 @@ from pydantic.json_schema import SkipJsonSchema
from sqlalchemy.orm import Session, sessionmaker
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from configs import dify_config
from controllers.common.controller_schemas import WorkflowRunPayload as WorkflowRunPayloadBase
from controllers.common.fields import GeneratedAppResponse, SimpleResultResponse
from controllers.common.schema import (
@@ -27,6 +28,7 @@ from controllers.service_api.app.error import (
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderQuotaExceededError,
WorkflowVersionExecutionNotAllowedError,
)
from controllers.service_api.schema import (
expect_user_json,
@@ -43,6 +45,7 @@ from core.errors.error import (
QuotaExceededError,
)
from core.helper.trace_id_helper import get_external_trace_id, get_trace_session_id, omit_trace_session_id_from_payload
from enums.cloud_plan import CloudPlan
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from fields.base import ResponseModel
@@ -56,6 +59,7 @@ from libs.helper import dump_response, to_timestamp
from models.model import App, AppMode, EndUser
from repositories.factory import DifyAPIRepositoryFactory
from services.app_generate_service import AppGenerateService
from services.billing_service import BillingService
from services.errors.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
from services.workflow_app_service import WorkflowAppService
@@ -396,6 +400,10 @@ class WorkflowRunByIdApi(Resource):
"- `completion_request_error` : Workflow execution request failed.\n"
"- `invalid_param` : Required parameter missing or invalid."
),
403: (
"`workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the "
"current plan. Upgrade to a paid plan."
),
404: "`not_found` : Workflow not found.",
429: (
"- `too_many_requests` : Too many concurrent requests for this app.\n"
@@ -421,6 +429,7 @@ class WorkflowRunByIdApi(Resource):
200: "Workflow executed successfully",
400: "Bad request - invalid parameters or workflow issues",
401: "Unauthorized - invalid API token",
403: "Forbidden - upgrade to a paid plan to execute a specific workflow version",
404: "Workflow not found",
429: "Rate limit exceeded",
500: "Internal server error",
@@ -442,6 +451,11 @@ class WorkflowRunByIdApi(Resource):
if app_mode != AppMode.WORKFLOW:
raise NotWorkflowAppError()
if dify_config.BILLING_ENABLED:
billing_info = BillingService.get_info(app_model.tenant_id, exclude_vector_space=True)
if billing_info["enabled"] and billing_info["subscription"]["plan"] == CloudPlan.SANDBOX:
raise WorkflowVersionExecutionNotAllowedError()
payload = WorkflowRunPayload.model_validate(omit_trace_session_id_from_payload(service_api_ns.payload) or {})
args = payload.model_dump(exclude_none=True)
trace_session_id = get_trace_session_id(request)
+25 -2
View File
@@ -7,10 +7,26 @@ from werkzeug.exceptions import NotFound, RequestEntityTooLarge
from controllers.trigger import bp
from core.trigger.debug.event_bus import TriggerDebugEventBus
from core.trigger.debug.events import WebhookDebugEvent, build_webhook_pool_key
from enums.quota_type import QuotaType
from services.errors.app import QuotaExceededError
from services.trigger.webhook_service import RawWebhookDataDict, WebhookService
logger = logging.getLogger(__name__)
_QUOTA_EXCEEDED_MESSAGES = {
QuotaType.TRIGGER: "Trigger event quota exceeded. Please upgrade your plan.",
QuotaType.WORKFLOW: "Workflow execution quota exceeded. Please upgrade your plan.",
}
_DEFAULT_QUOTA_EXCEEDED_MESSAGE = "Quota exceeded. Please upgrade your plan."
def _get_quota_exceeded_message(feature: str) -> str:
try:
quota_type = QuotaType(feature)
except ValueError:
return _DEFAULT_QUOTA_EXCEEDED_MESSAGE
return _QUOTA_EXCEEDED_MESSAGES.get(quota_type, _DEFAULT_QUOTA_EXCEEDED_MESSAGE)
def _prepare_webhook_execution(webhook_id: str, is_debug: bool = False):
"""Fetch trigger context, extract request data, and validate payload using unified processing.
@@ -60,8 +76,15 @@ def handle_webhook(webhook_id: str):
response_data, status_code = WebhookService.generate_webhook_response(node_config)
return jsonify(response_data), status_code
except ValueError as e:
raise NotFound(str(e))
except QuotaExceededError as error:
return jsonify(
{
"error": "Too Many Requests",
"message": _get_quota_exceeded_message(error.feature),
}
), 429
except ValueError as error:
raise NotFound(str(error))
except RequestEntityTooLarge:
raise
except Exception as e:
+16 -1
View File
@@ -27,6 +27,7 @@ from clients.agent_backend import (
AgentBackendInternalEventType,
AgentBackendRunClient,
AgentBackendRunEventAdapter,
AgentBackendRunFailedError,
AgentBackendRunFailedInternalEvent,
AgentBackendRunSucceededInternalEvent,
AgentBackendStreamInternalEvent,
@@ -94,7 +95,18 @@ def _agent_backend_failure_to_exception(event: AgentBackendRunFailedInternalEven
err_cls = _AGENT_BACKEND_INVOKE_ERROR_BY_REASON.get(event.reason or "")
if err_cls is not None:
return err_cls(event.error)
return AgentBackendError(event.error or "Agent backend run did not complete successfully.")
message = event.error or "Agent backend run did not complete successfully."
return AgentBackendRunFailedError(
event.run_id,
{
"error": event.error,
"reason": event.reason,
"source_event_id": event.source_event_id,
},
message=message,
reason=event.reason,
source_event_id=event.source_event_id,
)
def _prompt_messages_from_query(user_query: str | None) -> list[PromptMessage]:
@@ -690,6 +702,9 @@ class AgentAppRunner:
if not isinstance(terminal, AgentBackendRunSucceededInternalEvent):
if isinstance(terminal, AgentBackendRunFailedInternalEvent):
reason = terminal.reason
if reason == "sandbox_expired":
raise AgentBackendError("The agent session sandbox has expired. Please start a new conversation.")
raise _agent_backend_failure_to_exception(terminal)
raise AgentBackendError("Agent backend run did not complete successfully.")
@@ -205,6 +205,7 @@ class AgentChatAppGenerator(MessageBasedAppGenerator):
target=self._generate_worker,
kwargs={
"flask_app": current_app._get_current_object(), # type: ignore
"session": db.session(),
"context": context,
"application_generate_entity": application_generate_entity,
"queue_manager": queue_manager,
@@ -5,6 +5,7 @@ from typing import Any, Union, cast
from pydantic import JsonValue
from clients.agent_backend.errors import AgentBackendError
from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.entities.task_entities import AppBlockingResponse, AppStreamResponse
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
@@ -127,6 +128,7 @@ class AppGenerateResponseConverter[TBlockingResponse: AppBlockingResponse](ABC):
},
ModelCurrentlyNotSupportError: {"code": "model_currently_not_support", "status": 400},
InvokeError: {"code": "completion_request_error", "status": 400},
AgentBackendError: {"code": "completion_request_error", "status": 400},
InvokeRateLimitError: {"code": "rate_limit_error", "status": 429},
}
@@ -4,6 +4,7 @@ import time
from sqlalchemy import select
from sqlalchemy.orm import Session
from clients.agent_backend.errors import AgentBackendError
from core.app.apps.base_app_queue_manager import AppQueueManager
from core.app.entities.app_invoke_entities import (
AppGenerateEntity,
@@ -54,7 +55,7 @@ class BasedGenerateTaskPipeline[AppGenerateEntityT: AppGenerateEntity]:
match e:
case InvokeAuthorizationError():
err = InvokeAuthorizationError("Incorrect API key provided")
case InvokeError() | ValueError():
case InvokeError() | ValueError() | AgentBackendError():
err = e
case _:
description = getattr(e, "description", None)
+1 -1
View File
@@ -27,7 +27,7 @@ class ProviderCredentialsCache:
try:
cached_provider_credentials = cached_provider_credentials.decode("utf-8")
cached_provider_credentials = json.loads(cached_provider_credentials)
except JSONDecodeError:
except (JSONDecodeError, UnicodeDecodeError):
return None
return dict(cached_provider_credentials)
+1 -1
View File
@@ -24,7 +24,7 @@ class ProviderCredentialsCache(ABC):
try:
cached_credentials = cached_credentials.decode("utf-8")
return dict(json.loads(cached_credentials))
except JSONDecodeError:
except (JSONDecodeError, UnicodeDecodeError):
return None
return None
+1 -1
View File
@@ -30,7 +30,7 @@ class ToolParameterCache:
try:
cached_tool_parameter = cached_tool_parameter.decode("utf-8")
cached_tool_parameter = json.loads(cached_tool_parameter)
except JSONDecodeError:
except (JSONDecodeError, UnicodeDecodeError):
return None
return dict(cached_tool_parameter)
+6 -5
View File
@@ -103,16 +103,17 @@ class ApiTool(Tool):
elif not isinstance(credentials["api_key_value"], str):
raise ToolProviderCredentialValidationError("api_key_value must be a string")
api_key_value = credentials["api_key_value"]
if "api_key_header_prefix" in credentials:
api_key_header_prefix = credentials["api_key_header_prefix"]
if api_key_header_prefix == "basic" and credentials["api_key_value"]:
credentials["api_key_value"] = f"Basic {credentials['api_key_value']}"
elif api_key_header_prefix == "bearer" and credentials["api_key_value"]:
credentials["api_key_value"] = f"Bearer {credentials['api_key_value']}"
if api_key_header_prefix == "basic" and api_key_value:
api_key_value = f"Basic {api_key_value}"
elif api_key_header_prefix == "bearer" and api_key_value:
api_key_value = f"Bearer {api_key_value}"
elif api_key_header_prefix == "custom":
pass
headers[api_key_header] = credentials["api_key_value"]
headers[api_key_header] = api_key_value
elif credentials["auth_type"] == "api_key_query":
# For query parameter authentication, we don't add anything to headers
+2 -2
View File
@@ -35,10 +35,10 @@ if [[ "${MODE}" == "worker" ]]; then
if [[ -z "${CELERY_QUEUES}" ]]; then
if [[ "${EDITION}" == "CLOUD" ]]; then
# Cloud edition: separate queues for dataset and trigger tasks
DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution"
DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow_professional,workflow_team,workflow_sandbox,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution"
else
# Community edition (SELF_HOSTED): dataset, pipeline and workflow have separate queues
DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution"
DEFAULT_QUEUES="api_token,dataset,dataset_summary,priority_dataset,priority_pipeline,pipeline,mail,ops_trace,app_deletion,app_rbac,plugin,workflow_storage,conversation,workflow,schedule_poller,schedule_executor,triggered_workflow_dispatcher,trigger_refresh_publisher,trigger_refresh_executor,retention,workflow_based_app_execution"
fi
else
DEFAULT_QUEUES="${CELERY_QUEUES}"
+1
View File
@@ -155,6 +155,7 @@ def init_app(app: DifyApp) -> Celery:
"tasks.trigger_processing_tasks", # async trigger processing
"tasks.generate_summary_index_task", # summary index generation
"tasks.regenerate_summary_index_task", # summary index regeneration
"tasks.initialize_created_app_rbac_access_task", # app access initialization
"tasks.app_generate.resume_agent_app_task", # ENG-635: Agent v2 chat ask_human resume
]
day = dify_config.CELERY_BEAT_SCHEDULER_TIME
+1 -1
View File
@@ -209,7 +209,7 @@ class Dataset(Base):
pipeline_id = mapped_column(StringUUID, nullable=True)
chunk_structure = mapped_column(sa.String(255), nullable=True)
enable_api = mapped_column(sa.Boolean, nullable=False, server_default=sa.text("true"))
is_multimodal = mapped_column(sa.Boolean, default=False, nullable=False, server_default=db.text("false"))
is_multimodal = mapped_column(sa.Boolean, default=False, nullable=False, server_default=sa.text("false"))
@property
def total_documents(self):
+1
View File
@@ -20722,6 +20722,7 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| access_policy_ids | [ string ] | | No |
| account_ids | [ string ] | | No |
#### ReplaceUserAccessPoliciesResponse
+1 -1
View File
@@ -2235,7 +2235,7 @@ Execute a specific workflow version identified by its ID. Useful for running a p
| 200 | Successful response. The content type and structure depend on the `response_mode` parameter in the request. - If `response_mode` is `blocking`, returns `application/json` with a `WorkflowBlockingResponse` object. - If `response_mode` is `streaming`, returns `text/event-stream` with a stream of `ChunkWorkflowEvent` objects. | **application/json**: [GeneratedAppResponse](#generatedappresponse)<br>**text/event-stream**: [GeneratedAppResponse](#generatedappresponse)<br> |
| 400 | - `not_workflow_app` : App mode does not match the API route. - `bad_request` : Workflow is a draft or has an invalid ID format. - `provider_not_initialize` : No valid model provider credentials found. - `provider_quota_exceeded` : Model provider quota exhausted. - `model_currently_not_support` : Current model unavailable. - `completion_request_error` : Workflow execution request failed. - `invalid_param` : Required parameter missing or invalid. | |
| 401 | Unauthorized - invalid API token | |
| 403 | Forbidden - token scope, app, dataset, or workspace access denied | |
| 403 | `workflow_version_execution_not_allowed` : Workflow version execution is unavailable on the current plan. Upgrade to a paid plan. | |
| 404 | `not_found` : Workflow not found. | |
| 429 | - `too_many_requests` : Too many concurrent requests for this app. - `rate_limit_error` : The upstream model provider rate limit was exceeded. | |
| 500 | `internal_server_error` : Internal server error. | |
+9 -9
View File
@@ -1,21 +1,21 @@
[project]
name = "dify-api"
version = "1.15.0"
version = "1.16.0-rc1"
requires-python = "~=3.12.0"
dependencies = [
# Legacy: mature and widely deployed
"bleach>=6.4.0,<7.0.0",
"boto3>=1.43.24,<2.0.0",
"boto3>=1.43.46,<2.0.0",
"celery>=5.6.3,<6.0.0",
"croniter>=6.2.2,<7.0.0",
"dify-agent",
"flask>=3.1.3,<4.0.0",
"flask-cors>=6.0.2,<7.0.0",
"flask-cors>=6.0.5,<7.0.0",
"gevent>=26.4.0,<26.5.0",
"gevent-websocket==0.10.1",
"gmpy2>=2.3.0,<3.0.0",
"google-api-python-client>=2.196.0,<3.0.0",
"google-api-python-client>=2.198.0,<3.0.0",
"gunicorn>=26.0.0,<27.0.0",
"psycogreen>=1.0.2,<2.0.0",
"psycopg2-binary>=2.9.12,<3.0.0",
@@ -31,7 +31,7 @@ dependencies = [
"flask-migrate>=4.1.0,<5.0.0",
"flask-orjson>=2.0.0,<3.0.0",
"flask-restx>=1.3.2,<2.0.0",
"google-cloud-aiplatform>=1.151.0,<2.0.0",
"google-cloud-aiplatform>=1.160.0,<2.0.0",
"httpx[socks]==0.28.1",
"opentelemetry-distro==0.62b1",
"opentelemetry-instrumentation-celery==0.62b1",
@@ -191,11 +191,11 @@ dev = [
# Required for storage clients
############################################################
storage = [
"azure-storage-blob>=12.29.0,<13.0.0",
"bce-python-sdk==0.9.71",
"azure-storage-blob>=12.30.0,<13.0.0",
"bce-python-sdk==0.9.72",
"cos-python-sdk-v5>=1.9.44,<2.0.0",
"esdk-obs-python>=3.22.2,<4.0.0",
"google-cloud-storage>=3.11.0,<4.0.0",
"esdk-obs-python>=3.26.6,<4.0.0",
"google-cloud-storage>=3.12.1,<4.0.0",
"opendal==0.46.0",
"oss2>=2.19.1,<3.0.0",
"supabase>=2.31.0,<3.0.0",
+20
View File
@@ -10,6 +10,7 @@ import json
import logging
import secrets
import uuid
from collections.abc import Iterator
from datetime import UTC, datetime, timedelta
from hashlib import sha256
from typing import Any, NotRequired, TypedDict, cast
@@ -1563,6 +1564,25 @@ class TenantService:
return updated_accounts
@staticmethod
def iter_member_account_id_batches(tenant_id: str, batch_size: int, *, session: Session) -> Iterator[list[str]]:
"""Yield workspace member account ids in bounded, ordered batches."""
offset = 0
while True:
stmt = (
select(TenantAccountJoin.account_id)
.where(TenantAccountJoin.tenant_id == tenant_id)
.order_by(TenantAccountJoin.id)
.offset(offset)
.limit(batch_size)
)
account_ids = list(session.scalars(stmt).all())
if not account_ids:
return
yield account_ids
offset += batch_size
@staticmethod
def get_dataset_operator_members(tenant: Tenant, *, session: Session) -> list[Account]:
"""Get dataset admin members"""
+14 -5
View File
@@ -6,6 +6,7 @@ with support for different subscription tiers, rate limiting, and execution trac
"""
import json
import logging
from datetime import UTC, datetime
from typing import Any
@@ -21,7 +22,7 @@ from models.model import App, EndUser
from models.trigger import WorkflowTriggerLog, WorkflowTriggerLogDict
from models.workflow import Workflow
from repositories.sqlalchemy_workflow_trigger_log_repository import SQLAlchemyWorkflowTriggerLogRepository
from services.errors.app import QuotaExceededError, WorkflowNotFoundError, WorkflowQuotaLimitError
from services.errors.app import QuotaExceededError, WorkflowNotFoundError
from services.quota_service import QuotaService, unlimited
from services.workflow.entities import AsyncTriggerResponse, TriggerData, WorkflowTaskData
from services.workflow.queue_dispatcher import QueueDispatcherManager, QueuePriority
@@ -32,6 +33,8 @@ from tasks.async_workflow_tasks import (
execute_workflow_team,
)
logger = logging.getLogger(__name__)
class AsyncWorkflowService:
"""
@@ -71,7 +74,7 @@ class AsyncWorkflowService:
Raises:
WorkflowNotFoundError: If app or workflow not found
InvokeDailyRateLimitError: If daily rate limit exceeded
QuotaExceededError: If workflow execution quota is exhausted
Behavior:
- Non-blocking: Returns immediately after queuing
@@ -145,10 +148,15 @@ class AsyncWorkflowService:
trigger_log.error = f"Quota limit reached: {e}"
trigger_log_repo.update(trigger_log)
session.commit()
logger.info(
"Workflow quota exceeded for tenant %s, app %s, workflow %s, trigger log %s",
trigger_data.tenant_id,
trigger_data.app_id,
workflow.id,
trigger_log.id,
)
raise WorkflowQuotaLimitError(
f"Workflow execution quota limit reached for tenant {trigger_data.tenant_id}"
) from e
raise
# 8. Create task data
queue_name = dispatcher.get_queue_name()
@@ -206,6 +214,7 @@ class AsyncWorkflowService:
Raises:
ValueError: If trigger log not found
QuotaExceededError: If workflow execution quota is exhausted
Behavior:
- Non-blocking: Returns immediately after queuing retry
+5 -3
View File
@@ -255,6 +255,7 @@ class ResourceUserAccessPoliciesResponse(_RBACModel):
class ReplaceUserAccessPolicies(_RBACModel):
access_policy_ids: list[str] = Field(default_factory=list)
account_ids: list[str] = Field(default_factory=list)
@field_validator("access_policy_ids", mode="before")
@classmethod
@@ -1126,16 +1127,17 @@ class RBACService:
tenant_id: str,
account_id: str | None,
app_id: str,
target_account_id: str,
target_account_id: str | None,
payload: ReplaceUserAccessPolicies,
) -> ReplaceUserAccessPoliciesResponse:
request_data = payload.model_dump(mode="json")
data = _inner_call(
"PUT",
f"{_INNER_PREFIX}/apps/user-access-policies",
tenant_id=tenant_id,
account_id=account_id,
params={"app_id": app_id, "account_id": target_account_id},
json=payload.model_dump(mode="json"),
json=request_data,
)
return ReplaceUserAccessPoliciesResponse.model_validate(data or {})
@@ -1304,7 +1306,7 @@ class RBACService:
tenant_id=tenant_id,
account_id=account_id,
params={"dataset_id": dataset_id, "account_id": target_account_id},
json=payload.model_dump(mode="json"),
json=payload.model_dump(mode="json", exclude_unset=True),
)
return ReplaceUserAccessPoliciesResponse.model_validate(data or {})
-6
View File
@@ -18,12 +18,6 @@ class WorkflowIdFormatError(Exception):
pass
class WorkflowQuotaLimitError(Exception):
"""Raised when workflow execution quota is exceeded (for async/background workflows)."""
pass
class QuotaExceededError(ValueError):
"""Raised when billing quota is exceeded for a feature."""
@@ -29,12 +29,12 @@ import hashlib
import json
import logging
import time
from collections.abc import Sequence
from collections.abc import Callable, Sequence
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
from enum import Enum
from threading import Lock
from typing import Any, NotRequired, TypedDict, cast
from typing import Any, NotRequired, TypedDict, TypeVar, cast
import click
import pyarrow as pa
@@ -70,8 +70,10 @@ from services.retention.workflow_run.constants import (
ARCHIVE_BUNDLE_MANIFEST_NAME,
ARCHIVE_BUNDLE_SCHEMA_VERSION,
)
from services.retention.workflow_run.db_retry import is_retryable_db_disconnect, run_with_db_retry
logger = logging.getLogger(__name__)
T = TypeVar("T")
class TableStatsManifestEntry(TypedDict):
@@ -208,6 +210,8 @@ class WorkflowRunArchiver:
"workflow_pause_reasons",
"workflow_trigger_logs",
]
DB_RETRY_ATTEMPTS = 3
DB_RETRY_DELAYS_SECONDS = (1.0, 2.0)
start_from: datetime.datetime | None
end_before: datetime.datetime
@@ -455,16 +459,40 @@ class WorkflowRunArchiver:
"""Fetch a batch of workflow runs to archive."""
repo = self._get_workflow_run_repo()
tenant_ids = list(tenant_scope) if tenant_scope is not None else self.tenant_ids or None
return repo.get_runs_batch_by_time_range(
start_from=self.start_from,
end_before=self.end_before,
last_seen=last_seen,
batch_size=self.batch_size,
run_types=self.ARCHIVED_TYPE,
tenant_ids=tenant_ids,
tenant_prefixes=None if tenant_ids else self.tenant_prefixes or None,
run_shard_index=self.run_shard_index,
run_shard_total=self.run_shard_total,
return self._run_with_db_retry(
"workflow run batch fetch",
lambda: repo.get_runs_batch_by_time_range(
start_from=self.start_from,
end_before=self.end_before,
last_seen=last_seen,
batch_size=self.batch_size,
run_types=self.ARCHIVED_TYPE,
tenant_ids=tenant_ids,
tenant_prefixes=None if tenant_ids else self.tenant_prefixes or None,
run_shard_index=self.run_shard_index,
run_shard_total=self.run_shard_total,
),
)
@staticmethod
def _is_retryable_db_disconnect(exc: BaseException) -> bool:
return is_retryable_db_disconnect(exc)
@staticmethod
def _safe_rollback(session: Session, bundle_id: str) -> None:
try:
session.rollback()
except Exception:
logger.warning("Failed to rollback archive session for bundle %s", bundle_id, exc_info=True)
def _run_with_db_retry(self, operation_name: str, operation: Callable[[], T]) -> T:
return run_with_db_retry(
operation_name,
operation,
logger=logger,
attempts=self.DB_RETRY_ATTEMPTS,
delays_seconds=self.DB_RETRY_DELAYS_SECONDS,
)
def _tenant_scan_scopes(self) -> list[list[str] | None]:
@@ -532,16 +560,14 @@ class WorkflowRunArchiver:
if self.workers == 1 or len(bundle_groups) == 1:
results: list[ArchiveResult] = []
for bundle_runs in bundle_groups:
with session_maker() as session:
results.append(self._archive_bundle(session, storage, bundle_runs))
results.append(self._archive_bundle_with_retry(session_maker, storage, bundle_runs))
return results
results = []
max_workers = min(self.workers, len(bundle_groups))
def archive_in_worker(bundle_runs: Sequence[WorkflowRun]) -> ArchiveResult:
with session_maker() as session:
return self._archive_bundle(session, storage, bundle_runs)
return self._archive_bundle_with_retry(session_maker, storage, bundle_runs)
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [executor.submit(archive_in_worker, bundle_runs) for bundle_runs in bundle_groups]
@@ -549,6 +575,39 @@ class WorkflowRunArchiver:
results.append(future.result())
return results
def _archive_bundle_with_retry(
self,
session_maker: sessionmaker[Session],
storage: ArchiveStorage | None,
runs: Sequence[WorkflowRun],
) -> ArchiveResult:
identity = self._build_bundle_identity(runs)
try:
return self._run_with_db_retry(
f"archive workflow run bundle {identity.bundle_id}",
lambda: self._archive_bundle_once(session_maker, storage, runs),
)
except Exception as exc:
logger.exception("Failed to archive workflow run bundle %s after retries", identity.bundle_id)
return ArchiveResult(
bundle_id=identity.bundle_id,
tenant_id=identity.tenant_id,
object_prefix=identity.object_prefix,
run_count=len(runs),
success=False,
error=str(exc),
)
def _archive_bundle_once(
self,
session_maker: sessionmaker[Session],
storage: ArchiveStorage | None,
runs: Sequence[WorkflowRun],
) -> ArchiveResult:
with session_maker() as session:
return self._archive_bundle(session, storage, runs)
def _archive_bundle(
self,
session: Session,
@@ -645,9 +704,12 @@ class WorkflowRunArchiver:
result.success = True
except Exception as e:
if self._is_retryable_db_disconnect(e):
self._safe_rollback(session, identity.bundle_id)
raise
logger.exception("Failed to archive workflow run bundle %s", identity.bundle_id)
result.error = str(e)
session.rollback()
self._safe_rollback(session, identity.bundle_id)
result.elapsed_time = time.time() - start_time
return result
@@ -794,6 +856,9 @@ class WorkflowRunArchiver:
end_before = self.end_before
if end_before is None:
raise ValueError("archive window end must be set")
formatted_end_before = self._format_window_datetime(end_before)
if formatted_end_before is None:
raise ValueError("archive window end must be set")
return ArchiveManifestDict(
schema_version=ARCHIVE_BUNDLE_SCHEMA_VERSION,
archive_format=ARCHIVE_BUNDLE_FORMAT,
@@ -813,7 +878,7 @@ class WorkflowRunArchiver:
archived_at=datetime.datetime.now(datetime.UTC).isoformat(),
campaign_id=self.campaign_id,
archive_window_start=self._format_window_datetime(self.start_from),
archive_window_end=end_before.isoformat(),
archive_window_end=formatted_end_before,
run_shard=identity.shard,
tables=tables,
run_ids=[run.id for run in sorted_runs],
@@ -0,0 +1,66 @@
import logging
import time
from collections.abc import Callable
from sqlalchemy.exc import DBAPIError
from sqlalchemy.exc import OperationalError as SQLAlchemyOperationalError
DEFAULT_DB_RETRY_ATTEMPTS = 3
DEFAULT_DB_RETRY_DELAYS_SECONDS = (1.0, 2.0)
_DB_DISCONNECT_PATTERNS = (
"server closed the connection unexpectedly",
"connection already closed",
"closed the connection",
"connection not open",
"terminating connection",
"connection reset",
"broken pipe",
"connection invalidated",
)
def is_retryable_db_disconnect(exc: BaseException) -> bool:
if isinstance(exc, DBAPIError) and exc.connection_invalidated:
return True
if not _is_db_operational_error(exc):
return False
original_exception = exc.orig if isinstance(exc, DBAPIError) else None
message = f"{exc} {original_exception or ''}".lower()
return any(pattern in message for pattern in _DB_DISCONNECT_PATTERNS)
def run_with_db_retry[T](
operation_name: str,
operation: Callable[[], T],
*,
logger: logging.Logger,
attempts: int = DEFAULT_DB_RETRY_ATTEMPTS,
delays_seconds: tuple[float, ...] = DEFAULT_DB_RETRY_DELAYS_SECONDS,
) -> T:
for attempt in range(1, attempts + 1):
try:
return operation()
except Exception as exc:
if not is_retryable_db_disconnect(exc) or attempt == attempts:
raise
delay = delays_seconds[min(attempt - 1, len(delays_seconds) - 1)]
logger.warning(
"Retrying %s after retryable DB disconnect (attempt %s/%s, sleep %.1fs)",
operation_name,
attempt,
attempts,
delay,
exc_info=True,
)
time.sleep(delay)
raise RuntimeError(f"{operation_name} did not complete")
def _is_db_operational_error(exc: BaseException) -> bool:
if isinstance(exc, SQLAlchemyOperationalError):
return True
return exc.__class__.__name__ == "OperationalError" and exc.__class__.__module__.startswith(("psycopg", "psycopg2"))
+6 -7
View File
@@ -6,7 +6,7 @@ import uuid
from datetime import UTC, datetime
from typing import TypedDict, cast
from sqlalchemy import select
from sqlalchemy import select, update
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
@@ -912,12 +912,11 @@ class SummaryIndexService:
# Disable summary records (don't delete)
now = naive_utc_now()
for summary in summaries:
summary.enabled = False
summary.disabled_at = now
summary.disabled_by = disabled_by
session.add(summary)
session.execute(
update(DocumentSegmentSummary)
.where(DocumentSegmentSummary.id.in_(s.id for s in summaries))
.values(enabled=False, disabled_at=now, disabled_by=disabled_by)
)
session.commit()
logger.info("Disabled %s summary records for dataset %s", len(summaries), dataset.id)
+14 -7
View File
@@ -101,6 +101,7 @@ class WebhookService:
- Mapping[str, Any]: The node configuration data
Raises:
QuotaExceededError: If the app trigger is rate limited
ValueError: If webhook not found, app trigger not found, trigger disabled, or workflow not found
"""
with Session(db.engine) as session:
@@ -141,8 +142,10 @@ class WebhookService:
# Only check enabled status if not in debug mode
if app_trigger.status == AppTriggerStatus.RATE_LIMITED:
raise ValueError(
f"Webhook trigger is rate limited for webhook {webhook_id}, please upgrade your plan."
raise QuotaExceededError(
feature=QuotaType.TRIGGER.value,
tenant_id=webhook_trigger.tenant_id,
required=1,
)
if app_trigger.status != AppTriggerStatus.ENABLED:
@@ -799,6 +802,7 @@ class WebhookService:
workflow: The workflow to execute
Raises:
QuotaExceededError: If the tenant has exhausted its trigger or workflow execution quota
ValueError: If tenant owner is not found
Exception: If workflow execution fails
"""
@@ -824,11 +828,6 @@ class WebhookService:
quota_charge = QuotaService.reserve(QuotaType.TRIGGER, webhook_trigger.tenant_id)
except QuotaExceededError:
AppTriggerService.mark_tenant_triggers_rate_limited(webhook_trigger.tenant_id)
logger.info(
"Tenant %s rate limited, skipping webhook trigger %s",
webhook_trigger.tenant_id,
webhook_trigger.webhook_id,
)
raise
try:
@@ -841,6 +840,14 @@ class WebhookService:
quota_charge.refund()
raise
except QuotaExceededError as e:
logger.info(
"Tenant %s quota exceeded for feature %s, skipping webhook trigger %s",
webhook_trigger.tenant_id,
e.feature,
webhook_trigger.webhook_id,
)
raise
except Exception:
logger.exception("Failed to trigger workflow for webhook %s", webhook_trigger.webhook_id)
raise
@@ -0,0 +1,53 @@
"""Initialize default RBAC access for existing workspace members after app creation."""
import logging
from celery import shared_task
from configs import dify_config
from extensions.ext_database import db
from services.account_service import TenantService
from services.enterprise import rbac_service as enterprise_rbac_service
logger = logging.getLogger(__name__)
APP_RBAC_ACCOUNT_POLICY_BATCH_SIZE = 500
APP_RBAC_DEFAULT_ACCESS_POLICY_ID = "default"
APP_RBAC_QUEUE = "app_rbac"
@shared_task(queue=APP_RBAC_QUEUE, bind=True, max_retries=3, default_retry_delay=60)
def initialize_created_app_rbac_access_task(self, tenant_id: str, account_id: str, app_id: str) -> None:
"""Grant the default app policy to current workspace members.
App scope is persisted synchronously before this task is queued. Replacing
member policies is idempotent, so retrying the whole synchronization is safe
when the enterprise RBAC service is temporarily unavailable.
"""
if not dify_config.RBAC_ENABLED:
return
try:
for account_ids in TenantService.iter_member_account_id_batches(
tenant_id,
APP_RBAC_ACCOUNT_POLICY_BATCH_SIZE,
session=db.session(),
):
enterprise_rbac_service.RBACService.AppAccess.replace_user_access_policies(
tenant_id=tenant_id,
account_id=account_id,
app_id=app_id,
target_account_id=None,
payload=enterprise_rbac_service.ReplaceUserAccessPolicies(
access_policy_ids=[APP_RBAC_DEFAULT_ACCESS_POLICY_ID],
account_ids=account_ids,
),
)
except Exception as exc:
logger.exception(
"Failed to initialize app RBAC access; retrying: tenant_id=%s app_id=%s attempt=%s",
tenant_id,
app_id,
self.request.retries + 1,
)
raise self.retry(exc=exc)
@@ -6,8 +6,10 @@ from unittest.mock import ANY, MagicMock, patch
import pyarrow as pa
import pyarrow.parquet as pq
import pytest
from sqlalchemy.exc import OperationalError
from services.retention.workflow_run.archive_paid_plan_workflow_run import (
ArchiveResult,
ArchiveSummary,
WorkflowRunArchiver,
)
@@ -32,6 +34,30 @@ class FakeArchiveStorage:
return sorted(key for key in self.objects if key.startswith(prefix))
def _db_disconnect_error() -> OperationalError:
return OperationalError(
"select 1",
{},
RuntimeError("server closed the connection unexpectedly"),
connection_invalidated=True,
)
def _run(run_id: str = "run-1"):
run = MagicMock()
run.id = run_id
run.tenant_id = "tenant-1"
run.created_at = datetime.datetime(2025, 3, 15, 10, 0, 0)
return run
def _session_context(session):
context = MagicMock()
context.__enter__.return_value = session
context.__exit__.return_value = False
return context
class TestWorkflowRunArchiverInit:
def test_start_from_without_end_before_raises(self):
with pytest.raises(ValueError, match="start_from and end_before must be provided together"):
@@ -139,6 +165,32 @@ class TestWorkflowRunArchiverInit:
repo.get_runs_batch_by_time_range.assert_called_once()
assert repo.get_runs_batch_by_time_range.call_args.kwargs["tenant_ids"] == ["tenant-b"]
def test_get_runs_batch_retries_retryable_db_disconnect(self):
repo = MagicMock()
repo.get_runs_batch_by_time_range.side_effect = [_db_disconnect_error(), []]
archiver = WorkflowRunArchiver(workflow_run_repo=repo)
with patch("services.retention.workflow_run.db_retry.time.sleep") as sleep:
runs = archiver._get_runs_batch(None)
assert runs == []
assert repo.get_runs_batch_by_time_range.call_count == 2
sleep.assert_called_once_with(1.0)
def test_get_runs_batch_does_not_retry_non_db_broken_pipe_error(self):
repo = MagicMock()
repo.get_runs_batch_by_time_range.side_effect = RuntimeError("broken pipe")
archiver = WorkflowRunArchiver(workflow_run_repo=repo)
with (
patch("services.retention.workflow_run.db_retry.time.sleep") as sleep,
pytest.raises(RuntimeError, match="broken pipe"),
):
archiver._get_runs_batch(None)
repo.get_runs_batch_by_time_range.assert_called_once()
sleep.assert_not_called()
def test_start_message_includes_shard(self):
archiver = WorkflowRunArchiver(tenant_prefixes=["0"], run_shard_index=1, run_shard_total=4)
@@ -351,6 +403,72 @@ class TestDryRunArchive:
assert summary.table_stats["workflow_app_logs"].size_bytes == 32
class TestArchiveDbRetry:
def test_archive_bundle_groups_retries_with_fresh_session(self):
archiver = WorkflowRunArchiver(days=90)
run = _run()
session_maker = MagicMock(
side_effect=[
_session_context(MagicMock(name="session-1")),
_session_context(MagicMock(name="session-2")),
]
)
success = ArchiveResult(
bundle_id=archiver._build_bundle_identity([run]).bundle_id,
tenant_id=run.tenant_id,
object_prefix=archiver._build_bundle_identity([run]).object_prefix,
run_count=1,
success=True,
)
with (
patch.object(archiver, "_archive_bundle", side_effect=[_db_disconnect_error(), success]) as archive_bundle,
patch("services.retention.workflow_run.db_retry.time.sleep") as sleep,
):
results = archiver._archive_bundle_groups(session_maker, MagicMock(), [[run]])
assert results == [success]
assert archive_bundle.call_count == 2
assert session_maker.call_count == 2
sleep.assert_called_once_with(1.0)
def test_archive_bundle_groups_returns_failed_result_after_retry_exhaustion(self):
archiver = WorkflowRunArchiver(days=90)
run = _run()
session_maker = MagicMock(
side_effect=[
_session_context(MagicMock(name="session-1")),
_session_context(MagicMock(name="session-2")),
_session_context(MagicMock(name="session-3")),
]
)
with (
patch.object(archiver, "_archive_bundle", side_effect=[_db_disconnect_error()] * 3) as archive_bundle,
patch("services.retention.workflow_run.db_retry.time.sleep") as sleep,
):
results = archiver._archive_bundle_groups(session_maker, MagicMock(), [[run]])
assert len(results) == 1
assert results[0].success is False
assert "server closed the connection unexpectedly" in (results[0].error or "")
assert archive_bundle.call_count == archiver.DB_RETRY_ATTEMPTS
assert session_maker.call_count == archiver.DB_RETRY_ATTEMPTS
assert sleep.call_count == archiver.DB_RETRY_ATTEMPTS - 1
def test_archive_bundle_uses_safe_rollback_when_failure_rolls_back_badly(self):
archiver = WorkflowRunArchiver(days=90, dry_run=True)
session = MagicMock()
session.rollback.side_effect = RuntimeError("rollback failed")
with patch.object(archiver, "_extract_bundle_data", side_effect=RuntimeError("extract failed")):
result = archiver._archive_bundle(session, None, [_run()])
assert result.success is False
assert result.error == "extract failed"
session.rollback.assert_called_once()
class TestArchiveRunIdempotency:
def _index_payload(self, archiver: WorkflowRunArchiver, run_ids: list[str], run) -> tuple[str, bytes]:
identity = archiver._build_bundle_identity([run])
@@ -201,9 +201,13 @@ class TestWebhookServiceLookupWithContainers:
db_session_with_containers, app=app, node_id="node-1", status=AppTriggerStatus.RATE_LIMITED
)
with pytest.raises(ValueError, match="rate limited"):
with pytest.raises(QuotaExceededError) as exc_info:
WebhookService.get_webhook_trigger_and_workflow(webhook_trigger.webhook_id)
assert exc_info.value.feature == QuotaType.TRIGGER.value
assert exc_info.value.tenant_id == tenant.id
assert exc_info.value.required == 1
def test_get_webhook_trigger_and_workflow_raises_when_app_trigger_disabled(
self, db_session_with_containers: Session, flask_app_with_containers: Flask
):
@@ -0,0 +1,139 @@
import datetime
from unittest.mock import MagicMock
import click
import pytest
from sqlalchemy.exc import OperationalError
from commands import retention
def _db_disconnect_error() -> OperationalError:
return OperationalError(
"select 1",
{},
RuntimeError("server closed the connection unexpectedly"),
connection_invalidated=True,
)
def _session_context(session):
context = MagicMock()
context.__enter__.return_value = session
context.__exit__.return_value = False
return context
def test_resolve_archive_tenant_ids_from_plan_uses_explicit_sessions(monkeypatch):
end_before = datetime.datetime(2025, 4, 1, tzinfo=datetime.UTC)
sessions = [MagicMock(name="session-a"), MagicMock(name="session-b")]
session_maker = MagicMock(side_effect=[_session_context(sessions[0]), _session_context(sessions[1])])
calls = []
def get_candidate_tenants(session, prefix, *, start_from, end_before):
calls.append((session, prefix, start_from, end_before))
return [f"{prefix}-paid", f"{prefix}-free"]
monkeypatch.setattr(retention, "_get_archive_candidate_tenant_ids_by_prefix", get_candidate_tenants)
monkeypatch.setattr(
retention,
"_filter_paid_workflow_archive_tenant_ids",
lambda tenant_ids: (["a-paid", "b-paid"], ["a-free", "b-free"]),
)
tenant_plan = retention._resolve_archive_tenant_ids_from_plan(
session_maker=session_maker,
tenant_ids=None,
tenant_prefixes=["a", "b"],
start_from=None,
end_before=end_before,
)
assert tenant_plan["archive_tenant_ids"] == ["a-paid", "b-paid"]
assert tenant_plan["paid_tenant_ids"] == ["a-paid", "b-paid"]
assert tenant_plan["unpaid_tenant_ids"] == ["a-free", "b-free"]
assert calls == [
(sessions[0], "a", None, end_before),
(sessions[1], "b", None, end_before),
]
def test_safe_remove_scoped_session_discards_registry_and_disposes_after_remove_error(monkeypatch):
fake_db = MagicMock()
fake_db.session.remove.side_effect = RuntimeError("server closed the connection unexpectedly")
monkeypatch.setattr(retention, "db", fake_db)
retention._safe_remove_scoped_session("archive workflow run command")
fake_db.session.remove.assert_called_once()
fake_db.session.registry.clear.assert_called_once()
fake_db.engine.dispose.assert_called_once()
def test_archive_command_db_retry_retries_retryable_db_disconnect(monkeypatch):
operation = MagicMock(side_effect=[_db_disconnect_error(), "ok"])
sleep = MagicMock()
monkeypatch.setattr("services.retention.workflow_run.db_retry.time.sleep", sleep)
result = retention._run_archive_command_db_retry("archive plan", operation)
assert result == "ok"
assert operation.call_count == 2
sleep.assert_called_once_with(1.0)
def test_archive_plan_prefix_stats_retries_count_query_with_fresh_session(monkeypatch):
end_before = datetime.datetime(2025, 4, 1, tzinfo=datetime.UTC)
sessions = [MagicMock(name="session-1"), MagicMock(name="session-2")]
sessions[0].scalar.side_effect = _db_disconnect_error()
sessions[1].scalar.side_effect = [7, 9]
session_maker = MagicMock(side_effect=[_session_context(sessions[0]), _session_context(sessions[1])])
sleep = MagicMock()
monkeypatch.setattr(
retention,
"_get_archive_candidate_tenant_ids_by_prefix",
lambda session, prefix, *, start_from, end_before: [f"{prefix}-tenant"],
)
monkeypatch.setattr("services.retention.workflow_run.db_retry.time.sleep", sleep)
stats = retention._get_archive_plan_prefix_stats(
session_maker,
"a",
start_from=None,
end_before=end_before,
)
assert stats["tenant_ids"] == ["a-tenant"]
assert stats["workflow_runs"] == 7
assert stats["workflow_node_executions"] == 9
assert session_maker.call_count == 2
sleep.assert_called_once_with(1.0)
def test_archive_workflow_runs_raises_click_exception_when_tenant_plan_fails(monkeypatch):
fake_db = MagicMock()
monkeypatch.setattr(retention, "db", fake_db)
monkeypatch.setattr(
retention,
"_resolve_archive_tenant_ids_from_plan",
MagicMock(side_effect=RuntimeError("tenant plan failed")),
)
with pytest.raises(click.ClickException, match="Failed to resolve workflow archive tenant plan"):
retention.archive_workflow_runs.callback(
tenant_ids="tenant-1",
tenant_prefixes=None,
before_days=90,
from_days_ago=None,
to_days_ago=None,
start_from=None,
end_before=None,
batch_size=10000,
workers=1,
run_shard_index=None,
run_shard_total=None,
limit=None,
dry_run=True,
delete_after_archive=False,
)
@@ -13,11 +13,15 @@ from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[4]
SCRIPT_PATH = REPO_ROOT / "scripts" / "check_no_new_getattr.py"
SCRIPTS_DIR = REPO_ROOT / "scripts"
SCRIPT_PATH = SCRIPTS_DIR / "check_no_new_getattr.py"
GUARD_HELPER_PATH = SCRIPTS_DIR / "ast_grep_guard.py"
def load_guard_module() -> types.ModuleType:
spec = importlib.util.spec_from_file_location("check_no_new_getattr_under_test", SCRIPT_PATH)
if str(SCRIPTS_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPTS_DIR))
spec = importlib.util.spec_from_file_location("ast_grep_guard_under_test", GUARD_HELPER_PATH)
assert spec is not None
assert spec.loader is not None
module = importlib.util.module_from_spec(spec)
@@ -1,7 +1,15 @@
"""CLI migration tests using real command-owned SQLite sessions.
Migration services and package I/O remain fakes so these cases can focus on
CLI parsing and session lifecycle without fabricating the SQLAlchemy boundary.
"""
import json
from pathlib import Path
from click.testing import CliRunner
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session
from commands import data_migration
from commands.data_migration import (
@@ -20,24 +28,6 @@ from services.data_migration.entities import (
)
class FakeSessionContext:
session: object
entered: bool
exited: bool
def __init__(self, session: object) -> None:
self.session = session
self.entered = False
self.exited = False
def __enter__(self) -> object:
self.entered = True
return self.session
def __exit__(self, *_args: object) -> None:
self.exited = True
def test_export_command_requires_input_and_output():
result = CliRunner().invoke(export_migration_data, [])
@@ -99,9 +89,7 @@ def test_export_template_command_requires_overwrite_for_existing_output(tmp_path
assert "already exists" in result.output
def test_export_command_uses_cli_owned_session(monkeypatch, tmp_path: Path):
session = object()
session_context = FakeSessionContext(session)
def test_export_command_uses_cli_owned_session(monkeypatch, tmp_path: Path, sqlite_engine: Engine):
captured: dict[str, object] = {}
input_file = tmp_path / "export-config.json"
output_file = tmp_path / "migration-package.json"
@@ -120,7 +108,7 @@ def test_export_command_uses_cli_owned_session(monkeypatch, tmp_path: Path):
captured["path"] = path
captured["overwrite"] = overwrite
monkeypatch.setattr(data_migration.session_factory, "create_session", lambda: session_context)
monkeypatch.setattr(data_migration.session_factory, "create_session", lambda: Session(sqlite_engine))
monkeypatch.setattr(data_migration, "MigrationExportService", FakeMigrationExportService)
monkeypatch.setattr(data_migration, "MigrationPackageService", FakeMigrationPackageService)
@@ -130,17 +118,16 @@ def test_export_command_uses_cli_owned_session(monkeypatch, tmp_path: Path):
)
assert result.exit_code == 0
assert captured["session"] is session
captured_session = captured["session"]
assert isinstance(captured_session, Session)
assert captured_session.get_bind() is sqlite_engine
assert captured_session.in_transaction() is False
assert captured["package"] is package
assert captured["path"] == str(output_file)
assert captured["overwrite"] is False
assert session_context.entered
assert session_context.exited
def test_import_command_uses_cli_owned_session(monkeypatch, tmp_path: Path):
session = object()
session_context = FakeSessionContext(session)
def test_import_command_uses_cli_owned_session(monkeypatch, tmp_path: Path, sqlite_engine: Engine):
captured: dict[str, object] = {}
input_file = tmp_path / "migration-package.json"
input_file.write_text("{}")
@@ -166,7 +153,7 @@ def test_import_command_uses_cli_owned_session(monkeypatch, tmp_path: Path):
captured["path"] = path
return package
monkeypatch.setattr(data_migration.session_factory, "create_session", lambda: session_context)
monkeypatch.setattr(data_migration.session_factory, "create_session", lambda: Session(sqlite_engine))
monkeypatch.setattr(data_migration, "MigrationImportService", FakeMigrationImportService)
monkeypatch.setattr(data_migration, "MigrationPackageService", FakeMigrationPackageService)
@@ -176,10 +163,11 @@ def test_import_command_uses_cli_owned_session(monkeypatch, tmp_path: Path):
)
assert result.exit_code == 0
assert captured["session"] is session
captured_session = captured["session"]
assert isinstance(captured_session, Session)
assert captured_session.get_bind() is sqlite_engine
assert captured_session.in_transaction() is False
assert captured["path"] == str(input_file)
request = captured["request"]
assert request.package is package
assert request.options_override == ImportOptions(conflict_strategy=ConflictStrategy.SKIP)
assert session_context.entered
assert session_context.exited
@@ -556,6 +556,7 @@ def test_app_create_api_attaches_permission_keys(app, app_module):
with app.test_request_context("/apps", method="POST", json={}):
with pytest.MonkeyPatch.context() as monkeypatch:
monkeypatch.setattr(app_module.dify_config, "RBAC_ENABLED", True)
app_module.console_ns.payload = {
"name": "Created App",
"description": "Summary",
@@ -571,11 +572,25 @@ def test_app_create_api_attaches_permission_keys(app, app_module):
"batch_get",
lambda tenant_id, account_id, app_ids, session: {"app-new": ["app.acl.view_layout", "app.acl.edit"]},
)
initialize_rbac_task = MagicMock()
monkeypatch.setattr(
app_module,
"initialize_created_app_rbac_access_task",
initialize_rbac_task,
)
replace_whitelist = MagicMock()
monkeypatch.setattr(
app_module.enterprise_rbac_service.RBACService.AppAccess,
"replace_whitelist",
replace_whitelist,
)
resp, status = method(app_module.AppListApi(), "tenant-1", SimpleNamespace(id="acct-1"))
assert status == 201
assert resp["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
assert replace_whitelist.call_args.kwargs["payload"].scope is app_module.RBACResourceWhitelistScope.ALL
initialize_rbac_task.delay.assert_called_once_with("tenant-1", "acct-1", "app-new")
def test_app_list_api_attaches_permission_keys(app, app_module):
@@ -1,3 +1,4 @@
import uuid
from inspect import unwrap
from unittest.mock import MagicMock, PropertyMock, patch
@@ -310,6 +311,37 @@ class TestChatApi:
with pytest.raises(completion_module.NotFound):
method(api, MagicMock(), user, chat_app)
def test_invalid_conversation_id_fails_fast_as_not_found(self, app: Flask, chat_app, user) -> None:
# A nonexistent conversation_id must fail fast as 404, before the streaming
# generator is created. Previously the lookup only ran inside the generator,
# so an invalid id surfaced as a hang instead of a clean error.
payload_patch = patch.object(
type(completion_module.console_ns),
"payload",
new_callable=PropertyMock,
return_value={"inputs": {}, "query": "hi", "conversation_id": str(uuid.uuid4())},
)
generate_mock = MagicMock(return_value={"ok": True})
api = completion_module.ChatApi()
method = unwrap(api.post)
with (
app.test_request_context("/", json={}),
payload_patch,
patch.object(
completion_module.ConversationService,
"get_conversation",
side_effect=completion_module.services.errors.conversation.ConversationNotExistsError(),
),
patch.object(completion_module.AppGenerateService, "generate", generate_mock),
):
with pytest.raises(completion_module.NotFound):
method(api, MagicMock(), user, chat_app)
# The lookup must run before generation, so the generator is never started.
generate_mock.assert_not_called()
def test_app_unavailable_chat(self, app: Flask, chat_app, user, payload_patch):
api = completion_module.ChatApi()
method = unwrap(api.post)
@@ -262,6 +262,25 @@ class TestPaginationMapping:
class TestResourceAccessScopeBindings:
def test_app_whitelist_all_schedules_member_policy_sync(self, app):
with (
app.test_request_context(
"/workspaces/current/rbac/apps/app-1/whitelist",
method="PUT",
json={"scope": "all"},
),
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-actor")),
patch("controllers.console.workspace.rbac.dify_config.RBAC_ENABLED", True),
patch(
"controllers.console.workspace.rbac.svc.RBACService.AppAccess.replace_whitelist",
return_value=rbac_mod.svc.ResourceWhitelist(),
),
patch("controllers.console.workspace.rbac.initialize_created_app_rbac_access_task") as mock_sync_task,
):
inspect.unwrap(rbac_mod.RBACAppWhitelistApi.put)(rbac_mod.RBACAppWhitelistApi(), "app-1")
mock_sync_task.delay.assert_called_once_with("tenant-1", "acct-actor", "app-1")
def test_app_user_access_policy_assignment_forwards_ids(self, app):
with (
app.test_request_context(
@@ -42,6 +42,7 @@ from graphon.model_runtime.errors.invoke import InvokeError
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.app import IsDraftWorkflowError, WorkflowIdFormatError, WorkflowNotFoundError
from services.errors.conversation import ConversationNotExistsError
from services.errors.llm import InvokeRateLimitError
@@ -554,6 +555,35 @@ class TestChatApiController:
with pytest.raises(AgentNotPublishedError):
handler(api, session=Mock(), app_model=app_model, end_user=end_user)
def test_invalid_conversation_id_fails_fast_as_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
# A well-formed but nonexistent conversation_id must fail fast as 404, before the
# streaming generator is created. Previously the lookup only ran inside the generator,
# so an invalid id surfaced as a hang instead of a clean error.
monkeypatch.setattr(
ConversationService,
"get_conversation",
lambda *_args, **_kwargs: (_ for _ in ()).throw(ConversationNotExistsError()),
)
generate_mock = Mock(return_value={"text": "unused"})
monkeypatch.setattr(AppGenerateService, "generate", generate_mock)
api = ChatApi()
handler = unwrap(api.post)
app_model = SimpleNamespace(mode=AppMode.CHAT.value, id="app-1")
end_user = SimpleNamespace()
with app.test_request_context(
"/chat-messages",
method="POST",
json={"inputs": {}, "query": "hi", "conversation_id": str(uuid.uuid4())},
):
with pytest.raises(NotFound):
handler(api, session=Mock(), app_model=app_model, end_user=end_user)
# The lookup must run before generation, so the generator is never started.
generate_mock.assert_not_called()
class TestChatStopApiController:
def test_wrong_mode(self, app: Flask) -> None:
@@ -26,7 +26,7 @@ from flask import Flask
from sqlalchemy.orm import sessionmaker
from werkzeug.exceptions import BadRequest, NotFound
from controllers.service_api.app.error import NotWorkflowAppError
from controllers.service_api.app.error import NotWorkflowAppError, WorkflowVersionExecutionNotAllowedError
from controllers.service_api.app.workflow import (
AppQueueManager,
DifyAPIRepositoryFactory,
@@ -42,11 +42,13 @@ from controllers.service_api.app.workflow import (
)
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
from core.app.entities.app_invoke_entities import InvokeFrom
from enums.cloud_plan import CloudPlan
from graphon.enums import WorkflowExecutionStatus
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
from models.model import App, AppMode, EndUser
from models.workflow import WorkflowAppLog, WorkflowAppLogCreatedFrom, WorkflowRun, WorkflowType
from services.app_generate_service import AppGenerateService
from services.billing_service import BillingService
from services.errors.app import IsDraftWorkflowError, WorkflowNotFoundError
from services.errors.llm import InvokeRateLimitError
from services.workflow_app_service import LogView, LogViewDetails, WorkflowAppService
@@ -570,9 +572,117 @@ class TestWorkflowRunApi:
with pytest.raises(InvokeRateLimitHttpError):
handler(api, session=Mock(), app_model=app_model, end_user=end_user)
def test_sandbox_billing_does_not_gate_default_workflow_run(
self, app: Flask, monkeypatch: pytest.MonkeyPatch
) -> None:
workflow_module = sys.modules["controllers.service_api.app.workflow"]
monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", True)
billing_get_info = Mock(return_value={"enabled": True, "subscription": {"plan": CloudPlan.SANDBOX}})
generate = Mock(return_value={"result": "ok"})
monkeypatch.setattr(BillingService, "get_info", billing_get_info)
monkeypatch.setattr(AppGenerateService, "generate", generate)
api = WorkflowRunApi()
handler = unwrap(api.post)
with app.test_request_context("/workflows/run", method="POST", json={"inputs": {}}):
response = handler(
api,
session=Mock(),
app_model=_make_app_model(),
end_user=_make_end_user(),
)
assert response.get_json() == {"result": "ok"}
billing_get_info.assert_not_called()
generate.assert_called_once()
class TestWorkflowRunByIdApi:
def test_rejects_sandbox_plan_with_upgrade_error(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
workflow_module = sys.modules["controllers.service_api.app.workflow"]
monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", True)
billing_get_info = Mock(return_value={"enabled": True, "subscription": {"plan": CloudPlan.SANDBOX}})
generate = Mock()
monkeypatch.setattr(BillingService, "get_info", billing_get_info)
monkeypatch.setattr(AppGenerateService, "generate", generate)
api = WorkflowRunByIdApi()
handler = unwrap(api.post)
app_model = _make_app_model()
with app.test_request_context("/workflows/w1/run", method="POST", json={"inputs": {}}):
with pytest.raises(WorkflowVersionExecutionNotAllowedError) as exc_info:
handler(
api,
session=Mock(),
app_model=app_model,
end_user=_make_end_user(),
workflow_id="w1",
)
billing_get_info.assert_called_once_with(app_model.tenant_id, exclude_vector_space=True)
generate.assert_not_called()
assert exc_info.value.code == 403
assert exc_info.value.error_code == "workflow_version_execution_not_allowed"
assert exc_info.value.description == (
"Workflow version execution is not available on your current plan. Please upgrade to a paid plan."
)
assert exc_info.value.data == {
"code": "workflow_version_execution_not_allowed",
"message": exc_info.value.description,
"status": 403,
}
@pytest.mark.parametrize(
("billing_config_enabled", "billing_enabled", "plan"),
[
(False, True, CloudPlan.SANDBOX),
(True, False, CloudPlan.SANDBOX),
(True, True, CloudPlan.PROFESSIONAL),
],
)
def test_allows_execution_outside_enabled_sandbox_plan(
self,
app: Flask,
monkeypatch: pytest.MonkeyPatch,
billing_config_enabled: bool,
billing_enabled: bool,
plan: CloudPlan,
) -> None:
workflow_module = sys.modules["controllers.service_api.app.workflow"]
monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", billing_config_enabled)
billing_get_info = Mock(return_value={"enabled": billing_enabled, "subscription": {"plan": plan}})
generate = Mock(return_value={"result": "ok"})
monkeypatch.setattr(BillingService, "get_info", billing_get_info)
monkeypatch.setattr(AppGenerateService, "generate", generate)
api = WorkflowRunByIdApi()
handler = unwrap(api.post)
app_model = _make_app_model()
with app.test_request_context("/workflows/w1/run", method="POST", json={"inputs": {}}):
response = handler(
api,
session=Mock(),
app_model=app_model,
end_user=_make_end_user(),
workflow_id="w1",
)
assert response.get_json() == {"result": "ok"}
generate.assert_called_once()
if billing_config_enabled:
billing_get_info.assert_called_once_with(app_model.tenant_id, exclude_vector_space=True)
else:
billing_get_info.assert_not_called()
def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
workflow_module = sys.modules["controllers.service_api.app.workflow"]
monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", False)
monkeypatch.setattr(
AppGenerateService,
"generate",
@@ -589,6 +699,8 @@ class TestWorkflowRunByIdApi:
handler(api, session=Mock(), app_model=app_model, end_user=end_user, workflow_id="w1")
def test_draft_workflow(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
workflow_module = sys.modules["controllers.service_api.app.workflow"]
monkeypatch.setattr(workflow_module.dify_config, "BILLING_ENABLED", False)
monkeypatch.setattr(
AppGenerateService,
"generate",
@@ -5,6 +5,7 @@ import pytest
from werkzeug.exceptions import NotFound, RequestEntityTooLarge
import controllers.trigger.webhook as module
from services.errors.app import QuotaExceededError
@pytest.fixture(autouse=True)
@@ -83,6 +84,58 @@ class TestHandleWebhook:
assert status == 400
assert response["error"] == "Bad Request"
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
@patch.object(
module.WebhookService,
"trigger_workflow_execution",
side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1),
)
def test_quota_exceeded(self, mock_trigger, mock_extract, mock_get):
mock_get.return_value = (DummyWebhookTrigger(), "workflow", "node_config")
mock_extract.return_value = {"input": "x"}
response, status = module.handle_webhook("wh-1")
assert status == 429
assert response == {
"error": "Too Many Requests",
"message": "Trigger event quota exceeded. Please upgrade your plan.",
}
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow")
@patch.object(module.WebhookService, "extract_and_validate_webhook_data")
@patch.object(
module.WebhookService,
"trigger_workflow_execution",
side_effect=QuotaExceededError(feature="workflow", tenant_id="tenant-1", required=1),
)
def test_workflow_quota_exceeded(self, mock_trigger, mock_extract, mock_get):
mock_get.return_value = (DummyWebhookTrigger(), "workflow", "node_config")
mock_extract.return_value = {"input": "x"}
response, status = module.handle_webhook("wh-1")
assert status == 429
assert response == {
"error": "Too Many Requests",
"message": "Workflow execution quota exceeded. Please upgrade your plan.",
}
@patch.object(
module.WebhookService,
"get_webhook_trigger_and_workflow",
side_effect=QuotaExceededError(feature="trigger", tenant_id="tenant-1", required=1),
)
def test_rate_limited(self, mock_get):
response, status = module.handle_webhook("wh-1")
assert status == 429
assert response == {
"error": "Too Many Requests",
"message": "Trigger event quota exceeded. Please upgrade your plan.",
}
@patch.object(module.WebhookService, "get_webhook_trigger_and_workflow", side_effect=ValueError("missing"))
def test_value_error_not_found(self, mock_get):
with pytest.raises(NotFound):
@@ -37,6 +37,7 @@ from pydantic_ai.messages import (
from clients.agent_backend import (
AgentBackendError,
AgentBackendRunEventAdapter,
AgentBackendRunFailedError,
AgentBackendRunFailedInternalEvent,
AgentBackendStreamInternalEvent,
FakeAgentBackendRunClient,
@@ -1207,7 +1208,7 @@ def test_failed_run_raises_agent_backend_error():
store = _FakeSessionStore()
qm = _FakeQueueManager()
with pytest.raises(AgentBackendError):
with pytest.raises(AgentBackendRunFailedError, match="fake failure .*agent_run_id=fake-run-1"):
_run(_runner(client, store), qm)
# No message-end on failure; no snapshot saved.
assert not [e for e in qm.events if isinstance(e, QueueMessageEndEvent)]
@@ -1227,6 +1228,28 @@ def test_agent_backend_failure_to_exception_maps_rate_limit_reason():
assert str(err) == "quota exceeded"
def test_agent_backend_failure_to_exception_preserves_unknown_reason_context():
err = app_runner_module._agent_backend_failure_to_exception(
AgentBackendRunFailedInternalEvent(
run_id="run-1",
source_event_id="event-1",
error="Knowledge retrieval failed",
reason="knowledge_retrieve_failed",
)
)
assert isinstance(err, AgentBackendRunFailedError)
assert err.run_id == "run-1"
assert err.reason == "knowledge_retrieve_failed"
assert err.source_event_id == "event-1"
assert err.detail == {
"error": "Knowledge retrieval failed",
"reason": "knowledge_retrieve_failed",
"source_event_id": "event-1",
}
assert str(err) == "Knowledge retrieval failed (agent_run_id=run-1)"
def test_stopped_task_cancels_agent_backend_run_and_skips_session_save():
client = _RecordingFakeAgentBackendRunClient()
store = _FakeSessionStore()
@@ -116,10 +116,12 @@ class TestAgentChatAppGeneratorGenerate:
)
thread_obj = mocker.MagicMock()
mocker.patch(
thread_constructor = mocker.patch(
"core.app.apps.agent_chat.app_generator.threading.Thread",
return_value=thread_obj,
)
session = mocker.MagicMock()
mocker.patch("core.app.apps.agent_chat.app_generator.db.session", return_value=session)
mocker.patch(
"core.app.apps.agent_chat.app_generator.AgentChatAppGenerateResponseConverter.convert",
@@ -144,6 +146,7 @@ class TestAgentChatAppGeneratorGenerate:
assert result == {"result": "ok"}
assert generate_entity.call_args.kwargs["extras"]["trace_session_id"] == "session-1"
assert thread_constructor.call_args.kwargs["kwargs"]["session"] is session
thread_obj.start.assert_called_once()
def test_generate_without_file_config(self, generator, mocker: MockerFixture):
@@ -3,6 +3,7 @@ from unittest.mock import Mock
import pytest
from clients.agent_backend.errors import AgentBackendRunFailedError
from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter
from core.app.entities.queue_entities import QueueErrorEvent
from core.app.task_pipeline.based_generate_task_pipeline import BasedGenerateTaskPipeline
@@ -41,6 +42,22 @@ class TestBasedGenerateTaskPipeline:
err = pipeline.handle_error(event=event)
assert err is event.error
def test_handle_error_preserves_agent_backend_run_failed_error(self, pipeline):
event = QueueErrorEvent(
error=AgentBackendRunFailedError(
"run-1",
{"reason": "knowledge_retrieve_failed"},
message="Knowledge retrieval failed",
reason="knowledge_retrieve_failed",
)
)
err = pipeline.handle_error(event=event)
assert err is event.error
assert "Knowledge retrieval failed" in str(err)
assert "agent_run_id=run-1" in str(err)
def test_handle_error_updates_message_when_found(self, pipeline):
event = QueueErrorEvent(error=ValueError("oops"))
message = SimpleNamespace(status=MessageStatus.NORMAL, error=None)
@@ -74,6 +91,22 @@ class TestBasedGenerateTaskPipeline:
assert data == {"code": "rate_limit_error", "status": 429, "message": "quota exceeded"}
def test_stream_converter_maps_agent_backend_run_failed_error(self):
data = AppGenerateResponseConverter._error_to_stream_response(
AgentBackendRunFailedError(
"run-1",
{"reason": "knowledge_retrieve_failed"},
message="Knowledge retrieval failed",
reason="knowledge_retrieve_failed",
)
)
assert data == {
"code": "completion_request_error",
"status": 400,
"message": "Knowledge retrieval failed (agent_run_id=run-1)",
}
def test_handle_output_moderation_when_flagged(self, pipeline):
handler = Mock()
handler.moderation_completion.return_value = ("filtered", True)
@@ -0,0 +1,32 @@
import json
from pytest_mock import MockerFixture
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
def test_model_provider_credentials_cache_get_returns_decoded_dict(mocker: MockerFixture) -> None:
redis_client_mock = mocker.patch("core.helper.model_provider_cache.redis_client")
cache = ProviderCredentialsCache(
tenant_id="tenant",
identity_id="identity",
cache_type=ProviderCredentialsCacheType.PROVIDER,
)
payload = {"api_key": "secret"}
redis_client_mock.get.return_value = json.dumps(payload).encode("utf-8")
assert cache.get() == payload
def test_model_provider_credentials_cache_get_returns_none_for_invalid_utf8(mocker: MockerFixture) -> None:
redis_client_mock = mocker.patch("core.helper.model_provider_cache.redis_client")
cache = ProviderCredentialsCache(
tenant_id="tenant",
identity_id="identity",
cache_type=ProviderCredentialsCacheType.PROVIDER,
)
redis_client_mock.get.return_value = b"\xff"
assert cache.get() is None
@@ -0,0 +1,24 @@
import json
from pytest_mock import MockerFixture
from core.helper.provider_cache import ToolProviderCredentialsCache
def test_provider_credentials_cache_get_returns_decoded_dict(mocker: MockerFixture) -> None:
redis_client_mock = mocker.patch("core.helper.provider_cache.redis_client")
cache = ToolProviderCredentialsCache(tenant_id="tenant", provider="provider", credential_id="credential")
payload = {"api_key": "secret"}
redis_client_mock.get.return_value = json.dumps(payload).encode("utf-8")
assert cache.get() == payload
def test_provider_credentials_cache_get_returns_none_for_invalid_utf8(mocker: MockerFixture) -> None:
redis_client_mock = mocker.patch("core.helper.provider_cache.redis_client")
cache = ToolProviderCredentialsCache(tenant_id="tenant", provider="provider", credential_id="credential")
redis_client_mock.get.return_value = b"\xff"
assert cache.get() is None
@@ -38,6 +38,21 @@ def test_tool_parameter_cache_get_returns_none_for_invalid_json(mocker: MockerFi
assert cache.get() is None
def test_tool_parameter_cache_get_returns_none_for_invalid_utf8(mocker: MockerFixture) -> None:
redis_client_mock = mocker.patch("core.helper.tool_parameter_cache.redis_client")
cache = ToolParameterCache(
tenant_id="tenant",
provider="provider",
tool_name="tool",
cache_type=ToolParameterCacheType.PARAMETER,
identity_id="identity",
)
redis_client_mock.get.return_value = b"\xff"
assert cache.get() is None
def test_tool_parameter_cache_get_returns_none_when_key_is_missing(mocker: MockerFixture) -> None:
redis_client_mock = mocker.patch("core.helper.tool_parameter_cache.redis_client")
cache = ToolParameterCache(
@@ -85,6 +85,10 @@ def test_assembling_request_auth_header_assembly():
assert headers["Authorization"] == "Bearer abc"
tool.runtime.credentials = {"auth_type": "api_key_header", "api_key_header_prefix": "basic", "api_key_value": "abc"}
headers = tool.assembling_request(parameters={})
assert headers["Authorization"] == "Basic abc"
assert tool.runtime.credentials["api_key_value"] == "abc"
headers = tool.assembling_request(parameters={})
assert headers["Authorization"] == "Basic abc"
@@ -1,6 +1,9 @@
from __future__ import annotations
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import Mock, patch
import pytest
from sqlalchemy.orm import Session
from core.app.entities.app_invoke_entities import InvokeFrom
from core.tools.__base.tool_runtime import ToolRuntime
@@ -37,7 +40,8 @@ def _build_plugin_tool(*, has_runtime_parameters: bool) -> PluginTool:
)
def test_plugin_tool_invoke_and_fork_runtime():
@pytest.mark.parametrize("sqlite_session", [()], indirect=True)
def test_plugin_tool_invoke_and_fork_runtime(sqlite_session: Session):
tool = _build_plugin_tool(has_runtime_parameters=False)
manager = Mock()
manager.invoke.return_value = iter([tool.create_text_message("ok")])
@@ -47,7 +51,7 @@ def test_plugin_tool_invoke_and_fork_runtime():
"core.tools.plugin_tool.tool.convert_parameters_to_plugin_format",
return_value={"converted": 1},
):
messages = list(tool.invoke(session=MagicMock(), user_id="user-1", tool_parameters={"raw": 1}))
messages = list(tool.invoke(session=sqlite_session, user_id="user-1", tool_parameters={"raw": 1}))
assert [m.message.text for m in messages] == ["ok"]
manager.invoke.assert_called_once()
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
import json
import logging
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
@@ -7,7 +8,7 @@ import pytest
import services.async_workflow_service as async_workflow_service_module
from models.enums import AppTriggerType, CreatorUserRole, WorkflowRunTriggeredFrom, WorkflowTriggerStatus
from services.async_workflow_service import AsyncWorkflowService
from services.errors.app import QuotaExceededError, WorkflowNotFoundError, WorkflowQuotaLimitError
from services.errors.app import QuotaExceededError, WorkflowNotFoundError
from services.workflow.entities import AsyncTriggerResponse, TriggerData
from services.workflow.queue_dispatcher import QueuePriority
@@ -234,8 +235,10 @@ class TestAsyncWorkflowService:
trigger_data=trigger_data,
)
def test_should_mark_log_rate_limited_and_raise_when_quota_exceeded(self, async_workflow_trigger_mocks):
"""Test quota-exceeded path updates trigger log and raises WorkflowQuotaLimitError."""
def test_should_mark_log_rate_limited_and_reraise_when_quota_exceeded(
self, async_workflow_trigger_mocks, caplog: pytest.LogCaptureFixture
):
"""Test quota-exceeded path updates trigger log and preserves the quota exception."""
# Arrange
session = MagicMock()
session.commit = MagicMock()
@@ -254,22 +257,27 @@ class TestAsyncWorkflowService:
tenant_id="tenant-123",
required=1,
)
caplog.set_level(logging.INFO, logger=async_workflow_service_module.__name__)
# Act / Assert
with pytest.raises(
WorkflowQuotaLimitError,
match="Workflow execution quota limit reached for tenant tenant-123",
):
with pytest.raises(QuotaExceededError) as exc_info:
AsyncWorkflowService.trigger_workflow_async(
session=session,
user=SimpleNamespace(id="user-123"),
trigger_data=trigger_data,
)
assert exc_info.value.feature == "workflow"
assert exc_info.value.tenant_id == "tenant-123"
assert exc_info.value.required == 1
assert session.commit.call_count == 3
updated_log = mocks["repo"].update.call_args[0][0]
assert updated_log.status == WorkflowTriggerStatus.RATE_LIMITED
assert "Quota limit reached" in updated_log.error
assert (
"Workflow quota exceeded for tenant tenant-123, app app-123, workflow workflow-123, "
"trigger log trigger-log-123"
) in caplog.messages
mocks["professional_task"].delay.assert_not_called()
mocks["team_task"].delay.assert_not_called()
mocks["sandbox_task"].delay.assert_not_called()
@@ -347,7 +347,9 @@ def test_check_and_deduct_credits_releases_billing_reservation_when_commit_fails
)
def test_check_and_deduct_credits_logs_when_billing_release_fails() -> None:
def test_check_and_deduct_credits_logs_when_billing_release_fails(
caplog: pytest.LogCaptureFixture,
) -> None:
with (
patch("services.credit_pool_service.dify_config.BILLING_ENABLED", True),
patch("services.billing_service.BillingService.quota_reserve") as quota_reserve,
@@ -355,7 +357,6 @@ def test_check_and_deduct_credits_logs_when_billing_release_fails() -> None:
patch(
"services.billing_service.BillingService.quota_release", side_effect=RuntimeError("release failed")
) as quota_release,
patch("services.credit_pool_service.logger.warning") as logger_warning,
):
quota_reserve.return_value = {"reservation_id": "reservation-1", "available": 7, "reserved": 3}
@@ -368,9 +369,9 @@ def test_check_and_deduct_credits_logs_when_billing_release_fails() -> None:
bucket="trial",
reservation_id="reservation-1",
)
logger_warning.assert_called_once()
assert logger_warning.call_args.args[3] == "reservation-1"
assert logger_warning.call_args.kwargs["exc_info"] is True
assert len(caplog.records) == 1
assert "reservation-1" in caplog.records[0].message
assert caplog.records[0].exc_info is not None
def test_deduct_credits_capped_uses_billing_consume_capped_when_enabled() -> None:
@@ -10,9 +10,12 @@ from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
import services.summary_index_service as summary_module
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
from models.dataset import DocumentSegmentSummary
from models.enums import SegmentStatus, SummaryStatus
from services.summary_index_service import SummaryIndexService
@@ -653,32 +656,48 @@ def test_generate_summaries_for_document_applies_segment_ids_and_only_parent_chu
session.scalars.assert_called()
def test_disable_summaries_for_segments_handles_vector_delete_error(monkeypatch: pytest.MonkeyPatch) -> None:
dataset = _dataset()
summary1 = _summary_record(summary_content="s", node_id="n1")
summary2 = _summary_record(summary_content="s", node_id=None)
def test_disable_summaries_for_segments_updates_sqlite_records() -> None:
dataset = SimpleNamespace(id="dataset-1", indexing_technique=IndexTechniqueType.ECONOMY)
engine = create_engine("sqlite+pysqlite:///:memory:")
DocumentSegmentSummary.__table__.create(engine)
summary_rows = [
{
"id": "sum-1",
"dataset_id": dataset.id,
"document_id": "doc-1",
"chunk_id": "seg-1",
"summary_content": "s",
"summary_index_node_id": "n1",
"status": SummaryStatus.COMPLETED,
"enabled": True,
},
{
"id": "sum-2",
"dataset_id": dataset.id,
"document_id": "doc-1",
"chunk_id": "seg-1",
"summary_content": "s",
"summary_index_node_id": None,
"status": SummaryStatus.COMPLETED,
"enabled": True,
},
]
with engine.begin() as connection:
connection.execute(DocumentSegmentSummary.__table__.insert(), summary_rows)
session = MagicMock()
session.scalars.return_value.all.return_value = [summary1, summary2]
monkeypatch.setattr(
summary_module,
"session_factory",
SimpleNamespace(create_session=MagicMock(return_value=_SessionContext(session))),
)
monkeypatch.setattr(
summary_module,
"Vector",
MagicMock(return_value=MagicMock(delete_by_ids=MagicMock(side_effect=RuntimeError("boom")))),
)
monkeypatch.setitem(
sys.modules, "libs.datetime_utils", SimpleNamespace(naive_utc_now=MagicMock(return_value=datetime(2024, 1, 1)))
)
session_maker = sessionmaker(bind=engine, expire_on_commit=False)
summary_module.session_factory.configure(engine, expire_on_commit=False)
SummaryIndexService.disable_summaries_for_segments(dataset, segment_ids=["seg-1"], disabled_by="u")
assert summary1.enabled is False
assert summary1.disabled_by == "u"
session.commit.assert_called_once()
with session_maker() as session:
summaries = session.scalars(select(DocumentSegmentSummary).order_by(DocumentSegmentSummary.id)).all()
assert [(summary.id, summary.enabled, summary.disabled_by) for summary in summaries] == [
("sum-1", False, "u"),
("sum-2", False, "u"),
]
assert all(summary.disabled_at is not None for summary in summaries)
def test_disable_summaries_for_segments_no_summaries_noop(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -6,12 +6,56 @@ import pytest
from flask import Flask
from werkzeug.datastructures import FileStorage
from services.errors.app import QuotaExceededError
from services.trigger.webhook_service import WebhookService
class TestWebhookServiceUnit:
"""Unit tests for WebhookService focusing on business logic without database dependencies."""
def test_trigger_workflow_execution_propagates_quota_error_without_error_log(self):
webhook_trigger = MagicMock(
webhook_id="webhook-123",
tenant_id="tenant-123",
app_id="app-123",
node_id="node-123",
)
workflow = MagicMock(id="workflow-123")
quota_charge = MagicMock()
quota_error = QuotaExceededError(feature="workflow", tenant_id="tenant-123", required=1)
with (
patch(
"services.trigger.webhook_service.EndUserService.get_or_create_end_user_by_type",
return_value=MagicMock(id="end-user-123"),
),
patch("services.trigger.webhook_service.QuotaService.reserve", return_value=quota_charge),
patch("services.trigger.webhook_service.db"),
patch("services.trigger.webhook_service.Session"),
patch(
"services.trigger.webhook_service.AsyncWorkflowService.trigger_workflow_async",
side_effect=quota_error,
),
patch("services.trigger.webhook_service.logger.info") as mock_log_info,
patch("services.trigger.webhook_service.logger.exception") as mock_log_exception,
):
with pytest.raises(QuotaExceededError) as exc_info:
WebhookService.trigger_workflow_execution(
webhook_trigger,
{"body": {}, "headers": {}, "query_params": {}, "files": {}, "method": "POST"},
workflow,
)
assert exc_info.value is quota_error
quota_charge.refund.assert_called_once_with()
mock_log_info.assert_called_once_with(
"Tenant %s quota exceeded for feature %s, skipping webhook trigger %s",
webhook_trigger.tenant_id,
quota_error.feature,
webhook_trigger.webhook_id,
)
mock_log_exception.assert_not_called()
def test_extract_webhook_data_json(self):
"""Test webhook data extraction from JSON request."""
app = Flask(__name__)
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,69 @@
from unittest.mock import MagicMock
import pytest
APP_RBAC_QUEUE = "app_rbac"
def test_initialize_created_app_rbac_access_task_uses_rbac_queue():
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
assert initialize_created_app_rbac_access_task.queue == APP_RBAC_QUEUE
def test_initialize_created_app_rbac_access_task_batches_workspace_members(monkeypatch):
import tasks.initialize_created_app_rbac_access_task as task_module
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
monkeypatch.setattr(task_module.dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
task_module.TenantService,
"iter_member_account_id_batches",
lambda tenant_id, batch_size, session: iter([["acct-1", "acct-2"], ["acct-3"]]),
)
replace_whitelist = MagicMock()
replace_user_access_policies = MagicMock()
monkeypatch.setattr(
task_module.enterprise_rbac_service.RBACService.AppAccess,
"replace_whitelist",
replace_whitelist,
)
monkeypatch.setattr(
task_module.enterprise_rbac_service.RBACService.AppAccess,
"replace_user_access_policies",
replace_user_access_policies,
)
initialize_created_app_rbac_access_task.run("tenant-1", "actor-1", "app-1")
replace_whitelist.assert_not_called()
assert replace_user_access_policies.call_count == 2
assert replace_user_access_policies.call_args_list[0].kwargs["payload"].account_ids == ["acct-1", "acct-2"]
assert replace_user_access_policies.call_args_list[1].kwargs["payload"].account_ids == ["acct-3"]
for call in replace_user_access_policies.call_args_list:
assert call.kwargs["payload"].access_policy_ids == [task_module.APP_RBAC_DEFAULT_ACCESS_POLICY_ID]
def test_initialize_created_app_rbac_access_task_retries_on_failure(monkeypatch):
import tasks.initialize_created_app_rbac_access_task as task_module
from tasks.initialize_created_app_rbac_access_task import initialize_created_app_rbac_access_task
monkeypatch.setattr(task_module.dify_config, "RBAC_ENABLED", True)
monkeypatch.setattr(
task_module.TenantService,
"iter_member_account_id_batches",
lambda tenant_id, batch_size, session: iter([["acct-1"]]),
)
monkeypatch.setattr(
task_module.enterprise_rbac_service.RBACService.AppAccess,
"replace_user_access_policies",
MagicMock(side_effect=ConnectionError("RBAC unavailable")),
)
retry = MagicMock(return_value=RuntimeError("retry requested"))
monkeypatch.setattr(initialize_created_app_rbac_access_task, "retry", retry)
with pytest.raises(RuntimeError, match="retry requested"):
initialize_created_app_rbac_access_task.run("tenant-1", "actor-1", "app-1")
retry.assert_called_once()
assert isinstance(retry.call_args.kwargs["exc"], ConnectionError)
@@ -1,14 +1,20 @@
import logging
from collections.abc import Generator
from datetime import UTC, datetime
from unittest.mock import MagicMock, call, patch
from uuid import uuid4
import pytest
from agenton.compositor import CompositorSessionSnapshot
from sqlalchemy import delete, select
from sqlalchemy.orm import Session
from core.db.session_factory import session_factory
from graphon.enums import WorkflowExecutionStatus
from libs.archive_storage import ArchiveStorageNotConfiguredError
from models import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus
from models import AgentRuntimeSession, AgentRuntimeSessionOwnerType, AgentRuntimeSessionStatus, AppStar
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
from models.workflow import WorkflowArchiveLog
from tasks.remove_app_and_related_data_task import (
_cleanup_active_agent_runtime_sessions_for_app,
_delete_app_stars,
@@ -99,9 +105,12 @@ class TestDeleteDraftVariableOffloadData:
class TestDeleteWorkflowArchiveLogs:
@pytest.mark.parametrize("sqlite_session", [(WorkflowArchiveLog,)], indirect=True)
@patch("tasks.remove_app_and_related_data_task._delete_records")
@patch("tasks.remove_app_and_related_data_task.db")
def test_delete_app_workflow_archive_logs_calls_delete_records(self, mock_db, mock_delete_records):
def test_delete_app_workflow_archive_logs_calls_delete_records(
self, mock_db, mock_delete_records, sqlite_session: Session
):
tenant_id = "tenant-1"
app_id = "app-1"
@@ -113,16 +122,42 @@ class TestDeleteWorkflowArchiveLogs:
assert params == {"tenant_id": tenant_id, "app_id": app_id}
assert name == "workflow archive log"
mock_session = MagicMock()
archive_log = WorkflowArchiveLog(
tenant_id=str(uuid4()),
app_id=str(uuid4()),
workflow_id=str(uuid4()),
workflow_run_id=str(uuid4()),
created_by_role=CreatorUserRole.ACCOUNT,
created_by=str(uuid4()),
log_id=None,
log_created_at=None,
log_created_from=None,
run_version="1",
run_status=WorkflowExecutionStatus.SUCCEEDED,
run_triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
run_error=None,
run_elapsed_time=0,
run_total_tokens=0,
run_total_steps=1,
run_created_at=datetime.now(UTC),
run_finished_at=datetime.now(UTC),
run_exceptions_count=0,
trigger_metadata=None,
)
sqlite_session.add(archive_log)
sqlite_session.commit()
delete_func(mock_session, "log-1")
delete_func(sqlite_session, archive_log.id)
sqlite_session.commit()
sqlite_session.expunge_all()
mock_session.execute.assert_called_once()
assert sqlite_session.get(WorkflowArchiveLog, archive_log.id) is None
class TestDeleteAppStars:
@pytest.mark.parametrize("sqlite_session", [(AppStar,)], indirect=True)
@patch("tasks.remove_app_and_related_data_task._delete_records")
def test_delete_app_stars_calls_delete_records(self, mock_delete_records):
def test_delete_app_stars_calls_delete_records(self, mock_delete_records, sqlite_session: Session):
tenant_id = "tenant-1"
app_id = "app-1"
@@ -134,11 +169,15 @@ class TestDeleteAppStars:
assert params == {"tenant_id": tenant_id, "app_id": app_id}
assert name == "app star"
mock_session = MagicMock()
app_star = AppStar(tenant_id=str(uuid4()), app_id=str(uuid4()), account_id=str(uuid4()))
sqlite_session.add(app_star)
sqlite_session.commit()
delete_func(mock_session, "star-1")
delete_func(sqlite_session, app_star.id)
sqlite_session.commit()
sqlite_session.expunge_all()
mock_session.execute.assert_called_once()
assert sqlite_session.get(AppStar, app_star.id) is None
class TestDeleteArchivedWorkflowRunFiles:
Generated
+43 -42
View File
@@ -425,7 +425,7 @@ wheels = [
[[package]]
name = "azure-storage-blob"
version = "12.29.0"
version = "12.30.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "azure-core" },
@@ -433,9 +433,9 @@ dependencies = [
{ name = "isodate" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/59/25/fdcf1e381922dbab8ba23d6fd78d397fe6cbac6b480310218834b7bc91fe/azure_storage_blob-12.29.0.tar.gz", hash = "sha256:2824ddd7ebc9056034ebc76b17971a38e9aa5835abb0d565b9700493f2a6c657", size = 611359, upload-time = "2026-05-15T03:34:59.865Z" }
sdist = { url = "https://files.pythonhosted.org/packages/3b/48/84a820d898267f662b5c06f7cd76fdb8a9e272b44aa9376cef3ec0f6a294/azure_storage_blob-12.30.0.tar.gz", hash = "sha256:2cd74d4d5731e5eb6b8d5c5056ee115a5e88f8fdf22517b739836fda685018be", size = 618229, upload-time = "2026-06-08T11:45:35.575Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/2c/6ddee6a3e42d0236ba9259e4df7fa97fdc415ff0802b736c634baaf4b285/azure_storage_blob-12.29.0-py3-none-any.whl", hash = "sha256:ccf8a1bcd5e49df83ab85aab793b579e5ba2eeea2ad8900b2f62ca3a37dc391f", size = 434823, upload-time = "2026-05-15T03:35:01.837Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0b/e106f0fd7fa785867d9ffcc47dc9e6237c0e58f51058473b777487a98edc/azure_storage_blob-12.30.0-py3-none-any.whl", hash = "sha256:d415ac50b67a8da6b3ae7e9f1014b1b55cd7aafa0b8d4ca9b380568dc7360423", size = 435610, upload-time = "2026-06-08T11:45:37.213Z" },
]
[[package]]
@@ -474,7 +474,7 @@ wheels = [
[[package]]
name = "bce-python-sdk"
version = "0.9.71"
version = "0.9.72"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "crc32c" },
@@ -482,9 +482,9 @@ dependencies = [
{ name = "pycryptodome" },
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5a/74/72058f098b9e7184376f2b3d4c1d233ca7fdc52d0f527078f3ce4d9828b9/bce_python_sdk-0.9.71.tar.gz", hash = "sha256:7a917edaee39082694776e25a9e6556ec8072400a3be649f28eb13f9c7a0b5b5", size = 301508, upload-time = "2026-04-28T06:23:21.061Z" }
sdist = { url = "https://files.pythonhosted.org/packages/32/bb/1ccb8b28bfa0802356f8588e479adb61cfd2268b37fabc6c8a805d645cd5/bce_python_sdk-0.9.72.tar.gz", hash = "sha256:d9db568698792d74db4245252d98776eae8c9c5225fc0ba86548dfc52d478fcc", size = 302207, upload-time = "2026-06-08T12:10:32.326Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2d/2d/821ae8878dc36b77e56bb7e5dbf9a8e73209c11d38c0ba6b38b5778668ae/bce_python_sdk-0.9.71-py3-none-any.whl", hash = "sha256:9f64a99267616456bac487983d92cc778720bf4f102c8931e8e38aea3cb63268", size = 417000, upload-time = "2026-04-28T06:23:19.078Z" },
{ url = "https://files.pythonhosted.org/packages/2d/3a/f84b025ff6c8ec8fe222430cf9201b513dd11ada744417649a64ad295b6d/bce_python_sdk-0.9.72-py3-none-any.whl", hash = "sha256:54a0c121134d6f183f6013d9b33dbf5b6678b0815b6d09616bbceed0202dd797", size = 417800, upload-time = "2026-06-08T12:10:30.556Z" },
]
[[package]]
@@ -597,16 +597,16 @@ wheels = [
[[package]]
name = "boto3"
version = "1.43.24"
version = "1.43.46"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/8f/94dfa39ec618ecb2fe5b5b79428c95100e3ae3c1aa5083c283dd3cfb5ecd/boto3-1.43.24.tar.gz", hash = "sha256:ba5afa266bf7265e0c1a454fcfd48bffe5939cb16ed223bebc669c3dc8ee0bc8", size = 113154, upload-time = "2026-06-05T19:30:01.635Z" }
sdist = { url = "https://files.pythonhosted.org/packages/f2/e7/976bf3dfe0aa5d7f31bec2f2cf57c79641620c910a39bc843a237aa9592d/boto3-1.43.46.tar.gz", hash = "sha256:66c0d943b049a46a492ec4ec2ebe73c930b1842c7137bee83aad6d93e95d4d96", size = 112654, upload-time = "2026-07-10T19:32:12.498Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/59/b7/e66c9b37b96153aa371fe48d24194151293f6577dd3eaa1fc146c281456d/boto3-1.43.24-py3-none-any.whl", hash = "sha256:b18ef745274ef548a9660d733d985d4a971b16bd8a6af88165ea9d0e40913b86", size = 140536, upload-time = "2026-06-05T19:29:58.968Z" },
{ url = "https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl", hash = "sha256:69453e2c1bcb9fd9806527ab99950cacfc2826cb0dce9a3a0414d19270c06c3c", size = 140031, upload-time = "2026-07-10T19:32:11.129Z" },
]
[[package]]
@@ -629,16 +629,16 @@ bedrock-runtime = [
[[package]]
name = "botocore"
version = "1.43.24"
version = "1.43.46"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/78/67/55d0611b341482bc9649d16df765f849a1862184ac3709356decf632279f/botocore-1.43.24.tar.gz", hash = "sha256:0c02f2b40e99419d496ece0ea2dcdedb5c45998c16fd1674276c7dbb30767a16", size = 15471690, upload-time = "2026-06-05T19:29:33.731Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/f1/1917891851ac5ac09bb9f4862b8fc9252a009d7c24e8688bb67e4383d9e7/botocore-1.43.46.tar.gz", hash = "sha256:59f2e1ac3cdc66d191cae91c0804bc41847ce817dc8147cf43eaada8f76a5533", size = 15694635, upload-time = "2026-07-10T19:32:00.437Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/b7/360b5afe74c4d7cff871ea6e8f335e2e11de2945c9deb1eea6438f49faa2/botocore-1.43.24-py3-none-any.whl", hash = "sha256:42903b4bfafd8f15a735ed940473f28e4ba21b2ea67a9b9aaa11dfa7fcb19fd5", size = 15155182, upload-time = "2026-06-05T19:29:29.457Z" },
{ url = "https://files.pythonhosted.org/packages/0e/f2/4bd8f2f419088feb3ce55f0ca91040ff902f402edfd197450b20a2e1d533/botocore-1.43.46-py3-none-any.whl", hash = "sha256:cb673891e623ae6e6a1bf24d94ef169504f3eb02584adb5d5bee2f6aae819b60", size = 15380350, upload-time = "2026-07-10T19:31:57.616Z" },
]
[[package]]
@@ -1280,7 +1280,7 @@ wheels = [
[[package]]
name = "dify-agent"
version = "0.1.0"
version = "1.16.0rc1"
source = { editable = "../dify-agent" }
dependencies = [
{ name = "anyio" },
@@ -1338,7 +1338,7 @@ docs = [
[[package]]
name = "dify-api"
version = "1.15.0"
version = "1.16.0rc1"
source = { virtual = "." }
dependencies = [
{ name = "aliyun-log-python-sdk" },
@@ -1628,14 +1628,14 @@ requires-dist = [
{ name = "aliyun-log-python-sdk", specifier = "==0.9.44" },
{ name = "azure-identity", specifier = ">=1.25.3,<2.0.0" },
{ name = "bleach", specifier = ">=6.4.0,<7.0.0" },
{ name = "boto3", specifier = ">=1.43.24,<2.0.0" },
{ name = "boto3", specifier = ">=1.43.46,<2.0.0" },
{ name = "celery", specifier = ">=5.6.3,<6.0.0" },
{ name = "croniter", specifier = ">=6.2.2,<7.0.0" },
{ name = "dify-agent", editable = "../dify-agent" },
{ name = "fastopenapi", extras = ["flask"], specifier = "==0.7.0" },
{ name = "flask", specifier = ">=3.1.3,<4.0.0" },
{ name = "flask-compress", specifier = ">=1.24,<2.0.0" },
{ name = "flask-cors", specifier = ">=6.0.2,<7.0.0" },
{ name = "flask-cors", specifier = ">=6.0.5,<7.0.0" },
{ name = "flask-login", specifier = "==0.6.3" },
{ name = "flask-migrate", specifier = ">=4.1.0,<5.0.0" },
{ name = "flask-orjson", specifier = ">=2.0.0,<3.0.0" },
@@ -1643,8 +1643,8 @@ requires-dist = [
{ name = "gevent", specifier = ">=26.4.0,<26.5.0" },
{ name = "gevent-websocket", specifier = "==0.10.1" },
{ name = "gmpy2", specifier = ">=2.3.0,<3.0.0" },
{ name = "google-api-python-client", specifier = ">=2.196.0,<3.0.0" },
{ name = "google-cloud-aiplatform", specifier = ">=1.151.0,<2.0.0" },
{ name = "google-api-python-client", specifier = ">=2.198.0,<3.0.0" },
{ name = "google-cloud-aiplatform", specifier = ">=1.160.0,<2.0.0" },
{ name = "graphon", specifier = "==0.6.0" },
{ name = "gunicorn", specifier = ">=26.0.0,<27.0.0" },
{ name = "httpx", extras = ["socks"], specifier = "==0.28.1" },
@@ -1733,11 +1733,11 @@ dev = [
{ name = "xinference-client", specifier = ">=2.7.0" },
]
storage = [
{ name = "azure-storage-blob", specifier = ">=12.29.0,<13.0.0" },
{ name = "bce-python-sdk", specifier = "==0.9.71" },
{ name = "azure-storage-blob", specifier = ">=12.30.0,<13.0.0" },
{ name = "bce-python-sdk", specifier = "==0.9.72" },
{ name = "cos-python-sdk-v5", specifier = ">=1.9.44,<2.0.0" },
{ name = "esdk-obs-python", specifier = ">=3.22.2,<4.0.0" },
{ name = "google-cloud-storage", specifier = ">=3.11.0,<4.0.0" },
{ name = "esdk-obs-python", specifier = ">=3.26.6,<4.0.0" },
{ name = "google-cloud-storage", specifier = ">=3.12.1,<4.0.0" },
{ name = "opendal", specifier = "==0.46.0" },
{ name = "oss2", specifier = ">=2.19.1,<3.0.0" },
{ name = "supabase", specifier = ">=2.31.0,<3.0.0" },
@@ -2367,14 +2367,14 @@ wheels = [
[[package]]
name = "esdk-obs-python"
version = "3.26.2"
version = "3.26.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "crcmod" },
{ name = "pycryptodome" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ef/9a/090f718114eec808c04762d9ea64f9e6f170ee419a673beba8b7810ec758/esdk_obs_python-3.26.2.tar.gz", hash = "sha256:dc865356bb4be474e5eaa557ff226f0f89ac8f5afff61a1cc85143079bf6e223", size = 95922, upload-time = "2026-03-07T10:38:16.732Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e1/d4/c9a2b33935c620678bd5acf5e32feda70ef09f384b24eb17a5719f72f5fe/esdk_obs_python-3.26.6.tar.gz", hash = "sha256:5014e76e85ffa9eda821169302e74868991867ac947da73bc28eb0e6dfba3ab1", size = 116430, upload-time = "2026-07-12T07:09:36.698Z" }
[[package]]
name = "et-xmlfile"
@@ -2527,15 +2527,15 @@ wheels = [
[[package]]
name = "flask-cors"
version = "6.0.2"
version = "6.0.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "flask" },
{ name = "werkzeug" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/74/0fc0fa68d62f21daef41017dafab19ef4b36551521260987eb3a5394c7ba/flask_cors-6.0.2.tar.gz", hash = "sha256:6e118f3698249ae33e429760db98ce032a8bf9913638d085ca0f4c5534ad2423", size = 13472, upload-time = "2025-12-12T20:31:42.861Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/03/4e464a50860f9adf08b5c1d3479cb8ea1f12af2aa69535c7042c6e628135/flask_cors-6.0.5.tar.gz", hash = "sha256:30c5031552cd59f620ac0c8211dac45b345d3b2df310e7721879e4f46ef9c601", size = 101386, upload-time = "2026-06-08T20:20:17.765Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4f/af/72ad54402e599152de6d067324c46fe6a4f531c7c65baf7e96c63db55eaf/flask_cors-6.0.2-py3-none-any.whl", hash = "sha256:e57544d415dfd7da89a9564e1e3a9e515042df76e12130641ca6f3f2f03b699a", size = 13257, upload-time = "2025-12-12T20:31:41.3Z" },
{ url = "https://files.pythonhosted.org/packages/49/55/5bb1a2d918e9f02f131e47a59032bae70e48050e986e941511fd737a935c/flask_cors-6.0.5-py3-none-any.whl", hash = "sha256:68fcf75693e961f3af26683b23c4b9a8fb6b64de17d20d0c37b95e8de7ab2ed8", size = 16692, upload-time = "2026-06-08T20:20:16.247Z" },
]
[[package]]
@@ -2779,7 +2779,7 @@ grpc = [
[[package]]
name = "google-api-python-client"
version = "2.196.0"
version = "2.198.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
@@ -2788,9 +2788,9 @@ dependencies = [
{ name = "httplib2" },
{ name = "uritemplate" },
]
sdist = { url = "https://files.pythonhosted.org/packages/6d/f3/34ef8aca7909675fe327f96c1ed927f0520e7acf68af19157e96acc05e76/google_api_python_client-2.196.0.tar.gz", hash = "sha256:9f335d38f6caaa2747bcf64335ed1a9a19047d53e86538eda6a1b17d37f1743d", size = 14628129, upload-time = "2026-05-06T23:47:35.655Z" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/53/0cd38e3a29d72ce45e27feba2ce1cd8049d69af9c48cb14fb164f1be9133/google_api_python_client-2.198.0.tar.gz", hash = "sha256:dfe3e16fb241af6e9c460a33f65085b3450e05cea09364f6b5d8997fb7e43e2a", size = 15060142, upload-time = "2026-06-25T14:32:42.953Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/99/c7/1817b4edf966d5afcac1c0781ca36d621bc0cb58104c4e7c2a475ab185f7/google_api_python_client-2.196.0-py3-none-any.whl", hash = "sha256:2591e9b47dcb17e4e62a09370aaee3bcf323af8f28ccecdabcd0a42a23ca4db5", size = 15206663, upload-time = "2026-05-06T23:47:32.886Z" },
{ url = "https://files.pythonhosted.org/packages/d5/92/0fc9e7a09eb240c31b879bd8d2e43f81ed1f86c4798b79ead4a083921ab3/google_api_python_client-2.198.0-py3-none-any.whl", hash = "sha256:fabac935474e817da5e662ff61bf7139439d6f92b32d332a7318a2d45931e03e", size = 15644203, upload-time = "2026-06-25T14:32:39.963Z" },
]
[[package]]
@@ -2826,9 +2826,10 @@ wheels = [
[[package]]
name = "google-cloud-aiplatform"
version = "1.151.0"
version = "1.160.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "docstring-parser" },
{ name = "google-api-core", extra = ["grpc"] },
{ name = "google-auth" },
@@ -2842,9 +2843,9 @@ dependencies = [
{ name = "pydantic" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ec/f6/e2fbe175a011f5080da8c1f7d9169a6875a00ea2c7bee4193d952b097400/google_cloud_aiplatform-1.151.0.tar.gz", hash = "sha256:2f29b1853f790a7371a746c747bf1f664380b534254682441acd4b5ee26fafd2", size = 10617421, upload-time = "2026-05-07T21:56:52.91Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d6/c5/dad5053ce2bbf53079274c4781f7bdf45d1f85bfe0ea8fad88cd39fad52d/google_cloud_aiplatform-1.160.0.tar.gz", hash = "sha256:186a8db5099eda0e3cd3ecc73a4716d48c82fa1f00501582eacd541a6aa60534", size = 11174223, upload-time = "2026-07-08T00:48:13.036Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/4a/cd35f8ba622d563b1335222284d2838aa789b953b40516b1b997e50fe5b6/google_cloud_aiplatform-1.151.0-py2.py3-none-any.whl", hash = "sha256:61372bb0923b14b8027f45b83393452df3a85bf4ea86fa48e08844fb5ec50049", size = 8732627, upload-time = "2026-05-07T21:56:49.014Z" },
{ url = "https://files.pythonhosted.org/packages/32/06/230752e7697ba83f92a0d124f42b43e170ecf8c212a97a2f2fc8c788d4c1/google_cloud_aiplatform-1.160.0-py2.py3-none-any.whl", hash = "sha256:4886e035b5baad3fe52adcec1c9a9f8312b5c42d7dfed8e13b420d59e6f3b82a", size = 9369771, upload-time = "2026-07-08T00:48:09.327Z" },
]
[[package]]
@@ -2897,7 +2898,7 @@ wheels = [
[[package]]
name = "google-cloud-storage"
version = "3.11.0"
version = "3.12.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
@@ -2907,9 +2908,9 @@ dependencies = [
{ name = "google-resumable-media" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/22/09/8953e2993e604c8882fd441b5b2de624a2dfe7e6144c6166d7b477509596/google_cloud_storage-3.11.0.tar.gz", hash = "sha256:498bf37c999028f69a245f586b5e50d89f59df1fafc0e3a93783ac56be2a456b", size = 17335639, upload-time = "2026-06-03T16:14:04.649Z" }
sdist = { url = "https://files.pythonhosted.org/packages/da/ac/60b4cb0a6c8c6bb7cedb8971ba5e34a94096acf76e2cc242bcf1e6fc5c49/google_cloud_storage-3.12.1.tar.gz", hash = "sha256:1d81491c7663bc26c5056d00b834356f2253b910ef467f9cf9928a87fca1e04b", size = 17339353, upload-time = "2026-07-08T17:03:59.142Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/09/7e/ee0dd1a67ac75d29d0c438969d85d4fadbc4bcab47b0a8ccfa7eb22f643c/google_cloud_storage-3.11.0-py3-none-any.whl", hash = "sha256:cfcc33aa6b899ec9dd1771286f8e79fbed5c35c1c174718071b079aa827f37c2", size = 339654, upload-time = "2026-06-03T16:12:46.052Z" },
{ url = "https://files.pythonhosted.org/packages/80/6e/ca176e95bafac0fe7befeee7e0420e686de147571cd2908e308c5fe71bda/google_cloud_storage-3.12.1-py3-none-any.whl", hash = "sha256:9297ae0c2ce3f5400b1f2bb3a3e6d2cd256614366e03cd30600871df8e903afb", size = 340845, upload-time = "2026-07-08T17:03:31.418Z" },
]
[[package]]
@@ -5991,14 +5992,14 @@ wheels = [
[[package]]
name = "s3transfer"
version = "0.18.0"
version = "0.19.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560, upload-time = "2026-05-28T19:39:09.124Z" }
sdist = { url = "https://files.pythonhosted.org/packages/65/da/4bef7ce7bb989b222aa4785a413896dbec53306dfc59c6ce7d16a7ffbd6a/s3transfer-0.19.1.tar.gz", hash = "sha256:d3d6371dc3f1e5c5427b2b457bcf13bcf87bec334c95aed18642eae61f6926f3", size = 165354, upload-time = "2026-07-10T19:32:04.849Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572, upload-time = "2026-05-28T19:39:07.999Z" },
{ url = "https://files.pythonhosted.org/packages/24/23/e84c64ad0e8bc59cd1b2ef98def848deff0ef3456c542afe74d51e9e8c85/s3transfer-0.19.1-py3-none-any.whl", hash = "sha256:d5fd7005ee39307455ad5f310b5ea67f4b1960d7fed5b3671ee50c249de675de", size = 90072, upload-time = "2026-07-10T19:32:03.673Z" },
]
[[package]]
@@ -6151,11 +6152,11 @@ wheels = [
[[package]]
name = "soupsieve"
version = "2.8"
version = "2.8.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/e6/21ccce3262dd4889aa3332e5a119a3491a95e8f60939870a3a035aabac0d/soupsieve-2.8.tar.gz", hash = "sha256:e2dd4a40a628cb5f28f6d4b0db8800b8f581b65bb380b97de22ba5ca8d72572f", size = 103472, upload-time = "2025-08-27T15:39:51.78Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/14/a0/bb38d3b76b8cae341dad93a2dd83ab7462e6dbcdd84d43f54ee60a8dc167/soupsieve-2.8-py3-none-any.whl", hash = "sha256:0cc76456a30e20f5d7f2e14a98a4ae2ee4e5abdc7c5ea0aafe795f344bc7984c", size = 36679, upload-time = "2025-08-27T15:39:50.179Z" },
{ url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" },
]
[[package]]
+3 -2
View File
@@ -1,6 +1,6 @@
# AGENTS.md — difyctl (TypeScript CLI)
TypeScript port of difyctl. Stack: custom CLI framework (`src/framework/`), Node 22+, ESM, ky for HTTP, vitest, eslint via @antfu/eslint-config.
TypeScript port of difyctl. Stack: custom CLI framework (`src/framework/`), Node 22+, ESM, ky for HTTP, Vitest, Vite+ formatting, and ESLint via `@antfu/eslint-config`.
> Architecture patterns, scaffolding recipe, printer chain, strategy pattern, testing conventions, anti-patterns: see **[`ARD.md`]**.
@@ -59,7 +59,8 @@ pnpm test # vitest
pnpm test:coverage # with coverage
pnpm type-check # tsc, no emit
pnpm lint # eslint
pnpm lint:fix # eslint --fix
pnpm lint:fix # eslint semantic fixes
vp fmt # format with Oxfmt
pnpm build # production bundle (vp pack)
pnpm tree:gen # regenerate src/commands/tree.ts (registry)
pnpm tree:check # verify tree.ts is up-to-date with the fs
+32 -15
View File
@@ -80,7 +80,14 @@ export default class MyCommand extends DifyCommand {
// Authed: authedCtx() sets outputFormat + builds context
const ctx = await this.authedCtx({ format: flags.output })
process.stdout.write(await runMyThing({ /* args */ }, { bundle: ctx.bundle, http: ctx.http, io: ctx.io }))
process.stdout.write(
await runMyThing(
{
/* args */
},
{ bundle: ctx.bundle, http: ctx.http, io: ctx.io },
),
)
}
}
```
@@ -102,7 +109,7 @@ import { ErrorCode } from '../../errors/codes.js'
throw new BaseError({
code: ErrorCode.UsageMissingArg,
message: 'workspace id required',
hint: 'pass --workspace or run \'difyctl use workspace <id>\'',
hint: "pass --workspace or run 'difyctl use workspace <id>'",
})
```
@@ -150,10 +157,7 @@ export type IOStreams = {
`runWithSpinner` wraps async call with animated spinner on stderr. Auto-disables for structured output — no manual `enabled:` flag needed.
```typescript
const result = await runWithSpinner(
{ io, label: 'Fetching apps' },
() => client.list(params),
)
const result = await runWithSpinner({ io, label: 'Fetching apps' }, () => client.list(params))
```
`STRUCTURED_FORMATS = new Set(['json', 'yaml', 'name'])` drives disable check. New structured format = add to this set only — no other callsites change.
@@ -174,9 +178,15 @@ Output rendering separated from data fetching via protocol objects.
```typescript
// handlers.ts — implement the protocol on the data object
export class MyListOutput implements TablePrintable {
tableColumns() { return COLUMNS }
tableRows() { return this.rows.map(r => r.tableRow()) }
json() { return { items: this.rows.map(r => r.json()) } }
tableColumns() {
return COLUMNS
}
tableRows() {
return this.rows.map((r) => r.tableRow())
}
json() {
return { items: this.rows.map((r) => r.json()) }
}
}
// index.ts — wrap and return
@@ -215,10 +225,16 @@ One file per resource under `src/api/`. Each exports class wrapping `KyInstance`
```typescript
export class AppsClient {
private readonly http: KyInstance
constructor(http: KyInstance) { this.http = http }
constructor(http: KyInstance) {
this.http = http
}
async list(params: ListParams): Promise<ListResponse> { /* ... */ throw new Error('elided') }
async describe(id: string, workspaceId: string, fields: string[]): Promise<DescribeResponse> { /* ... */ throw new Error('elided') }
async list(params: ListParams): Promise<ListResponse> {
/* ... */ throw new Error('elided')
}
async describe(id: string, workspaceId: string, fields: string[]): Promise<DescribeResponse> {
/* ... */ throw new Error('elided')
}
}
```
@@ -292,7 +308,8 @@ expect(JSON.parse(out).workspaces).toHaveLength(2)
| `pnpm test:coverage` | Coverage report |
| `pnpm type-check` | `tsc --noEmit` — catches type errors without build |
| `pnpm lint` | ESLint check |
| `pnpm lint:fix` | ESLint auto-fix (perfectionist sort, chaining) |
| `pnpm lint:fix` | ESLint code-quality fixes |
| `vp fmt` | Oxfmt formatting and import sorting |
| `pnpm build` | Production bundle (`vp pack`) |
| `pnpm tree:gen` | Regenerate `src/commands/tree.ts` (registry) |
| `pnpm tree:check` | Verify `tree.ts` matches the filesystem |
@@ -306,7 +323,7 @@ expect(JSON.parse(out).workspaces).toHaveLength(2)
## Lint rules that catch contributors
Repo runs `@antfu/eslint-config` + perfectionist + unicorn.
Repo runs `@antfu/eslint-config` for code-quality rules and Vite+ Oxfmt for formatting.
| Rule | What it catches |
| ---------------------------------- | -------------------------------------------------- |
@@ -316,7 +333,7 @@ Repo runs `@antfu/eslint-config` + perfectionist + unicorn.
| `unicorn/no-new-array` | Use `Array.from({ length: n })` not `new Array(n)` |
| `noUncheckedIndexedAccess` (tsc) | `arr[i]` is `T \| undefined`; guard before use |
`pnpm lint:fix` resolves perfectionist + chaining auto.
Run `pnpm lint:fix` for ESLint fixes, then `vp fmt` so Oxfmt produces the final layout.
---
+2 -2
View File
@@ -54,11 +54,11 @@ For agents (and scripting), start with `difyctl help agent` — the cross-comman
`difyctl skills install` installs a single, pure-delegation `SKILL.md` into your local agents so they auto-load it. The skill does not freeze the command set — it points the agent at `difyctl help -o json` for the live surface, so it never drifts from your binary. It is embedded in the binary (version-stamped) rather than checked in.
- `difyctl skills install` — dry-run: detect installed agents (Claude Code, Codex, opencode, Cursor, pi) and print where the skill would land. Writes nothing.
- `difyctl skills install` — dry-run: detect installed agents (Claude Code, Codex, opencode, Cursor, pi, Amp, OpenClaw, Qoder, Windsurf, Hermes) and print where the skill would land. Writes nothing.
- `difyctl skills install --yes` — write to every detected agent, printing each path. `--agent claude-code[,cursor]` restricts to a subset; `<dir>` forces one explicit directory (handy when your agent isn't detected).
- `difyctl skills install --stdout` — print the `SKILL.md` to stdout (for piping or self-install); writes nothing.
Detection is by config-directory existence (`~/.claude`, `~/.codex`, `~/.config/opencode`, `~/.cursor`, `~/.pi`). If a copy ever looks stale, run `difyctl version` and re-run `difyctl skills install`.
Detection is by config-directory existence (`~/.claude`, `~/.codex`, `~/.config/opencode`, `~/.cursor`, `~/.pi`, `~/.config/amp`, `~/.openclaw`, `~/.qoder`, `~/.codeium/windsurf`, `~/.hermes`). Codex, Amp and OpenClaw all read the shared `~/.agents/skills` directory, so one copy there serves all three. If a copy ever looks stale, run `difyctl version` and re-run `difyctl skills install`.
## Output formats
+48 -28
View File
@@ -6,13 +6,31 @@ import markdownPreferences from 'eslint-plugin-markdown-preferences'
export default antfu(
{
ignores: original => [
'context/**',
'docs/**',
'dist/**',
'coverage/**',
...original,
],
stylistic: false,
perfectionist: {
overrides: {
'perfectionist/sort-imports': 'off',
},
},
jsonc: {
overrides: {
'jsonc/space-unary-ops': 'off',
},
},
yaml: {
overrides: {
'yaml/block-mapping': 'off',
'yaml/block-sequence': 'off',
'yaml/plain-scalar': 'off',
},
},
toml: {
overrides: {
'toml/comma-style': 'off',
'toml/no-space-dots': 'off',
},
},
ignores: (original) => ['context/**', 'docs/**', 'dist/**', 'coverage/**', ...original],
typescript: {
overrides: {
'ts/consistent-type-definitions': ['error', 'type'],
@@ -26,36 +44,30 @@ export default antfu(
'test/prefer-lowercase-title': 'off',
},
},
stylistic: {
overrides: {
'antfu/top-level-function': 'off',
},
},
e18e: false,
},
markdownPreferences.configs.standard,
{
files: [GLOB_MARKDOWN],
plugins: { md },
plugins: {
md,
'markdown-preferences': markdownPreferences,
},
rules: {
'md/no-url-trailing-slash': 'error',
'markdown-preferences/definitions-last': 'error',
'markdown-preferences/prefer-link-reference-definitions': [
'error',
{
minLinks: 1,
},
],
'markdown-preferences/ordered-list-marker-sequence': [
'error',
{ increment: 'never' },
],
'markdown-preferences/definitions-last': 'error',
'markdown-preferences/sort-definitions': 'error',
},
},
{
rules: {
'node/prefer-global/process': 'off',
'unicorn/number-literal-case': 'off',
},
},
{
@@ -67,14 +79,22 @@ export default antfu(
{
files: ['src/**/*.ts'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['../**', './*/**', '..'],
message: 'Use the @/ (or @test/) alias for parent-directory or nested relative imports; keep ./ only for same-folder siblings.',
},
],
}],
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['../**', './*/**', '..'],
message:
'Use the @/ (or @test/) alias for parent-directory or nested relative imports; keep ./ only for same-folder siblings.',
},
],
},
],
},
},
)
).override('antfu/sort/package-json', {
rules: {
'jsonc/sort-keys': 'off',
},
})
+48 -49
View File
@@ -1,61 +1,19 @@
{
"name": "@langgenius/difyctl",
"type": "module",
"version": "0.2.0-alpha",
"description": "Dify command-line interface",
"difyctl": {
"channel": "alpha",
"compat": {
"minDify": "1.16.0",
"maxDify": "1.16.0"
},
"release": {
"tagPrefix": "difyctl-v",
"binName": "difyctl",
"checksumsSuffix": "-checksums.txt",
"targets": [
{
"id": "linux-x64",
"bunTarget": "bun-linux-x64",
"exe": false
},
{
"id": "linux-arm64",
"bunTarget": "bun-linux-arm64",
"exe": false
},
{
"id": "darwin-x64",
"bunTarget": "bun-darwin-x64",
"exe": false
},
{
"id": "darwin-arm64",
"bunTarget": "bun-darwin-arm64",
"exe": false
},
{
"id": "windows-x64",
"bunTarget": "bun-windows-x64",
"exe": true
}
]
}
},
"license": "Apache-2.0",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"files": [
"README.md",
"bin",
"dist"
],
"engines": {
"node": "^22.22.1"
"type": "module",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"scripts": {
"build": "vp pack",
@@ -98,7 +56,6 @@
"devDependencies": {
"@dify/tsconfig": "workspace:*",
"@hono/node-server": "catalog:",
"@types/js-yaml": "catalog:",
"@types/lockfile": "catalog:",
"@types/node": "catalog:",
"@typescript/native": "catalog:",
@@ -109,5 +66,47 @@
"vite": "catalog:",
"vite-plus": "catalog:",
"vitest": "catalog:"
},
"engines": {
"node": "^22.22.1"
},
"difyctl": {
"channel": "alpha",
"compat": {
"minDify": "1.16.0",
"maxDify": "1.16.0"
},
"release": {
"tagPrefix": "difyctl-v",
"binName": "difyctl",
"checksumsSuffix": "-checksums.txt",
"targets": [
{
"id": "linux-x64",
"bunTarget": "bun-linux-x64",
"exe": false
},
{
"id": "linux-arm64",
"bunTarget": "bun-linux-arm64",
"exe": false
},
{
"id": "darwin-x64",
"bunTarget": "bun-darwin-x64",
"exe": false
},
{
"id": "darwin-arm64",
"bunTarget": "bun-darwin-arm64",
"exe": false
},
{
"id": "windows-x64",
"bunTarget": "bun-windows-x64",
"exe": true
}
]
}
}
}
+80 -68
View File
@@ -26,7 +26,6 @@ import { Buffer } from 'node:buffer'
* Output file:
* .provision-output.json (also written to GITHUB_OUTPUT if set)
*/
import { appendFile, readFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -36,7 +35,7 @@ import { fileURLToPath } from 'node:url'
const host = process.env.DIFY_E2E_HOST ?? ''
const email = process.env.DIFY_E2E_EMAIL ?? ''
const password = process.env.DIFY_E2E_PASSWORD ?? ''
const edition = ((process.env.DIFY_E2E_EDITION ?? 'ee').toLowerCase()) as 'ee' | 'ce'
const edition = (process.env.DIFY_E2E_EDITION ?? 'ee').toLowerCase() as 'ee' | 'ce'
const preToken = process.env.DIFY_E2E_TOKEN ?? ''
if (!host || !email || !password) {
@@ -49,10 +48,10 @@ const base = host.replace(/\/$/, '')
// ── Helpers ───────────────────────────────────────────────────────────────────
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms))
return new Promise((r) => setTimeout(r, ms))
}
async function consoleLogin(): Promise<{ cookieString: string, csrfToken: string }> {
async function consoleLogin(): Promise<{ cookieString: string; csrfToken: string }> {
const passwordB64 = Buffer.from(password, 'utf8').toString('base64')
const res = await fetch(`${base}/console/api/login`, {
method: 'POST',
@@ -60,14 +59,15 @@ async function consoleLogin(): Promise<{ cookieString: string, csrfToken: string
body: JSON.stringify({ email, password: passwordB64, remember_me: false }),
signal: AbortSignal.timeout(15_000),
})
if (!res.ok)
throw new Error(`console/api/login failed: HTTP ${res.status}`)
if (!res.ok) throw new Error(`console/api/login failed: HTTP ${res.status}`)
const setCookies = res.headers.getSetCookie?.() ?? []
const cookieString = setCookies.map(c => c.split(';')[0]).join('; ')
const cookieString = setCookies.map((c) => c.split(';')[0]).join('; ')
// cookie names may have __Host- prefix on HTTPS deployments
const csrfPair = setCookies.map(c => c.split(';')[0]).find(p => p.includes('csrf_token='))
const csrfToken = csrfPair ? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length) : ''
const csrfPair = setCookies.map((c) => c.split(';')[0]).find((p) => p.includes('csrf_token='))
const csrfToken = csrfPair
? csrfPair.slice(csrfPair.indexOf('csrf_token=') + 'csrf_token='.length)
: ''
return { cookieString, csrfToken }
}
@@ -78,8 +78,9 @@ async function validateToken(token: string): Promise<boolean> {
signal: AbortSignal.timeout(10_000),
})
return res.ok
} catch {
return false
}
catch { return false }
}
async function mintToken(cookieStr: string, csrf: string, label: string): Promise<string> {
@@ -90,28 +91,27 @@ async function mintToken(cookieStr: string, csrf: string, label: string): Promis
body: JSON.stringify({ client_id: 'difyctl', device_label: label }),
signal: AbortSignal.timeout(15_000),
})
if (!codeRes.ok)
throw new Error(`device/code failed: HTTP ${codeRes.status}`)
const { device_code, user_code } = await codeRes.json() as { device_code: string, user_code: string }
if (!codeRes.ok) throw new Error(`device/code failed: HTTP ${codeRes.status}`)
const { device_code, user_code } = (await codeRes.json()) as {
device_code: string
user_code: string
}
// Step 2: approve (with retry)
let approveRes: Response | undefined
for (let i = 1; i <= 5; i++) {
approveRes = await fetch(`${base}/openapi/v1/oauth/device/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': cookieStr, 'X-CSRFToken': csrf },
headers: { 'Content-Type': 'application/json', Cookie: cookieStr, 'X-CSRFToken': csrf },
body: JSON.stringify({ user_code }),
signal: AbortSignal.timeout(10_000),
})
if (approveRes.ok)
break
if (approveRes.status !== 429 && approveRes.status < 500)
break
if (approveRes.ok) break
if (approveRes.status !== 429 && approveRes.status < 500) break
console.warn(`[provision] device/approve HTTP ${approveRes.status}; retry ${i}/5 in ${i * 2}s`)
await sleep(i * 2_000)
}
if (!approveRes?.ok)
throw new Error(`device/approve failed: HTTP ${approveRes?.status}`)
if (!approveRes?.ok) throw new Error(`device/approve failed: HTTP ${approveRes?.status}`)
// Step 3: exchange token
const tokenRes = await fetch(`${base}/openapi/v1/oauth/device/token`, {
@@ -120,39 +120,42 @@ async function mintToken(cookieStr: string, csrf: string, label: string): Promis
body: JSON.stringify({ device_code, client_id: 'difyctl' }),
signal: AbortSignal.timeout(10_000),
})
if (!tokenRes.ok)
throw new Error(`device/token failed: HTTP ${tokenRes.status}`)
const body = await tokenRes.json() as { token?: string }
if (!body.token)
throw new Error(`device/token missing token: ${JSON.stringify(body)}`)
if (!tokenRes.ok) throw new Error(`device/token failed: HTTP ${tokenRes.status}`)
const body = (await tokenRes.json()) as { token?: string }
if (!body.token) throw new Error(`device/token missing token: ${JSON.stringify(body)}`)
return body.token
}
async function discoverWorkspaces(cookieStr: string, csrf: string) {
const res = await fetch(`${base}/console/api/workspaces`, {
headers: { 'Cookie': cookieStr, 'X-CSRF-Token': csrf },
headers: { Cookie: cookieStr, 'X-CSRF-Token': csrf },
signal: AbortSignal.timeout(10_000),
})
if (!res.ok)
throw new Error(`list workspaces failed: HTTP ${res.status}`)
const data = await res.json() as { workspaces?: Array<{ id: string, name: string }> }
if (!res.ok) throw new Error(`list workspaces failed: HTTP ${res.status}`)
const data = (await res.json()) as { workspaces?: Array<{ id: string; name: string }> }
const all = data.workspaces ?? []
if (edition === 'ee') {
const ws0 = all.find(w => w.name === 'auto_test0')
const ws1 = all.find(w => w.name === 'auto_test1')
if (!ws0)
throw new Error('[provision] EE: workspace "auto_test0" not found')
const ws0 = all.find((w) => w.name === 'auto_test0')
const ws1 = all.find((w) => w.name === 'auto_test1')
if (!ws0) throw new Error('[provision] EE: workspace "auto_test0" not found')
console.warn(`[provision] EE primary: ${ws0.name} (${ws0.id})`)
console.warn(`[provision] EE secondary: ${ws1?.name ?? 'reuses primary'} (${ws1?.id ?? ws0.id})`)
console.warn(
`[provision] EE secondary: ${ws1?.name ?? 'reuses primary'} (${ws1?.id ?? ws0.id})`,
)
return { primaryWsId: ws0.id, primaryWsName: ws0.name, secondaryWsId: ws1?.id ?? ws0.id }
}
const auto = all.filter(w => w.name.toLowerCase().includes('auto')).sort((a, b) => a.name.localeCompare(b.name))
const auto = all
.filter((w) => w.name.toLowerCase().includes('auto'))
.sort((a, b) => a.name.localeCompare(b.name))
const primary = auto[0] ?? all[0]
if (!primary)
throw new Error('[provision] No workspaces found')
return { primaryWsId: primary.id, primaryWsName: primary.name, secondaryWsId: auto[1]?.id ?? primary.id }
if (!primary) throw new Error('[provision] No workspaces found')
return {
primaryWsId: primary.id,
primaryWsName: primary.name,
secondaryWsId: auto[1]?.id ?? primary.id,
}
}
async function provisionApps(
@@ -162,7 +165,7 @@ async function provisionApps(
secondaryWsId: string,
): Promise<Record<string, string>> {
const mkH = (extra: Record<string, string> = {}) => ({
'Cookie': cookieStr,
Cookie: cookieStr,
'X-CSRF-Token': csrf,
...extra,
})
@@ -202,9 +205,10 @@ async function provisionApps(
}
const dsl = await readFile(join(fixturesDir, dslFile), 'utf8')
const appName = (dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1]
?.trim()
.replace(/^['"]|['"]$/g, '') ?? dslFile
const appName =
(dsl.match(/^[ \t]+name:[ \t]*(\S[^\n]*)$/m) ?? [])[1]
?.trim()
.replace(/^['"]|['"]$/g, '') ?? dslFile
const appMode = (dsl.match(/^[ \t]+mode:[ \t]*(\S+)/m) ?? [])[1] ?? ''
// Find existing or import
@@ -212,34 +216,34 @@ async function provisionApps(
`${base}/console/api/apps?name=${encodeURIComponent(appName)}&limit=50&page=1`,
{ headers: mkH(), signal: AbortSignal.timeout(10_000) },
)
const searchData = await searchRes.json() as { data?: Array<{ id: string, name: string }> }
let appId = searchData.data?.find(a => a.name === appName)?.id
const searchData = (await searchRes.json()) as { data?: Array<{ id: string; name: string }> }
let appId = searchData.data?.find((a) => a.name === appName)?.id
if (appId) {
console.warn(`[provision] ${dslFile}: exists id=${appId}`)
}
else {
} else {
const importRes = await fetch(`${base}/console/api/apps/imports`, {
method: 'POST',
headers: { ...mkH(), 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: 'yaml-content', yaml_content: dsl }),
signal: AbortSignal.timeout(30_000),
})
const importData = await importRes.json() as { app_id?: string, import_id?: string }
const importData = (await importRes.json()) as { app_id?: string; import_id?: string }
if (importRes.status === 202 && importData.import_id) {
const confirmRes = await fetch(`${base}/console/api/apps/imports/${importData.import_id}/confirm`, {
method: 'POST',
headers: mkH(),
signal: AbortSignal.timeout(15_000),
})
const confirmData = await confirmRes.json() as { app_id?: string }
const confirmRes = await fetch(
`${base}/console/api/apps/imports/${importData.import_id}/confirm`,
{
method: 'POST',
headers: mkH(),
signal: AbortSignal.timeout(15_000),
},
)
const confirmData = (await confirmRes.json()) as { app_id?: string }
appId = confirmData.app_id
}
else {
} else {
appId = importData.app_id
}
if (!appId)
throw new Error(`import failed: ${JSON.stringify(importData)}`)
if (!appId) throw new Error(`import failed: ${JSON.stringify(importData)}`)
console.warn(`[provision] ${dslFile}: imported id=${appId}`)
}
@@ -270,8 +274,7 @@ async function provisionApps(
}
results[envVar] = appId
}
catch (err) {
} catch (err) {
console.warn(`[provision] ${dslFile} skipped: ${err}`)
}
}
@@ -281,7 +284,9 @@ async function provisionApps(
async function writeOutputs(outputs: Record<string, string>) {
const ghOutput = process.env.GITHUB_OUTPUT
const lines = `${Object.entries(outputs).map(([k, v]) => `${k}=${v}`).join('\n')}\n`
const lines = `${Object.entries(outputs)
.map(([k, v]) => `${k}=${v}`)
.join('\n')}\n`
// Always write local JSON for debugging
const { writeFile } = await import('node:fs/promises')
@@ -310,18 +315,19 @@ async function main() {
// 2. Token
let primaryToken = preToken
if (primaryToken && await validateToken(primaryToken)) {
if (primaryToken && (await validateToken(primaryToken))) {
console.warn(`[provision] Using pre-set token: ${primaryToken.slice(0, 20)}`)
}
else {
if (primaryToken)
console.warn('[provision] Pre-set token invalid, minting fresh…')
} else {
if (primaryToken) console.warn('[provision] Pre-set token invalid, minting fresh…')
primaryToken = await mintToken(cookieString, csrfToken, 'e2e-provision')
console.warn(`[provision] Minted token: ${primaryToken.slice(0, 20)}`)
}
// 3. Discover workspaces
const { primaryWsId, primaryWsName, secondaryWsId } = await discoverWorkspaces(cookieString, csrfToken)
const { primaryWsId, primaryWsName, secondaryWsId } = await discoverWorkspaces(
cookieString,
csrfToken,
)
// 4. Provision apps
const appIds = await provisionApps(cookieString, csrfToken, primaryWsId, secondaryWsId)
@@ -333,10 +339,16 @@ async function main() {
// their describe calls rejected with "workspace_id does not match app's workspace".
await fetch(`${base}/console/api/workspaces/switch`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': cookieString, 'X-CSRF-Token': csrfToken },
headers: {
'Content-Type': 'application/json',
Cookie: cookieString,
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify({ tenant_id: primaryWsId }),
signal: AbortSignal.timeout(10_000),
}).catch((err: unknown) => console.warn(`[provision] switch-back to primary failed (non-fatal): ${err}`))
}).catch((err: unknown) =>
console.warn(`[provision] switch-back to primary failed (non-fatal): ${err}`),
)
console.warn(`[provision] Session workspace reset to primary: ${primaryWsId}`)
// 5. Write outputs
+67 -26
View File
@@ -14,18 +14,22 @@ import {
describe('pathToTokens', () => {
it('extracts tokens for nested command', () => {
expect(pathToTokens('src/commands/auth/devices/list/index.ts', 'src/commands'))
.toEqual(['auth', 'devices', 'list'])
expect(pathToTokens('src/commands/auth/devices/list/index.ts', 'src/commands')).toEqual([
'auth',
'devices',
'list',
])
})
it('extracts tokens for top-level command', () => {
expect(pathToTokens('src/commands/version/index.ts', 'src/commands'))
.toEqual(['version'])
expect(pathToTokens('src/commands/version/index.ts', 'src/commands')).toEqual(['version'])
})
it('normalizes backslashes (windows-style paths)', () => {
expect(pathToTokens('src\\commands\\auth\\login\\index.ts', 'src/commands'))
.toEqual(['auth', 'login'])
expect(pathToTokens('src\\commands\\auth\\login\\index.ts', 'src/commands')).toEqual([
'auth',
'login',
])
})
})
@@ -49,22 +53,35 @@ describe('tokensToIdentifier', () => {
describe('buildTree', () => {
it('assembles a nested tree from entries', () => {
const entries = [
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' },
{ tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: '@/commands/auth/devices/list/index' },
{
tokens: ['auth', 'login'],
identifier: 'AuthLogin',
importPath: '@/commands/auth/login/index',
},
{
tokens: ['auth', 'devices', 'list'],
identifier: 'AuthDevicesList',
importPath: '@/commands/auth/devices/list/index',
},
{ tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' },
]
const tree = buildTree(entries)
expect(tree.subcommands.get('auth')?.command).toBeUndefined()
expect(tree.subcommands.get('auth')?.subcommands.get('login')?.command).toBe('AuthLogin')
expect(tree.subcommands.get('auth')?.subcommands.get('devices')?.subcommands.get('list')?.command)
.toBe('AuthDevicesList')
expect(
tree.subcommands.get('auth')?.subcommands.get('devices')?.subcommands.get('list')?.command,
).toBe('AuthDevicesList')
expect(tree.subcommands.get('version')?.command).toBe('Version')
})
it('supports a parent command with its own children', () => {
const entries = [
{ tokens: ['run', 'app'], identifier: 'RunApp', importPath: '@/commands/run/app/index' },
{ tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: '@/commands/run/app/resume/index' },
{
tokens: ['run', 'app', 'resume'],
identifier: 'RunAppResume',
importPath: '@/commands/run/app/resume/index',
},
]
const tree = buildTree(entries)
const runApp = tree.subcommands.get('run')?.subcommands.get('app')
@@ -76,9 +93,17 @@ describe('buildTree', () => {
describe('formatModule', () => {
it('produces a deterministic ESM file with imports + tree literal', () => {
const entries: CommandEntry[] = [
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' },
{
tokens: ['auth', 'login'],
identifier: 'AuthLogin',
importPath: '@/commands/auth/login/index',
},
{ tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' },
{ tokens: ['auth', 'devices', 'list'], identifier: 'AuthDevicesList', importPath: '@/commands/auth/devices/list/index' },
{
tokens: ['auth', 'devices', 'list'],
identifier: 'AuthDevicesList',
importPath: '@/commands/auth/devices/list/index',
},
]
const tree = buildTree(entries)
const out = formatModule(entries, tree)
@@ -111,7 +136,11 @@ export const commandTree: CommandTree = {
it('emits parent-with-own-command shape', () => {
const entries: CommandEntry[] = [
{ tokens: ['run', 'app'], identifier: 'RunApp', importPath: '@/commands/run/app/index' },
{ tokens: ['run', 'app', 'resume'], identifier: 'RunAppResume', importPath: '@/commands/run/app/resume/index' },
{
tokens: ['run', 'app', 'resume'],
identifier: 'RunAppResume',
importPath: '@/commands/run/app/resume/index',
},
]
const tree = buildTree(entries)
const out = formatModule(entries, tree)
@@ -130,7 +159,11 @@ export const commandTree: CommandTree = {
it('imports sorted alphabetically by import path', () => {
const entries: CommandEntry[] = [
{ tokens: ['version'], identifier: 'Version', importPath: '@/commands/version/index' },
{ tokens: ['auth', 'login'], identifier: 'AuthLogin', importPath: '@/commands/auth/login/index' },
{
tokens: ['auth', 'login'],
identifier: 'AuthLogin',
importPath: '@/commands/auth/login/index',
},
]
const out = formatModule(entries, buildTree(entries))
const authIdx = out.indexOf('AuthLogin')
@@ -140,8 +173,16 @@ export const commandTree: CommandTree = {
it('quotes hyphenated keys and leaves plain identifier keys unquoted', () => {
const entries: CommandEntry[] = [
{ tokens: ['export', 'app'], identifier: 'ExportApp', importPath: '@/commands/export/app/index' },
{ tokens: ['export', 'studio-app'], identifier: 'ExportStudioApp', importPath: '@/commands/export/studio-app/index' },
{
tokens: ['export', 'app'],
identifier: 'ExportApp',
importPath: '@/commands/export/app/index',
},
{
tokens: ['export', 'studio-app'],
identifier: 'ExportStudioApp',
importPath: '@/commands/export/studio-app/index',
},
]
const out = formatModule(entries, buildTree(entries))
expect(out).toContain(`'studio-app': { command: ExportStudioApp, subcommands: {} },`)
@@ -156,7 +197,10 @@ function makeFixture(): string {
mkdirSync(join(commands, 'auth', 'login'), { recursive: true })
writeFileSync(join(commands, 'auth', 'login', 'index.ts'), 'export default class Login {}\n')
mkdirSync(join(commands, 'auth', 'devices', 'list'), { recursive: true })
writeFileSync(join(commands, 'auth', 'devices', 'list', 'index.ts'), 'export default class DevicesList {}\n')
writeFileSync(
join(commands, 'auth', 'devices', 'list', 'index.ts'),
'export default class DevicesList {}\n',
)
mkdirSync(join(commands, '_shared'), { recursive: true })
writeFileSync(join(commands, '_shared', 'index.ts'), 'export default class Shared {}\n')
mkdirSync(join(commands, 'version'), { recursive: true })
@@ -168,12 +212,12 @@ describe('discoverCommands', () => {
it('returns sorted entries, skipping _-prefixed segments', async () => {
const root = makeFixture()
const entries = await discoverCommands(join(root, 'src', 'commands'))
expect(entries.map(e => e.tokens.join('/'))).toEqual([
expect(entries.map((e) => e.tokens.join('/'))).toEqual([
'auth/devices/list',
'auth/login',
'version',
])
expect(entries.find(e => e.tokens[0] === '_shared')).toBeUndefined()
expect(entries.find((e) => e.tokens[0] === '_shared')).toBeUndefined()
})
it('errors on a loose .ts file under commands/', async () => {
@@ -205,8 +249,7 @@ describe('generate', () => {
const commandsDir = join(root, 'src', 'commands')
await generate({ commandsDir, mode: 'write' })
const result = await generate({ commandsDir, mode: 'check' })
if (result.mode !== 'check')
throw new Error('expected check mode')
if (result.mode !== 'check') throw new Error('expected check mode')
expect(result.ok).toBe(true)
})
@@ -216,10 +259,8 @@ describe('generate', () => {
await generate({ commandsDir, mode: 'write' })
writeFileSync(join(commandsDir, 'tree.generated.ts'), '// stale\n')
const result = await generate({ commandsDir, mode: 'check' })
if (result.mode !== 'check')
throw new Error('expected check mode')
if (result.mode !== 'check') throw new Error('expected check mode')
expect(result.ok).toBe(false)
if (!result.ok)
expect(result.diff).toBeDefined()
if (!result.ok) expect(result.diff).toBeDefined()
})
})
+40 -65
View File
@@ -57,26 +57,22 @@ export type TreeNode = {
export function pathToTokens(filePath: string, commandsRoot: string): string[] {
const normalized = filePath.replace(/\\/g, '/')
const root = commandsRoot.replace(/\\/g, '/').replace(/\/$/, '')
const trimmed = normalized.startsWith(`${root}/`)
? normalized.slice(root.length + 1)
: normalized
const trimmed = normalized.startsWith(`${root}/`) ? normalized.slice(root.length + 1) : normalized
const withoutIndex = trimmed.replace(/\/index\.ts$/, '')
return withoutIndex.split('/').filter(s => s.length > 0)
return withoutIndex.split('/').filter((s) => s.length > 0)
}
function capitalize(part: string): string {
if (part.length === 0)
return ''
if (part.length === 0) return ''
return part[0]!.toUpperCase() + part.slice(1)
}
export function tokensToIdentifier(tokens: readonly string[]): string {
const id = tokens
.flatMap(t => t.split(/[-_]/))
.flatMap((t) => t.split(/[-_]/))
.map(capitalize)
.join('')
if (RESERVED_JS_KEYWORDS.has(id.toLowerCase()))
return `_${id}`
if (RESERVED_JS_KEYWORDS.has(id.toLowerCase())) return `_${id}`
return id
}
@@ -103,18 +99,15 @@ const HEADER = `// @generated by scripts/generate-command-tree.ts — DO NOT EDI
`
function compareStrings(a: string, b: string): number {
if (a < b)
return -1
if (a > b)
return 1
if (a < b) return -1
if (a > b) return 1
return 0
}
function emitImports(entries: readonly CommandEntry[]): string {
const sorted = [...entries].sort((a, b) => compareStrings(a.importPath, b.importPath))
const lines = [`import type { CommandTree } from '@/framework/registry'`]
for (const e of sorted)
lines.push(`import ${e.identifier} from '${e.importPath}'`)
for (const e of sorted) lines.push(`import ${e.identifier} from '${e.importPath}'`)
return lines.join('\n')
}
@@ -123,13 +116,11 @@ function emitNode(node: TreeNode, indent: string): string {
const keys = [...node.subcommands.keys()].sort()
const parts: string[] = []
if (node.command !== undefined)
parts.push(`${inner}command: ${node.command},`)
if (node.command !== undefined) parts.push(`${inner}command: ${node.command},`)
if (keys.length === 0) {
parts.push(`${inner}subcommands: {},`)
}
else {
} else {
parts.push(`${inner}subcommands: {`)
for (const key of keys) {
const child = node.subcommands.get(key)!
@@ -154,14 +145,9 @@ function emitKey(key: string): string {
function emitEntry(key: string, node: TreeNode, indent: string): string {
const k = emitKey(key)
const isLeaf = node.subcommands.size === 0 && node.command !== undefined
if (isLeaf)
return `${indent}${k}: { command: ${node.command}, subcommands: {} },`
if (isLeaf) return `${indent}${k}: { command: ${node.command}, subcommands: {} },`
return [
`${indent}${k}: {`,
emitNode(node, indent),
`${indent}},`,
].join('\n')
return [`${indent}${k}: {`, emitNode(node, indent), `${indent}},`].join('\n')
}
export function formatModule(entries: readonly CommandEntry[], tree: TreeNode): string {
@@ -181,10 +167,8 @@ async function walk(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true })
for (const e of entries) {
const full = join(dir, e.name)
if (e.isDirectory())
out.push(...await walk(full))
else if (e.isFile())
out.push(full)
if (e.isDirectory()) out.push(...(await walk(full)))
else if (e.isFile()) out.push(full)
}
return out
}
@@ -195,21 +179,20 @@ function toPosix(p: string): string {
export async function discoverCommands(commandsDir: string): Promise<CommandEntry[]> {
const all = await walk(commandsDir)
const tsFiles = all.filter(f => f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts'))
const tsFiles = all.filter(
(f) => f.endsWith('.ts') && !f.endsWith('.test.ts') && !f.endsWith('.d.ts'),
)
const loose: string[] = []
for (const abs of tsFiles) {
const rel = toPosix(relative(commandsDir, abs))
if (isExcludedCommandPath(rel))
continue
if (rel === 'tree.ts' || rel === 'tree.generated.ts')
continue
if (isExcludedCommandPath(rel)) continue
if (rel === 'tree.ts' || rel === 'tree.generated.ts') continue
// Only flag files directly under commands/ (no path separator — no parent folder)
if (!rel.includes('/'))
loose.push(rel)
if (!rel.includes('/')) loose.push(rel)
}
if (loose.length > 0) {
const list = loose.map(p => ` - src/commands/${p}`).join('\n')
const list = loose.map((p) => ` - src/commands/${p}`).join('\n')
throw new Error(
`commands must live under their own folder (see CLAUDE memory: feedback_cli_command_structure). Found:\n${list}`,
)
@@ -218,15 +201,11 @@ export async function discoverCommands(commandsDir: string): Promise<CommandEntr
const entries: CommandEntry[] = []
for (const abs of tsFiles) {
const rel = toPosix(relative(commandsDir, abs))
if (isExcludedCommandPath(rel))
continue
if (!rel.endsWith('/index.ts'))
continue
if (isExcludedCommandPath(rel)) continue
if (!rel.endsWith('/index.ts')) continue
const tokens = pathToTokens(rel, '')
if (tokens.length === 0)
continue
if (tokens[0]!.startsWith('-'))
throw new Error(`command token cannot start with '-': ${rel}`)
if (tokens.length === 0) continue
if (tokens[0]!.startsWith('-')) throw new Error(`command token cannot start with '-': ${rel}`)
entries.push({
tokens,
identifier: tokensToIdentifier(tokens),
@@ -236,8 +215,7 @@ export async function discoverCommands(commandsDir: string): Promise<CommandEntr
entries.sort((a, b) => compareStrings(a.importPath, b.importPath))
if (entries.length === 0)
throw new Error(`no commands found under ${commandsDir}`)
if (entries.length === 0) throw new Error(`no commands found under ${commandsDir}`)
assertUniqueIdentifiers(entries)
return entries
@@ -258,10 +236,10 @@ export type GenerateOptions = {
readonly mode: 'write' | 'check'
}
export type GenerateResult
= | { mode: 'write', wrote: boolean, path: string }
| { mode: 'check', ok: true, path: string }
| { mode: 'check', ok: false, path: string, diff: string }
export type GenerateResult =
| { mode: 'write'; wrote: boolean; path: string }
| { mode: 'check'; ok: true; path: string }
| { mode: 'check'; ok: false; path: string; diff: string }
export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
const entries = await discoverCommands(opts.commandsDir)
@@ -273,12 +251,10 @@ export async function generate(opts: GenerateOptions): Promise<GenerateResult> {
let onDisk = ''
try {
onDisk = await readFile(target, 'utf8')
}
catch {
} catch {
onDisk = ''
}
if (onDisk === content)
return { mode: 'check', ok: true, path: target }
if (onDisk === content) return { mode: 'check', ok: true, path: target }
return { mode: 'check', ok: false, path: target, diff: shortDiff(onDisk, content) }
}
@@ -295,10 +271,8 @@ function shortDiff(a: string, b: string): string {
const max = Math.max(aLines.length, bLines.length)
for (let i = 0; i < max; i++) {
if (aLines[i] !== bLines[i]) {
if (aLines[i] !== undefined)
lines.push(`- ${aLines[i]}`)
if (bLines[i] !== undefined)
lines.push(`+ ${bLines[i]}`)
if (aLines[i] !== undefined) lines.push(`- ${aLines[i]}`)
if (bLines[i] !== undefined) lines.push(`+ ${bLines[i]}`)
}
}
return lines.slice(0, 40).join('\n')
@@ -318,12 +292,13 @@ async function main(): Promise<void> {
process.stderr.write(`tree:check ok\n`)
return
}
process.stderr.write(`tree:check FAILED — tree.generated.ts is stale.\nDiff (first 40 lines):\n${result.diff}\n\nRun \`pnpm tree:gen\` and commit.\n`)
process.stderr.write(
`tree:check FAILED — tree.generated.ts is stale.\nDiff (first 40 lines):\n${result.diff}\n\nRun \`pnpm tree:gen\` and commit.\n`,
)
process.exit(1)
}
const invokedDirectly = process.argv[1] !== undefined
&& fileURLToPath(import.meta.url) === process.argv[1]
const invokedDirectly =
process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]
if (invokedDirectly)
await main()
if (invokedDirectly) await main()
+80 -20
View File
@@ -44,11 +44,20 @@ const FETCH_STUB = [
].join('\n')
/* eslint-enable no-template-curly-in-string */
function runLib(program: string, env: Record<string, string> = {}): { code: number, stdout: string, stderr: string } {
function runLib(
program: string,
env: Record<string, string> = {},
): { code: number; stdout: string; stderr: string } {
const full = `. "${SCRIPT}"\n${FETCH_STUB}\n${program}`
const r = spawnSync('sh', ['-c', full], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', ...env },
env: {
...process.env,
DIFYCTL_INSTALL_LIB: '1',
DIFY_VERSION: '',
DIFYCTL_VERSION: '',
...env,
},
})
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
}
@@ -56,11 +65,21 @@ function runLib(program: string, env: Record<string, string> = {}): { code: numb
// Like runLib but with a caller-supplied fetch_json stub, so we can drive the
// real rate_limit_hint / maybe_ratelimit_exit / fetch_hit_ratelimit (which the
// script defines) by writing a classified reason to FETCH_ERR_FILE.
function runLibStub(stub: string, program: string, env: Record<string, string> = {}): { code: number, stderr: string } {
function runLibStub(
stub: string,
program: string,
env: Record<string, string> = {},
): { code: number; stderr: string } {
const full = `. "${SCRIPT}"\n${stub}\n${program}`
const r = spawnSync('sh', ['-c', full], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', ...env },
env: {
...process.env,
DIFYCTL_INSTALL_LIB: '1',
DIFY_VERSION: '',
DIFYCTL_VERSION: '',
...env,
},
})
return { code: r.status ?? 1, stderr: r.stderr ?? '' }
}
@@ -71,9 +90,17 @@ function failStub(reason: string): string {
return `fetch_json() { printf '%s' '${reason}' > "$FETCH_ERR_FILE"; return 1; }`
}
const REL_1142 = JSON.stringify({ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }] })
const REL_1150 = JSON.stringify({ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-linux-x64' }] })
const LIST_NEWEST_FIRST = JSON.stringify({ releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }] })
const REL_1142 = JSON.stringify({
tag_name: '1.14.2',
assets: [{ name: 'difyctl-v0.2.0-linux-x64' }, { name: 'difyctl-v0.2.0-checksums.txt' }],
})
const REL_1150 = JSON.stringify({
tag_name: '1.15.0',
assets: [{ name: 'difyctl-v0.3.0-linux-x64' }],
})
const LIST_NEWEST_FIRST = JSON.stringify({
releases: [{ tag_name: '1.15.0' }, { tag_name: '1.14.2' }],
})
const RELEASE = JSON.stringify({
tag_name: '1.14.2',
@@ -138,7 +165,10 @@ describe('install-cli asset_version', () => {
describe('install-cli resolve_release', () => {
it('DIFY_VERSION pins the release directly', () => {
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', { DIFY_VERSION: '1.14.2', TAG_1_14_2: REL_1142 })
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
DIFY_VERSION: '1.14.2',
TAG_1_14_2: REL_1142,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
@@ -150,7 +180,9 @@ describe('install-cli resolve_release', () => {
})
it('blank resolves to the latest stable release', () => {
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', { LATEST_JSON: REL_1150 })
const r = runLib('resolve_release linux-x64; printf "%s" "$DIFY_TAG"', {
LATEST_JSON: REL_1150,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.15.0')
})
@@ -225,14 +257,18 @@ describe('install-cli rate limit', () => {
})
it('DIFY_VERSION: rate limit wins over the misleading "not found" message', () => {
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', { DIFY_VERSION: '1.15.0' })
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', {
DIFY_VERSION: '1.15.0',
})
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('not found')
})
it('DIFYCTL_VERSION: rate limit surfaces from the nested subshell, not "not found"', () => {
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', { DIFYCTL_VERSION: '0.2.0' })
const r = runLibStub(failStub(`ratelimit:${futureReset}`), 'resolve_release linux-x64', {
DIFYCTL_VERSION: '0.2.0',
})
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('not found')
@@ -288,32 +324,50 @@ esac
// Drive the real fetch_json with FAKE_CURL first on PATH. Returns "OK|<body>" or
// "FAIL|<FETCH_ERR_FILE contents>", plus any -H lines the fake curl received.
function runRealFetch(mode: string, env: Record<string, string> = {}): { result: string, headers: string } {
function runRealFetch(
mode: string,
env: Record<string, string> = {},
): { result: string; headers: string } {
const dir = mkdtempSync(join(tmpdir(), 'difyctl-fakecurl-'))
const hdrLog = join(dir, 'hdrlog')
writeFileSync(join(dir, 'curl'), FAKE_CURL)
chmodSync(join(dir, 'curl'), 0o755)
const program = 'if body=$(fetch_json "https://api.github.com/repos/x/releases/latest"); then printf \'OK|%s\' "$body"; else printf \'FAIL|%s\' "$(cat "$FETCH_ERR_FILE" 2>/dev/null)"; fi'
const program =
'if body=$(fetch_json "https://api.github.com/repos/x/releases/latest"); then printf \'OK|%s\' "$body"; else printf \'FAIL|%s\' "$(cat "$FETCH_ERR_FILE" 2>/dev/null)"; fi'
const r = spawnSync('sh', ['-c', `. "${SCRIPT}"\n${program}`], {
encoding: 'utf8',
env: { ...process.env, PATH: `${dir}:${process.env.PATH ?? ''}`, DIFYCTL_INSTALL_LIB: '1', DIFY_VERSION: '', DIFYCTL_VERSION: '', FAKE_MODE: mode, FAKE_HDR_LOG: hdrLog, ...env },
env: {
...process.env,
PATH: `${dir}:${process.env.PATH ?? ''}`,
DIFYCTL_INSTALL_LIB: '1',
DIFY_VERSION: '',
DIFYCTL_VERSION: '',
FAKE_MODE: mode,
FAKE_HDR_LOG: hdrLog,
...env,
},
})
let headers = ''
try {
headers = readFileSync(hdrLog, 'utf8')
} catch {
/* no headers logged */
}
catch { /* no headers logged */ }
rmSync(dir, { recursive: true, force: true })
return { result: (r.stdout ?? '').trim(), headers }
}
describe('install-cli fetch_json (real, fake curl on PATH)', () => {
it('returns the response body on 200', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{"tag_name":"1.15.0"}' }).result).toBe('OK|{"tag_name":"1.15.0"}')
expect(runRealFetch('ok', { FAKE_BODY: '{"tag_name":"1.15.0"}' }).result).toBe(
'OK|{"tag_name":"1.15.0"}',
)
})
it('classifies a 403 with x-ratelimit-remaining:0 as a rate limit and captures the reset', () => {
expect(runRealFetch('ratelimit', { FAKE_RESET: '1893456000' }).result).toBe('FAIL|ratelimit:1893456000')
expect(runRealFetch('ratelimit', { FAKE_RESET: '1893456000' }).result).toBe(
'FAIL|ratelimit:1893456000',
)
})
it('classifies a 403 with tokens left as a plain http error, not a rate limit', () => {
@@ -329,14 +383,20 @@ describe('install-cli fetch_json (real, fake curl on PATH)', () => {
})
it('sends an Authorization bearer header when GITHUB_TOKEN is set', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: 'ghp_secret' }).headers).toContain('Authorization: Bearer ghp_secret')
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: 'ghp_secret' }).headers).toContain(
'Authorization: Bearer ghp_secret',
)
})
it('falls back to GH_TOKEN when GITHUB_TOKEN is unset', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: 'gho_fallback' }).headers).toContain('Authorization: Bearer gho_fallback')
expect(
runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: 'gho_fallback' }).headers,
).toContain('Authorization: Bearer gho_fallback')
})
it('sends no Authorization header when neither token is set', () => {
expect(runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: '' }).headers).not.toContain('Authorization')
expect(
runRealFetch('ok', { FAKE_BODY: '{}', GITHUB_TOKEN: '', GH_TOKEN: '' }).headers,
).not.toContain('Authorization')
})
})
+13 -6
View File
@@ -12,23 +12,30 @@ const MANIFEST = JSON.stringify({
channel: 'edge',
version: '0.1.0-edge.2fd7b82',
baseUrl: 'https://pub.example.r2.dev/difyctl/edge/0.1.0-edge.2fd7b82',
targets: { 'windows-x64': { asset: 'difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe', sha256: 'deadbeef' } },
targets: {
'windows-x64': { asset: 'difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe', sha256: 'deadbeef' },
},
})
function pwsh(program: string): { code: number, stdout: string, stderr: string } {
function pwsh(program: string): { code: number; stdout: string; stderr: string } {
const full = `. '${SCRIPT}'\n${program}`
const r = spawnSync('pwsh', ['-NoProfile', '-Command', full], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_INSTALL_LIB: '1' },
})
return { code: r.status ?? 1, stdout: (r.stdout ?? '').replace(/\r\n/g, '\n').trim(), stderr: r.stderr ?? '' }
return {
code: r.status ?? 1,
stdout: (r.stdout ?? '').replace(/\r\n/g, '\n').trim(),
stderr: r.stderr ?? '',
}
}
d('install-r2.ps1', () => {
it('parses a target asset + sha from the manifest', () => {
const prog = `$m = ConvertFrom-Json @'\n${MANIFEST}\n'@\n`
+ `Write-Output (Get-TargetField $m 'windows-x64' 'asset')\n`
+ `Write-Output (Get-TargetField $m 'windows-x64' 'sha256')`
const prog =
`$m = ConvertFrom-Json @'\n${MANIFEST}\n'@\n` +
`Write-Output (Get-TargetField $m 'windows-x64' 'asset')\n` +
`Write-Output (Get-TargetField $m 'windows-x64' 'sha256')`
const { stdout } = pwsh(prog)
expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-windows-x64.exe\ndeadbeef')
})
+28 -10
View File
@@ -49,7 +49,10 @@ const CHECKSUMS = [
'beadc0de difyctl-v0.1.0-edge.ce4af868-windows-x64.exe',
].join('\n')
function lib(program: string, env: Record<string, string> = {}): { code: number, stdout: string, stderr: string } {
function lib(
program: string,
env: Record<string, string> = {},
): { code: number; stdout: string; stderr: string } {
const full = `. "${SCRIPT}"\n${program}`
const r = spawnSync('sh', ['-c', full], {
encoding: 'utf8',
@@ -63,24 +66,33 @@ describe('install-r2 manifest parsing', () => {
// so detect_target intentionally dies (Windows installs go through install-r2.ps1).
it.skipIf(process.platform === 'win32')('detect_target maps to one of the 5 ids', () => {
const { stdout } = lib('detect_target')
expect(['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']).toContain(stdout)
expect(['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64', 'windows-x64']).toContain(
stdout,
)
})
it('manifest_str reads a top-level string field', () => {
const { stdout } = lib(`printf '%s' '${MANIFEST}' > "$tmp_m"; manifest_str "$tmp_m" channel`, {})
const { stdout } = lib(
`printf '%s' '${MANIFEST}' > "$tmp_m"; manifest_str "$tmp_m" channel`,
{},
)
expect(stdout).toBe('edge')
})
it('manifest_target_field extracts per-target values from a single line', () => {
const prog = `printf '%s' '${MANIFEST}' > "$tmp_m"\n`
+ 'manifest_target_field "$tmp_m" darwin-arm64 asset\n'
+ 'manifest_target_field "$tmp_m" darwin-arm64 sha256'
const prog =
`printf '%s' '${MANIFEST}' > "$tmp_m"\n` +
'manifest_target_field "$tmp_m" darwin-arm64 asset\n' +
'manifest_target_field "$tmp_m" darwin-arm64 sha256'
const { stdout } = lib(prog)
expect(stdout).toBe('difyctl-v0.1.0-edge.2fd7b82-darwin-arm64\ncafef00d')
})
it('requires DIFYCTL_R2_BASE when run as the installer (not lib)', () => {
const r = spawnSync('sh', [SCRIPT], { encoding: 'utf8', env: { ...process.env, DIFYCTL_R2_BASE: '' } })
const r = spawnSync('sh', [SCRIPT], {
encoding: 'utf8',
env: { ...process.env, DIFYCTL_R2_BASE: '' },
})
expect(r.status).not.toBe(0)
expect(r.stderr).toMatch(/DIFYCTL_R2_BASE/)
})
@@ -93,14 +105,18 @@ describe('install-r2 manifest parsing', () => {
it('sha256_check passes on the correct hash', () => {
// sha256('hello') = 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824
const r = lib('f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 && echo OK')
const r = lib(
'f="$(mktemp)"; printf \'hello\' > "$f"; sha256_check "$f" 2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824 && echo OK',
)
expect(r.stdout).toBe('OK')
})
})
describe('install-r2 version/commit pin', () => {
it('index_resolve matches a build by exact version', () => {
const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" version 0.1.0-edge.aaaa111`)
const r = lib(
`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" version 0.1.0-edge.aaaa111`,
)
expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111')
})
@@ -110,7 +126,9 @@ describe('install-r2 version/commit pin', () => {
})
it('index_resolve matches the full 40-char commit too', () => {
const r = lib(`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit aaaa111bbbbcccc000011112222333344445555`)
const r = lib(
`printf '%s' '${INDEX}' > "$tmp_m"; index_resolve "$tmp_m" commit aaaa111bbbbcccc000011112222333344445555`,
)
expect(r.stdout).toBe('0.1.0-edge.aaaa111\t0.1.0-edge.aaaa111')
})
+81 -42
View File
@@ -5,9 +5,13 @@ import { describe, expect, it } from 'vitest'
const SCRIPT = fileURLToPath(new URL('./install.ps1', import.meta.url))
function hasPwsh(): boolean {
const r = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'], {
encoding: 'utf8',
})
const r = spawnSync(
'pwsh',
['-NoProfile', '-NonInteractive', '-Command', '$PSVersionTable.PSVersion.Major'],
{
encoding: 'utf8',
},
)
return r.status === 0
}
@@ -16,26 +20,26 @@ const PWSH = hasPwsh()
const STUB = [
'function Invoke-RestMethod {',
' param([string]$Uri, $Headers)',
' if ($Uri -like \'*/releases/latest\') {',
' if (-not $env:HX_LATEST) { throw \'mock 404\' }',
" if ($Uri -like '*/releases/latest') {",
" if (-not $env:HX_LATEST) { throw 'mock 404' }",
' return ($env:HX_LATEST | ConvertFrom-Json)',
' }',
' elseif ($Uri -like \'*/releases?per_page=100\') {',
' if (-not $env:HX_LIST) { throw \'mock 404\' }',
" elseif ($Uri -like '*/releases?per_page=100') {",
" if (-not $env:HX_LIST) { throw 'mock 404' }",
' return ($env:HX_LIST | ConvertFrom-Json)',
' }',
' elseif ($Uri -like \'*/releases/tags/*\') {',
' $t = $Uri -replace \'.*/releases/tags/\', \'\'',
' $k = \'HX_TAG_\' + ($t -replace \'[.\\-]\', \'_\')',
" elseif ($Uri -like '*/releases/tags/*') {",
" $t = $Uri -replace '.*/releases/tags/', ''",
" $k = 'HX_TAG_' + ($t -replace '[.\\-]', '_')",
' $v = [Environment]::GetEnvironmentVariable($k)',
' if (-not $v) { throw \'mock 404\' }',
" if (-not $v) { throw 'mock 404' }",
' return ($v | ConvertFrom-Json)',
' }',
' throw "unexpected uri $Uri"',
'}',
].join('\n')
type Run = { code: number, stdout: string, stderr: string }
type Run = { code: number; stdout: string; stderr: string }
function runPwsh(body: string, env: Record<string, string> = {}): Run {
const script = `$ErrorActionPreference='Stop'\n${STUB}\n. '${SCRIPT}'\n${body}`
@@ -54,8 +58,14 @@ function runPwsh(body: string, env: Record<string, string> = {}): Run {
return { code: r.status ?? 1, stdout: (r.stdout ?? '').trim(), stderr: r.stderr ?? '' }
}
const REL_1142 = JSON.stringify({ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }] })
const REL_1150 = JSON.stringify({ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }] })
const REL_1142 = JSON.stringify({
tag_name: '1.14.2',
assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }],
})
const REL_1150 = JSON.stringify({
tag_name: '1.15.0',
assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }],
})
const LIST_NEWEST_FIRST = JSON.stringify([
{ tag_name: '1.15.0', assets: [{ name: 'difyctl-v0.3.0-windows-x64.exe' }] },
{ tag_name: '1.14.2', assets: [{ name: 'difyctl-v0.2.0-windows-x64.exe' }] },
@@ -63,25 +73,31 @@ const LIST_NEWEST_FIRST = JSON.stringify([
describe.skipIf(!PWSH)('install.ps1 Get-AssetSemver', () => {
it('extracts the version from a windows .exe asset name', () => {
const r = runPwsh('(Get-AssetSemver \'difyctl-v0.2.0-windows-x64.exe\').Version')
const r = runPwsh("(Get-AssetSemver 'difyctl-v0.2.0-windows-x64.exe').Version")
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.2.0')
})
it('extracts a prerelease version and its rc number', () => {
const r = runPwsh('$a = Get-AssetSemver \'difyctl-v0.1.0-rc.1-windows-x64.exe\'; "$($a.Version) $($a.Rc)"')
const r = runPwsh(
'$a = Get-AssetSemver \'difyctl-v0.1.0-rc.1-windows-x64.exe\'; "$($a.Version) $($a.Rc)"',
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.1.0-rc.1 1')
})
it('rejects a non-windows asset (returns null)', () => {
const r = runPwsh('if ($null -eq (Get-AssetSemver \'difyctl-v0.2.0-linux-x64\')) { \'NULL\' } else { \'OBJ\' }')
const r = runPwsh(
"if ($null -eq (Get-AssetSemver 'difyctl-v0.2.0-linux-x64')) { 'NULL' } else { 'OBJ' }",
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
it('rejects a malformed core version (returns null)', () => {
const r = runPwsh('if ($null -eq (Get-AssetSemver \'difyctl-vx.y.z-windows-x64.exe\')) { \'NULL\' } else { \'OBJ\' }')
const r = runPwsh(
"if ($null -eq (Get-AssetSemver 'difyctl-vx.y.z-windows-x64.exe')) { 'NULL' } else { 'OBJ' }",
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
@@ -89,33 +105,39 @@ describe.skipIf(!PWSH)('install.ps1 Get-AssetSemver', () => {
describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
it('picks the highest semver among several windows builds', () => {
const rel = JSON.stringify({ assets: [
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'difyctl-v0.10.0-windows-x64.exe' },
{ name: 'difyctl-v0.9.0-windows-x64.exe' },
] })
const rel = JSON.stringify({
assets: [
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'difyctl-v0.10.0-windows-x64.exe' },
{ name: 'difyctl-v0.9.0-windows-x64.exe' },
],
})
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.10.0')
})
it('prefers the stable release over an rc of the same core', () => {
const rel = JSON.stringify({ assets: [
{ name: 'difyctl-v0.2.0-rc.1-windows-x64.exe' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
] })
const rel = JSON.stringify({
assets: [
{ name: 'difyctl-v0.2.0-rc.1-windows-x64.exe' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
],
})
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Version`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('0.2.0')
})
it('ignores checksums and non-windows assets', () => {
const rel = JSON.stringify({ assets: [
{ name: 'difyctl-v0.2.0-linux-x64' },
{ name: 'difyctl-v0.2.0-checksums.txt' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'some-other-asset.zip' },
] })
const rel = JSON.stringify({
assets: [
{ name: 'difyctl-v0.2.0-linux-x64' },
{ name: 'difyctl-v0.2.0-checksums.txt' },
{ name: 'difyctl-v0.2.0-windows-x64.exe' },
{ name: 'some-other-asset.zip' },
],
})
const r = runPwsh(`(Select-Asset ('${rel}' | ConvertFrom-Json)).Name`)
expect(r.code).toBe(0)
expect(r.stdout).toBe('difyctl-v0.2.0-windows-x64.exe')
@@ -123,7 +145,9 @@ describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
it('yields null when no windows asset is present', () => {
const rel = JSON.stringify({ assets: [{ name: 'difyctl-v0.2.0-linux-x64' }] })
const r = runPwsh(`if ($null -eq (Select-Asset ('${rel}' | ConvertFrom-Json))) { 'NULL' } else { 'OBJ' }`)
const r = runPwsh(
`if ($null -eq (Select-Asset ('${rel}' | ConvertFrom-Json))) { 'NULL' } else { 'OBJ' }`,
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
@@ -131,7 +155,10 @@ describe.skipIf(!PWSH)('install.ps1 Select-Asset', () => {
describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
it('DIFY_VERSION pins the release directly', () => {
const r = runPwsh('(Resolve-Release).tag_name', { DIFY_VERSION: '1.14.2', HX_TAG_1_14_2: REL_1142 })
const r = runPwsh('(Resolve-Release).tag_name', {
DIFY_VERSION: '1.14.2',
HX_TAG_1_14_2: REL_1142,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
@@ -155,13 +182,19 @@ describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
})
it('DIFYCTL_VERSION resolves to the release hosting that build', () => {
const r = runPwsh('(Resolve-Release).tag_name', { DIFYCTL_VERSION: '0.2.0', HX_LIST: LIST_NEWEST_FIRST })
const r = runPwsh('(Resolve-Release).tag_name', {
DIFYCTL_VERSION: '0.2.0',
HX_LIST: LIST_NEWEST_FIRST,
})
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
it('DIFYCTL_VERSION not hosted anywhere throws', () => {
const r = runPwsh('(Resolve-Release).tag_name', { DIFYCTL_VERSION: '9.9.9', HX_LIST: LIST_NEWEST_FIRST })
const r = runPwsh('(Resolve-Release).tag_name', {
DIFYCTL_VERSION: '9.9.9',
HX_LIST: LIST_NEWEST_FIRST,
})
expect(r.code).not.toBe(0)
expect(r.stderr).toContain('difyctl 9.9.9 not found on any Dify release')
})
@@ -169,13 +202,16 @@ describe.skipIf(!PWSH)('install.ps1 Resolve-Release', () => {
describe.skipIf(!PWSH)('install.ps1 Find-ReleaseForDifyctl', () => {
it('returns the newest release whose assets host the wanted build', () => {
const r = runPwsh('(Find-ReleaseForDifyctl \'0.2.0\').tag_name', { HX_LIST: LIST_NEWEST_FIRST })
const r = runPwsh("(Find-ReleaseForDifyctl '0.2.0').tag_name", { HX_LIST: LIST_NEWEST_FIRST })
expect(r.code).toBe(0)
expect(r.stdout).toBe('1.14.2')
})
it('returns nothing when no release hosts the wanted build', () => {
const r = runPwsh('$x = Find-ReleaseForDifyctl \'9.9.9\'; if ($null -eq $x) { \'NULL\' } else { $x.tag_name }', { HX_LIST: LIST_NEWEST_FIRST })
const r = runPwsh(
"$x = Find-ReleaseForDifyctl '9.9.9'; if ($null -eq $x) { 'NULL' } else { $x.tag_name }",
{ HX_LIST: LIST_NEWEST_FIRST },
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
@@ -198,7 +234,8 @@ const futureReset = String(Math.floor(Date.now() / 1000) + 1800)
describe.skipIf(!PWSH)('install.ps1 rate limit', () => {
it('classifies a 403 with x-ratelimit-remaining:0 as rate-limited, returning the reset', () => {
const r = runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': futureReset })}
const r =
runPwsh(`${fakeErr(403, { 'x-ratelimit-remaining': '0', 'x-ratelimit-reset': futureReset })}
$i = Get-RateLimitInfo $err; if ($null -eq $i) { 'NULL' } else { $i.Reset }`)
expect(r.code).toBe(0)
expect(r.stdout).toBe(futureReset)
@@ -219,7 +256,9 @@ describe.skipIf(!PWSH)('install.ps1 rate limit', () => {
})
it('returns null for an error without a response (e.g. a plain string throw)', () => {
const r = runPwsh('$err = [pscustomobject]@{ Exception = [pscustomobject]@{} }; if ($null -eq (Get-RateLimitInfo $err)) { \'NULL\' } else { \'OBJ\' }')
const r = runPwsh(
"$err = [pscustomobject]@{ Exception = [pscustomobject]@{} }; if ($null -eq (Get-RateLimitInfo $err)) { 'NULL' } else { 'OBJ' }",
)
expect(r.code).toBe(0)
expect(r.stdout).toBe('NULL')
})
@@ -233,7 +272,7 @@ describe.skipIf(!PWSH)('install.ps1 rate limit', () => {
})
it('Write-RateLimitHint omits the ETA line when the reset epoch is missing', () => {
const r = runPwsh('Write-RateLimitHint \'\'')
const r = runPwsh("Write-RateLimitHint ''")
expect(r.code).toBe(0)
expect(r.stderr).toContain('rate limit exceeded')
expect(r.stderr).not.toContain('resets in ~')
+6 -16
View File
@@ -27,8 +27,7 @@ const GIT_PROBE_OPTS: ExecSyncOptions = {
export const defaultGitProbe: GitProbe = (cmd) => {
try {
return execSync(cmd, GIT_PROBE_OPTS).toString().trim() || null
}
catch {
} catch {
return null
}
}
@@ -36,7 +35,7 @@ export const defaultGitProbe: GitProbe = (cmd) => {
type PackageManifest = {
difyctl?: {
channel?: string
compat?: { minDify?: string, maxDify?: string }
compat?: { minDify?: string; maxDify?: string }
}
}
@@ -48,8 +47,7 @@ const defaultPackageReader: PackageReader = () => {
try {
const pkgPath = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', 'package.json')
return JSON.parse(readFileSync(pkgPath, 'utf8')) as PackageManifest
}
catch {
} catch {
return {}
}
}
@@ -69,20 +67,12 @@ export function resolveBuildInfo(opts: ResolveOptions = {}): BuildInfo {
const channel = env.DIFYCTL_CHANNEL ?? pkg.difyctl?.channel ?? 'dev'
if (!(BUILD_CHANNELS as readonly string[]).includes(channel)) {
throw new Error(
`invalid DIFYCTL_CHANNEL: ${channel} (expected ${BUILD_CHANNELS.join(' | ')})`,
)
throw new Error(`invalid DIFYCTL_CHANNEL: ${channel} (expected ${BUILD_CHANNELS.join(' | ')})`)
}
const version
= env.DIFYCTL_VERSION
?? git('git describe --tags --dirty --always')
?? '0.0.0-dev'
const version = env.DIFYCTL_VERSION ?? git('git describe --tags --dirty --always') ?? '0.0.0-dev'
const commit
= env.DIFYCTL_COMMIT
?? git('git rev-parse HEAD')
?? 'none'
const commit = env.DIFYCTL_COMMIT ?? git('git rev-parse HEAD') ?? 'none'
const buildDate = env.DIFYCTL_BUILD_DATE ?? now().toISOString()
const minDify = env.DIFYCTL_MIN_DIFY ?? pkg.difyctl?.compat?.minDify ?? '0.0.0'
+4 -4
View File
@@ -2,8 +2,8 @@ import { resolveBuildInfo } from './lib/resolve-buildinfo.js'
const info = resolveBuildInfo()
process.stdout.write(
`version: ${info.version}\n`
+ `commit: ${info.commit}\n`
+ `built: ${info.buildDate}\n`
+ `channel: ${info.channel}\n`,
`version: ${info.version}\n` +
`commit: ${info.commit}\n` +
`built: ${info.buildDate}\n` +
`channel: ${info.channel}\n`,
)
+57 -59
View File
@@ -31,8 +31,8 @@ const CHANNELS = [
{ name: 'edge', prerelease: true, versionForm: /^\d+\.\d+\.\d+-edge\.[0-9a-f]{7,40}$/ },
]
const channelByName = name => CHANNELS.find(c => c.name === name)
const channelNames = () => CHANNELS.map(c => c.name).join(', ')
const channelByName = (name) => CHANNELS.find((c) => c.name === name)
const channelNames = () => CHANNELS.map((c) => c.name).join(', ')
function parsePrecedence(v) {
const s = String(v).replace(/^v/, '').replace(/\+.*$/, '')
@@ -51,8 +51,7 @@ function edgeVersion(sha) {
die('edge-version requires a git short sha (7-40 hex chars)')
const { version } = loadPkg()
const core = versionCore(version)
if (!/^\d+\.\d+\.\d+$/.test(core))
die(`cannot derive edge base from version: ${version}`)
if (!/^\d+\.\d+\.\d+$/.test(core)) die(`cannot derive edge base from version: ${version}`)
return `${core}-edge.${sha}`
}
@@ -63,8 +62,7 @@ function channelVersionProblem(version, channel) {
if (typeof version !== 'string' || version.length === 0)
return 'version must be a non-empty string'
const ch = channelByName(channel)
if (!ch)
return `unknown channel: ${channel} (expected one of: ${channelNames()})`
if (!ch) return `unknown channel: ${channel} (expected one of: ${channelNames()})`
if (!ch.versionForm.test(version))
return `version ${version} does not match the ${channel} channel form`
return null
@@ -72,8 +70,7 @@ function channelVersionProblem(version, channel) {
function validateVersionForChannel(version, channelName) {
const problem = channelVersionProblem(version, channelName)
if (problem)
die(problem)
if (problem) die(problem)
return `valid: ${version} is a ${channelName} version`
}
@@ -82,21 +79,16 @@ function comparePre(a, b) {
const bparts = b.split('.')
const len = Math.max(aparts.length, bparts.length)
for (let i = 0; i < len; i++) {
if (aparts[i] === undefined)
return -1
if (bparts[i] === undefined)
return 1
if (aparts[i] === undefined) return -1
if (bparts[i] === undefined) return 1
const an = /^\d+$/.test(aparts[i])
const bn = /^\d+$/.test(bparts[i])
if (an && bn) {
const d = Number(aparts[i]) - Number(bparts[i])
if (d !== 0)
return d < 0 ? -1 : 1
}
else if (an !== bn) {
if (d !== 0) return d < 0 ? -1 : 1
} else if (an !== bn) {
return an ? -1 : 1
}
else if (aparts[i] !== bparts[i]) {
} else if (aparts[i] !== bparts[i]) {
return aparts[i] < bparts[i] ? -1 : 1
}
}
@@ -109,15 +101,11 @@ function comparePrecedence(a, b) {
for (let i = 0; i < SEMVER_CORE_LEN; i++) {
const x = A.nums[i] ?? 0
const y = B.nums[i] ?? 0
if (x !== y)
return x < y ? -1 : 1
if (x !== y) return x < y ? -1 : 1
}
if (A.pre === B.pre)
return 0
if (A.pre === '')
return 1
if (B.pre === '')
return -1
if (A.pre === B.pre) return 0
if (A.pre === '') return 1
if (B.pre === '') return -1
return comparePre(A.pre, B.pre)
}
@@ -129,8 +117,7 @@ function die(msg) {
function loadPkg() {
const pkgUrl = new URL('../package.json', import.meta.url)
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8'))
if (!pkg.difyctl?.release)
die('cli/package.json missing difyctl.release')
if (!pkg.difyctl?.release) die('cli/package.json missing difyctl.release')
return {
version: pkg.version,
channel: pkg.difyctl.channel,
@@ -151,32 +138,29 @@ function githubEnv() {
tagPrefix: release.tagPrefix,
difyctlTag: `${release.tagPrefix}${version}`,
}
return Object.entries(fields).map(([k, v]) => `${k}=${v}`).join('\n')
return Object.entries(fields)
.map(([k, v]) => `${k}=${v}`)
.join('\n')
}
function requireVersion(version) {
if (!version)
die('version argument is required')
if (!version) die('version argument is required')
return version
}
function assetName(release, version, id) {
const target = release.targets.find(t => t.id === id)
if (!target)
die(`unknown target id: ${id}`)
const target = release.targets.find((t) => t.id === id)
if (!target) die(`unknown target id: ${id}`)
const suffix = target.exe ? '.exe' : ''
return `${release.tagPrefix}${version}-${id}${suffix}`
}
function validateRelease(release) {
const problems = []
const str = v => typeof v === 'string' && v.length > 0
if (!str(release.tagPrefix))
problems.push('tagPrefix must be a non-empty string')
if (!str(release.binName))
problems.push('binName must be a non-empty string')
if (!str(release.checksumsSuffix))
problems.push('checksumsSuffix must be a non-empty string')
const str = (v) => typeof v === 'string' && v.length > 0
if (!str(release.tagPrefix)) problems.push('tagPrefix must be a non-empty string')
if (!str(release.binName)) problems.push('binName must be a non-empty string')
if (!str(release.checksumsSuffix)) problems.push('checksumsSuffix must be a non-empty string')
if (!Array.isArray(release.targets) || release.targets.length === 0) {
problems.push('targets must be a non-empty array')
return problems
@@ -184,15 +168,12 @@ function validateRelease(release) {
const seen = new Set()
for (const t of release.targets) {
const label = t?.id ?? JSON.stringify(t)
if (!str(t?.id))
problems.push(`target ${label}: id must be a non-empty string`)
else if (seen.has(t.id))
problems.push(`duplicate target id: ${t.id}`)
if (!str(t?.id)) problems.push(`target ${label}: id must be a non-empty string`)
else if (seen.has(t.id)) problems.push(`duplicate target id: ${t.id}`)
else seen.add(t.id)
if (!str(t?.bunTarget) || !BUN_TARGET_RE.test(t.bunTarget))
problems.push(`target ${label}: bunTarget must match ${BUN_TARGET_RE}`)
if (typeof t?.exe !== 'boolean')
problems.push(`target ${label}: exe must be a boolean`)
if (typeof t?.exe !== 'boolean') problems.push(`target ${label}: exe must be a boolean`)
else if (str(t?.bunTarget) && t.exe !== t.bunTarget.startsWith('bun-windows-'))
problems.push(`target ${label}: exe must be true iff bunTarget is bun-windows-*`)
}
@@ -210,7 +191,11 @@ function main(argv) {
case 'tag':
return `${loadPkg().release.tagPrefix}${requireVersion(rest[0])}`
case 'asset':
return assetName(loadPkg().release, requireVersion(rest[0]), rest[1] ?? die('target id is required'))
return assetName(
loadPkg().release,
requireVersion(rest[0]),
rest[1] ?? die('target id is required'),
)
case 'checksums': {
const { release } = loadPkg()
return `${release.tagPrefix}${requireVersion(rest[0])}${release.checksumsSuffix}`
@@ -218,9 +203,11 @@ function main(argv) {
case 'tag-prefix':
return loadPkg().release.tagPrefix
case 'targets':
return loadPkg().release.targets.map(t => `${t.bunTarget}\t${t.id}\t${t.exe ? 1 : 0}`).join('\n')
return loadPkg()
.release.targets.map((t) => `${t.bunTarget}\t${t.id}\t${t.exe ? 1 : 0}`)
.join('\n')
case 'channels':
return CHANNELS.map(c => c.name).join('\n')
return CHANNELS.map((c) => c.name).join('\n')
case 'github-env':
return githubEnv()
case 'compat-check': {
@@ -228,14 +215,18 @@ function main(argv) {
const difyVersion = requireVersion(rest[0])
if (!compat.minDify || !compat.maxDify)
die('cli/package.json missing difyctl.compat.minDify/maxDify')
if (comparePrecedence(difyVersion, compat.minDify) < 0 || comparePrecedence(difyVersion, compat.maxDify) > 0)
die(`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`)
if (
comparePrecedence(difyVersion, compat.minDify) < 0 ||
comparePrecedence(difyVersion, compat.maxDify) > 0
)
die(
`Dify ${difyVersion} is outside difyctl compatibility window ${compat.minDify}..${compat.maxDify}; bump difyctl.compat in cli/package.json`,
)
return `compatible: Dify ${difyVersion} within ${compat.minDify}..${compat.maxDify}`
}
case 'prerelease': {
const ch = channelByName(rest[0] ?? die('channel argument is required'))
if (!ch)
die(`unknown channel: ${rest[0]} (expected one of: ${channelNames()})`)
if (!ch) die(`unknown channel: ${rest[0]} (expected one of: ${channelNames()})`)
return String(ch.prerelease)
}
case 'validate': {
@@ -257,9 +248,16 @@ function main(argv) {
}
}
const invokedDirectly = process.argv[1]
&& realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
if (invokedDirectly)
process.stdout.write(`${main(process.argv.slice(2))}\n`)
const invokedDirectly =
process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)
if (invokedDirectly) process.stdout.write(`${main(process.argv.slice(2))}\n`)
export { assetName, channelByName, CHANNELS, edgeVersion, loadPkg, validateVersionForChannel, versionCore }
export {
assetName,
channelByName,
CHANNELS,
edgeVersion,
loadPkg,
validateVersionForChannel,
versionCore,
}
+3 -4
View File
@@ -4,13 +4,12 @@ import { describe, expect, it } from 'vitest'
const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url))
function run(args: string[]): { code: number, stdout: string, stderr: string } {
function run(args: string[]): { code: number; stdout: string; stderr: string } {
try {
const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' })
return { code: 0, stdout, stderr: '' }
}
catch (e) {
const err = e as { status?: number, stdout?: string, stderr?: string }
} catch (e) {
const err = e as { status?: number; stdout?: string; stderr?: string }
return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
}
}
+25 -23
View File
@@ -24,8 +24,7 @@ function parseArgs(argv) {
function requireArgs(args, keys) {
for (const k of keys) {
if (!args[k])
die(`missing --${k}`)
if (!args[k]) die(`missing --${k}`)
}
}
@@ -34,8 +33,7 @@ function shaMap(checksumsPath) {
const map = new Map()
for (const line of readFileSync(checksumsPath, 'utf8').split('\n')) {
const m = line.match(/^([0-9a-f]{64})\s+(\S+)$/i)
if (m)
map.set(m[2], m[1])
if (m) map.set(m[2], m[1])
}
return map
}
@@ -46,14 +44,15 @@ function emitManifest(args) {
const { release, compat } = loadPkg()
const shas = shaMap(args.checksums)
const targetLines = release.targets.map((t) => {
const asset = assetName(release, args.version, t.id)
const sha = shas.get(asset)
if (!sha)
die(`no sha256 for ${asset} in ${args.checksums}`)
// one target per line: install-r2.sh grep/sed depends on this layout
return ` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(asset)}, "sha256": ${JSON.stringify(sha)} }`
}).join(',\n')
const targetLines = release.targets
.map((t) => {
const asset = assetName(release, args.version, t.id)
const sha = shas.get(asset)
if (!sha) die(`no sha256 for ${asset} in ${args.checksums}`)
// one target per line: install-r2.sh grep/sed depends on this layout
return ` ${JSON.stringify(t.id)}: { "asset": ${JSON.stringify(asset)}, "sha256": ${JSON.stringify(sha)} }`
})
.join(',\n')
const head = {
schema: 1,
@@ -65,20 +64,20 @@ function emitManifest(args) {
compat: { minDify: compat.minDify, maxDify: compat.maxDify },
baseUrl: args['base-url'],
}
const headLines = Object.entries(head).map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`).join(',\n')
const headLines = Object.entries(head)
.map(([k, v]) => ` ${JSON.stringify(k)}: ${JSON.stringify(v)}`)
.join(',\n')
process.stdout.write(`{\n${headLines},\n "targets": {\n${targetLines}\n }\n}\n`)
}
// Newline-delimited dir names of binaries that still exist in R2. Absent file =
// no reconciliation (caller could not list); empty file = no survivors.
function loadExistingDirs(path) {
if (!path || !existsSync(path))
return null
if (!path || !existsSync(path)) return null
const set = new Set()
for (const line of readFileSync(path, 'utf8').split('\n')) {
const d = line.trim()
if (d)
set.add(d)
if (d) set.add(d)
}
return set
}
@@ -93,23 +92,26 @@ function emitIndex(args) {
if (raw && raw !== '-') {
try {
current = JSON.parse(raw)
}
catch {
} catch {
die(`current index at ${args.current} is not valid JSON`)
}
}
}
const entry = { version: args.version, commit: args.commit, buildDate: args['build-date'], dir: args.version }
const kept = (current.builds ?? []).filter(b => b.version !== entry.version)
const entry = {
version: args.version,
commit: args.commit,
buildDate: args['build-date'],
dir: args.version,
}
const kept = (current.builds ?? []).filter((b) => b.version !== entry.version)
let builds = [entry, ...kept]
// Reconcile to binaries that still exist in R2: lifecycle/TTL on the bin prefix
// is the only deletion mechanism, so the ledger never advertises a build whose
// binary is gone. The new build is always kept (just uploaded). No count cap.
const existing = loadExistingDirs(args['existing-dirs'])
if (existing)
builds = builds.filter(b => b.dir === entry.dir || existing.has(b.dir))
if (existing) builds = builds.filter((b) => b.dir === entry.dir || existing.has(b.dir))
const index = { schema: 1, channel: args.channel, updated: args['build-date'], builds }
process.stdout.write(`${JSON.stringify(index, null, 2)}\n`)
+136 -34
View File
@@ -7,12 +7,15 @@ import { describe, expect, it } from 'vitest'
const SCRIPT = fileURLToPath(new URL('./release-r2-edge.mjs', import.meta.url))
function run(args: string[]): { code: number, stdout: string, stderr: string } {
function run(args: string[]): { code: number; stdout: string; stderr: string } {
try {
return { code: 0, stdout: execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' }), stderr: '' }
}
catch (e) {
const err = e as { status?: number, stdout?: string, stderr?: string }
return {
code: 0,
stdout: execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' }),
stderr: '',
}
} catch (e) {
const err = e as { status?: number; stdout?: string; stderr?: string }
return { code: err.status ?? 1, stdout: err.stdout ?? '', stderr: err.stderr ?? '' }
}
}
@@ -42,9 +45,9 @@ type ManifestJson = {
version: string
commit: string
buildDate: string
compat: { minDify: string, maxDify: string }
compat: { minDify: string; maxDify: string }
baseUrl: string
targets: Record<string, { asset: string, sha256: string }>
targets: Record<string, { asset: string; sha256: string }>
}
type IndexBuild = {
@@ -61,10 +64,34 @@ type IndexJson = {
builds: IndexBuild[]
}
function buildManifest(version = VERSION): { code: number, json: ManifestJson, stdout: string, stderr: string } {
function buildManifest(version = VERSION): {
code: number
json: ManifestJson
stdout: string
stderr: string
} {
const checksums = writeChecksums(version)
const r = run(['manifest', '--channel', 'edge', '--version', version, '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', BASE_URL, '--checksums', checksums])
return { code: r.code, json: (r.code === 0 ? JSON.parse(r.stdout) : null) as ManifestJson, stdout: r.stdout, stderr: r.stderr }
const r = run([
'manifest',
'--channel',
'edge',
'--version',
version,
'--commit',
'abc1234',
'--build-date',
'2026-06-14T12:00:00Z',
'--base-url',
BASE_URL,
'--checksums',
checksums,
])
return {
code: r.code,
json: (r.code === 0 ? JSON.parse(r.stdout) : null) as ManifestJson,
stdout: r.stdout,
stderr: r.stderr,
}
}
describe('release-r2-edge manifest', () => {
@@ -86,9 +113,13 @@ describe('release-r2-edge manifest', () => {
it('lists all 5 targets with asset name + sha256 from the checksums file', () => {
const { json } = buildManifest()
expect(Object.keys(json.targets).sort()).toEqual(
['darwin-arm64', 'darwin-x64', 'linux-arm64', 'linux-x64', 'windows-x64'],
)
expect(Object.keys(json.targets).sort()).toEqual([
'darwin-arm64',
'darwin-x64',
'linux-arm64',
'linux-x64',
'windows-x64',
])
expect(json.targets['linux-x64'].asset).toBe(`difyctl-v${VERSION}-linux-x64`)
expect(json.targets['windows-x64'].asset).toBe(`difyctl-v${VERSION}-windows-x64.exe`)
expect(json.targets['linux-x64'].sha256).toMatch(/^\d{64}$/)
@@ -108,20 +139,51 @@ describe('release-r2-edge manifest', () => {
const dir = mkdtempSync(join(tmpdir(), 'difyctl-manifest-'))
const file = join(dir, `difyctl-v${VERSION}-checksums.txt`)
writeFileSync(file, `${'0'.repeat(64)} difyctl-v${VERSION}-linux-x64\n`) // only 1 of 5
const r = run(['manifest', '--channel', 'edge', '--version', VERSION, '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', BASE_URL, '--checksums', file])
const r = run([
'manifest',
'--channel',
'edge',
'--version',
VERSION,
'--commit',
'abc1234',
'--build-date',
'2026-06-14T12:00:00Z',
'--base-url',
BASE_URL,
'--checksums',
file,
])
expect(r.code).not.toBe(0)
})
it('rejects a malformed dropped-value argument (no silent misparse)', () => {
// --version has no value; --commit must NOT be swallowed as the version
const r = run(['manifest', '--channel', 'edge', '--version', '--commit', 'abc1234', '--build-date', '2026-06-14T12:00:00Z', '--base-url', 'https://x', '--checksums', '/nonexistent'])
const r = run([
'manifest',
'--channel',
'edge',
'--version',
'--commit',
'abc1234',
'--build-date',
'2026-06-14T12:00:00Z',
'--base-url',
'https://x',
'--checksums',
'/nonexistent',
])
expect(r.code).not.toBe(0)
})
})
// ---- index ----
function runIndex(currentContent: string | null, build: Record<string, string>, existingDirs?: string[]) {
function runIndex(
currentContent: string | null,
build: Record<string, string>,
existingDirs?: string[],
) {
let currentArg = '-'
if (currentContent !== null) {
const dir = mkdtempSync(join(tmpdir(), 'difyctl-index-'))
@@ -135,7 +197,25 @@ function runIndex(currentContent: string | null, build: Record<string, string>,
writeFileSync(f, `${existingDirs.join('\n')}\n`)
extra.push('--existing-dirs', f)
}
const r = spawnSync('node', [SCRIPT, 'index', '--current', currentArg, '--channel', 'edge', '--version', build.version, '--commit', build.commit, '--build-date', build.buildDate, ...extra], { encoding: 'utf8' })
const r = spawnSync(
'node',
[
SCRIPT,
'index',
'--current',
currentArg,
'--channel',
'edge',
'--version',
build.version,
'--commit',
build.commit,
'--build-date',
build.buildDate,
...extra,
],
{ encoding: 'utf8' },
)
return {
code: r.status ?? 1,
index: (r.status === 0 ? JSON.parse(r.stdout) : null) as IndexJson,
@@ -151,7 +231,11 @@ describe('release-r2-edge index', () => {
expect(index.schema).toBe(1)
expect(index.channel).toBe('edge')
expect(index.builds).toHaveLength(1)
expect(index.builds[0]).toMatchObject({ version: B1.version, commit: B1.commit, dir: B1.version })
expect(index.builds[0]).toMatchObject({
version: B1.version,
commit: B1.commit,
dir: B1.version,
})
})
it('treats an empty current file as fresh (first publish, curl wrote nothing)', () => {
@@ -167,40 +251,58 @@ describe('release-r2-edge index', () => {
})
it('prepends the new build (publish order; newest at [0])', () => {
const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version }] })
const current = JSON.stringify({
schema: 1,
channel: 'edge',
builds: [
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
],
})
const { index } = runIndex(current, B2)
expect(index.builds.map(b => b.version)).toEqual([B2.version, B1.version])
expect(index.builds.map((b) => b.version)).toEqual([B2.version, B1.version])
})
it('dedups a re-cut of the same version (no duplicate, moves to top)', () => {
const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [
{ version: B2.version, commit: B2.commit, buildDate: B2.buildDate, dir: B2.version },
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
] })
const current = JSON.stringify({
schema: 1,
channel: 'edge',
builds: [
{ version: B2.version, commit: B2.commit, buildDate: B2.buildDate, dir: B2.version },
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
],
})
const { index } = runIndex(current, B1) // re-cut B1
expect(index.builds.map(b => b.version)).toEqual([B1.version, B2.version])
expect(index.builds.map((b) => b.version)).toEqual([B1.version, B2.version])
})
it('reconciles to surviving binary dirs (drops a build whose binary expired)', () => {
const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
] })
const current = JSON.stringify({
schema: 1,
channel: 'edge',
builds: [
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
],
})
// B1's binary is gone (not in existing); the new B2 is always kept.
const { index } = runIndex(current, B2, [B2.version])
expect(index.builds.map(b => b.version)).toEqual([B2.version])
expect(index.builds.map((b) => b.version)).toEqual([B2.version])
})
it('keeps the new build even when it is absent from the existing-dirs list', () => {
const { index } = runIndex(null, B1, []) // empty survivors, fresh ledger
expect(index.builds.map(b => b.version)).toEqual([B1.version])
expect(index.builds.map((b) => b.version)).toEqual([B1.version])
})
it('does not reconcile when no --existing-dirs is given (list unavailable)', () => {
const current = JSON.stringify({ schema: 1, channel: 'edge', builds: [
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
] })
const current = JSON.stringify({
schema: 1,
channel: 'edge',
builds: [
{ version: B1.version, commit: B1.commit, buildDate: B1.buildDate, dir: B1.version },
],
})
const { index } = runIndex(current, B2) // no existing-dirs → keep all
expect(index.builds.map(b => b.version)).toEqual([B2.version, B1.version])
expect(index.builds.map((b) => b.version)).toEqual([B2.version, B1.version])
})
it('dies on a non-empty current file that is not valid JSON', () => {
+10 -6
View File
@@ -6,7 +6,7 @@ const SCRIPT = fileURLToPath(new URL('./release-r2-publish.sh', import.meta.url)
// Stub `aws` + `curl` + `node` as shell functions that just log action verbs to
// $ORDER_LOG, then run the publish `main` and assert the order of operations.
function runPublish(): { code: number, order: string[], stderr: string } {
function runPublish(): { code: number; order: string[]; stderr: string } {
const stub = [
'ORDER_LOG="$(mktemp)"',
'aws() {',
@@ -23,10 +23,10 @@ function runPublish(): { code: number, order: string[], stderr: string } {
'node() {',
' case "$*" in',
' *release-naming.mjs*targets*)',
' printf \'bun-linux-x64\\tlinux-x64\\t0\\nbun-linux-arm64\\tlinux-arm64\\t0\\nbun-darwin-x64\\tdarwin-x64\\t0\\nbun-darwin-arm64\\tdarwin-arm64\\t0\\nbun-windows-x64\\twindows-x64\\t1\\n\' ;;',
' *release-naming.mjs*\' asset \'*) printf \'difyctl-vX\\n\' ;;',
' *release-r2-edge.mjs*\' index \'*) echo \'{}\' ;;',
' *release-r2-edge.mjs*\' manifest \'*) echo \'{}\' ;;',
" printf 'bun-linux-x64\\tlinux-x64\\t0\\nbun-linux-arm64\\tlinux-arm64\\t0\\nbun-darwin-x64\\tdarwin-x64\\t0\\nbun-darwin-arm64\\tdarwin-arm64\\t0\\nbun-windows-x64\\twindows-x64\\t1\\n' ;;",
" *release-naming.mjs*' asset '*) printf 'difyctl-vX\\n' ;;",
" *release-r2-edge.mjs*' index '*) echo '{}' ;;",
" *release-r2-edge.mjs*' manifest '*) echo '{}' ;;",
' *) : ;;',
' esac',
'}',
@@ -49,7 +49,11 @@ function runPublish(): { code: number, order: string[], stderr: string } {
DIST_DIR: '/tmp',
},
})
return { code: r.status ?? 1, order: (r.stdout ?? '').trim().split('\n').filter(Boolean), stderr: r.stderr ?? '' }
return {
code: r.status ?? 1,
order: (r.stdout ?? '').trim().split('\n').filter(Boolean),
stderr: r.stderr ?? '',
}
}
describe('release-r2-publish order', () => {
+26 -13
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env -S bun
import { execSync } from 'node:child_process'
type Check = { name: string, run: () => void }
type Check = { name: string; run: () => void }
const baseUrlIdx = process.argv.indexOf('--base-url')
const baseUrl = baseUrlIdx > -1 ? process.argv[baseUrlIdx + 1] : 'http://localhost:5001'
@@ -17,16 +17,30 @@ function cli(args: string): string {
}
const checks: Check[] = [
{ name: 'config show', run: () => { cli('config show') } },
{ name: 'get workspace', run: () => {
if (!cli('get workspace').includes('id'))
throw new Error('no workspace listed')
} },
{ name: 'get apps', run: () => { cli('get apps') } },
{ name: 'difyctl version prints compat', run: () => {
if (!cli('version').includes('compat:'))
throw new Error('no compat line')
} },
{
name: 'config show',
run: () => {
cli('config show')
},
},
{
name: 'get workspace',
run: () => {
if (!cli('get workspace').includes('id')) throw new Error('no workspace listed')
},
},
{
name: 'get apps',
run: () => {
cli('get apps')
},
},
{
name: 'difyctl version prints compat',
run: () => {
if (!cli('version').includes('compat:')) throw new Error('no compat line')
},
},
]
let failed = 0
@@ -34,8 +48,7 @@ for (const c of checks) {
try {
c.run()
console.log(`[x] ${c.name}`)
}
catch (err) {
} catch (err) {
failed++
console.log(`[ ] ${c.name}${(err as Error).message}`)
}
+9 -8
View File
@@ -19,7 +19,7 @@ describe('AccountSessionsClient.list', () => {
})
it('GETs account/sessions with no query when paging is unset', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list()
@@ -29,7 +29,7 @@ describe('AccountSessionsClient.list', () => {
})
it('forwards page/limit when supplied', async () => {
stub = await startStubServer(cap => jsonResponder(200, LIST_BODY, cap))
stub = await startStubServer((cap) => jsonResponder(200, LIST_BODY, cap))
await makeClient(stub.url).list({ page: 2, limit: 25 })
@@ -50,7 +50,7 @@ describe('AccountSessionsClient.revoke', () => {
// The server replies 200 + {status:"revoked"}; revoke() returns void but the
// typed client still parses the body — this guards against a regression where
// a non-empty 200 body trips JSON handling.
stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
await expect(makeClient(stub.url).revoke('sess-1')).resolves.toBeUndefined()
expect(stub.captured.method).toBe('DELETE')
@@ -58,7 +58,7 @@ describe('AccountSessionsClient.revoke', () => {
})
it('URL-encodes the session id', async () => {
stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
await makeClient(stub.url).revoke('sess/1 2')
@@ -66,16 +66,17 @@ describe('AccountSessionsClient.revoke', () => {
})
it('propagates 404 as a classified BaseError', async () => {
stub = await startStubServer(cap =>
jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap))
stub = await startStubServer((cap) =>
jsonResponder(404, { error: { code: 'not_found', message: 'session not found' } }, cap),
)
await expect(makeClient(stub.url).revoke('missing')).rejects.toSatisfy(
err => isHttpClientError(err) && err.httpStatus === 404,
(err) => isHttpClientError(err) && err.httpStatus === 404,
)
})
it('revokeSelf DELETEs the self subresource', async () => {
stub = await startStubServer(cap => jsonResponder(200, { status: 'revoked' }, cap))
stub = await startStubServer((cap) => jsonResponder(200, { status: 'revoked' }, cap))
await expect(makeClient(stub.url).revokeSelf()).resolves.toBeUndefined()
expect(stub.captured.method).toBe('DELETE')

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