Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
abb2ea82d7 | ||
|
|
408cd12835 | ||
|
|
ca46d07ea8 | ||
|
|
e721801b37 | ||
|
|
f03648435a | ||
|
|
41683013e3 | ||
|
|
0e84444809 | ||
|
|
b893b53f0a | ||
|
|
4c1c92eb2f | ||
|
|
7311f1ba6d | ||
|
|
f4dfac7214 | ||
|
|
6d8e390c95 | ||
|
|
a84c2d36a3 | ||
|
|
fde08d24fe | ||
|
|
b54107e9b9 | ||
|
|
594c21d98c | ||
|
|
6e59c723c9 | ||
|
|
4efab8d7a3 | ||
|
|
2a2193dabb | ||
|
|
1246aa7f3a | ||
|
|
3defd50709 | ||
|
|
6e5ba18d65 | ||
|
|
e3a08b40f2 | ||
|
|
6f47950fe2 | ||
|
|
4043adacd7 | ||
|
|
b9fde89781 | ||
|
|
d612307658 | ||
|
|
7e05f28a46 | ||
|
|
44fb074359 | ||
|
|
775ed70bac | ||
|
|
3c8e0e2113 | ||
|
|
489e77658e | ||
|
|
4a8c6773a3 | ||
|
|
d0f0134314 | ||
|
|
9b3650e2ca | ||
|
|
0038bcc4bb | ||
|
|
b325597d29 | ||
|
|
b7255e6f27 | ||
|
|
c3fb5344cb | ||
|
|
888bea9911 | ||
|
|
2da37a9416 | ||
|
|
f902551d0c | ||
|
|
faba726a45 | ||
|
|
7da3d7074a | ||
|
|
f9dd37420b | ||
|
|
eb74946b25 | ||
|
|
b5e35fc2fc | ||
|
|
7bfbb2bbe8 | ||
|
|
d177998255 | ||
|
|
74ee665af6 | ||
|
|
d655153a3d | ||
|
|
925f97be20 | ||
|
|
9252d81826 | ||
|
|
9d5819a9c1 | ||
|
|
99e3b1a401 | ||
|
|
45957225cd |
@@ -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/`.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
'',
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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]
|
||||
|
||||
Vendored
+29
-36
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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})")
|
||||
|
||||
@@ -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
@@ -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}, "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {})
|
||||
|
||||
|
||||
@@ -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 +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)
|
||||
|
||||
|
||||
@@ -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])
|
||||
|
||||
+5
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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 ~')
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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`,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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 ?? '' }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`)
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user